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