Skip to main content

meerkat_runtime/
policy_table.rs

1//! §17 DefaultPolicyTable — resolves Input × runtime_idle to PolicyDecision.
2//!
3//! All input kinds × 2 idle states, exact per spec §17.
4
5use crate::identifiers::{InputKind, KindId, PolicyVersion};
6use crate::input::Input;
7use crate::policy::{
8    ApplyMode, ConsumePoint, DrainPolicy, PolicyDecision, QueueMode, RoutingDisposition, WakeMode,
9};
10
11/// The default policy version for the built-in table.
12pub const DEFAULT_POLICY_VERSION: PolicyVersion = PolicyVersion(1);
13
14/// Helper to construct a PolicyDecision with transcript defaults.
15#[allow(clippy::too_many_arguments)]
16fn pd(
17    apply_mode: ApplyMode,
18    wake_mode: WakeMode,
19    queue_mode: QueueMode,
20    consume_point: ConsumePoint,
21    drain_policy: DrainPolicy,
22    routing_disposition: RoutingDisposition,
23    record_transcript: bool,
24) -> PolicyDecision {
25    PolicyDecision {
26        apply_mode,
27        wake_mode,
28        queue_mode,
29        consume_point,
30        drain_policy,
31        routing_disposition,
32        record_transcript,
33        emit_operator_content: record_transcript,
34        policy_version: DEFAULT_POLICY_VERSION,
35    }
36}
37
38/// Default policy table implementing §17.
39pub struct DefaultPolicyTable;
40
41impl DefaultPolicyTable {
42    /// Resolve a policy decision for the given input and runtime state.
43    ///
44    /// If the input carries an explicit `handling_mode`, the override is
45    /// honored for actionable input kinds only. Response progress
46    /// (`peer_response_progress`) always falls through to kind-based
47    /// defaults — the policy table does not apply handling_mode overrides
48    /// for progress updates. Response terminal inputs honor handling_mode
49    /// normally.
50    pub fn resolve(input: &Input, runtime_idle: bool) -> PolicyDecision {
51        let kind = input.kind();
52        // ResponseProgress must not have its policy overridden by
53        // handling_mode. Admission validation rejects this combination,
54        // but the policy table also refuses to honor it so the contract
55        // holds for any caller of resolve(), not just the driver path.
56        let is_response_progress = matches!(kind, InputKind::PeerResponseProgress);
57        if matches!(kind, InputKind::PeerResponseTerminal)
58            && let Some(mode) = input.handling_mode()
59        {
60            let (wake_mode, drain_policy, routing_disposition) = match mode {
61                meerkat_core::types::HandlingMode::Queue => (
62                    WakeMode::WakeIfIdle,
63                    DrainPolicy::QueueNextTurn,
64                    RoutingDisposition::Queue,
65                ),
66                meerkat_core::types::HandlingMode::Steer => (
67                    if runtime_idle {
68                        WakeMode::WakeIfIdle
69                    } else {
70                        WakeMode::InterruptYielding
71                    },
72                    DrainPolicy::SteerBatch,
73                    RoutingDisposition::Steer,
74                ),
75            };
76            return pd(
77                ApplyMode::StageRunStart,
78                wake_mode,
79                QueueMode::Fifo,
80                ConsumePoint::OnRunComplete,
81                drain_policy,
82                routing_disposition,
83                true,
84            );
85        }
86        if !is_response_progress && let Some(mode) = input.handling_mode() {
87            return match mode {
88                meerkat_core::types::HandlingMode::Queue => pd(
89                    ApplyMode::StageRunStart,
90                    if runtime_idle {
91                        WakeMode::WakeIfIdle
92                    } else {
93                        WakeMode::None
94                    },
95                    QueueMode::Fifo,
96                    ConsumePoint::OnRunComplete,
97                    DrainPolicy::QueueNextTurn,
98                    RoutingDisposition::Queue,
99                    !matches!(input, Input::Continuation(_)),
100                ),
101                meerkat_core::types::HandlingMode::Steer => pd(
102                    ApplyMode::StageRunBoundary,
103                    if runtime_idle {
104                        WakeMode::WakeIfIdle
105                    } else {
106                        WakeMode::InterruptYielding
107                    },
108                    QueueMode::Fifo,
109                    ConsumePoint::OnRunComplete,
110                    DrainPolicy::SteerBatch,
111                    RoutingDisposition::Steer,
112                    !matches!(input, Input::Continuation(_)),
113                ),
114            };
115        }
116
117        Self::resolve_by_kind(KindId::new(kind), runtime_idle)
118    }
119
120    /// Resolve by typed kind (for testing and extensibility).
121    pub fn resolve_by_kind(kind: KindId, runtime_idle: bool) -> PolicyDecision {
122        match (kind.kind(), runtime_idle) {
123            // PromptInput — StageRunStart, WakeIfIdle (idle) / None (running)
124            (InputKind::Prompt, true) => pd(
125                ApplyMode::StageRunStart,
126                WakeMode::WakeIfIdle,
127                QueueMode::Fifo,
128                ConsumePoint::OnRunComplete,
129                DrainPolicy::QueueNextTurn,
130                RoutingDisposition::Queue,
131                true,
132            ),
133            (InputKind::Prompt, false) => pd(
134                ApplyMode::StageRunStart,
135                WakeMode::None,
136                QueueMode::Fifo,
137                ConsumePoint::OnRunComplete,
138                DrainPolicy::QueueNextTurn,
139                RoutingDisposition::Queue,
140                true,
141            ),
142
143            // PeerInput(Message) — StageRunStart, WakeIfIdle.
144            //
145            // A default peer message is queued work for the next turn. While
146            // a turn is running it must wake the runtime after the current
147            // run settles, but it must not request a cooperative boundary
148            // cancel; explicit `handling_mode=steer` is the typed path for
149            // in-turn steering.
150            (InputKind::PeerMessage, true) => pd(
151                ApplyMode::StageRunStart,
152                WakeMode::WakeIfIdle,
153                QueueMode::Fifo,
154                ConsumePoint::OnRunComplete,
155                DrainPolicy::QueueNextTurn,
156                RoutingDisposition::Queue,
157                true,
158            ),
159            (InputKind::PeerMessage, false) => pd(
160                ApplyMode::StageRunStart,
161                WakeMode::WakeIfIdle,
162                QueueMode::Fifo,
163                ConsumePoint::OnRunComplete,
164                DrainPolicy::QueueNextTurn,
165                RoutingDisposition::Queue,
166                true,
167            ),
168
169            // PeerInput(Request) — same as Message
170            (InputKind::PeerRequest, true) => pd(
171                ApplyMode::StageRunStart,
172                WakeMode::WakeIfIdle,
173                QueueMode::Fifo,
174                ConsumePoint::OnRunComplete,
175                DrainPolicy::QueueNextTurn,
176                RoutingDisposition::Queue,
177                true,
178            ),
179            (InputKind::PeerRequest, false) => pd(
180                ApplyMode::StageRunStart,
181                WakeMode::WakeIfIdle,
182                QueueMode::Fifo,
183                ConsumePoint::OnRunComplete,
184                DrainPolicy::QueueNextTurn,
185                RoutingDisposition::Queue,
186                true,
187            ),
188
189            // PeerInput(ResponseProgress) — StageRunBoundary, None, Coalesce
190            (InputKind::PeerResponseProgress, _) => pd(
191                ApplyMode::StageRunBoundary,
192                WakeMode::None,
193                QueueMode::Coalesce,
194                ConsumePoint::OnRunComplete,
195                DrainPolicy::SteerBatch,
196                RoutingDisposition::Steer,
197                true,
198            ),
199
200            // PeerInput(ResponseTerminal) — StageRunStart, WakeIfIdle, Fifo
201            //
202            // Terminal peer responses are both authoritative system-context
203            // facts for later turns AND turn-kicking events for turn-driven
204            // async request/response flows: a peer that issued `send_request`
205            // and is waiting for the response would otherwise strand on an
206            // idle session after the response lands. Staging as a runnable
207            // input with `QueueMode::Fifo` queues a turn-start so the runtime
208            // loop's `WakeIfIdle` path has something to dequeue and execute.
209            //
210            // The payload still flows through the durable typed
211            // system-context append path (`input_to_context_append`), so the
212            // peer terminal response fact is deduped on
213            // `peer_response_terminal:{peer_id}:{request_id}` rather than
214            // stacking as ordinary user appends (`input_to_append` returns
215            // `None` for this convention).
216            //
217            // Autonomous-host members are unaffected: their continuous loop
218            // dequeues and runs a turn regardless; turn-driven members (the
219            // realtime audio case) now react to the response instead of
220            // sitting on the appended context forever.
221            (InputKind::PeerResponseTerminal, _) => pd(
222                ApplyMode::StageRunStart,
223                WakeMode::WakeIfIdle,
224                QueueMode::Fifo,
225                ConsumePoint::OnRunComplete,
226                DrainPolicy::QueueNextTurn,
227                RoutingDisposition::Queue,
228                true,
229            ),
230
231            // FlowStepInput — StageRunStart, WakeIfIdle/None
232            (InputKind::FlowStep, true) => pd(
233                ApplyMode::StageRunStart,
234                WakeMode::WakeIfIdle,
235                QueueMode::Fifo,
236                ConsumePoint::OnRunComplete,
237                DrainPolicy::QueueNextTurn,
238                RoutingDisposition::Queue,
239                true,
240            ),
241            (InputKind::FlowStep, false) => pd(
242                ApplyMode::StageRunStart,
243                WakeMode::None,
244                QueueMode::Fifo,
245                ConsumePoint::OnRunComplete,
246                DrainPolicy::QueueNextTurn,
247                RoutingDisposition::Queue,
248                true,
249            ),
250
251            // ExternalEventInput — StageRunStart, WakeIfIdle/None
252            (InputKind::ExternalEvent, true) => pd(
253                ApplyMode::StageRunStart,
254                WakeMode::WakeIfIdle,
255                QueueMode::Fifo,
256                ConsumePoint::OnRunComplete,
257                DrainPolicy::QueueNextTurn,
258                RoutingDisposition::Queue,
259                true,
260            ),
261            (InputKind::ExternalEvent, false) => pd(
262                ApplyMode::StageRunStart,
263                WakeMode::None,
264                QueueMode::Fifo,
265                ConsumePoint::OnRunComplete,
266                DrainPolicy::QueueNextTurn,
267                RoutingDisposition::Queue,
268                true,
269            ),
270
271            // Continuation work remains explicit ordinary runtime work.
272            (InputKind::Continuation, true) => pd(
273                ApplyMode::StageRunBoundary,
274                WakeMode::WakeIfIdle,
275                QueueMode::Fifo,
276                ConsumePoint::OnRunComplete,
277                DrainPolicy::SteerBatch,
278                RoutingDisposition::Steer,
279                false,
280            ),
281            (InputKind::Continuation, false) => pd(
282                ApplyMode::StageRunBoundary,
283                WakeMode::InterruptYielding,
284                QueueMode::Fifo,
285                ConsumePoint::OnRunComplete,
286                DrainPolicy::SteerBatch,
287                RoutingDisposition::Steer,
288                false,
289            ),
290
291            // Typed operation/lifecycle inputs are admitted explicitly but do
292            // not inject ordinary transcript-visible work in this phase.
293            (InputKind::Operation, _) => pd(
294                ApplyMode::Ignore,
295                WakeMode::None,
296                QueueMode::Priority,
297                ConsumePoint::OnAccept,
298                DrainPolicy::Ignore,
299                RoutingDisposition::Drop,
300                false,
301            ),
302        }
303    }
304}
305
306#[cfg(test)]
307#[allow(clippy::unwrap_used)]
308mod tests {
309    use super::*;
310
311    fn assert_cell(
312        kind: InputKind,
313        idle: bool,
314        expected_apply: ApplyMode,
315        expected_wake: WakeMode,
316        expected_queue: QueueMode,
317        expected_consume: ConsumePoint,
318        expected_transcript: bool,
319    ) {
320        let decision = DefaultPolicyTable::resolve_by_kind(KindId::new(kind), idle);
321        assert_eq!(
322            decision.apply_mode, expected_apply,
323            "kind={kind:?}, idle={idle}: apply_mode"
324        );
325        assert_eq!(
326            decision.wake_mode, expected_wake,
327            "kind={kind:?}, idle={idle}: wake_mode"
328        );
329        assert_eq!(
330            decision.queue_mode, expected_queue,
331            "kind={kind:?}, idle={idle}: queue_mode"
332        );
333        assert_eq!(
334            decision.consume_point, expected_consume,
335            "kind={kind:?}, idle={idle}: consume_point"
336        );
337        assert_eq!(
338            decision.record_transcript, expected_transcript,
339            "kind={kind:?}, idle={idle}: record_transcript"
340        );
341    }
342
343    #[test]
344    fn prompt_idle() {
345        assert_cell(
346            InputKind::Prompt,
347            true,
348            ApplyMode::StageRunStart,
349            WakeMode::WakeIfIdle,
350            QueueMode::Fifo,
351            ConsumePoint::OnRunComplete,
352            true,
353        );
354    }
355    #[test]
356    fn prompt_running() {
357        assert_cell(
358            InputKind::Prompt,
359            false,
360            ApplyMode::StageRunStart,
361            WakeMode::None,
362            QueueMode::Fifo,
363            ConsumePoint::OnRunComplete,
364            true,
365        );
366    }
367    #[test]
368    fn peer_message_idle() {
369        assert_cell(
370            InputKind::PeerMessage,
371            true,
372            ApplyMode::StageRunStart,
373            WakeMode::WakeIfIdle,
374            QueueMode::Fifo,
375            ConsumePoint::OnRunComplete,
376            true,
377        );
378    }
379    #[test]
380    fn peer_message_running() {
381        assert_cell(
382            InputKind::PeerMessage,
383            false,
384            ApplyMode::StageRunStart,
385            WakeMode::WakeIfIdle,
386            QueueMode::Fifo,
387            ConsumePoint::OnRunComplete,
388            true,
389        );
390    }
391    #[test]
392    fn peer_request_idle() {
393        assert_cell(
394            InputKind::PeerRequest,
395            true,
396            ApplyMode::StageRunStart,
397            WakeMode::WakeIfIdle,
398            QueueMode::Fifo,
399            ConsumePoint::OnRunComplete,
400            true,
401        );
402    }
403    #[test]
404    fn peer_request_running() {
405        assert_cell(
406            InputKind::PeerRequest,
407            false,
408            ApplyMode::StageRunStart,
409            WakeMode::WakeIfIdle,
410            QueueMode::Fifo,
411            ConsumePoint::OnRunComplete,
412            true,
413        );
414    }
415    #[test]
416    fn peer_response_progress_idle() {
417        assert_cell(
418            InputKind::PeerResponseProgress,
419            true,
420            ApplyMode::StageRunBoundary,
421            WakeMode::None,
422            QueueMode::Coalesce,
423            ConsumePoint::OnRunComplete,
424            true,
425        );
426    }
427    #[test]
428    fn peer_response_progress_running() {
429        assert_cell(
430            InputKind::PeerResponseProgress,
431            false,
432            ApplyMode::StageRunBoundary,
433            WakeMode::None,
434            QueueMode::Coalesce,
435            ConsumePoint::OnRunComplete,
436            true,
437        );
438    }
439    #[test]
440    fn peer_response_terminal_idle() {
441        assert_cell(
442            InputKind::PeerResponseTerminal,
443            true,
444            ApplyMode::StageRunStart,
445            WakeMode::WakeIfIdle,
446            QueueMode::Fifo,
447            ConsumePoint::OnRunComplete,
448            true,
449        );
450    }
451    #[test]
452    fn peer_response_terminal_running() {
453        assert_cell(
454            InputKind::PeerResponseTerminal,
455            false,
456            ApplyMode::StageRunStart,
457            WakeMode::WakeIfIdle,
458            QueueMode::Fifo,
459            ConsumePoint::OnRunComplete,
460            true,
461        );
462    }
463    #[test]
464    fn flow_step_idle() {
465        assert_cell(
466            InputKind::FlowStep,
467            true,
468            ApplyMode::StageRunStart,
469            WakeMode::WakeIfIdle,
470            QueueMode::Fifo,
471            ConsumePoint::OnRunComplete,
472            true,
473        );
474    }
475    #[test]
476    fn flow_step_running() {
477        assert_cell(
478            InputKind::FlowStep,
479            false,
480            ApplyMode::StageRunStart,
481            WakeMode::None,
482            QueueMode::Fifo,
483            ConsumePoint::OnRunComplete,
484            true,
485        );
486    }
487    #[test]
488    fn external_event_idle() {
489        assert_cell(
490            InputKind::ExternalEvent,
491            true,
492            ApplyMode::StageRunStart,
493            WakeMode::WakeIfIdle,
494            QueueMode::Fifo,
495            ConsumePoint::OnRunComplete,
496            true,
497        );
498    }
499    #[test]
500    fn external_event_running() {
501        assert_cell(
502            InputKind::ExternalEvent,
503            false,
504            ApplyMode::StageRunStart,
505            WakeMode::None,
506            QueueMode::Fifo,
507            ConsumePoint::OnRunComplete,
508            true,
509        );
510    }
511    #[test]
512    fn continuation_idle() {
513        assert_cell(
514            InputKind::Continuation,
515            true,
516            ApplyMode::StageRunBoundary,
517            WakeMode::WakeIfIdle,
518            QueueMode::Fifo,
519            ConsumePoint::OnRunComplete,
520            false,
521        );
522    }
523    #[test]
524    fn continuation_running() {
525        assert_cell(
526            InputKind::Continuation,
527            false,
528            ApplyMode::StageRunBoundary,
529            WakeMode::InterruptYielding,
530            QueueMode::Fifo,
531            ConsumePoint::OnRunComplete,
532            false,
533        );
534    }
535    #[test]
536    fn operation_idle() {
537        assert_cell(
538            InputKind::Operation,
539            true,
540            ApplyMode::Ignore,
541            WakeMode::None,
542            QueueMode::Priority,
543            ConsumePoint::OnAccept,
544            false,
545        );
546    }
547    #[test]
548    fn operation_running() {
549        assert_cell(
550            InputKind::Operation,
551            false,
552            ApplyMode::Ignore,
553            WakeMode::None,
554            QueueMode::Priority,
555            ConsumePoint::OnAccept,
556            false,
557        );
558    }
559
560    #[test]
561    fn resolve_via_input_object() {
562        use crate::input::*;
563        use chrono::Utc;
564        use meerkat_core::lifecycle::InputId;
565
566        let header = InputHeader {
567            id: InputId::new(),
568            timestamp: Utc::now(),
569            source: InputOrigin::Operator,
570            durability: InputDurability::Durable,
571            visibility: InputVisibility::default(),
572            idempotency_key: None,
573            supersession_key: None,
574            correlation_id: None,
575        };
576        let input = Input::Prompt(PromptInput {
577            header,
578            text: "hello".into(),
579            blocks: None,
580            typed_turn_appends: Vec::new(),
581            turn_metadata: None,
582        });
583        let decision = DefaultPolicyTable::resolve(&input, true);
584        assert_eq!(decision.apply_mode, ApplyMode::StageRunStart);
585        assert_eq!(decision.wake_mode, WakeMode::WakeIfIdle);
586    }
587
588    #[test]
589    fn explicit_steer_metadata_maps_to_checkpoint_policy() {
590        use crate::input::*;
591        use chrono::Utc;
592        use meerkat_core::lifecycle::InputId;
593        use meerkat_core::lifecycle::run_primitive::RuntimeTurnMetadata;
594
595        let input = Input::Prompt(PromptInput {
596            header: InputHeader {
597                id: InputId::new(),
598                timestamp: Utc::now(),
599                source: InputOrigin::Operator,
600                durability: InputDurability::Durable,
601                visibility: InputVisibility::default(),
602                idempotency_key: None,
603                supersession_key: None,
604                correlation_id: None,
605            },
606            text: "hello".into(),
607            blocks: None,
608            typed_turn_appends: Vec::new(),
609            turn_metadata: Some(RuntimeTurnMetadata {
610                handling_mode: Some(meerkat_core::types::HandlingMode::Steer),
611                ..Default::default()
612            }),
613        });
614        let decision = DefaultPolicyTable::resolve(&input, true);
615        assert_eq!(decision.apply_mode, ApplyMode::StageRunBoundary);
616        assert_eq!(decision.drain_policy, DrainPolicy::SteerBatch);
617        assert_eq!(decision.routing_disposition, RoutingDisposition::Steer);
618    }
619
620    #[test]
621    fn peer_message_running_wakes_after_current_turn() {
622        let decision =
623            DefaultPolicyTable::resolve_by_kind(KindId::new(InputKind::PeerMessage), false);
624        assert_eq!(
625            decision.wake_mode,
626            WakeMode::WakeIfIdle,
627            "peer_message while running must wake the runtime after the current turn"
628        );
629    }
630
631    #[test]
632    fn peer_request_running_wakes_after_current_turn() {
633        let decision =
634            DefaultPolicyTable::resolve_by_kind(KindId::new(InputKind::PeerRequest), false);
635        assert_eq!(
636            decision.wake_mode,
637            WakeMode::WakeIfIdle,
638            "peer_request while running must wake the runtime after the current turn"
639        );
640    }
641
642    #[test]
643    fn peer_message_idle_still_wakes() {
644        // Peer messages while idle should still wake normally.
645        let decision =
646            DefaultPolicyTable::resolve_by_kind(KindId::new(InputKind::PeerMessage), true);
647        assert_eq!(
648            decision.wake_mode,
649            WakeMode::WakeIfIdle,
650            "peer_message while idle must use WakeIfIdle"
651        );
652    }
653
654    #[test]
655    fn peer_request_idle_still_wakes() {
656        // Peer requests while idle should still wake normally.
657        let decision =
658            DefaultPolicyTable::resolve_by_kind(KindId::new(InputKind::PeerRequest), true);
659        assert_eq!(
660            decision.wake_mode,
661            WakeMode::WakeIfIdle,
662            "peer_request while idle must use WakeIfIdle"
663        );
664    }
665
666    // -----------------------------------------------------------------------
667    // Peer handling_mode override tests
668    // -----------------------------------------------------------------------
669
670    use crate::input::{
671        InputDurability, InputHeader, InputOrigin, InputVisibility, PeerConvention, PeerInput,
672    };
673    use chrono::Utc;
674    use meerkat_core::lifecycle::InputId;
675    use meerkat_core::types::HandlingMode;
676
677    fn make_peer_input(
678        convention: Option<PeerConvention>,
679        handling_mode: Option<HandlingMode>,
680    ) -> Input {
681        Input::Peer(PeerInput {
682            header: InputHeader {
683                id: InputId::new(),
684                timestamp: Utc::now(),
685                source: InputOrigin::Peer {
686                    peer_id: "p".into(),
687                    display_identity: None,
688                    runtime_id: None,
689                },
690                durability: InputDurability::Durable,
691                visibility: InputVisibility::default(),
692                idempotency_key: None,
693                supersession_key: None,
694                correlation_id: None,
695            },
696            convention,
697            body: "test".into(),
698            payload: None,
699            blocks: None,
700            handling_mode,
701        })
702    }
703
704    #[test]
705    fn peer_message_with_explicit_queue_resolves_queue_semantics() {
706        let input = make_peer_input(Some(PeerConvention::Message), Some(HandlingMode::Queue));
707        let decision = DefaultPolicyTable::resolve(&input, true);
708        assert_eq!(decision.routing_disposition, RoutingDisposition::Queue);
709        assert_eq!(decision.apply_mode, ApplyMode::StageRunStart);
710
711        let running_decision = DefaultPolicyTable::resolve(&input, false);
712        assert_eq!(
713            running_decision.routing_disposition,
714            RoutingDisposition::Queue
715        );
716        assert_eq!(
717            running_decision.wake_mode,
718            WakeMode::None,
719            "explicit queue means next boundary, not interrupt-yielding, while the target is running"
720        );
721    }
722
723    #[test]
724    fn peer_message_with_explicit_steer_resolves_steer_semantics() {
725        let input = make_peer_input(Some(PeerConvention::Message), Some(HandlingMode::Steer));
726        let decision = DefaultPolicyTable::resolve(&input, true);
727        assert_eq!(decision.routing_disposition, RoutingDisposition::Steer);
728        assert_eq!(decision.apply_mode, ApplyMode::StageRunBoundary);
729    }
730
731    #[test]
732    fn peer_request_with_explicit_steer_resolves_steer_semantics() {
733        let input = make_peer_input(
734            Some(PeerConvention::Request {
735                request_id: "r".into(),
736                intent: "i".into(),
737            }),
738            Some(HandlingMode::Steer),
739        );
740        let decision = DefaultPolicyTable::resolve(&input, false);
741        assert_eq!(decision.routing_disposition, RoutingDisposition::Steer);
742    }
743
744    #[test]
745    fn peer_no_convention_with_explicit_steer_resolves_steer_semantics() {
746        let input = make_peer_input(None, Some(HandlingMode::Steer));
747        let decision = DefaultPolicyTable::resolve(&input, true);
748        assert_eq!(decision.routing_disposition, RoutingDisposition::Steer);
749    }
750
751    #[test]
752    fn peer_message_without_override_preserves_kind_default() {
753        let input = make_peer_input(Some(PeerConvention::Message), None);
754        let decision = DefaultPolicyTable::resolve(&input, true);
755        // Kind-based default for peer_message idle is Queue
756        assert_eq!(decision.routing_disposition, RoutingDisposition::Queue);
757        assert_eq!(decision.apply_mode, ApplyMode::StageRunStart);
758        assert_eq!(decision.wake_mode, WakeMode::WakeIfIdle);
759    }
760
761    // -----------------------------------------------------------------------
762    // P2: Policy table refuses to honor handling_mode for response progress
763    // -----------------------------------------------------------------------
764
765    #[test]
766    fn response_progress_with_handling_mode_falls_through_to_kind_default() {
767        // Even if a ResponseProgress somehow carries handling_mode=Steer,
768        // the policy table must ignore it and use kind-based defaults.
769        let input = make_peer_input(
770            Some(PeerConvention::ResponseProgress {
771                request_id: "r".into(),
772                phase: crate::input::ResponseProgressPhase::InProgress,
773            }),
774            Some(HandlingMode::Steer),
775        );
776        let decision = DefaultPolicyTable::resolve(&input, true);
777        // Kind default for peer_response_progress: Coalesce, StageRunBoundary, Steer
778        // — but via kind-based resolution, NOT via the handling_mode override path.
779        assert_eq!(decision.queue_mode, QueueMode::Coalesce);
780        assert_eq!(decision.apply_mode, ApplyMode::StageRunBoundary);
781        assert_eq!(decision.wake_mode, WakeMode::None);
782    }
783
784    #[test]
785    fn response_terminal_with_steer_gets_steer_semantics() {
786        let input = make_peer_input(
787            Some(PeerConvention::ResponseTerminal {
788                request_id: "r".into(),
789                status: crate::input::ResponseTerminalStatus::Completed,
790            }),
791            Some(HandlingMode::Steer),
792        );
793        let decision = DefaultPolicyTable::resolve(&input, true);
794        assert_eq!(decision.routing_disposition, RoutingDisposition::Steer);
795        assert_eq!(
796            decision.apply_mode,
797            ApplyMode::StageRunStart,
798            "terminal peer-response apply intent owns the context+reaction boundary; steer only changes urgency/lane"
799        );
800        assert_eq!(decision.drain_policy, DrainPolicy::SteerBatch);
801        assert_eq!(decision.wake_mode, WakeMode::WakeIfIdle);
802        assert!(decision.record_transcript);
803    }
804
805    #[test]
806    fn response_terminal_with_queue_handling_mode_gets_queue_semantics() {
807        let input = make_peer_input(
808            Some(PeerConvention::ResponseTerminal {
809                request_id: "r".into(),
810                status: crate::input::ResponseTerminalStatus::Completed,
811            }),
812            Some(HandlingMode::Queue),
813        );
814        let decision = DefaultPolicyTable::resolve(&input, true);
815        assert_eq!(decision.routing_disposition, RoutingDisposition::Queue);
816        assert_eq!(decision.apply_mode, ApplyMode::StageRunStart);
817        assert_eq!(decision.wake_mode, WakeMode::WakeIfIdle);
818
819        let running_decision = DefaultPolicyTable::resolve(&input, false);
820        assert_eq!(
821            running_decision.routing_disposition,
822            RoutingDisposition::Queue
823        );
824        assert_eq!(running_decision.apply_mode, ApplyMode::StageRunStart);
825        assert_eq!(running_decision.wake_mode, WakeMode::WakeIfIdle);
826    }
827
828    #[test]
829    fn response_terminal_without_handling_mode_keeps_kind_default() {
830        let input = make_peer_input(
831            Some(PeerConvention::ResponseTerminal {
832                request_id: "r".into(),
833                status: crate::input::ResponseTerminalStatus::Completed,
834            }),
835            None,
836        );
837        let decision = DefaultPolicyTable::resolve(&input, true);
838        // Kind default for peer_response_terminal idle: queue a turn-start so
839        // turn-driven async request/response flows (realtime audio members
840        // waiting for `send_response`) react to the response instead of
841        // stranding on durable context. The rendered notice still flows
842        // through `input_to_context_append` for authoritative system-context
843        // dedup on `peer_response_terminal:{peer_id}:{request_id}`.
844        assert_eq!(decision.routing_disposition, RoutingDisposition::Queue);
845        assert_eq!(decision.apply_mode, ApplyMode::StageRunStart);
846        assert_eq!(decision.wake_mode, WakeMode::WakeIfIdle);
847    }
848}