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        /// `None` on rows persisted by pre-durable-callback (v0.8.7) binaries,
117        /// which wrote this variant without the field. The option is part of
118        /// the persisted contract: a legacy row must re-serialize
119        /// byte-identically so its stored `candidate_digest` keeps verifying.
120        /// Live producers always write `Some`.
121        #[serde(default, skip_serializing_if = "Option::is_none")]
122        tool_use_id: Option<String>,
123        tool_name: String,
124        args: serde_json::Value,
125    },
126    CallbackBatchPending {
127        pending_tool_calls: Vec<meerkat_core::error::PendingCallbackToolCall>,
128    },
129    /// The runtime executor observed that the agent's generated turn machine
130    /// had already reached a typed hard-failure terminal.  The metadata is
131    /// durable because recovery must publish the same failure class and detail
132    /// as the live completion path; display-text reclassification is forbidden.
133    MachineTerminalFailure {
134        error: meerkat_core::TurnErrorMetadata,
135    },
136    Cancelled,
137    RuntimeTerminated {
138        reason: String,
139    },
140}
141
142impl InteractionTerminalCandidate {
143    pub(crate) fn core_apply_terminal(
144        &self,
145    ) -> Option<meerkat_core::lifecycle::core_executor::CoreApplyTerminal> {
146        use meerkat_core::lifecycle::core_executor::CoreApplyTerminal;
147        match self {
148            Self::RunResult { result } => Some(CoreApplyTerminal::RunResult(result.clone())),
149            Self::CompletedWithoutResult => Some(CoreApplyTerminal::NoPendingBoundary),
150            Self::CallbackPending {
151                tool_use_id,
152                tool_name,
153                args,
154            } => Some(CoreApplyTerminal::CallbackPending {
155                // A v0.8.7 row never recorded the id; empty means "identity
156                // unknown, pre-0.8.8 row". Durable-callback consumers that
157                // need the id never see such rows because the protocol did
158                // not exist when they were written.
159                tool_use_id: tool_use_id.clone().unwrap_or_default(),
160                tool_name: tool_name.clone(),
161                args: args.clone(),
162            }),
163            Self::CallbackBatchPending { pending_tool_calls } => {
164                Some(CoreApplyTerminal::CallbackBatchPending {
165                    pending_tool_calls: pending_tool_calls.clone(),
166                })
167            }
168            Self::MachineTerminalFailure { error } => {
169                Some(CoreApplyTerminal::MachineTerminalFailure {
170                    error: error.clone(),
171                })
172            }
173            Self::Cancelled | Self::RuntimeTerminated { .. } => None,
174        }
175    }
176
177    pub(crate) fn terminal_observation(
178        &self,
179    ) -> crate::meerkat_machine::dsl::RuntimeCompletionTerminalObservation {
180        use crate::meerkat_machine::dsl::RuntimeCompletionTerminalObservation;
181        match self {
182            Self::RunResult { .. } => RuntimeCompletionTerminalObservation::RunResult,
183            Self::CallbackPending { .. } | Self::CallbackBatchPending { .. } => {
184                RuntimeCompletionTerminalObservation::CallbackPending
185            }
186            Self::RuntimeTerminated { .. } => {
187                RuntimeCompletionTerminalObservation::RuntimeTerminated
188            }
189            Self::MachineTerminalFailure { .. } | Self::Cancelled => {
190                RuntimeCompletionTerminalObservation::MachineTerminal
191            }
192            Self::CompletedWithoutResult => RuntimeCompletionTerminalObservation::NoResult,
193        }
194    }
195
196    pub(crate) fn completion_error_metadata(&self) -> Option<meerkat_core::TurnErrorMetadata> {
197        match self {
198            Self::MachineTerminalFailure { error } => Some(error.clone()),
199            _ => None,
200        }
201    }
202}
203
204/// Durable receipt proving that the exact interaction terminal row was
205/// appended (or byte-identically replayed) in the session event store.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub(crate) struct InteractionTerminalPublication {
208    pub(crate) terminal_seq: u64,
209    pub(crate) payload_digest: String,
210}
211
212/// Structurally valid durable phases for one directed-terminal outbox row.
213///
214/// The batch candidate remains on exactly one owner row until publication.
215/// Once an exact event-store receipt is durable, both the candidate and the
216/// finalized event are compacted away; their immutable digests and the exact
217/// terminal sequence remain as the retry/provenance witness.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(tag = "phase", rename_all = "snake_case")]
220pub(crate) enum InteractionTerminalOutboxPhase {
221    Candidate,
222    Finalized {
223        finalization_failed: bool,
224        #[serde(default, skip_serializing_if = "Option::is_none")]
225        finalized_event: Option<AgentEvent>,
226        finalized_payload_digest: String,
227    },
228    Published {
229        finalization_failed: bool,
230        publication: InteractionTerminalPublication,
231    },
232}
233
234/// Typed durable identity for one exact terminal batch.
235#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
236#[serde(tag = "scope", rename_all = "snake_case")]
237pub(crate) enum InteractionTerminalBatchKey {
238    Run { run_id: RunId },
239    RuntimeTermination { candidate_owner_input_id: InputId },
240}
241
242impl InteractionTerminalBatchKey {
243    pub(crate) fn run_id(&self) -> Option<&RunId> {
244        match self {
245            Self::Run { run_id } => Some(run_id),
246            Self::RuntimeTermination { .. } => None,
247        }
248    }
249}
250
251/// Retry carrier for an exact per-input terminal publication.
252///
253/// This is shell payload, not a competing lifecycle machine: candidate
254/// creation is bound to the machine-owned run commit/failure path, final event
255/// creation consumes generated runtime-completion authority, and publication
256/// is accepted only with an exact event-store receipt.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub(crate) struct InteractionTerminalOutbox {
259    pub(crate) interaction_id: InteractionId,
260    pub(crate) input_id: InputId,
261    /// Stable position in the directed event batch. Ordinals are contiguous
262    /// from zero and the shared candidate owner is always ordinal zero.
263    pub(crate) batch_ordinal: u16,
264    pub(crate) batch_key: InteractionTerminalBatchKey,
265    pub(crate) owner_session_id: SessionId,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub(crate) owner_agent_runtime_id: Option<String>,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub(crate) owner_fence_token: Option<u64>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub(crate) owner_runtime_generation: Option<u64>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub(crate) owner_runtime_epoch_id: Option<String>,
274    /// Input row that owns the batch's single shared candidate payload.
275    pub(crate) candidate_owner_input_id: InputId,
276    /// Shared candidate payload, present only on the owner row.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub(crate) candidate: Option<InteractionTerminalCandidate>,
279    pub(crate) candidate_digest: String,
280    /// Exact completion-waiter recipients for the whole runtime batch. The
281    /// vector is stored only on the candidate owner and compacted after the
282    /// terminal publication receipt is durable.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub(crate) completion_input_ids: Option<Vec<InputId>>,
285    /// Shared immutable proof for the owner-only recipient vector.
286    pub(crate) completion_input_ids_digest: String,
287    pub(crate) phase: InteractionTerminalOutboxPhase,
288}
289
290pub(crate) fn interaction_terminal_payload_digest<T: Serialize>(
291    payload: &T,
292) -> Result<String, String> {
293    serde_json::to_vec(payload)
294        .map(|encoded| format!("{:x}", Sha256::digest(encoded)))
295        .map_err(|error| format!("failed to encode interaction terminal payload: {error}"))
296}
297
298pub(crate) fn interaction_terminal_event_id(event: &AgentEvent) -> Option<InteractionId> {
299    match event {
300        AgentEvent::InteractionComplete { interaction_id, .. }
301        | AgentEvent::InteractionCallbackPending { interaction_id, .. }
302        | AgentEvent::InteractionFailed { interaction_id, .. } => Some(*interaction_id),
303        _ => None,
304    }
305}
306
307pub(crate) fn interaction_terminal_event_for_id(
308    event: &AgentEvent,
309    interaction_id: InteractionId,
310) -> Option<AgentEvent> {
311    match event {
312        AgentEvent::InteractionComplete {
313            result,
314            structured_output,
315            ..
316        } => Some(AgentEvent::InteractionComplete {
317            interaction_id,
318            result: result.clone(),
319            structured_output: structured_output.clone(),
320        }),
321        AgentEvent::InteractionCallbackPending {
322            tool_name,
323            args,
324            pending_tool_calls,
325            ..
326        } => Some(AgentEvent::InteractionCallbackPending {
327            interaction_id,
328            tool_name: tool_name.clone(),
329            args: args.clone(),
330            pending_tool_calls: pending_tool_calls.clone(),
331        }),
332        AgentEvent::InteractionFailed { reason, .. } => Some(AgentEvent::InteractionFailed {
333            interaction_id,
334            reason: reason.clone(),
335        }),
336        _ => None,
337    }
338}
339
340impl InteractionTerminalOutbox {
341    pub(crate) fn validate(&self) -> Result<(), String> {
342        if self.input_id.0 != self.interaction_id.0 {
343            return Err("interaction terminal outbox input/interaction identity mismatch".into());
344        }
345        if self.candidate_digest.is_empty() {
346            return Err("interaction terminal outbox candidate digest is empty".into());
347        }
348        if self.completion_input_ids_digest.is_empty() {
349            return Err("interaction terminal outbox completion recipient digest is empty".into());
350        }
351        if self.owner_agent_runtime_id.is_none()
352            || self.owner_fence_token.is_none()
353            || self.owner_runtime_generation.is_none()
354        {
355            return Err(
356                "interaction terminal outbox is missing required runtime placement binding".into(),
357            );
358        }
359        match (&self.batch_key, self.candidate.as_ref()) {
360            (
361                InteractionTerminalBatchKey::RuntimeTermination {
362                    candidate_owner_input_id,
363                },
364                candidate,
365            ) => {
366                if candidate_owner_input_id != &self.candidate_owner_input_id {
367                    return Err("runtime-termination batch key/candidate-owner mismatch".into());
368                }
369                if candidate.is_some_and(|candidate| {
370                    !matches!(
371                        candidate,
372                        InteractionTerminalCandidate::RuntimeTerminated { .. }
373                    )
374                }) {
375                    return Err("runtime-termination batch carried a run-scoped candidate".into());
376                }
377            }
378            (
379                InteractionTerminalBatchKey::Run { .. },
380                Some(InteractionTerminalCandidate::RuntimeTerminated { .. }),
381            ) => {
382                return Err("run-scoped terminal batch carried runtime termination".into());
383            }
384            (InteractionTerminalBatchKey::Run { .. }, _) => {}
385        }
386        let published = matches!(self.phase, InteractionTerminalOutboxPhase::Published { .. });
387        let owns_candidate = self.input_id == self.candidate_owner_input_id;
388        if owns_candidate != (self.batch_ordinal == 0) {
389            return Err("interaction terminal outbox candidate owner/ordinal mismatch".into());
390        }
391        match (&self.completion_input_ids, owns_candidate, published) {
392            (Some(input_ids), true, false) => {
393                if input_ids.is_empty() || input_ids.len() > 256 {
394                    return Err(
395                        "interaction terminal completion recipient set has invalid size".into(),
396                    );
397                }
398                let unique = input_ids.iter().collect::<std::collections::HashSet<_>>();
399                if unique.len() != input_ids.len() {
400                    return Err(
401                        "interaction terminal completion recipient set contains duplicates".into(),
402                    );
403                }
404                if !input_ids.contains(&self.input_id) {
405                    return Err(
406                        "interaction terminal candidate owner is not a completion recipient".into(),
407                    );
408                }
409                if interaction_terminal_payload_digest(input_ids)?
410                    != self.completion_input_ids_digest
411                {
412                    return Err("interaction terminal completion recipient digest mismatch".into());
413                }
414            }
415            (None, false, false) | (None, _, true) => {}
416            (Some(_), false, _) => {
417                return Err("non-owner interaction outbox duplicated completion recipients".into());
418            }
419            (Some(_), true, true) => {
420                return Err("published interaction outbox retained completion recipients".into());
421            }
422            (None, true, false) => {
423                return Err("interaction outbox candidate owner lost completion recipients".into());
424            }
425        }
426        match (&self.candidate, owns_candidate, published) {
427            (Some(candidate), true, false) => {
428                if interaction_terminal_payload_digest(candidate)? != self.candidate_digest {
429                    return Err("interaction terminal outbox candidate digest mismatch".into());
430                }
431                if let InteractionTerminalCandidate::RunResult { result } = candidate
432                    && result.session_id != self.owner_session_id
433                {
434                    return Err("interaction terminal candidate session/owner mismatch".into());
435                }
436            }
437            (None, false, false) => {}
438            (None, _, true) => {}
439            (Some(_), false, _) => {
440                return Err("non-owner interaction outbox duplicated shared candidate".into());
441            }
442            (Some(_), true, true) => {
443                return Err("published interaction outbox retained its shared candidate".into());
444            }
445            (None, true, false) => {
446                return Err("interaction outbox candidate owner has no candidate".into());
447            }
448        }
449        match &self.phase {
450            InteractionTerminalOutboxPhase::Candidate => {}
451            InteractionTerminalOutboxPhase::Finalized {
452                finalization_failed,
453                finalized_event: Some(event),
454                finalized_payload_digest: digest,
455            } if self.input_id == self.candidate_owner_input_id => {
456                if interaction_terminal_event_id(event) != Some(self.interaction_id) {
457                    return Err(
458                        "interaction terminal outbox finalized event identity mismatch".into(),
459                    );
460                }
461                if interaction_terminal_payload_digest(event)? != *digest {
462                    return Err("interaction terminal outbox finalized digest mismatch".into());
463                }
464                let Some(candidate) = self.candidate.as_ref() else {
465                    return Err("finalized candidate owner lost shared candidate".into());
466                };
467                if !interaction_terminal_candidate_matches_event(
468                    candidate,
469                    self.interaction_id,
470                    event,
471                    *finalization_failed,
472                ) {
473                    return Err("interaction terminal finalized event/candidate mismatch".into());
474                }
475            }
476            InteractionTerminalOutboxPhase::Finalized {
477                finalized_event: None,
478                finalized_payload_digest,
479                ..
480            } if self.input_id != self.candidate_owner_input_id
481                && !finalized_payload_digest.is_empty() => {}
482            InteractionTerminalOutboxPhase::Finalized { .. } => {
483                return Err(
484                    "interaction terminal outbox finalized payload ownership is invalid".into(),
485                );
486            }
487            InteractionTerminalOutboxPhase::Published { publication, .. } => {
488                if publication.terminal_seq == 0 {
489                    return Err("interaction terminal publication sequence must be non-zero".into());
490                }
491                if publication.payload_digest.is_empty() {
492                    return Err("interaction terminal publication digest is empty".into());
493                }
494            }
495        }
496        Ok(())
497    }
498}
499
500/// Validate immutable cross-row identity and ordering for one exact terminal
501/// batch. Callers must order rows by `batch_ordinal` first.
502pub(crate) fn validate_interaction_terminal_outbox_batch_shape(
503    outboxes: &[InteractionTerminalOutbox],
504) -> Result<(), String> {
505    if outboxes.is_empty() || outboxes.len() > 256 {
506        return Err("interaction terminal batch has invalid directed-row count".into());
507    }
508    let owner = &outboxes[0];
509    if owner.batch_ordinal != 0 || owner.input_id != owner.candidate_owner_input_id {
510        return Err("interaction terminal batch has no ordinal-zero candidate owner".into());
511    }
512    let mut row_input_ids = std::collections::HashSet::new();
513    for (ordinal, outbox) in outboxes.iter().enumerate() {
514        outbox.validate()?;
515        if usize::from(outbox.batch_ordinal) != ordinal {
516            return Err("interaction terminal batch ordinals are not contiguous".into());
517        }
518        if outbox.batch_key != owner.batch_key
519            || outbox.candidate_owner_input_id != owner.candidate_owner_input_id
520            || outbox.candidate_digest != owner.candidate_digest
521            || outbox.completion_input_ids_digest != owner.completion_input_ids_digest
522        {
523            return Err("interaction terminal batch has split immutable identity".into());
524        }
525        if !row_input_ids.insert(outbox.input_id.clone()) {
526            return Err("interaction terminal batch repeats a directed input".into());
527        }
528    }
529    Ok(())
530}
531
532/// Validate the cross-row invariants of one unpublished exact terminal batch.
533/// Callers must order rows by `batch_ordinal` before invoking this helper.
534pub(crate) fn validate_unpublished_interaction_terminal_outbox_batch(
535    outboxes: &[InteractionTerminalOutbox],
536) -> Result<Vec<InputId>, String> {
537    validate_interaction_terminal_outbox_batch_shape(outboxes)?;
538    let owner = &outboxes[0];
539    let completion_input_ids = owner.completion_input_ids.clone().ok_or_else(|| {
540        "unpublished interaction terminal batch owner lost completion recipients".to_string()
541    })?;
542    for outbox in outboxes {
543        if matches!(
544            outbox.phase,
545            InteractionTerminalOutboxPhase::Published { .. }
546        ) {
547            return Err("published row appeared in an unpublished terminal batch".into());
548        }
549        if !completion_input_ids.contains(&outbox.input_id) {
550            return Err("directed terminal input is not a completion recipient".into());
551        }
552    }
553    Ok(completion_input_ids)
554}
555
556pub(crate) fn interaction_terminal_candidate_matches_event(
557    candidate: &InteractionTerminalCandidate,
558    interaction_id: InteractionId,
559    event: &AgentEvent,
560    finalization_failed: bool,
561) -> bool {
562    use meerkat_core::event::InteractionFailureReason;
563    if interaction_terminal_event_id(event) != Some(interaction_id) {
564        return false;
565    }
566    if finalization_failed {
567        return matches!(
568            (candidate, event),
569            (
570                InteractionTerminalCandidate::RunResult { .. },
571                AgentEvent::InteractionFailed {
572                    reason: InteractionFailureReason::FinalizationFailed { .. },
573                    ..
574                },
575            ) | (
576                InteractionTerminalCandidate::CompletedWithoutResult
577                    | InteractionTerminalCandidate::CallbackPending { .. }
578                    | InteractionTerminalCandidate::CallbackBatchPending { .. }
579                    | InteractionTerminalCandidate::MachineTerminalFailure { .. },
580                AgentEvent::InteractionFailed {
581                    reason: InteractionFailureReason::Abandoned { .. },
582                    ..
583                },
584            )
585        );
586    }
587    match (candidate, event) {
588        (
589            InteractionTerminalCandidate::RunResult { result },
590            AgentEvent::InteractionComplete {
591                result: event_result,
592                structured_output: event_structured,
593                ..
594            },
595        ) if result.extraction_error.is_none() => {
596            result.text == *event_result && result.structured_output == *event_structured
597        }
598        (
599            InteractionTerminalCandidate::RunResult { result },
600            AgentEvent::InteractionFailed {
601                reason:
602                    InteractionFailureReason::ExtractionFailed {
603                        last_output,
604                        attempts,
605                        reason,
606                    },
607                ..
608            },
609        ) if result.extraction_error.is_some() => {
610            let Some(extraction) = result.extraction_error.as_ref() else {
611                return false;
612            };
613            extraction.last_output == *last_output
614                && extraction.attempts == *attempts
615                && extraction.reason == *reason
616        }
617        (
618            InteractionTerminalCandidate::CompletedWithoutResult,
619            AgentEvent::InteractionComplete {
620                result,
621                structured_output,
622                ..
623            },
624        ) => result.is_empty() && structured_output.is_none(),
625        (
626            InteractionTerminalCandidate::CallbackPending {
627                tool_use_id,
628                tool_name,
629                args,
630            },
631            AgentEvent::InteractionCallbackPending {
632                tool_name: event_tool,
633                args: event_args,
634                pending_tool_calls,
635                ..
636            },
637        ) => {
638            tool_name == event_tool
639                && args == event_args
640                && match tool_use_id {
641                    Some(tool_use_id) => {
642                        pending_tool_calls.as_slice()
643                            == [meerkat_core::error::PendingCallbackToolCall {
644                                tool_use_id: tool_use_id.clone(),
645                                tool_name: tool_name.clone(),
646                                args: args.clone(),
647                            }]
648                    }
649                    // A v0.8.7 candidate pairs with a v0.8.7 finalized event
650                    // (no pending set) or with an event this binary finalized
651                    // from the same legacy candidate (unknown-identity id).
652                    None => {
653                        pending_tool_calls.is_empty()
654                            || pending_tool_calls.as_slice()
655                                == [meerkat_core::error::PendingCallbackToolCall {
656                                    tool_use_id: String::new(),
657                                    tool_name: tool_name.clone(),
658                                    args: args.clone(),
659                                }]
660                    }
661                }
662        }
663        (
664            InteractionTerminalCandidate::CallbackBatchPending { pending_tool_calls },
665            AgentEvent::InteractionCallbackPending {
666                pending_tool_calls: event_pending,
667                ..
668            },
669        ) => pending_tool_calls == event_pending,
670        (
671            InteractionTerminalCandidate::MachineTerminalFailure { error },
672            AgentEvent::InteractionFailed {
673                reason: InteractionFailureReason::Abandoned { detail },
674                ..
675            },
676        ) => error.detail.as_deref() == Some(detail.as_str()),
677        (
678            InteractionTerminalCandidate::Cancelled,
679            AgentEvent::InteractionFailed {
680                reason: InteractionFailureReason::Cancelled,
681                ..
682            },
683        ) => true,
684        (
685            InteractionTerminalCandidate::RuntimeTerminated { reason },
686            AgentEvent::InteractionFailed {
687                reason: InteractionFailureReason::Abandoned { detail },
688                ..
689            },
690        ) => reason == detail,
691        _ => false,
692    }
693}
694
695/// An event on an input's state (for event sourcing).
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct InputStateEvent {
698    pub timestamp: DateTime<Utc>,
699    pub state: InputLifecycleState,
700    #[serde(skip_serializing_if = "Option::is_none")]
701    pub detail: Option<String>,
702}
703
704/// DSL-owned lifecycle projection for an input.
705///
706/// Carries the fields that are authoritative in the MeerkatMachine DSL
707/// (`input_phases`, `input_run_associations`, `input_boundary_sequences`,
708/// `input_terminal_kind` + `input_superseded_by` / `input_aggregate_id` /
709/// `input_abandon_reason` / `input_abandon_attempt_count`, and
710/// `input_attempt_counts` / `input_admission_seq` / `input_recovery_lanes`) so
711/// they can travel alongside a persisted [`InputState`] at the store boundary,
712/// where no live DSL is available to query. Inside a running driver, these
713/// values are always read from the DSL directly, never from the seed.
714#[derive(Debug, Clone, PartialEq, Eq)]
715pub struct InputStateSeed {
716    pub phase: InputLifecycleState,
717    pub last_run_id: Option<RunId>,
718    pub last_boundary_sequence: Option<u64>,
719    pub admission_sequence: Option<u64>,
720    pub terminal_outcome: Option<InputTerminalOutcome>,
721    pub attempt_count: u32,
722    pub recovery_lane: Option<HandlingMode>,
723}
724
725impl InputStateSeed {
726    /// Freshly-accepted input: no run association, no boundary sequence,
727    /// no terminal outcome, zero attempts.
728    pub fn new_accepted() -> Self {
729        Self {
730            phase: InputLifecycleState::Accepted,
731            last_run_id: None,
732            last_boundary_sequence: None,
733            admission_sequence: None,
734            terminal_outcome: None,
735            attempt_count: 0,
736            recovery_lane: None,
737        }
738    }
739}
740
741/// Persisted bundle: shell [`InputState`] plus its [`InputStateSeed`].
742///
743/// Used at the store boundary so the DSL-owned fields survive persistence
744/// without being re-shadowed onto `InputState` itself. Recovery treats the
745/// seed as a durable witness and re-enters the recovered facts through typed
746/// machine inputs; it does not hydrate DSL state directly from this bundle.
747#[derive(Debug, Clone)]
748pub struct StoredInputState {
749    pub state: InputState,
750    pub seed: InputStateSeed,
751}
752
753impl StoredInputState {
754    /// Convenience: freshly-accepted bundle.
755    pub fn new_accepted(input_id: InputId) -> Self {
756        Self {
757            state: InputState::new_accepted(input_id),
758            seed: InputStateSeed::new_accepted(),
759        }
760    }
761}
762
763/// Store-write wrapper for an input-state bundle whose DSL-owned seed facts
764/// came from a generated MeerkatMachine-owned snapshot.
765#[derive(Debug, Clone)]
766pub struct InputStatePersistenceRecord {
767    bundle: StoredInputState,
768}
769
770impl InputStatePersistenceRecord {
771    /// Package a store-bound input-state bundle that was read from generated
772    /// MeerkatMachine authority. This is intentionally crate-private so
773    /// callers cannot mint persistence records from handwritten seed facts.
774    pub(crate) fn from_machine_snapshot(bundle: StoredInputState) -> Result<Self, String> {
775        crate::meerkat_machine::authorize_stored_input_state_seed(
776            &bundle.state.input_id,
777            &bundle.seed,
778        )?;
779        Ok(Self { bundle })
780    }
781
782    /// Raw bundle approved for durable persistence.
783    pub fn as_stored(&self) -> &StoredInputState {
784        &self.bundle
785    }
786
787    /// Clone the approved raw bundle.
788    pub fn clone_stored(&self) -> StoredInputState {
789        self.bundle.clone()
790    }
791
792    /// Consume the approved record into its raw bundle.
793    pub fn into_stored(self) -> StoredInputState {
794        self.bundle
795    }
796}
797
798/// Per-input shell data. Plain fields, no hidden state machine.
799///
800/// All DSL-owned lifecycle fields (`phase`, `last_run_id`,
801/// `last_boundary_sequence`, `terminal_outcome`, `attempt_count`,
802/// `recovery_lane`) are
803/// authoritative in the DSL. Live code reads them via
804/// `EphemeralRuntimeDriver::input_phase` / `input_last_run_id` /
805/// `input_last_boundary_sequence` / `input_terminal_outcome` /
806/// `input_attempt_count` / `input_recovery_lane`. Persistence callsites
807/// serialize them via [`InputStateSeed`] bundled on [`StoredInputState`].
808#[derive(Debug, Clone)]
809pub struct InputState {
810    pub input_id: InputId,
811    pub history: Vec<InputStateHistoryEntry>,
812    pub updated_at: DateTime<Utc>,
813    pub policy: Option<PolicySnapshot>,
814    /// Runtime-stamped run semantics captured at admission and persisted so
815    /// recovery does not reclassify execution kind from payload shape.
816    pub runtime_semantics: Option<RuntimeInputSemantics>,
817    pub durability: Option<crate::input::InputDurability>,
818    pub idempotency_key: Option<crate::identifiers::IdempotencyKey>,
819    pub recovery_count: u32,
820    pub reconstruction_source: Option<ReconstructionSource>,
821    /// Exact directed-terminal retry carrier, when this input came from the
822    /// tracked cross-host flow lane.
823    pub(crate) interaction_terminal_outbox: Option<InteractionTerminalOutbox>,
824    pub persisted_input: Option<Input>,
825    pub created_at: DateTime<Utc>,
826}
827
828impl InputState {
829    /// Create a fresh InputState. Paired DSL state starts in the `Accepted`
830    /// phase via [`InputStateSeed::new_accepted`]; callers that need the
831    /// bundle use [`StoredInputState::new_accepted`].
832    pub fn new_accepted(input_id: InputId) -> Self {
833        let now = Utc::now();
834        Self {
835            input_id,
836            history: Vec::new(),
837            updated_at: now,
838            policy: None,
839            runtime_semantics: None,
840            durability: None,
841            idempotency_key: None,
842            recovery_count: 0,
843            reconstruction_source: None,
844            interaction_terminal_outbox: None,
845            persisted_input: None,
846            created_at: now,
847        }
848    }
849
850    pub fn history(&self) -> &[InputStateHistoryEntry] {
851        &self.history
852    }
853
854    pub fn updated_at(&self) -> DateTime<Utc> {
855        self.updated_at
856    }
857}
858
859// ---------------------------------------------------------------------------
860// Custom Serialize / Deserialize โ€” preserves the on-disk wire format
861// ---------------------------------------------------------------------------
862//
863// `InputStateSerde` is the on-disk contract exercised by
864// `recovery_contract`, `recovery_replay`, and `driver_persistent` tests.
865// Field names, types, defaults, and `skip_serializing_if` markers are kept
866// verbatim from the pre-5G/1 release. Since `InputState` no longer owns the
867// three DSL-authoritative fields, serialization flows through
868// [`StoredInputState`] where shell + seed can be bundled into the wire
869// struct.
870
871#[derive(Serialize, Deserialize)]
872struct InputStateSerde {
873    stored_input_state_version: u32,
874    input_id: InputId,
875    current_state: InputLifecycleState,
876    #[serde(skip_serializing_if = "Option::is_none")]
877    policy: Option<PolicySnapshot>,
878    #[serde(default, skip_serializing_if = "Option::is_none")]
879    runtime_semantics: Option<RuntimeInputSemantics>,
880    #[serde(skip_serializing_if = "Option::is_none")]
881    terminal_outcome: Option<InputTerminalOutcome>,
882    #[serde(skip_serializing_if = "Option::is_none")]
883    durability: Option<crate::input::InputDurability>,
884    #[serde(skip_serializing_if = "Option::is_none")]
885    idempotency_key: Option<crate::identifiers::IdempotencyKey>,
886    #[serde(default)]
887    attempt_count: u32,
888    #[serde(default)]
889    recovery_count: u32,
890    #[serde(default, skip_serializing_if = "Vec::is_empty")]
891    history: Vec<InputStateHistoryEntry>,
892    #[serde(skip_serializing_if = "Option::is_none")]
893    reconstruction_source: Option<ReconstructionSource>,
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    interaction_terminal_outbox: Option<InteractionTerminalOutbox>,
896    #[serde(default, skip_serializing_if = "Option::is_none")]
897    persisted_input: Option<Input>,
898    #[serde(default, skip_serializing_if = "Option::is_none")]
899    last_run_id: Option<RunId>,
900    #[serde(default, skip_serializing_if = "Option::is_none")]
901    last_boundary_sequence: Option<u64>,
902    #[serde(default, skip_serializing_if = "Option::is_none")]
903    admission_sequence: Option<u64>,
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    recovery_lane: Option<HandlingMode>,
906    created_at: DateTime<Utc>,
907    updated_at: DateTime<Utc>,
908}
909
910impl Serialize for StoredInputState {
911    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
912        let helper = InputStateSerde {
913            stored_input_state_version:
914                meerkat_core::generated::session_persistence_version_authority::stored_input_state_version(
915                ),
916            input_id: self.state.input_id.clone(),
917            current_state: self.seed.phase,
918            policy: self.state.policy.clone(),
919            runtime_semantics: self.state.runtime_semantics,
920            terminal_outcome: self.seed.terminal_outcome.clone(),
921            durability: self.state.durability,
922            idempotency_key: self.state.idempotency_key.clone(),
923            attempt_count: self.seed.attempt_count,
924            recovery_count: self.state.recovery_count,
925            history: self.state.history.clone(),
926            reconstruction_source: self.state.reconstruction_source.clone(),
927            interaction_terminal_outbox: self.state.interaction_terminal_outbox.clone(),
928            persisted_input: self.state.persisted_input.clone(),
929            last_run_id: self.seed.last_run_id.clone(),
930            last_boundary_sequence: self.seed.last_boundary_sequence,
931            admission_sequence: self.seed.admission_sequence,
932            recovery_lane: self.seed.recovery_lane,
933            created_at: self.state.created_at,
934            updated_at: self.state.updated_at,
935        };
936        helper.serialize(serializer)
937    }
938}
939
940impl<'de> Deserialize<'de> for StoredInputState {
941    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
942        let helper = InputStateSerde::deserialize(deserializer)?;
943        let observed_stored_input_state_version = helper.stored_input_state_version;
944        let _stored_input_state_version =
945            meerkat_core::generated::session_persistence_version_authority::restore_stored_input_state_version(
946                observed_stored_input_state_version,
947            )
948            .map_err(<D::Error as serde::de::Error>::custom)?;
949        if observed_stored_input_state_version == 3 && helper.interaction_terminal_outbox.is_some()
950        {
951            return Err(<D::Error as serde::de::Error>::custom(
952                "stored input state v3 cannot carry a v4 interaction terminal outbox",
953            ));
954        }
955        if let Some(outbox) = helper.interaction_terminal_outbox.as_ref() {
956            outbox
957                .validate()
958                .map_err(<D::Error as serde::de::Error>::custom)?;
959        }
960        let state = InputState {
961            input_id: helper.input_id,
962            history: helper.history,
963            updated_at: helper.updated_at,
964            policy: helper.policy,
965            runtime_semantics: helper.runtime_semantics,
966            durability: helper.durability,
967            idempotency_key: helper.idempotency_key,
968            recovery_count: helper.recovery_count,
969            reconstruction_source: helper.reconstruction_source,
970            interaction_terminal_outbox: helper.interaction_terminal_outbox,
971            persisted_input: helper.persisted_input,
972            created_at: helper.created_at,
973        };
974        let seed = InputStateSeed {
975            phase: helper.current_state,
976            last_run_id: helper.last_run_id,
977            last_boundary_sequence: helper.last_boundary_sequence,
978            admission_sequence: helper.admission_sequence,
979            terminal_outcome: helper.terminal_outcome,
980            attempt_count: helper.attempt_count,
981            recovery_lane: helper.recovery_lane,
982        };
983        Ok(StoredInputState { state, seed })
984    }
985}
986
987#[cfg(test)]
988#[allow(clippy::unwrap_used)]
989mod tests {
990    use super::*;
991    use crate::policy::{
992        ApplyMode, ConsumePoint, DrainPolicy, QueueMode, RoutingDisposition, WakeMode,
993    };
994    use meerkat_core::ops::{OpEvent, OperationId};
995
996    fn terminal_outbox_batch_fixture() -> Vec<InteractionTerminalOutbox> {
997        let mut completion_input_ids = vec![InputId::new(), InputId::new(), InputId::new()];
998        completion_input_ids.sort_by_key(|input_id| input_id.0);
999        let directed_input_ids = vec![
1000            completion_input_ids[0].clone(),
1001            completion_input_ids[2].clone(),
1002        ];
1003        let candidate = InteractionTerminalCandidate::CompletedWithoutResult;
1004        let candidate_digest = interaction_terminal_payload_digest(&candidate).unwrap();
1005        let completion_input_ids_digest =
1006            interaction_terminal_payload_digest(&completion_input_ids).unwrap();
1007        let candidate_owner_input_id = directed_input_ids[0].clone();
1008        let batch_key = InteractionTerminalBatchKey::Run {
1009            run_id: RunId::new(),
1010        };
1011        directed_input_ids
1012            .into_iter()
1013            .enumerate()
1014            .map(|(ordinal, input_id)| {
1015                let owns_candidate = input_id == candidate_owner_input_id;
1016                InteractionTerminalOutbox {
1017                    interaction_id: InteractionId(input_id.0),
1018                    input_id,
1019                    batch_ordinal: ordinal as u16,
1020                    batch_key: batch_key.clone(),
1021                    owner_session_id: SessionId::new(),
1022                    owner_agent_runtime_id: Some("fixture-runtime".to_string()),
1023                    owner_fence_token: Some(7),
1024                    owner_runtime_generation: Some(3),
1025                    owner_runtime_epoch_id: Some("fixture-epoch".to_string()),
1026                    candidate_owner_input_id: candidate_owner_input_id.clone(),
1027                    candidate: owns_candidate.then(|| candidate.clone()),
1028                    candidate_digest: candidate_digest.clone(),
1029                    completion_input_ids: owns_candidate.then(|| completion_input_ids.clone()),
1030                    completion_input_ids_digest: completion_input_ids_digest.clone(),
1031                    phase: InteractionTerminalOutboxPhase::Candidate,
1032                }
1033            })
1034            .collect()
1035    }
1036
1037    #[test]
1038    fn terminal_outbox_batch_preserves_full_mixed_completion_recipients() {
1039        let outboxes = terminal_outbox_batch_fixture();
1040        let recipients = validate_unpublished_interaction_terminal_outbox_batch(&outboxes).unwrap();
1041
1042        assert_eq!(recipients.len(), 3);
1043        assert_eq!(outboxes.len(), 2);
1044        assert!(recipients.contains(&outboxes[0].input_id));
1045        assert!(recipients.contains(&outboxes[1].input_id));
1046    }
1047
1048    #[test]
1049    fn terminal_outbox_batch_rejects_noncontiguous_or_reordered_ordinals() {
1050        let mut outboxes = terminal_outbox_batch_fixture();
1051        outboxes[1].batch_ordinal = 2;
1052        assert!(
1053            validate_unpublished_interaction_terminal_outbox_batch(&outboxes)
1054                .unwrap_err()
1055                .contains("ordinals are not contiguous")
1056        );
1057    }
1058
1059    #[test]
1060    fn terminal_outbox_owner_rejects_duplicate_completion_recipients() {
1061        let mut outboxes = terminal_outbox_batch_fixture();
1062        let owner = &mut outboxes[0];
1063        let recipients = owner.completion_input_ids.as_mut().unwrap();
1064        recipients.push(recipients[0].clone());
1065        owner.completion_input_ids_digest =
1066            interaction_terminal_payload_digest(recipients).unwrap();
1067
1068        assert!(
1069            owner
1070                .validate()
1071                .unwrap_err()
1072                .contains("contains duplicates")
1073        );
1074    }
1075
1076    #[test]
1077    fn terminal_outbox_resource_bounds_reject_257_rows_or_recipients() {
1078        let fixture = terminal_outbox_batch_fixture();
1079        let oversized_rows = vec![fixture[0].clone(); 257];
1080        assert!(
1081            validate_interaction_terminal_outbox_batch_shape(&oversized_rows)
1082                .unwrap_err()
1083                .contains("invalid directed-row count")
1084        );
1085
1086        let mut owner = fixture[0].clone();
1087        let recipients = (0..257).map(|_| InputId::new()).collect::<Vec<_>>();
1088        owner.completion_input_ids_digest =
1089            interaction_terminal_payload_digest(&recipients).unwrap();
1090        owner.completion_input_ids = Some(recipients);
1091        assert!(
1092            owner
1093                .validate()
1094                .unwrap_err()
1095                .contains("recipient set has invalid size")
1096        );
1097    }
1098
1099    #[test]
1100    fn published_terminal_outbox_compaction_retains_only_immutable_proofs() {
1101        let mut outbox = terminal_outbox_batch_fixture().remove(0);
1102        let recipient_digest = outbox.completion_input_ids_digest.clone();
1103        let candidate_digest = outbox.candidate_digest.clone();
1104        outbox.candidate = None;
1105        outbox.completion_input_ids = None;
1106        outbox.phase = InteractionTerminalOutboxPhase::Published {
1107            finalization_failed: false,
1108            publication: InteractionTerminalPublication {
1109                terminal_seq: 9,
1110                payload_digest: "published-event-digest".to_string(),
1111            },
1112        };
1113
1114        outbox.validate().unwrap();
1115        assert_eq!(outbox.candidate_digest, candidate_digest);
1116        assert_eq!(outbox.completion_input_ids_digest, recipient_digest);
1117    }
1118
1119    #[test]
1120    fn published_terminal_batch_rejects_split_immutable_recipient_proof() {
1121        let mut outboxes = terminal_outbox_batch_fixture();
1122        for outbox in &mut outboxes {
1123            outbox.candidate = None;
1124            outbox.completion_input_ids = None;
1125            outbox.phase = InteractionTerminalOutboxPhase::Published {
1126                finalization_failed: false,
1127                publication: InteractionTerminalPublication {
1128                    terminal_seq: u64::from(outbox.batch_ordinal) + 1,
1129                    payload_digest: format!("event-{}", outbox.batch_ordinal),
1130                },
1131            };
1132        }
1133        outboxes[1].completion_input_ids_digest = "split-proof".to_string();
1134
1135        assert!(
1136            validate_interaction_terminal_outbox_batch_shape(&outboxes)
1137                .unwrap_err()
1138                .contains("split immutable identity")
1139        );
1140    }
1141
1142    #[test]
1143    fn new_accepted_starts_with_no_shell_history() {
1144        let id = InputId::new();
1145        let state = InputState::new_accepted(id.clone());
1146        assert_eq!(state.input_id, id);
1147        assert!(state.history.is_empty());
1148    }
1149
1150    #[test]
1151    fn seed_new_accepted_defaults_match_queue_lifecycle() {
1152        let seed = InputStateSeed::new_accepted();
1153        assert_eq!(seed.phase, InputLifecycleState::Accepted);
1154        assert!(seed.last_run_id.is_none());
1155        assert!(seed.last_boundary_sequence.is_none());
1156        assert!(seed.admission_sequence.is_none());
1157        assert!(seed.terminal_outcome.is_none());
1158        assert_eq!(seed.attempt_count, 0);
1159    }
1160
1161    #[test]
1162    fn lifecycle_state_serde() {
1163        for state in [
1164            InputLifecycleState::Accepted,
1165            InputLifecycleState::Queued,
1166            InputLifecycleState::Staged,
1167            InputLifecycleState::Applied,
1168            InputLifecycleState::AppliedPendingConsumption,
1169            InputLifecycleState::Consumed,
1170            InputLifecycleState::Superseded,
1171            InputLifecycleState::Coalesced,
1172            InputLifecycleState::Abandoned,
1173        ] {
1174            let json = serde_json::to_value(state).unwrap();
1175            let parsed: InputLifecycleState = serde_json::from_value(json).unwrap();
1176            assert_eq!(state, parsed);
1177        }
1178    }
1179
1180    #[test]
1181    fn stored_input_state_serde_roundtrip_preserves_fields() {
1182        let mut state = InputState::new_accepted(InputId::new());
1183        let policy = PolicyDecision {
1184            apply_mode: ApplyMode::StageRunStart,
1185            wake_mode: WakeMode::WakeIfIdle,
1186            queue_mode: QueueMode::Fifo,
1187            consume_point: ConsumePoint::OnRunComplete,
1188            drain_policy: DrainPolicy::QueueNextTurn,
1189            routing_disposition: RoutingDisposition::Queue,
1190            record_transcript: true,
1191            emit_operator_content: true,
1192            policy_version: PolicyVersion(1),
1193        };
1194        state.policy = Some(PolicySnapshot {
1195            version: PolicyVersion(1),
1196            decision: policy.clone(),
1197        });
1198        state.runtime_semantics = Some(
1199            crate::policy_table::generated_admission_projection_for_kind(
1200                crate::identifiers::KindId::new(crate::identifiers::InputKind::Prompt),
1201                true,
1202            )
1203            .expect("generated admission projection")
1204            .runtime_semantics,
1205        );
1206        state.history.push(InputStateHistoryEntry {
1207            timestamp: state.updated_at,
1208            from: InputLifecycleState::Accepted,
1209            to: InputLifecycleState::Queued,
1210            reason: Some("QueueAccepted".into()),
1211        });
1212        let bundle = StoredInputState {
1213            state,
1214            seed: InputStateSeed {
1215                phase: InputLifecycleState::Queued,
1216                last_run_id: None,
1217                last_boundary_sequence: None,
1218                admission_sequence: Some(42),
1219                terminal_outcome: None,
1220                attempt_count: 0,
1221                recovery_lane: Some(HandlingMode::Queue),
1222            },
1223        };
1224
1225        let json = serde_json::to_value(&bundle).unwrap();
1226        let parsed: StoredInputState = serde_json::from_value(json).unwrap();
1227        assert_eq!(parsed.state.input_id, bundle.state.input_id);
1228        assert_eq!(parsed.seed.phase, bundle.seed.phase);
1229        assert_eq!(
1230            parsed.seed.admission_sequence,
1231            bundle.seed.admission_sequence
1232        );
1233        assert_eq!(parsed.seed.recovery_lane, bundle.seed.recovery_lane);
1234        assert_eq!(
1235            parsed.state.runtime_semantics,
1236            bundle.state.runtime_semantics
1237        );
1238        assert_eq!(parsed.state.history.len(), 1);
1239    }
1240
1241    #[test]
1242    fn stored_input_state_v3_fixture_migrates_to_v4() {
1243        let fixture = include_str!("../tests/fixtures/stored_input_state_v3.json");
1244        let restored: StoredInputState =
1245            serde_json::from_str(fixture).expect("serialized v3 fixture must migrate");
1246        assert_eq!(restored.seed.phase, InputLifecycleState::Queued);
1247        assert_eq!(restored.seed.attempt_count, 2);
1248        assert_eq!(restored.state.recovery_count, 1);
1249        assert_eq!(restored.seed.admission_sequence, Some(17));
1250        assert_eq!(restored.seed.recovery_lane, Some(HandlingMode::Queue));
1251        assert!(restored.state.interaction_terminal_outbox.is_none());
1252
1253        let migrated = serde_json::to_value(&restored).expect("serialize migrated v4 row");
1254        assert_eq!(
1255            migrated["stored_input_state_version"],
1256            meerkat_core::generated::session_persistence_version_authority::STORED_INPUT_STATE_VERSION,
1257        );
1258    }
1259
1260    /// v0.8.7 regression witness (release-bricking class): a stored-input-state
1261    /// v4 row whose interaction terminal outbox carries the
1262    /// pre-durable-callback `callback_pending` candidate shape (no
1263    /// `tool_use_id`) must decode AND keep verifying against its stored
1264    /// candidate digest โ€” v0.8.7 computed that digest over exactly these
1265    /// bytes, so the decoded candidate must re-serialize byte-identically.
1266    #[test]
1267    fn stored_input_state_v087_callback_pending_row_still_decodes() {
1268        let candidate_json =
1269            r#"{"candidate_type":"callback_pending","tool_name":"external","args":{"value":1}}"#;
1270        let completion_ids_json = r#"["00000000-0000-0000-0000-0000000000aa"]"#;
1271        let candidate_digest = format!("{:x}", Sha256::digest(candidate_json.as_bytes()));
1272        let completion_ids_digest = format!("{:x}", Sha256::digest(completion_ids_json.as_bytes()));
1273        let row = format!(
1274            r#"{{
1275                "stored_input_state_version": 4,
1276                "input_id": "00000000-0000-0000-0000-0000000000aa",
1277                "current_state": "applied",
1278                "created_at": "2026-01-01T00:00:00Z",
1279                "updated_at": "2026-01-01T00:00:00Z",
1280                "interaction_terminal_outbox": {{
1281                    "interaction_id": "00000000-0000-0000-0000-0000000000aa",
1282                    "input_id": "00000000-0000-0000-0000-0000000000aa",
1283                    "batch_ordinal": 0,
1284                    "batch_key": {{"scope":"run","run_id":"00000000-0000-0000-0000-0000000000bb"}},
1285                    "owner_session_id": "00000000-0000-0000-0000-0000000000cc",
1286                    "owner_agent_runtime_id": "runtime-a",
1287                    "owner_fence_token": 7,
1288                    "owner_runtime_generation": 3,
1289                    "owner_runtime_epoch_id": "epoch-3",
1290                    "candidate_owner_input_id": "00000000-0000-0000-0000-0000000000aa",
1291                    "candidate": {candidate_json},
1292                    "candidate_digest": "{candidate_digest}",
1293                    "completion_input_ids": {completion_ids_json},
1294                    "completion_input_ids_digest": "{completion_ids_digest}",
1295                    "phase": {{"phase":"candidate"}}
1296                }}
1297            }}"#
1298        );
1299
1300        let restored: StoredInputState =
1301            serde_json::from_str(&row).expect("v0.8.7 callback-pending row must decode");
1302        let outbox = restored
1303            .state
1304            .interaction_terminal_outbox
1305            .as_ref()
1306            .expect("outbox survives decode");
1307        let candidate = outbox.candidate.as_ref().expect("owner keeps candidate");
1308        assert!(matches!(
1309            candidate,
1310            InteractionTerminalCandidate::CallbackPending {
1311                tool_use_id: None,
1312                ..
1313            }
1314        ));
1315        assert_eq!(
1316            interaction_terminal_payload_digest(candidate).unwrap(),
1317            outbox.candidate_digest,
1318            "legacy candidate must re-serialize byte-identically under its stored digest"
1319        );
1320        // Recovery projects the unknown identity as empty, never a fabricated id.
1321        assert!(matches!(
1322            candidate.core_apply_terminal(),
1323            Some(meerkat_core::lifecycle::core_executor::CoreApplyTerminal::CallbackPending {
1324                tool_use_id,
1325                ..
1326            }) if tool_use_id.is_empty()
1327        ));
1328    }
1329
1330    /// The legacy (identity-less) callback candidate pairs with both event
1331    /// shapes it can durably meet: a v0.8.7-finalized event (no pending set)
1332    /// and an event this binary finalizes from the same legacy candidate.
1333    /// A candidate WITH identity still demands the exact pending set.
1334    #[test]
1335    fn legacy_callback_candidate_matches_legacy_and_reprojected_events() {
1336        let interaction_id = InteractionId(uuid::Uuid::new_v4());
1337        let args = serde_json::json!({"value": 1});
1338        let legacy_candidate = InteractionTerminalCandidate::CallbackPending {
1339            tool_use_id: None,
1340            tool_name: "external".to_string(),
1341            args: args.clone(),
1342        };
1343        let event = |pending_tool_calls| AgentEvent::InteractionCallbackPending {
1344            interaction_id,
1345            tool_name: "external".to_string(),
1346            args: args.clone(),
1347            pending_tool_calls,
1348        };
1349
1350        let legacy_event = event(Vec::new());
1351        let reprojected_event = event(vec![meerkat_core::error::PendingCallbackToolCall {
1352            tool_use_id: String::new(),
1353            tool_name: "external".to_string(),
1354            args: args.clone(),
1355        }]);
1356        assert!(interaction_terminal_candidate_matches_event(
1357            &legacy_candidate,
1358            interaction_id,
1359            &legacy_event,
1360            false,
1361        ));
1362        assert!(interaction_terminal_candidate_matches_event(
1363            &legacy_candidate,
1364            interaction_id,
1365            &reprojected_event,
1366            false,
1367        ));
1368
1369        let modern_candidate = InteractionTerminalCandidate::CallbackPending {
1370            tool_use_id: Some("call-9".to_string()),
1371            tool_name: "external".to_string(),
1372            args: args.clone(),
1373        };
1374        assert!(!interaction_terminal_candidate_matches_event(
1375            &modern_candidate,
1376            interaction_id,
1377            &legacy_event,
1378            false,
1379        ));
1380        let exact_event = event(vec![meerkat_core::error::PendingCallbackToolCall {
1381            tool_use_id: "call-9".to_string(),
1382            tool_name: "external".to_string(),
1383            args: args.clone(),
1384        }]);
1385        assert!(interaction_terminal_candidate_matches_event(
1386            &modern_candidate,
1387            interaction_id,
1388            &exact_event,
1389            false,
1390        ));
1391    }
1392
1393    #[test]
1394    fn stored_input_state_unlisted_legacy_version_still_fails_closed() {
1395        let mut fixture: serde_json::Value =
1396            serde_json::from_str(include_str!("../tests/fixtures/stored_input_state_v3.json"))
1397                .expect("fixture json");
1398        for rejected in [2, 5] {
1399            fixture["stored_input_state_version"] = serde_json::json!(rejected);
1400            let error = serde_json::from_value::<StoredInputState>(fixture.clone())
1401                .expect_err("unlisted historical and future versions must fail closed");
1402            assert!(error.to_string().contains("expected current 4"));
1403        }
1404    }
1405
1406    #[test]
1407    fn stored_input_state_rejects_legacy_persisted_input_tags() {
1408        // Pre-rename `system_generated` / `projected` persisted input tags are
1409        // retired shapes: a stored row carrying them must fail closed instead
1410        // of being folded into the canonical `continuation` / `operation` tags.
1411        let continuation_bundle = StoredInputState {
1412            state: InputState {
1413                persisted_input: Some(Input::Continuation(
1414                    crate::input::ContinuationInput::detached_background_op_completed(),
1415                )),
1416                ..InputState::new_accepted(InputId::new())
1417            },
1418            seed: InputStateSeed::new_accepted(),
1419        };
1420        let mut continuation_json = serde_json::to_value(&continuation_bundle).unwrap();
1421        continuation_json["persisted_input"]["input_type"] =
1422            serde_json::Value::String("system_generated".into());
1423        serde_json::from_value::<StoredInputState>(continuation_json)
1424            .expect_err("legacy system_generated persisted input tag must be rejected");
1425
1426        let operation_bundle = StoredInputState {
1427            state: InputState {
1428                persisted_input: Some(Input::Operation(crate::input::OperationInput {
1429                    header: crate::input::InputHeader {
1430                        id: InputId::new(),
1431                        timestamp: Utc::now(),
1432                        source: crate::input::InputOrigin::System,
1433                        durability: crate::input::InputDurability::Derived,
1434                        visibility: crate::input::InputVisibility::default(),
1435                        idempotency_key: None,
1436                        supersession_key: None,
1437                        correlation_id: None,
1438                    },
1439                    operation_id: OperationId::new(),
1440                    event: OpEvent::Cancelled {
1441                        id: OperationId::new(),
1442                    },
1443                })),
1444                ..InputState::new_accepted(InputId::new())
1445            },
1446            seed: InputStateSeed::new_accepted(),
1447        };
1448        let mut operation_json = serde_json::to_value(&operation_bundle).unwrap();
1449        operation_json["persisted_input"]["input_type"] =
1450            serde_json::Value::String("projected".into());
1451        serde_json::from_value::<StoredInputState>(operation_json)
1452            .expect_err("legacy projected persisted input tag must be rejected");
1453    }
1454
1455    #[test]
1456    fn stored_input_state_rejects_legacy_dual_carrier_persisted_input_shape() {
1457        // The retired persisted prompt shape carried `text` + optional
1458        // `blocks`; the single typed `content` owner replaced both. A stored
1459        // row holding the old shape must fail closed.
1460        let bundle = StoredInputState {
1461            state: InputState {
1462                persisted_input: Some(Input::Prompt(crate::input::PromptInput::new("hello", None))),
1463                ..InputState::new_accepted(InputId::new())
1464            },
1465            seed: InputStateSeed::new_accepted(),
1466        };
1467        let mut json = serde_json::to_value(&bundle).unwrap();
1468        let persisted = json["persisted_input"]
1469            .as_object_mut()
1470            .expect("persisted_input object");
1471        persisted.remove("content");
1472        persisted.insert("text".into(), serde_json::Value::String("hello".into()));
1473        persisted.insert("blocks".into(), serde_json::Value::Null);
1474        serde_json::from_value::<StoredInputState>(json)
1475            .expect_err("legacy text+blocks persisted prompt shape must be rejected");
1476    }
1477
1478    #[test]
1479    fn abandon_reason_serde() {
1480        for reason in [
1481            InputAbandonReason::Retired,
1482            InputAbandonReason::Reset,
1483            InputAbandonReason::Destroyed,
1484            InputAbandonReason::Cancelled,
1485        ] {
1486            let json = serde_json::to_value(&reason).unwrap();
1487            let parsed: InputAbandonReason = serde_json::from_value(json).unwrap();
1488            assert_eq!(reason, parsed);
1489        }
1490    }
1491
1492    #[test]
1493    fn terminal_outcome_consumed_serde() {
1494        let outcome = InputTerminalOutcome::Consumed;
1495        let json = serde_json::to_value(&outcome).unwrap();
1496        assert_eq!(json["outcome_type"], "consumed");
1497        let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
1498        assert_eq!(outcome, parsed);
1499    }
1500
1501    #[test]
1502    fn terminal_outcome_superseded_serde() {
1503        let outcome = InputTerminalOutcome::Superseded {
1504            superseded_by: InputId::new(),
1505        };
1506        let json = serde_json::to_value(&outcome).unwrap();
1507        assert_eq!(json["outcome_type"], "superseded");
1508        let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
1509        assert!(matches!(parsed, InputTerminalOutcome::Superseded { .. }));
1510    }
1511
1512    #[test]
1513    fn terminal_outcome_abandoned_serde() {
1514        let outcome = InputTerminalOutcome::Abandoned {
1515            reason: InputAbandonReason::Retired,
1516        };
1517        let json = serde_json::to_value(&outcome).unwrap();
1518        let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
1519        assert!(matches!(
1520            parsed,
1521            InputTerminalOutcome::Abandoned {
1522                reason: InputAbandonReason::Retired,
1523            }
1524        ));
1525    }
1526
1527    #[test]
1528    fn callback_batch_candidate_accepts_abandoned_projection_on_finalization_failure() {
1529        let interaction_id = InteractionId(uuid::Uuid::new_v4());
1530        let candidate = InteractionTerminalCandidate::CallbackBatchPending {
1531            pending_tool_calls: vec![meerkat_core::error::PendingCallbackToolCall {
1532                tool_use_id: "call-1".to_string(),
1533                tool_name: "external".to_string(),
1534                args: serde_json::json!({"value": 1}),
1535            }],
1536        };
1537        let event = AgentEvent::InteractionFailed {
1538            interaction_id,
1539            reason: meerkat_core::event::InteractionFailureReason::abandoned(
1540                "terminal publication failed",
1541            ),
1542        };
1543
1544        assert!(interaction_terminal_candidate_matches_event(
1545            &candidate,
1546            interaction_id,
1547            &event,
1548            true,
1549        ));
1550    }
1551
1552    #[test]
1553    fn reconstruction_source_serde() {
1554        let sources = vec![
1555            ReconstructionSource::Projection {
1556                rule_id: "rule-1".into(),
1557                source_event_id: "evt-1".into(),
1558            },
1559            ReconstructionSource::Coalescing {
1560                source_input_ids: vec![InputId::new(), InputId::new()],
1561            },
1562        ];
1563        for source in sources {
1564            let json = serde_json::to_value(&source).unwrap();
1565            assert!(json["source_type"].is_string());
1566            let parsed: ReconstructionSource = serde_json::from_value(json).unwrap();
1567            let _ = parsed;
1568        }
1569    }
1570
1571    #[test]
1572    fn input_state_event_serde() {
1573        let event = InputStateEvent {
1574            timestamp: Utc::now(),
1575            state: InputLifecycleState::Queued,
1576            detail: Some("queued for processing".into()),
1577        };
1578        let json = serde_json::to_value(&event).unwrap();
1579        let parsed: InputStateEvent = serde_json::from_value(json).unwrap();
1580        assert_eq!(parsed.state, InputLifecycleState::Queued);
1581    }
1582}