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