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