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