1use 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#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum AdmissionQueueAction {
23 None,
24 EnqueueTo { target: HandlingMode },
25 EnqueueFront { target: HandlingMode },
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ExistingQueuedAdmissionAction {
31 Coalesce { existing_id: InputId },
32 Supersede { existing_id: InputId },
33}
34
35#[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#[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#[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#[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#[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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
321#[serde(tag = "reject_type", rename_all = "snake_case")]
322#[non_exhaustive]
323pub enum RejectReason {
324 NotReady {
326 state: RuntimeState,
328 },
329 DurabilityViolation {
331 detail: String,
333 },
334 PeerHandlingModeInvalid {
336 detail: String,
338 },
339 PeerResponseTerminalInvalid {
341 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#[derive(Debug, Clone)]
366#[non_exhaustive]
367#[allow(clippy::large_enum_variant)]
368pub enum AcceptOutcome {
369 Accepted {
371 input_id: InputId,
373 policy: PolicyDecision,
375 state: InputState,
377 seed: InputStateSeed,
379 },
380 Deduplicated {
382 input_id: InputId,
384 existing_id: InputId,
386 existing_seed: InputStateSeed,
392 },
393 Rejected {
395 reason: RejectReason,
397 },
398}
399
400impl AcceptOutcome {
401 pub fn is_accepted(&self) -> bool {
403 matches!(self, Self::Accepted { .. })
404 }
405
406 pub fn is_deduplicated(&self) -> bool {
408 matches!(self, Self::Deduplicated { .. })
409 }
410
411 pub fn is_rejected(&self) -> bool {
413 matches!(self, Self::Rejected { .. })
414 }
415}
416
417pub fn handling_mode_from_policy(policy: &PolicyDecision) -> HandlingMode {
419 match policy.routing_disposition {
420 crate::policy::RoutingDisposition::Steer | crate::policy::RoutingDisposition::Immediate => {
421 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}