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 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
298#[serde(tag = "reject_type", rename_all = "snake_case")]
299#[non_exhaustive]
300pub enum RejectReason {
301 NotReady {
303 state: RuntimeState,
305 },
306 DurabilityViolation {
308 detail: String,
310 },
311 PeerHandlingModeInvalid {
313 detail: String,
315 },
316 PeerResponseTerminalInvalid {
318 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#[derive(Debug, Clone)]
343#[non_exhaustive]
344#[allow(clippy::large_enum_variant)]
345pub enum AcceptOutcome {
346 Accepted {
348 input_id: InputId,
350 policy: PolicyDecision,
352 state: InputState,
354 seed: InputStateSeed,
356 },
357 Deduplicated {
359 input_id: InputId,
361 existing_id: InputId,
363 },
364 Rejected {
366 reason: RejectReason,
368 },
369}
370
371impl AcceptOutcome {
372 pub fn is_accepted(&self) -> bool {
374 matches!(self, Self::Accepted { .. })
375 }
376
377 pub fn is_deduplicated(&self) -> bool {
379 matches!(self, Self::Deduplicated { .. })
380 }
381
382 pub fn is_rejected(&self) -> bool {
384 matches!(self, Self::Rejected { .. })
385 }
386}
387
388pub fn handling_mode_from_policy(policy: &PolicyDecision) -> HandlingMode {
390 match policy.routing_disposition {
391 crate::policy::RoutingDisposition::Steer | crate::policy::RoutingDisposition::Immediate => {
392 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}