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