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    #[cfg(test)]
236    pub(crate) fn policy(&self) -> &PolicyDecision {
237        &self.policy
238    }
239
240    pub(crate) fn stages_run_boundary(&self) -> bool {
241        self.policy.apply_mode == crate::policy::ApplyMode::StageRunBoundary
242    }
243
244    pub(crate) fn authority(&self) -> &MachineAdmissionAuthority {
245        &self.authority
246    }
247
248    pub(crate) fn semantically_equivalent_to(&self, other: &Self) -> bool {
249        self.policy == other.policy
250            && self.handling_mode == other.handling_mode
251            && self.runtime_semantics == other.runtime_semantics
252            && self.primitive_projection == other.primitive_projection
253            && self.admission_plan == other.admission_plan
254            && self.coarse_flags == other.coarse_flags
255            && self.requires_active_pre_admission == other.requires_active_pre_admission
256            && self.authority == other.authority
257    }
258
259    pub(crate) fn consume_execution_capability(
260        self,
261        input_id: &InputId,
262    ) -> Result<
263        (
264            PolicyDecision,
265            HandlingMode,
266            crate::ingress_types::RuntimeInputSemantics,
267            crate::ingress_types::RuntimeInputProjection,
268            AdmissionPlan,
269        ),
270        String,
271    > {
272        let Self {
273            policy,
274            handling_mode,
275            runtime_semantics,
276            primitive_projection,
277            admission_plan,
278            execution_capability,
279            ..
280        } = self;
281        let execution_capability = execution_capability.ok_or_else(|| {
282            "runtime ingress execution capability was not minted for this admission resolution"
283                .to_string()
284        })?;
285        execution_capability.validate_for(input_id, handling_mode, &admission_plan)?;
286        Ok((
287            policy,
288            handling_mode,
289            runtime_semantics,
290            primitive_projection,
291            admission_plan,
292        ))
293    }
294}
295
296/// Typed reason why an input was rejected at the accept boundary.
297#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
298#[serde(tag = "reject_type", rename_all = "snake_case")]
299#[non_exhaustive]
300pub enum RejectReason {
301    /// Runtime is not in a state that accepts input (e.g. stopped, destroyed).
302    NotReady {
303        /// The runtime state that caused the rejection.
304        state: RuntimeState,
305    },
306    /// Input failed durability validation.
307    DurabilityViolation {
308        /// Description of the violation.
309        detail: String,
310    },
311    /// Peer input carried a forbidden handling_mode.
312    PeerHandlingModeInvalid {
313        /// Description of the violation.
314        detail: String,
315    },
316    /// Peer response terminal fact failed typed validation.
317    PeerResponseTerminalInvalid {
318        /// Description of the violation.
319        detail: String,
320    },
321}
322
323impl fmt::Display for RejectReason {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        match self {
326            Self::NotReady { state } => {
327                write!(f, "runtime not accepting input while in state: {state}")
328            }
329            Self::DurabilityViolation { detail } => write!(f, "{detail}"),
330            Self::PeerHandlingModeInvalid { detail } => write!(f, "{detail}"),
331            Self::PeerResponseTerminalInvalid { detail } => write!(f, "{detail}"),
332        }
333    }
334}
335
336/// Outcome of `RuntimeDriver::accept_input()`.
337///
338/// Domain envelope returned to in-process callers. Surface crates translate it
339/// into the wire shape (`RuntimeAcceptResult` in `meerkat-contracts`) before
340/// emitting it on the network, so this type intentionally has no
341/// `Serialize`/`Deserialize` derives.
342#[derive(Debug, Clone)]
343#[non_exhaustive]
344#[allow(clippy::large_enum_variant)]
345pub enum AcceptOutcome {
346    /// Input was accepted and processing has begun.
347    Accepted {
348        /// The assigned input ID.
349        input_id: InputId,
350        /// The policy decision applied to this input.
351        policy: PolicyDecision,
352        /// Current input state.
353        state: InputState,
354        /// Machine-owned lifecycle seed paired with `state`.
355        seed: InputStateSeed,
356    },
357    /// Input was deduplicated (idempotency key matched an existing input).
358    Deduplicated {
359        /// The new input ID that was deduplicated.
360        input_id: InputId,
361        /// The existing input ID that was matched.
362        existing_id: InputId,
363    },
364    /// Input was rejected (validation failed, durability violation, etc.).
365    Rejected {
366        /// Why the input was rejected.
367        reason: RejectReason,
368    },
369}
370
371impl AcceptOutcome {
372    /// Check if the input was accepted.
373    pub fn is_accepted(&self) -> bool {
374        matches!(self, Self::Accepted { .. })
375    }
376
377    /// Check if the input was deduplicated.
378    pub fn is_deduplicated(&self) -> bool {
379        matches!(self, Self::Deduplicated { .. })
380    }
381
382    /// Check if the input was rejected.
383    pub fn is_rejected(&self) -> bool {
384        matches!(self, Self::Rejected { .. })
385    }
386}
387
388/// Derive the handling mode from a resolved policy decision.
389pub fn handling_mode_from_policy(policy: &PolicyDecision) -> HandlingMode {
390    match policy.routing_disposition {
391        crate::policy::RoutingDisposition::Steer | crate::policy::RoutingDisposition::Immediate => {
392            // Immediate routing must use the steer lane so runtime-owned
393            // semantic facts (for example terminal peer responses) cannot get
394            // stranded behind ordinary queued prompts before their immediate
395            // apply boundary is drained. This preserves the checked-in policy
396            // contract without upgrading WakeIfIdle into an active-turn
397            // interrupt on this branch.
398            HandlingMode::Steer
399        }
400        _ => HandlingMode::Queue,
401    }
402}
403
404#[cfg(test)]
405#[allow(clippy::unwrap_used)]
406mod tests {
407    use super::*;
408    use crate::identifiers::PolicyVersion;
409    use crate::policy::{
410        ApplyMode, ConsumePoint, DrainPolicy, QueueMode, RoutingDisposition, WakeMode,
411    };
412
413    #[test]
414    fn accepted_classifier() {
415        let outcome = AcceptOutcome::Accepted {
416            input_id: InputId::new(),
417            policy: PolicyDecision {
418                apply_mode: ApplyMode::StageRunStart,
419                wake_mode: WakeMode::WakeIfIdle,
420                queue_mode: QueueMode::Fifo,
421                consume_point: ConsumePoint::OnRunComplete,
422                drain_policy: DrainPolicy::QueueNextTurn,
423                routing_disposition: RoutingDisposition::Queue,
424                record_transcript: true,
425                emit_operator_content: true,
426                policy_version: PolicyVersion(1),
427            },
428            state: InputState::new_accepted(InputId::new()),
429            seed: InputStateSeed::new_accepted(),
430        };
431        assert!(outcome.is_accepted());
432        assert!(!outcome.is_deduplicated());
433        assert!(!outcome.is_rejected());
434    }
435
436    #[test]
437    fn deduplicated_classifier() {
438        let outcome = AcceptOutcome::Deduplicated {
439            input_id: InputId::new(),
440            existing_id: InputId::new(),
441        };
442        assert!(!outcome.is_accepted());
443        assert!(outcome.is_deduplicated());
444        assert!(!outcome.is_rejected());
445    }
446
447    #[test]
448    fn rejected_classifier() {
449        let outcome = AcceptOutcome::Rejected {
450            reason: RejectReason::DurabilityViolation {
451                detail: "durability violation".into(),
452            },
453        };
454        assert!(!outcome.is_accepted());
455        assert!(!outcome.is_deduplicated());
456        assert!(outcome.is_rejected());
457    }
458
459    #[test]
460    fn reject_reason_display() {
461        let not_ready = RejectReason::NotReady {
462            state: RuntimeState::Stopped,
463        };
464        assert_eq!(
465            not_ready.to_string(),
466            "runtime not accepting input while in state: stopped"
467        );
468
469        let durability = RejectReason::DurabilityViolation {
470            detail: "Derived durability forbidden for prompt".into(),
471        };
472        assert_eq!(
473            durability.to_string(),
474            "Derived durability forbidden for prompt"
475        );
476
477        let peer = RejectReason::PeerHandlingModeInvalid {
478            detail: "handling_mode is forbidden on ResponseProgress peer inputs".into(),
479        };
480        assert_eq!(
481            peer.to_string(),
482            "handling_mode is forbidden on ResponseProgress peer inputs"
483        );
484
485        let terminal = RejectReason::PeerResponseTerminalInvalid {
486            detail: "correlation id cannot be empty".into(),
487        };
488        assert_eq!(terminal.to_string(), "correlation id cannot be empty");
489    }
490
491    #[test]
492    fn reject_reason_serde_round_trip() {
493        let reasons = vec![
494            RejectReason::NotReady {
495                state: RuntimeState::Destroyed,
496            },
497            RejectReason::DurabilityViolation {
498                detail: "external derived".into(),
499            },
500            RejectReason::PeerHandlingModeInvalid {
501                detail: "forbidden".into(),
502            },
503            RejectReason::PeerResponseTerminalInvalid {
504                detail: "bad terminal".into(),
505            },
506        ];
507        for reason in reasons {
508            let json = serde_json::to_value(&reason).unwrap();
509            let parsed: RejectReason = serde_json::from_value(json).unwrap();
510            assert_eq!(parsed, reason);
511        }
512    }
513
514    #[test]
515    fn immediate_routing_uses_steer_handling_mode() {
516        let policy = PolicyDecision {
517            apply_mode: ApplyMode::InjectNow,
518            wake_mode: WakeMode::WakeIfIdle,
519            queue_mode: QueueMode::None,
520            consume_point: ConsumePoint::OnApply,
521            drain_policy: DrainPolicy::Immediate,
522            routing_disposition: RoutingDisposition::Immediate,
523            record_transcript: true,
524            emit_operator_content: true,
525            policy_version: PolicyVersion(1),
526        };
527
528        assert_eq!(handling_mode_from_policy(&policy), HandlingMode::Steer);
529    }
530}