Skip to main content

meerkat_core/
interaction.rs

1//! Interaction types for the core agent loop.
2//!
3//! These types provide a simplified adapter layer in core (no comms dependency).
4//! `CommsContent` in meerkat-comms remains canonical with richer types.
5//! The comms runtime converts at the boundary.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::BTreeSet;
10use uuid::Uuid;
11
12use crate::comms::{
13    PeerId, PeerLifecycleKind, PeerName, PeerRoute, SUPERVISOR_BRIDGE_INTENT, TrustedPeerDescriptor,
14};
15use crate::types::{ContentBlock, HandlingMode, RenderMetadata};
16
17/// Unique identifier for an interaction.
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct InteractionId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
21
22impl std::fmt::Display for InteractionId {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        self.0.fmt(f)
25    }
26}
27
28/// Typed status for response interactions.
29///
30/// Mirrors `CommsStatus` from `meerkat-comms` — the comms runtime converts at the boundary.
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ResponseStatus {
35    Accepted,
36    Completed,
37    Failed,
38}
39
40/// Terminality projection for a typed `ResponseStatus`.
41///
42/// Runtime-backed peer ingress receives this as part of the typed
43/// `PeerIngressClassification` emitted by the machine authority. Downstream
44/// runtime/public projections must consume that carried terminality instead of
45/// re-matching raw response status after admission.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub enum TerminalityClass {
49    Progress,
50    Terminal { disposition: TerminalDisposition },
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54#[non_exhaustive]
55pub enum TerminalDisposition {
56    Completed,
57    Failed,
58}
59
60/// Single source of truth for "is this response terminal?".
61pub fn classify_response_terminality(status: ResponseStatus) -> TerminalityClass {
62    match status {
63        ResponseStatus::Accepted => TerminalityClass::Progress,
64        ResponseStatus::Completed => TerminalityClass::Terminal {
65            disposition: TerminalDisposition::Completed,
66        },
67        ResponseStatus::Failed => TerminalityClass::Terminal {
68            disposition: TerminalDisposition::Failed,
69        },
70    }
71}
72
73/// Simplified interaction content for the core agent loop.
74///
75/// This is an adapter type — `CommsContent` in meerkat-comms has richer types
76/// (`MessageIntent`, `CommsStatus`, etc.). The comms runtime converts at the boundary.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(tag = "type", rename_all = "snake_case")]
79pub enum InteractionContent {
80    /// A simple text message.
81    Message {
82        body: String,
83        /// Optional multimodal content blocks.
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        blocks: Option<Vec<ContentBlock>>,
86    },
87    /// A request for the agent to perform an action.
88    Request {
89        intent: String,
90        params: Value,
91        #[serde(default, skip_serializing_if = "Option::is_none")]
92        blocks: Option<Vec<ContentBlock>>,
93    },
94    /// A response to a previous request.
95    Response {
96        in_reply_to: InteractionId,
97        status: ResponseStatus,
98        result: Value,
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        blocks: Option<Vec<ContentBlock>>,
101    },
102}
103
104/// An interaction drained from the inbox, ready for classification.
105#[derive(Debug, Clone)]
106pub struct InboxInteraction {
107    /// Unique identifier for this interaction.
108    pub id: InteractionId,
109    /// Machine route identity for peer senders. Plain external events leave
110    /// this unset because they are source-labelled, not peer-routed.
111    pub from_route: Option<PeerId>,
112    /// Who sent this interaction (peer display name or source label).
113    pub from: String,
114    /// The interaction content.
115    pub content: InteractionContent,
116    /// Pre-rendered text suitable for injection into an LLM session.
117    pub rendered_text: String,
118    /// Runtime-owned handling hint for ordinary work admitted from plain events.
119    pub handling_mode: HandlingMode,
120    /// Optional normalized rendering metadata carried alongside the interaction.
121    pub render_metadata: Option<RenderMetadata>,
122}
123
124/// Canonical model-facing text projection for an external event.
125///
126/// The visible identity of an external event is its source label
127/// (`webhook`, `rpc`, `stdin`, etc.). Optional body text may follow, but
128/// structured payload remains typed metadata rather than prompt text.
129pub fn format_external_event_projection(source_name: &str, body: Option<&str>) -> String {
130    let label = format!("External event via {source_name}");
131    let body = body.map(str::trim).filter(|body| !body.is_empty());
132
133    match body {
134        Some(body) => format!("{label}: {body}"),
135        None => label,
136    }
137}
138
139/// Canonical model-facing text projection for a peer message.
140pub fn format_peer_message_projection(from_peer: &str, body: &str) -> String {
141    format!("Peer message from {from_peer}:\n{body}")
142}
143
144/// Schema-shaped model-facing `send_response` call affordance.
145///
146/// This helper owns the field names used when a prompt tells a model how to
147/// answer a correlated peer request. The MCP `SendResponseInput` schema must
148/// accept the object rendered here; comms tests pin that boundary.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct SendResponseCallProjection {
151    pub peer_id: PeerId,
152    pub display_name: Option<String>,
153    pub in_reply_to: String,
154}
155
156impl SendResponseCallProjection {
157    pub const TOOL_NAME: &'static str = "send_response";
158    pub const PEER_ID_FIELD: &'static str = "peer_id";
159    pub const DISPLAY_NAME_FIELD: &'static str = "display_name";
160    pub const IN_REPLY_TO_FIELD: &'static str = "in_reply_to";
161    pub const STATUS_FIELD: &'static str = "status";
162    pub const RESULT_FIELD: &'static str = "result";
163
164    pub fn new(
165        peer_id: PeerId,
166        display_name: Option<&str>,
167        in_reply_to: impl Into<String>,
168    ) -> Self {
169        Self {
170            peer_id,
171            display_name: display_name
172                .map(str::trim)
173                .filter(|name| !name.is_empty())
174                .map(ToOwned::to_owned),
175            in_reply_to: in_reply_to.into(),
176        }
177    }
178
179    /// A concrete, schema-valid example argument object for a completed reply.
180    ///
181    /// The model may replace `status` with `"failed"`. Public result payloads
182    /// are typed by the comms contract, so the generic projection omits a
183    /// result body instead of advertising arbitrary JSON.
184    pub fn completed_example_args(&self) -> Value {
185        let mut args = serde_json::Map::new();
186        args.insert(
187            Self::PEER_ID_FIELD.to_string(),
188            Value::String(self.peer_id.to_string()),
189        );
190        if let Some(display_name) = &self.display_name {
191            args.insert(
192                Self::DISPLAY_NAME_FIELD.to_string(),
193                Value::String(display_name.clone()),
194            );
195        }
196        args.insert(
197            Self::IN_REPLY_TO_FIELD.to_string(),
198            Value::String(self.in_reply_to.clone()),
199        );
200        args.insert(
201            Self::STATUS_FIELD.to_string(),
202            Value::String("completed".to_string()),
203        );
204        Value::Object(args)
205    }
206
207    pub fn instruction_text(&self) -> String {
208        let args = serde_json::to_string(&self.completed_example_args())
209            .unwrap_or_else(|_| "{}".to_string());
210        format!(
211            "Reply with {} with arguments {args}. Use status=\"failed\" instead of \"completed\" when the request cannot be fulfilled, and include result only when the request contract provides a typed result payload.",
212            Self::TOOL_NAME
213        )
214    }
215}
216
217/// Canonical model-facing text projection for a correlated peer request.
218pub fn format_peer_request_projection(
219    from_peer_id: PeerId,
220    display_name: Option<&str>,
221    request_id: impl std::fmt::Display,
222    intent: &str,
223    params: &Value,
224) -> String {
225    let params_str = if params.is_null() || matches!(params, Value::Object(map) if map.is_empty()) {
226        String::new()
227    } else {
228        format!(
229            "\nParams: {}",
230            serde_json::to_string_pretty(params).unwrap_or_default()
231        )
232    };
233    let request_id = request_id.to_string();
234    let display_suffix = display_name
235        .map(str::trim)
236        .filter(|name| !name.is_empty())
237        .map(|name| format!(" (display_name: {name})"))
238        .unwrap_or_default();
239    let response_call =
240        SendResponseCallProjection::new(from_peer_id, display_name, request_id.clone());
241
242    format!(
243        "Peer request from peer_id {from_peer_id}{display_suffix} (id: {request_id})\n\
244         Intent: {intent}{params_str}\n\
245         Request ID: {request_id}\n\
246         \n\
247         This is a correlated peer request. {} \
248         Do not answer this request with send_message.",
249        response_call.instruction_text()
250    )
251}
252
253/// Canonical model-facing text projection for a peer response.
254pub fn format_peer_response_projection(
255    from_peer: &str,
256    in_reply_to: impl std::fmt::Display,
257    status: ResponseStatus,
258    result: &Value,
259) -> String {
260    let status_str = match status {
261        ResponseStatus::Accepted => "accepted",
262        ResponseStatus::Completed => "completed",
263        ResponseStatus::Failed => "failed",
264    };
265    let result_str = if result.is_null() || matches!(result, Value::Object(map) if map.is_empty()) {
266        String::new()
267    } else {
268        format!(
269            "\nResult: {}",
270            serde_json::to_string_pretty(result).unwrap_or_default()
271        )
272    };
273
274    format!(
275        "Peer response from {from_peer} (to request: {in_reply_to})\n\
276         Status: {status_str}{result_str}"
277    )
278}
279
280/// Canonical model-facing text projection for a peer ack.
281pub fn format_peer_ack_projection(from_peer: &str, in_reply_to: impl std::fmt::Display) -> String {
282    format!("Peer ack from {from_peer} (to request: {in_reply_to})")
283}
284
285/// Classification result for incoming peer/event traffic.
286///
287/// Stored with each inbox entry at ingress time. Downstream consumers
288/// switch on this enum instead of re-classifying.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum PeerInputClass {
291    /// A peer message that should route through canonical runtime admission.
292    ActionableMessage,
293    /// A peer request that should route through canonical runtime admission.
294    ActionableRequest,
295    /// A non-terminal response to a previous outbound request.
296    ResponseProgress,
297    /// A terminal response to a previous outbound request.
298    ResponseTerminal,
299    /// Peer added lifecycle event.
300    PeerLifecycleAdded,
301    /// Peer retired lifecycle event.
302    PeerLifecycleRetired,
303    /// Peer unwired lifecycle event.
304    PeerLifecycleUnwired,
305    /// Member kickoff failed lifecycle event.
306    PeerLifecycleKickoffFailed,
307    /// Member kickoff cancelled lifecycle event.
308    PeerLifecycleKickoffCancelled,
309    /// A request whose intent is in the silent-intents set (inline-only, no LLM turn).
310    SilentRequest,
311    /// An ack envelope (filtered at ingress, never reaches agent loop).
312    Ack,
313    /// A plain (unauthenticated) event from an external source.
314    PlainEvent,
315}
316
317impl PeerInputClass {
318    /// Returns true if this class is actionable runtime ingress.
319    pub fn is_actionable(&self) -> bool {
320        matches!(
321            self,
322            Self::ActionableMessage
323                | Self::ActionableRequest
324                | Self::ResponseProgress
325                | Self::ResponseTerminal
326                | Self::PlainEvent
327                | Self::PeerLifecycleKickoffFailed
328                | Self::PeerLifecycleKickoffCancelled
329        )
330    }
331}
332
333/// Typed auth exemption recognized by peer ingress authority.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
335pub enum PeerIngressAuthExemption {
336    /// Supervisor bridge bootstrap request.
337    SupervisorBridge,
338}
339
340impl PeerIngressAuthExemption {
341    pub const fn intent(self) -> &'static str {
342        match self {
343            Self::SupervisorBridge => SUPERVISOR_BRIDGE_INTENT,
344        }
345    }
346
347    pub fn matches_intent(self, intent: &str) -> bool {
348        self.intent() == intent
349    }
350}
351
352/// Auth decision attached to a classified peer ingress item.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
354pub enum PeerIngressAuthDecision {
355    /// Sender must be trusted when peer auth is required.
356    Required,
357    /// The item is allowed through the trust gate for a typed bootstrap reason.
358    Exempt(PeerIngressAuthExemption),
359}
360
361impl PeerIngressAuthDecision {
362    pub const fn is_exempt(self) -> bool {
363        matches!(self, Self::Exempt(_))
364    }
365}
366
367/// Typed peer convention admitted at the peer-ingress seam.
368///
369/// This is the core-side ingress convention, not a rendered prompt. Runtime
370/// prompt/schema projections derive from it after admission so `InboxInteraction::from`
371/// never has to carry both display and canonical identity.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum PeerIngressConvention {
374    Message,
375    Request {
376        request_id: String,
377        intent: String,
378    },
379    Response {
380        in_reply_to: InteractionId,
381        status: ResponseStatus,
382    },
383    Ack {
384        in_reply_to: InteractionId,
385    },
386    Lifecycle {
387        kind: PeerLifecycleKind,
388        peer: String,
389    },
390    PlainEvent {
391        source_name: String,
392    },
393}
394
395/// Typed fact admitted at the peer-ingress seam.
396///
397/// The legacy `InboxInteraction::from` field remains a compatibility display
398/// label. Runtime routing, trust, bridge response resolution, and prompt/schema
399/// projection must consume the matching typed field on this fact.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct PeerIngressFact {
402    /// Interaction/correlation identifier stamped at ingress.
403    pub interaction_id: InteractionId,
404    /// Pre-computed ingress class.
405    pub class: PeerInputClass,
406    /// Coarse admitted kind.
407    pub kind: PeerIngressKind,
408    /// Canonical comms peer id. This is the runtime prompt/schema peer id.
409    pub canonical_peer_id: Option<PeerId>,
410    /// Human-facing display label for diagnostics and legacy rendered text.
411    pub display_name: Option<PeerName>,
412    /// Ed25519 signing public key / trust subject when ingress was signed.
413    pub signing_pubkey: Option<[u8; 32]>,
414    /// Resolved route/binding handle for replies to this sender.
415    pub route: Option<PeerRoute>,
416    /// Auth decision used by peer ingress admission.
417    pub auth: Option<PeerIngressAuthDecision>,
418    /// Typed peer convention admitted at ingress.
419    pub convention: PeerIngressConvention,
420}
421
422/// Sender identity admitted with a peer ingress fact.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct PeerIngressIdentity {
425    pub canonical_peer_id: PeerId,
426    pub display_label: String,
427    pub signing_pubkey: Option<[u8; 32]>,
428    pub convention: PeerIngressConvention,
429}
430
431impl PeerIngressIdentity {
432    pub fn new(
433        canonical_peer_id: PeerId,
434        display_label: impl Into<String>,
435        convention: PeerIngressConvention,
436    ) -> Self {
437        Self {
438            canonical_peer_id,
439            display_label: display_label.into(),
440            signing_pubkey: None,
441            convention,
442        }
443    }
444
445    pub fn with_signing_pubkey(mut self, signing_pubkey: [u8; 32]) -> Self {
446        self.signing_pubkey = Some(signing_pubkey);
447        self
448    }
449}
450
451impl PeerIngressFact {
452    pub fn peer(
453        interaction_id: InteractionId,
454        class: PeerInputClass,
455        kind: PeerIngressKind,
456        auth: Option<PeerIngressAuthDecision>,
457        identity: PeerIngressIdentity,
458    ) -> Self {
459        let PeerIngressIdentity {
460            canonical_peer_id,
461            display_label,
462            signing_pubkey,
463            convention,
464        } = identity;
465        let display_name = PeerName::new(display_label).ok();
466        let route = Some(match &display_name {
467            Some(name) => PeerRoute::with_display_name(canonical_peer_id, name.clone()),
468            None => PeerRoute::new(canonical_peer_id),
469        });
470        Self {
471            interaction_id,
472            class,
473            kind,
474            canonical_peer_id: Some(canonical_peer_id),
475            display_name,
476            signing_pubkey,
477            route,
478            auth,
479            convention,
480        }
481    }
482
483    pub fn plain_event(
484        interaction_id: InteractionId,
485        source_name: impl Into<String>,
486        class: PeerInputClass,
487        kind: PeerIngressKind,
488    ) -> Self {
489        let source_name = source_name.into();
490        Self {
491            interaction_id,
492            class,
493            kind,
494            canonical_peer_id: None,
495            display_name: None,
496            signing_pubkey: None,
497            route: None,
498            auth: None,
499            convention: PeerIngressConvention::PlainEvent { source_name },
500        }
501    }
502
503    /// Compatibility constructor for tests and legacy non-classified seams.
504    ///
505    /// Prefer constructing a full `peer(...)` fact with canonical peer id,
506    /// signing subject, and route when the ingress came from comms.
507    pub fn legacy_peer_label(
508        interaction_id: InteractionId,
509        label: impl Into<String>,
510        class: PeerInputClass,
511        kind: PeerIngressKind,
512        auth: Option<PeerIngressAuthDecision>,
513        convention: PeerIngressConvention,
514    ) -> Self {
515        let label = label.into();
516        let canonical_peer_id = PeerId::parse(&label).ok();
517        let display_name = PeerName::new(label).ok();
518        let route = canonical_peer_id.map(|peer_id| match &display_name {
519            Some(name) => PeerRoute::with_display_name(peer_id, name.clone()),
520            None => PeerRoute::new(peer_id),
521        });
522        Self {
523            interaction_id,
524            class,
525            kind,
526            canonical_peer_id,
527            display_name,
528            signing_pubkey: None,
529            route,
530            auth,
531            convention,
532        }
533    }
534
535    pub fn canonical_peer_id_string(&self) -> Option<String> {
536        self.canonical_peer_id.map(|peer_id| peer_id.as_str())
537    }
538
539    pub fn display_label(&self) -> Option<String> {
540        self.display_name.as_ref().map(PeerName::as_string)
541    }
542
543    pub fn diagnostic_label(&self) -> String {
544        self.display_label()
545            .or_else(|| self.canonical_peer_id_string())
546            .unwrap_or_else(|| "<unknown-peer-ingress>".to_string())
547    }
548
549    pub fn plain_event_source_name(&self) -> Option<&str> {
550        match &self.convention {
551            PeerIngressConvention::PlainEvent { source_name } => Some(source_name.as_str()),
552            _ => None,
553        }
554    }
555}
556
557/// Typed output of machine-owned peer ingress classification.
558#[derive(Debug, Clone, PartialEq, Eq)]
559pub struct PeerIngressClassification {
560    pub class: PeerInputClass,
561    pub kind: PeerIngressKind,
562    pub auth: PeerIngressAuthDecision,
563    pub lifecycle_kind: Option<PeerLifecycleKind>,
564    pub response_terminality: Option<TerminalityClass>,
565}
566
567impl PeerIngressClassification {
568    pub const fn required(class: PeerInputClass, kind: PeerIngressKind) -> Self {
569        Self {
570            class,
571            kind,
572            auth: PeerIngressAuthDecision::Required,
573            lifecycle_kind: None,
574            response_terminality: None,
575        }
576    }
577
578    pub const fn lifecycle(kind: PeerLifecycleKind) -> Self {
579        let class = match kind {
580            PeerLifecycleKind::PeerAdded => PeerInputClass::PeerLifecycleAdded,
581            PeerLifecycleKind::PeerRetired => PeerInputClass::PeerLifecycleRetired,
582            PeerLifecycleKind::PeerUnwired => PeerInputClass::PeerLifecycleUnwired,
583        };
584        Self {
585            class,
586            kind: PeerIngressKind::Request,
587            auth: PeerIngressAuthDecision::Required,
588            lifecycle_kind: Some(kind),
589            response_terminality: None,
590        }
591    }
592}
593
594/// Parsed transport facts for one peer-envelope ingress item.
595///
596/// This is intentionally a typed adapter shape: comms may parse the envelope
597/// mechanics into this struct, but the policy below owns all semantic
598/// classification derived from it.
599#[derive(Debug, Clone, PartialEq)]
600pub struct PeerIngressEnvelopeFacts {
601    pub item_id: String,
602    pub from_peer: String,
603    pub from_peer_id: PeerId,
604    pub kind: PeerIngressEnvelopeKind,
605}
606
607#[derive(Debug, Clone, PartialEq)]
608pub enum PeerIngressEnvelopeKind {
609    Message {
610        body: String,
611    },
612    Request {
613        intent: String,
614        params: Value,
615    },
616    Lifecycle {
617        kind: PeerLifecycleKind,
618        params: Value,
619    },
620    Response {
621        in_reply_to: String,
622        status: ResponseStatus,
623        result: Value,
624    },
625    Ack {
626        in_reply_to: String,
627    },
628}
629
630/// Parsed transport facts for one plain external event.
631#[derive(Debug, Clone, PartialEq, Eq)]
632pub struct PeerIngressPlainEventFacts {
633    pub source_name: String,
634    pub body: String,
635}
636
637/// Complete typed admission facts produced by peer-ingress classification.
638#[derive(Debug, Clone, PartialEq, Eq)]
639pub struct PeerIngressAdmission {
640    pub classification: PeerIngressClassification,
641    pub lifecycle_peer: Option<String>,
642    pub request_id: Option<String>,
643    pub rendered_text: String,
644}
645
646/// Standalone compatibility adapter for peer ingress classification.
647///
648/// Runtime-backed comms must use the MeerkatMachine
649/// `PeerIngressClassified` effect as authority. This adapter exists only for
650/// standalone comms runtimes without a session DSL and for tests that need a
651/// wire-compatible projection of machine behavior. Raw inbox ingress and
652/// runtime-required classified ingress must not use it as a second authority
653/// for auth exemptions, lifecycle intent, or response terminality.
654#[derive(Debug, Clone, PartialEq, Eq)]
655pub struct PeerIngressMachinePolicy {
656    silent_request_intents: BTreeSet<String>,
657    auth_exemptions: BTreeSet<PeerIngressAuthExemption>,
658}
659
660impl Default for PeerIngressMachinePolicy {
661    fn default() -> Self {
662        Self::from_silent_intents(std::iter::empty::<String>())
663    }
664}
665
666impl PeerIngressMachinePolicy {
667    pub fn from_silent_intents<I, S>(silent_intents: I) -> Self
668    where
669        I: IntoIterator<Item = S>,
670        S: Into<String>,
671    {
672        Self {
673            silent_request_intents: silent_intents.into_iter().map(Into::into).collect(),
674            auth_exemptions: BTreeSet::from([PeerIngressAuthExemption::SupervisorBridge]),
675        }
676    }
677
678    pub fn classify_message(&self) -> PeerIngressClassification {
679        PeerIngressClassification::required(
680            PeerInputClass::ActionableMessage,
681            PeerIngressKind::Message,
682        )
683    }
684
685    pub fn classify_request_intent(&self, intent: &str) -> PeerIngressClassification {
686        let auth = self
687            .auth_exemptions
688            .iter()
689            .copied()
690            .find(|exemption| exemption.matches_intent(intent))
691            .map(PeerIngressAuthDecision::Exempt)
692            .unwrap_or(PeerIngressAuthDecision::Required);
693
694        let mut classification = if let Some(kind) = classify_lifecycle_intent(intent) {
695            PeerIngressClassification::lifecycle(kind)
696        } else if self.silent_request_intents.contains(intent) {
697            PeerIngressClassification::required(
698                PeerInputClass::SilentRequest,
699                PeerIngressKind::Request,
700            )
701        } else {
702            PeerIngressClassification::required(
703                PeerInputClass::ActionableRequest,
704                PeerIngressKind::Request,
705            )
706        };
707        classification.auth = auth;
708        classification
709    }
710
711    pub fn classify_lifecycle(&self, kind: PeerLifecycleKind) -> PeerIngressClassification {
712        PeerIngressClassification::lifecycle(kind)
713    }
714
715    pub fn classify_response(&self, status: ResponseStatus) -> PeerIngressClassification {
716        let terminality = classify_response_terminality(status);
717        let class = match terminality {
718            TerminalityClass::Progress => PeerInputClass::ResponseProgress,
719            TerminalityClass::Terminal { .. } => PeerInputClass::ResponseTerminal,
720        };
721        let mut classification =
722            PeerIngressClassification::required(class, PeerIngressKind::Response);
723        classification.response_terminality = Some(terminality);
724        classification
725    }
726
727    pub fn classify_ack(&self) -> PeerIngressClassification {
728        PeerIngressClassification::required(PeerInputClass::Ack, PeerIngressKind::Ack)
729    }
730
731    pub fn classify_plain_event(&self) -> PeerIngressClassification {
732        PeerIngressClassification::required(PeerInputClass::PlainEvent, PeerIngressKind::PlainEvent)
733    }
734
735    pub fn classify_external_envelope(
736        &self,
737        facts: &PeerIngressEnvelopeFacts,
738    ) -> PeerIngressAdmission {
739        match &facts.kind {
740            PeerIngressEnvelopeKind::Message { .. } => {
741                let classification = self.classify_message();
742                PeerIngressAdmission {
743                    rendered_text: render_peer_ingress_admitted_text(facts, &classification),
744                    classification,
745                    lifecycle_peer: None,
746                    request_id: None,
747                }
748            }
749            PeerIngressEnvelopeKind::Request { intent, params } => {
750                let classification = self.classify_request_intent(intent);
751                let lifecycle_peer = classification
752                    .lifecycle_kind
753                    .map(|_| peer_lifecycle_subject(params, facts.from_peer.as_str()));
754                let rendered_text = render_peer_ingress_admitted_text(facts, &classification);
755                PeerIngressAdmission {
756                    classification,
757                    lifecycle_peer,
758                    request_id: Some(facts.item_id.clone()),
759                    rendered_text,
760                }
761            }
762            PeerIngressEnvelopeKind::Lifecycle { kind, params } => {
763                let classification = self.classify_lifecycle(*kind);
764                PeerIngressAdmission {
765                    rendered_text: render_peer_ingress_admitted_text(facts, &classification),
766                    classification,
767                    lifecycle_peer: Some(peer_lifecycle_subject(params, facts.from_peer.as_str())),
768                    request_id: None,
769                }
770            }
771            PeerIngressEnvelopeKind::Response {
772                in_reply_to,
773                status,
774                ..
775            } => {
776                let classification = self.classify_response(*status);
777                PeerIngressAdmission {
778                    rendered_text: render_peer_ingress_admitted_text(facts, &classification),
779                    classification,
780                    lifecycle_peer: None,
781                    request_id: Some(in_reply_to.clone()),
782                }
783            }
784            PeerIngressEnvelopeKind::Ack { in_reply_to } => {
785                let classification = self.classify_ack();
786                PeerIngressAdmission {
787                    rendered_text: render_peer_ingress_admitted_text(facts, &classification),
788                    classification,
789                    lifecycle_peer: None,
790                    request_id: Some(in_reply_to.clone()),
791                }
792            }
793        }
794    }
795
796    pub fn classify_plain_event_facts(
797        &self,
798        facts: &PeerIngressPlainEventFacts,
799    ) -> PeerIngressAdmission {
800        PeerIngressAdmission {
801            classification: self.classify_plain_event(),
802            lifecycle_peer: None,
803            request_id: None,
804            rendered_text: format_external_event_projection(&facts.source_name, Some(&facts.body)),
805        }
806    }
807}
808
809/// Derive model-facing text after typed peer ingress admission.
810///
811/// Classification is the authority. This renderer only projects already
812/// admitted facts into prompt text, so callers cannot change routing or auth
813/// by editing prose formatting.
814pub fn render_peer_ingress_admitted_text(
815    facts: &PeerIngressEnvelopeFacts,
816    classification: &PeerIngressClassification,
817) -> String {
818    match &facts.kind {
819        PeerIngressEnvelopeKind::Message { body } => {
820            format_peer_message_projection(&facts.from_peer, body)
821        }
822        PeerIngressEnvelopeKind::Request { intent, params } => {
823            if classification.lifecycle_kind.is_some() {
824                String::new()
825            } else {
826                format_peer_request_projection(
827                    facts.from_peer_id,
828                    Some(&facts.from_peer),
829                    facts.item_id.as_str(),
830                    intent,
831                    params,
832                )
833            }
834        }
835        PeerIngressEnvelopeKind::Lifecycle { .. } => String::new(),
836        PeerIngressEnvelopeKind::Response {
837            in_reply_to,
838            status,
839            result,
840        } => format_peer_response_projection(&facts.from_peer, in_reply_to, *status, result),
841        PeerIngressEnvelopeKind::Ack { in_reply_to } => {
842            format_peer_ack_projection(&facts.from_peer, in_reply_to)
843        }
844    }
845}
846
847/// Extract the lifecycle subject from typed request/lifecycle parameters.
848pub fn peer_lifecycle_subject(params: &Value, fallback_peer: &str) -> String {
849    params
850        .get("peer")
851        .and_then(Value::as_str)
852        .filter(|peer| !peer.is_empty())
853        .unwrap_or(fallback_peer)
854        .to_string()
855}
856
857fn classify_lifecycle_intent(intent: &str) -> Option<PeerLifecycleKind> {
858    if intent == PeerLifecycleKind::PeerAdded.as_str() {
859        Some(PeerLifecycleKind::PeerAdded)
860    } else if intent == PeerLifecycleKind::PeerRetired.as_str() {
861        Some(PeerLifecycleKind::PeerRetired)
862    } else if intent == PeerLifecycleKind::PeerUnwired.as_str() {
863        Some(PeerLifecycleKind::PeerUnwired)
864    } else {
865        None
866    }
867}
868
869/// Canonical peer/event ingress candidate handed to runtime admission.
870///
871/// This is the typed, machine-authored drain unit for runtime-backed peer
872/// ingress. It preserves ingress classification so downstream code does not
873/// re-derive semantics after drain.
874#[derive(Debug, Clone)]
875pub struct PeerInputCandidate {
876    /// The original interaction data.
877    pub interaction: InboxInteraction,
878    /// Typed admitted ingress fact. Consumers must use this for canonical peer
879    /// identity, display labels, trust subjects, route handles, and convention.
880    pub ingress: PeerIngressFact,
881    /// For lifecycle events, the peer name that was added/retired.
882    pub lifecycle_peer: Option<String>,
883    /// For response events, the machine-owned progress/terminal classifier.
884    pub response_terminality: Option<TerminalityClass>,
885}
886
887impl PeerInputCandidate {
888    pub fn new(
889        interaction: InboxInteraction,
890        ingress: PeerIngressFact,
891        lifecycle_peer: Option<String>,
892    ) -> Self {
893        Self {
894            interaction,
895            ingress,
896            lifecycle_peer,
897            response_terminality: None,
898        }
899    }
900
901    pub fn class(&self) -> PeerInputClass {
902        self.ingress.class
903    }
904
905    pub fn kind(&self) -> PeerIngressKind {
906        self.ingress.kind
907    }
908
909    pub fn auth(&self) -> Option<PeerIngressAuthDecision> {
910        self.ingress.auth
911    }
912}
913
914/// Back-compat alias for older runtime and diagnostic seams.
915pub type ClassifiedInboxInteraction = PeerInputCandidate;
916
917/// Coarse source kind for a queued peer-ingress item.
918///
919/// This is a diagnostic shape for MeerkatMachine mapping work. It records the
920/// kind that was admitted at ingress without exposing transport internals.
921#[derive(Debug, Clone, Copy, PartialEq, Eq)]
922pub enum PeerIngressKind {
923    Message,
924    Request,
925    Response,
926    Ack,
927    PlainEvent,
928}
929
930/// Display-only peer or source label captured for ingress diagnostics.
931///
932/// This is deliberately not a routing, trust, or admission identity. Canonical
933/// peer authority lives in the admitted ingress fact and runtime/machine
934/// admission state; snapshot rows only expose this label so operators can read
935/// queue diagnostics.
936#[derive(Debug, Clone, PartialEq, Eq)]
937pub struct PeerIngressDiagnosticDisplay(String);
938
939impl PeerIngressDiagnosticDisplay {
940    pub fn new(value: impl Into<String>) -> Self {
941        Self(value.into())
942    }
943
944    pub fn as_str(&self) -> &str {
945        &self.0
946    }
947}
948
949impl std::fmt::Display for PeerIngressDiagnosticDisplay {
950    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
951        self.0.fmt(f)
952    }
953}
954
955/// Diagnostic copy of the admission-time trust observation for a queued item.
956///
957/// This records what admission observed when the item was queued. It is not a
958/// live trust oracle and must not be used to reconstruct routing or admission
959/// authority from a snapshot row.
960#[derive(Debug, Clone, Copy, PartialEq, Eq)]
961pub enum PeerIngressAdmissionDiagnostic {
962    TrustedAtAdmission,
963    UntrustedAtAdmission,
964}
965
966impl PeerIngressAdmissionDiagnostic {
967    pub const fn from_trusted(trusted: bool) -> Self {
968        if trusted {
969            Self::TrustedAtAdmission
970        } else {
971            Self::UntrustedAtAdmission
972        }
973    }
974
975    pub const fn trusted_at_admission(self) -> bool {
976        matches!(self, Self::TrustedAtAdmission)
977    }
978}
979
980/// Snapshot of one queued peer-ingress item.
981///
982/// Snapshot rows are diagnostics derived from the canonical admitted ingress
983/// candidate. They are intentionally incomplete for route/trust reconstruction:
984/// peer labels are display-only, correlation ids are typed, and admission
985/// details are diagnostic copies rather than authority.
986#[derive(Debug, Clone, PartialEq, Eq)]
987pub struct PeerIngressEntrySnapshot {
988    /// Stable typed ingress-time identity for this queued raw item.
989    pub raw_item_id: InteractionId,
990    /// Interaction/correlation identifier when one exists.
991    pub interaction_id: Option<InteractionId>,
992    /// Pre-computed ingress classification.
993    pub class: PeerInputClass,
994    /// Coarse admitted kind.
995    pub kind: PeerIngressKind,
996    /// Display-only sender label, if applicable. Not route/trust authority.
997    pub from_peer_display: Option<PeerIngressDiagnosticDisplay>,
998    /// Canonical sender peer id fixed at ingress time, if applicable.
999    pub canonical_peer_id: Option<PeerId>,
1000    /// Display peer name fixed at ingress time, if applicable.
1001    pub display_name: Option<PeerName>,
1002    /// Signing public key / trust subject fixed at ingress time, if applicable.
1003    pub signing_pubkey: Option<[u8; 32]>,
1004    /// Resolved reply route fixed at ingress time, if applicable.
1005    pub route: Option<PeerRoute>,
1006    /// Display-only lifecycle peer label, if applicable. Not route/trust authority.
1007    pub lifecycle_peer_display: Option<PeerIngressDiagnosticDisplay>,
1008    /// Request envelope id or reply-to correlation when one exists.
1009    pub request_correlation_id: Option<InteractionId>,
1010    /// Auth decision used by peer ingress admission, if this queued entry came
1011    /// from authenticated peer transport. Plain events leave this unset.
1012    pub auth: Option<PeerIngressAuthDecision>,
1013    /// Admission-time trust diagnostic, when peer authority owns the entry.
1014    /// Plain external events leave this unset.
1015    pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
1016    /// Machine-owned response progress/terminal classifier when this entry is
1017    /// a response.
1018    pub response_terminality: Option<TerminalityClass>,
1019}
1020
1021/// Non-destructive snapshot of the queued peer-ingress surface.
1022///
1023/// This is intentionally queue-shaped rather than a full PeerComms model. It
1024/// is the current honest owner-visible slice of peer ingress while the broader
1025/// MeerkatMachine refactor proceeds.
1026#[derive(Debug, Clone, PartialEq, Eq, Default)]
1027pub struct PeerIngressQueueSnapshot {
1028    pub total_count: usize,
1029    pub actionable_count: usize,
1030    pub response_count: usize,
1031    pub lifecycle_count: usize,
1032    pub silent_request_count: usize,
1033    pub ack_count: usize,
1034    pub plain_event_count: usize,
1035    pub queued_entries: Vec<PeerIngressEntrySnapshot>,
1036}
1037
1038/// Canonical phase of the peer-ingress authority.
1039///
1040/// This is distinct from the raw classified queue snapshot: plain external
1041/// events can be queued while the peer authority itself remains `Absent`.
1042#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1043pub enum PeerIngressAuthorityPhase {
1044    #[default]
1045    Absent,
1046    Received,
1047    Dropped,
1048    Delivered,
1049}
1050
1051/// Runtime-owned peer snapshot for the current Meerkat session.
1052///
1053/// This wraps the queued ingress surface with the trust membership that governs
1054/// which peer identities are admitted into that queue.
1055#[derive(Debug, Clone, PartialEq, Eq)]
1056pub struct PeerIngressRuntimeSnapshot {
1057    /// This runtime's public peer identity.
1058    pub self_peer_id: crate::comms::PeerId,
1059    /// Whether unauthenticated peer envelopes are rejected at ingress.
1060    pub auth_required: bool,
1061    /// Current phase of the peer-ingress authority.
1062    pub authority_phase: PeerIngressAuthorityPhase,
1063    /// Current trusted peer set visible to this runtime.
1064    pub trusted_peers: Vec<TrustedPeerDescriptor>,
1065    /// Current length of the authority-owned typed peer submission queue.
1066    pub submission_queue_len: usize,
1067    /// Non-destructive snapshot of the queued ingress surface.
1068    pub queue: PeerIngressQueueSnapshot,
1069}
1070
1071#[cfg(test)]
1072#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1073mod tests {
1074    use super::*;
1075
1076    #[test]
1077    fn interaction_id_json_roundtrip() {
1078        let id = InteractionId(Uuid::new_v4());
1079        let json = serde_json::to_string(&id).unwrap();
1080        let parsed: InteractionId = serde_json::from_str(&json).unwrap();
1081        assert_eq!(id, parsed);
1082    }
1083
1084    #[test]
1085    fn interaction_content_message_json_roundtrip() {
1086        let content = InteractionContent::Message {
1087            body: "hello".to_string(),
1088            blocks: None,
1089        };
1090        let json = serde_json::to_value(&content).unwrap();
1091        assert_eq!(json["type"], "message");
1092        let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1093        assert_eq!(content, parsed);
1094    }
1095
1096    #[test]
1097    fn interaction_content_request_json_roundtrip() {
1098        let content = InteractionContent::Request {
1099            intent: "review".to_string(),
1100            params: serde_json::json!({"pr": 42}),
1101            blocks: None,
1102        };
1103        let json = serde_json::to_value(&content).unwrap();
1104        assert_eq!(json["type"], "request");
1105        let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1106        assert_eq!(content, parsed);
1107    }
1108
1109    #[test]
1110    fn interaction_content_response_json_roundtrip() {
1111        let id = InteractionId(Uuid::new_v4());
1112        let content = InteractionContent::Response {
1113            in_reply_to: id,
1114            status: ResponseStatus::Completed,
1115            result: serde_json::json!({"ok": true}),
1116            blocks: None,
1117        };
1118        let json = serde_json::to_value(&content).unwrap();
1119        assert_eq!(json["type"], "response");
1120        assert_eq!(json["status"], "completed");
1121        let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1122        assert_eq!(content, parsed);
1123    }
1124
1125    #[test]
1126    fn response_status_json_roundtrip_all_variants() {
1127        for (variant, expected_str) in [
1128            (ResponseStatus::Accepted, "accepted"),
1129            (ResponseStatus::Completed, "completed"),
1130            (ResponseStatus::Failed, "failed"),
1131        ] {
1132            let json = serde_json::to_value(variant).unwrap();
1133            assert_eq!(json, expected_str);
1134            let parsed: ResponseStatus = serde_json::from_value(json).unwrap();
1135            assert_eq!(variant, parsed);
1136        }
1137    }
1138
1139    #[test]
1140    fn classify_response_terminality_covers_all_variants() {
1141        assert_eq!(
1142            classify_response_terminality(ResponseStatus::Accepted),
1143            TerminalityClass::Progress
1144        );
1145        assert_eq!(
1146            classify_response_terminality(ResponseStatus::Completed),
1147            TerminalityClass::Terminal {
1148                disposition: TerminalDisposition::Completed
1149            }
1150        );
1151        assert_eq!(
1152            classify_response_terminality(ResponseStatus::Failed),
1153            TerminalityClass::Terminal {
1154                disposition: TerminalDisposition::Failed
1155            }
1156        );
1157    }
1158
1159    #[test]
1160    fn peer_ingress_policy_owns_response_terminal_classification() {
1161        let policy = PeerIngressMachinePolicy::default();
1162
1163        assert_eq!(
1164            policy.classify_response(ResponseStatus::Accepted).class,
1165            PeerInputClass::ResponseProgress
1166        );
1167        assert_eq!(
1168            policy.classify_response(ResponseStatus::Completed).class,
1169            PeerInputClass::ResponseTerminal
1170        );
1171        assert_eq!(
1172            policy.classify_response(ResponseStatus::Failed).class,
1173            PeerInputClass::ResponseTerminal
1174        );
1175    }
1176
1177    #[test]
1178    fn peer_ingress_policy_auth_exempts_supervisor_bridge() {
1179        let policy = PeerIngressMachinePolicy::default();
1180        let classification = policy.classify_request_intent(crate::SUPERVISOR_BRIDGE_INTENT);
1181
1182        assert_eq!(classification.class, PeerInputClass::ActionableRequest);
1183        assert_eq!(
1184            classification.auth,
1185            PeerIngressAuthDecision::Exempt(PeerIngressAuthExemption::SupervisorBridge)
1186        );
1187    }
1188
1189    #[test]
1190    fn peer_ingress_policy_owns_lifecycle_and_silent_routing() {
1191        let policy = PeerIngressMachinePolicy::from_silent_intents(["probe.silent"]);
1192
1193        let lifecycle = policy.classify_request_intent(PeerLifecycleKind::PeerUnwired.as_str());
1194        assert_eq!(lifecycle.class, PeerInputClass::PeerLifecycleUnwired);
1195        assert_eq!(
1196            lifecycle.lifecycle_kind,
1197            Some(PeerLifecycleKind::PeerUnwired)
1198        );
1199
1200        let silent = policy.classify_request_intent("probe.silent");
1201        assert_eq!(silent.class, PeerInputClass::SilentRequest);
1202        assert_eq!(silent.auth, PeerIngressAuthDecision::Required);
1203    }
1204
1205    #[test]
1206    fn interaction_message_with_blocks_roundtrip() {
1207        let content = InteractionContent::Message {
1208            body: "hello".to_string(),
1209            blocks: Some(vec![
1210                ContentBlock::Text {
1211                    text: "hello".to_string(),
1212                },
1213                ContentBlock::Image {
1214                    media_type: "image/png".to_string(),
1215                    data: "iVBORw0KGgo=".into(),
1216                },
1217            ]),
1218        };
1219        let json = serde_json::to_value(&content).unwrap();
1220        assert_eq!(json["type"], "message");
1221        assert!(json["blocks"].is_array());
1222        let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1223        assert_eq!(content, parsed);
1224    }
1225
1226    #[test]
1227    fn inbox_interaction_preserves_runtime_hints() {
1228        let interaction = InboxInteraction {
1229            id: InteractionId(Uuid::new_v4()),
1230            from_route: None,
1231            from: "event:webhook".into(),
1232            content: InteractionContent::Message {
1233                body: "hello".into(),
1234                blocks: None,
1235            },
1236            rendered_text: "External event via webhook: hello".into(),
1237            handling_mode: HandlingMode::Steer,
1238            render_metadata: Some(RenderMetadata {
1239                class: crate::types::RenderClass::SystemNotice,
1240                salience: crate::types::RenderSalience::Urgent,
1241            }),
1242        };
1243
1244        assert_eq!(interaction.handling_mode, HandlingMode::Steer);
1245        assert!(interaction.render_metadata.is_some());
1246    }
1247
1248    #[test]
1249    fn interaction_message_without_blocks_compat() {
1250        // Old format (no blocks field) should deserialize with blocks: None
1251        let old_json = r#"{"type":"message","body":"hello"}"#;
1252        let parsed: InteractionContent = serde_json::from_str(old_json).unwrap();
1253        match parsed {
1254            InteractionContent::Message { body, blocks } => {
1255                assert_eq!(body, "hello");
1256                assert_eq!(blocks, None);
1257            }
1258            other => panic!("Expected Message, got {other:?}"),
1259        }
1260
1261        // Serialize with blocks: None should omit the field
1262        let content = InteractionContent::Message {
1263            body: "test".to_string(),
1264            blocks: None,
1265        };
1266        let json = serde_json::to_string(&content).unwrap();
1267        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1268        assert!(
1269            value.get("blocks").is_none(),
1270            "blocks: None should not appear in JSON"
1271        );
1272    }
1273}