Skip to main content

meerkat_runtime/
accept.rs

1//! §14 AcceptOutcome — result of accepting an input.
2
3use meerkat_core::lifecycle::InputId;
4use meerkat_core::types::HandlingMode;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8use crate::input_state::{InputState, InputStateSeed};
9use crate::meerkat_machine::dsl as mm_dsl;
10use crate::policy::PolicyDecision;
11use crate::runtime_state::RuntimeState;
12
13// `AcceptOutcome` is a domain envelope. The wire shape lives in
14// `meerkat-contracts::wire::runtime::RuntimeAcceptResult` and is materialized
15// by per-surface handlers (see `meerkat-rpc::handlers::runtime`). The envelope
16// therefore carries the live `InputState` shell (no Serialize/Deserialize) and
17// a typed `RejectReason` that retains its own serde derives because rejection
18// payloads are translated into wire-facing strings.
19
20/// Machine-owned queue action for an admitted input.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum AdmissionQueueAction {
23    None,
24    EnqueueTo { target: HandlingMode },
25    EnqueueFront { target: HandlingMode },
26}
27
28/// Machine-owned action against an existing queued input.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ExistingQueuedAdmissionAction {
31    Coalesce { existing_id: InputId },
32    Supersede { existing_id: InputId },
33}
34
35/// Machine-owned admission plan for an accepted input.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum AdmissionPlan {
38    ConsumedOnAccept,
39    Queued {
40        persist_and_queue: bool,
41        queue_action: AdmissionQueueAction,
42        existing_action: Option<ExistingQueuedAdmissionAction>,
43    },
44}
45
46/// Coarse accept flags used by the MeerkatMachine DSL's
47/// `AcceptWithCompletion` branches.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct CoarseAdmissionFlags {
50    pub request_immediate_processing: bool,
51    pub interrupt_yielding: bool,
52    pub wake_if_idle: bool,
53}
54
55/// Typed machine input that authorized a live admission resolution.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub(crate) struct MachineAdmissionAuthority {
58    input_id: String,
59    input_kind: mm_dsl::AdmissionInputKind,
60    requested_lane: Option<mm_dsl::InputLane>,
61    continuation_kind: mm_dsl::AdmissionContinuationKind,
62    silent_intent_match: bool,
63    existing_superseded_input_id: Option<String>,
64    runtime_running: bool,
65    active_turn_boundary_available: bool,
66    without_wake: bool,
67}
68
69impl MachineAdmissionAuthority {
70    #[allow(clippy::too_many_arguments)]
71    pub(crate) fn new(
72        input_id: String,
73        input_kind: mm_dsl::AdmissionInputKind,
74        requested_lane: Option<mm_dsl::InputLane>,
75        continuation_kind: mm_dsl::AdmissionContinuationKind,
76        silent_intent_match: bool,
77        existing_superseded_input_id: Option<InputId>,
78        runtime_running: bool,
79        active_turn_boundary_available: bool,
80        without_wake: bool,
81    ) -> Self {
82        Self {
83            input_id,
84            input_kind,
85            requested_lane,
86            continuation_kind,
87            silent_intent_match,
88            existing_superseded_input_id: existing_superseded_input_id.map(|id| id.to_string()),
89            runtime_running,
90            active_turn_boundary_available,
91            without_wake,
92        }
93    }
94
95    pub(crate) fn input_id(&self) -> &str {
96        &self.input_id
97    }
98
99    pub(crate) fn without_wake(&self) -> bool {
100        self.without_wake
101    }
102
103    pub(crate) fn active_turn_boundary_available(&self) -> bool {
104        self.active_turn_boundary_available
105    }
106
107    pub(crate) fn to_dsl_input(&self) -> mm_dsl::MeerkatMachineInput {
108        mm_dsl::MeerkatMachineInput::ResolveAdmissionPlan {
109            input_id: self.input_id.clone(),
110            input_kind: self.input_kind,
111            requested_lane: self.requested_lane,
112            continuation_kind: self.continuation_kind,
113            silent_intent_match: self.silent_intent_match,
114            existing_superseded_input_id: self.existing_superseded_input_id.clone(),
115            runtime_running: self.runtime_running,
116            active_turn_boundary_available: self.active_turn_boundary_available,
117            without_wake: self.without_wake,
118        }
119    }
120}
121
122/// Runtime-local proof that generated admission authority authorized execution
123/// of the accepted input's semantic admission plan.
124#[must_use = "runtime ingress execution capability must be consumed by accept_resolved_input"]
125#[derive(Debug, PartialEq, Eq)]
126pub(crate) struct RuntimeIngressExecutionCapability {
127    input_id: String,
128    lane: mm_dsl::InputLane,
129    plan: mm_dsl::AdmissionPlanKind,
130}
131
132impl RuntimeIngressExecutionCapability {
133    fn from_admission_resolved_effect(
134        input_id: String,
135        lane: mm_dsl::InputLane,
136        plan: mm_dsl::AdmissionPlanKind,
137    ) -> Self {
138        Self {
139            input_id,
140            lane,
141            plan,
142        }
143    }
144
145    fn validate_for(
146        self,
147        input_id: &InputId,
148        handling_mode: HandlingMode,
149        admission_plan: &AdmissionPlan,
150    ) -> Result<(), String> {
151        if self.input_id != input_id.to_string() {
152            return Err(format!(
153                "runtime ingress capability id '{}' did not match accepted input '{input_id}'",
154                self.input_id
155            ));
156        }
157
158        let expected_lane = mm_dsl::InputLane::from(handling_mode);
159        if self.lane != expected_lane {
160            return Err(format!(
161                "runtime ingress capability lane {:?} did not match accepted input lane {expected_lane:?}",
162                self.lane
163            ));
164        }
165
166        let expected_plan = match admission_plan {
167            AdmissionPlan::ConsumedOnAccept => mm_dsl::AdmissionPlanKind::ConsumedOnAccept,
168            AdmissionPlan::Queued { .. } => mm_dsl::AdmissionPlanKind::Queued,
169        };
170        if self.plan != expected_plan {
171            return Err(format!(
172                "runtime ingress capability plan {:?} did not match accepted input plan {expected_plan:?}",
173                self.plan
174            ));
175        }
176
177        Ok(())
178    }
179}
180
181/// Machine-owned resolution of an accepted input's semantic admission path.
182// Cannot derive `Eq`: `RuntimeInputProjection` carries a typed
183// `peer_response_terminal` fact whose render payload is a `serde_json::Value`.
184#[derive(Debug, PartialEq)]
185pub struct ResolvedAdmission {
186    policy: PolicyDecision,
187    handling_mode: HandlingMode,
188    runtime_semantics: crate::ingress_types::RuntimeInputSemantics,
189    primitive_projection: crate::ingress_types::RuntimeInputProjection,
190    admission_plan: AdmissionPlan,
191    coarse_flags: CoarseAdmissionFlags,
192    requires_active_pre_admission: bool,
193    authority: MachineAdmissionAuthority,
194    execution_capability: Option<RuntimeIngressExecutionCapability>,
195}
196
197impl ResolvedAdmission {
198    #[allow(clippy::too_many_arguments)]
199    pub(crate) fn from_machine_resolution(
200        policy: PolicyDecision,
201        handling_mode: HandlingMode,
202        runtime_semantics: crate::ingress_types::RuntimeInputSemantics,
203        primitive_projection: crate::ingress_types::RuntimeInputProjection,
204        admission_plan: AdmissionPlan,
205        coarse_flags: CoarseAdmissionFlags,
206        requires_active_pre_admission: bool,
207        authority: MachineAdmissionAuthority,
208        execution_capability: Option<(String, mm_dsl::InputLane, mm_dsl::AdmissionPlanKind)>,
209    ) -> Self {
210        Self {
211            policy,
212            handling_mode,
213            runtime_semantics,
214            primitive_projection,
215            admission_plan,
216            coarse_flags,
217            requires_active_pre_admission,
218            authority,
219            execution_capability: execution_capability.map(|(input_id, lane, plan)| {
220                RuntimeIngressExecutionCapability::from_admission_resolved_effect(
221                    input_id, lane, plan,
222                )
223            }),
224        }
225    }
226
227    pub(crate) fn coarse_flags(&self) -> CoarseAdmissionFlags {
228        self.coarse_flags
229    }
230
231    pub(crate) fn requires_active_runtime_pre_admission(&self) -> bool {
232        self.requires_active_pre_admission
233    }
234
235    pub(crate) fn policy(&self) -> &PolicyDecision {
236        &self.policy
237    }
238
239    /// Exact input rows whose durable lifecycle may change when this
240    /// admission is committed. The generated plan can mutate only the newly
241    /// admitted input and, for coalesce/supersede, one named queued input.
242    pub(crate) fn persistence_changed_input_ids(&self, input_id: &InputId) -> Vec<InputId> {
243        let mut input_ids = vec![input_id.clone()];
244        let existing_id = match &self.admission_plan {
245            AdmissionPlan::Queued {
246                existing_action:
247                    Some(
248                        ExistingQueuedAdmissionAction::Coalesce { existing_id }
249                        | ExistingQueuedAdmissionAction::Supersede { existing_id },
250                    ),
251                ..
252            } => Some(existing_id),
253            AdmissionPlan::ConsumedOnAccept | AdmissionPlan::Queued { .. } => None,
254        };
255        if let Some(existing_id) = existing_id
256            && existing_id != input_id
257        {
258            input_ids.push(existing_id.clone());
259        }
260        input_ids
261    }
262
263    pub(crate) fn stages_run_boundary(&self) -> bool {
264        self.policy.apply_mode == crate::policy::ApplyMode::StageRunBoundary
265    }
266
267    pub(crate) fn authority(&self) -> &MachineAdmissionAuthority {
268        &self.authority
269    }
270
271    pub(crate) fn semantically_equivalent_to(&self, other: &Self) -> bool {
272        self.policy == other.policy
273            && self.handling_mode == other.handling_mode
274            && self.runtime_semantics == other.runtime_semantics
275            && self.primitive_projection == other.primitive_projection
276            && self.admission_plan == other.admission_plan
277            && self.coarse_flags == other.coarse_flags
278            && self.requires_active_pre_admission == other.requires_active_pre_admission
279            && self.authority == other.authority
280    }
281
282    pub(crate) fn consume_execution_capability(
283        self,
284        input_id: &InputId,
285    ) -> Result<
286        (
287            PolicyDecision,
288            HandlingMode,
289            crate::ingress_types::RuntimeInputSemantics,
290            crate::ingress_types::RuntimeInputProjection,
291            AdmissionPlan,
292        ),
293        String,
294    > {
295        let Self {
296            policy,
297            handling_mode,
298            runtime_semantics,
299            primitive_projection,
300            admission_plan,
301            execution_capability,
302            ..
303        } = self;
304        let execution_capability = execution_capability.ok_or_else(|| {
305            "runtime ingress execution capability was not minted for this admission resolution"
306                .to_string()
307        })?;
308        execution_capability.validate_for(input_id, handling_mode, &admission_plan)?;
309        Ok((
310            policy,
311            handling_mode,
312            runtime_semantics,
313            primitive_projection,
314            admission_plan,
315        ))
316    }
317}
318
319/// Typed reason why an input was rejected at the accept boundary.
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
321#[serde(tag = "reject_type", rename_all = "snake_case")]
322#[non_exhaustive]
323pub enum RejectReason {
324    /// Runtime is not in a state that accepts input (e.g. stopped, destroyed).
325    NotReady {
326        /// The runtime state that caused the rejection.
327        state: RuntimeState,
328    },
329    /// Input failed durability validation.
330    DurabilityViolation {
331        /// Description of the violation.
332        detail: String,
333    },
334    /// Peer input carried a forbidden handling_mode.
335    PeerHandlingModeInvalid {
336        /// Description of the violation.
337        detail: String,
338    },
339    /// Peer response terminal fact failed typed validation.
340    PeerResponseTerminalInvalid {
341        /// Description of the violation.
342        detail: String,
343    },
344}
345
346impl fmt::Display for RejectReason {
347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348        match self {
349            Self::NotReady { state } => {
350                write!(f, "runtime not accepting input while in state: {state}")
351            }
352            Self::DurabilityViolation { detail } => write!(f, "{detail}"),
353            Self::PeerHandlingModeInvalid { detail } => write!(f, "{detail}"),
354            Self::PeerResponseTerminalInvalid { detail } => write!(f, "{detail}"),
355        }
356    }
357}
358
359/// Outcome of `RuntimeDriver::accept_input()`.
360///
361/// Domain envelope returned to in-process callers. Surface crates translate it
362/// into the wire shape (`RuntimeAcceptResult` in `meerkat-contracts`) before
363/// emitting it on the network, so this type intentionally has no
364/// `Serialize`/`Deserialize` derives.
365#[derive(Debug, Clone)]
366#[non_exhaustive]
367#[allow(clippy::large_enum_variant)]
368pub enum AcceptOutcome {
369    /// Input was accepted and processing has begun.
370    Accepted {
371        /// The assigned input ID.
372        input_id: InputId,
373        /// The policy decision applied to this input.
374        policy: PolicyDecision,
375        /// Current input state.
376        state: InputState,
377        /// Machine-owned lifecycle seed paired with `state`.
378        seed: InputStateSeed,
379    },
380    /// Input was deduplicated (idempotency key matched an existing input).
381    Deduplicated {
382        /// The new input ID that was deduplicated.
383        input_id: InputId,
384        /// The existing input ID that was matched.
385        existing_id: InputId,
386        /// Exact machine/store-owned lifecycle seed for the matched input.
387        ///
388        /// Persistent runtimes may already have archived the terminal input
389        /// from live machine state, so consumers must classify this retained
390        /// receipt instead of re-reading a live row after admission.
391        existing_seed: InputStateSeed,
392    },
393    /// Input was rejected (validation failed, durability violation, etc.).
394    Rejected {
395        /// Why the input was rejected.
396        reason: RejectReason,
397    },
398}
399
400impl AcceptOutcome {
401    /// Check if the input was accepted.
402    pub fn is_accepted(&self) -> bool {
403        matches!(self, Self::Accepted { .. })
404    }
405
406    /// Check if the input was deduplicated.
407    pub fn is_deduplicated(&self) -> bool {
408        matches!(self, Self::Deduplicated { .. })
409    }
410
411    /// Check if the input was rejected.
412    pub fn is_rejected(&self) -> bool {
413        matches!(self, Self::Rejected { .. })
414    }
415}
416
417/// Derive the handling mode from a resolved policy decision.
418pub fn handling_mode_from_policy(policy: &PolicyDecision) -> HandlingMode {
419    match policy.routing_disposition {
420        crate::policy::RoutingDisposition::Steer | crate::policy::RoutingDisposition::Immediate => {
421            // Immediate routing must use the steer lane so runtime-owned
422            // semantic facts (for example terminal peer responses) cannot get
423            // stranded behind ordinary queued prompts before their immediate
424            // apply boundary is drained. This preserves the checked-in policy
425            // contract without upgrading WakeIfIdle into an active-turn
426            // interrupt on this branch.
427            HandlingMode::Steer
428        }
429        _ => HandlingMode::Queue,
430    }
431}
432
433#[cfg(test)]
434#[allow(clippy::unwrap_used)]
435mod tests {
436    use super::*;
437    use crate::identifiers::PolicyVersion;
438    use crate::policy::{
439        ApplyMode, ConsumePoint, DrainPolicy, QueueMode, RoutingDisposition, WakeMode,
440    };
441
442    #[test]
443    fn accepted_classifier() {
444        let outcome = AcceptOutcome::Accepted {
445            input_id: InputId::new(),
446            policy: PolicyDecision {
447                apply_mode: ApplyMode::StageRunStart,
448                wake_mode: WakeMode::WakeIfIdle,
449                queue_mode: QueueMode::Fifo,
450                consume_point: ConsumePoint::OnRunComplete,
451                drain_policy: DrainPolicy::QueueNextTurn,
452                routing_disposition: RoutingDisposition::Queue,
453                record_transcript: true,
454                emit_operator_content: true,
455                policy_version: PolicyVersion(1),
456            },
457            state: InputState::new_accepted(InputId::new()),
458            seed: InputStateSeed::new_accepted(),
459        };
460        assert!(outcome.is_accepted());
461        assert!(!outcome.is_deduplicated());
462        assert!(!outcome.is_rejected());
463    }
464
465    #[test]
466    fn deduplicated_classifier() {
467        let outcome = AcceptOutcome::Deduplicated {
468            input_id: InputId::new(),
469            existing_id: InputId::new(),
470            existing_seed: InputStateSeed::new_accepted(),
471        };
472        assert!(!outcome.is_accepted());
473        assert!(outcome.is_deduplicated());
474        assert!(!outcome.is_rejected());
475    }
476
477    #[test]
478    fn rejected_classifier() {
479        let outcome = AcceptOutcome::Rejected {
480            reason: RejectReason::DurabilityViolation {
481                detail: "durability violation".into(),
482            },
483        };
484        assert!(!outcome.is_accepted());
485        assert!(!outcome.is_deduplicated());
486        assert!(outcome.is_rejected());
487    }
488
489    #[test]
490    fn reject_reason_display() {
491        let not_ready = RejectReason::NotReady {
492            state: RuntimeState::Stopped,
493        };
494        assert_eq!(
495            not_ready.to_string(),
496            "runtime not accepting input while in state: stopped"
497        );
498
499        let durability = RejectReason::DurabilityViolation {
500            detail: "Derived durability forbidden for prompt".into(),
501        };
502        assert_eq!(
503            durability.to_string(),
504            "Derived durability forbidden for prompt"
505        );
506
507        let peer = RejectReason::PeerHandlingModeInvalid {
508            detail: "handling_mode is forbidden on ResponseProgress peer inputs".into(),
509        };
510        assert_eq!(
511            peer.to_string(),
512            "handling_mode is forbidden on ResponseProgress peer inputs"
513        );
514
515        let terminal = RejectReason::PeerResponseTerminalInvalid {
516            detail: "correlation id cannot be empty".into(),
517        };
518        assert_eq!(terminal.to_string(), "correlation id cannot be empty");
519    }
520
521    #[test]
522    fn reject_reason_serde_round_trip() {
523        let reasons = vec![
524            RejectReason::NotReady {
525                state: RuntimeState::Destroyed,
526            },
527            RejectReason::DurabilityViolation {
528                detail: "external derived".into(),
529            },
530            RejectReason::PeerHandlingModeInvalid {
531                detail: "forbidden".into(),
532            },
533            RejectReason::PeerResponseTerminalInvalid {
534                detail: "bad terminal".into(),
535            },
536        ];
537        for reason in reasons {
538            let json = serde_json::to_value(&reason).unwrap();
539            let parsed: RejectReason = serde_json::from_value(json).unwrap();
540            assert_eq!(parsed, reason);
541        }
542    }
543
544    #[test]
545    fn immediate_routing_uses_steer_handling_mode() {
546        let policy = PolicyDecision {
547            apply_mode: ApplyMode::InjectNow,
548            wake_mode: WakeMode::WakeIfIdle,
549            queue_mode: QueueMode::None,
550            consume_point: ConsumePoint::OnApply,
551            drain_policy: DrainPolicy::Immediate,
552            routing_disposition: RoutingDisposition::Immediate,
553            record_transcript: true,
554            emit_operator_content: true,
555            policy_version: PolicyVersion(1),
556        };
557
558        assert_eq!(handling_mode_from_policy(&policy), HandlingMode::Steer);
559    }
560}