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