Skip to main content

meerkat_contracts/wire/
comms.rs

1//! Wire types for `comms/*`.
2//!
3//! The public wire owns its request/result contract and projects into the
4//! core comms command envelope only after serde has closed over intent,
5//! params, and result shape.
6//!
7//! The `comms/send` command payload remains flat:
8//! `{ "session_id": "...", "kind": "<variant>", ... }`.
9
10pub use meerkat_core::comms::{
11    CommsCommandError, CommsPeerRequestIntent, InputSource, InputStreamMode, PeerAddress,
12    PeerCapabilitySet, PeerDirectoryEntry, PeerDirectoryListing, PeerDirectorySource, PeerId,
13    PeerLifecycleKind, PeerName, PeerSendability, PeerTransport, SendTaintOverride,
14    SenderContentTaint,
15};
16pub use meerkat_core::interaction::ResponseStatus;
17pub use meerkat_core::types::HandlingMode;
18
19use super::supervisor_bridge::{BridgeCommand, BridgePeerSpec, BridgeReply};
20use serde::{Deserialize, Serialize};
21
22/// Typed params for one-way peer lifecycle notifications.
23///
24/// This is the public wire projection of the topology-update payloads that
25/// used to travel as arbitrary JSON. `peer_spec` is the canonical typed
26/// identity when the sender has it; `peer`, `role`, and `description` remain
27/// inert presentation metadata for older projections.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[serde(deny_unknown_fields)]
31pub struct CommsPeerLifecycleParams {
32    pub peer: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub role: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub description: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub peer_spec: Option<BridgePeerSpec>,
39}
40
41impl CommsPeerLifecycleParams {
42    fn into_json_value(self) -> Result<serde_json::Value, serde_json::Error> {
43        serde_json::to_value(self)
44    }
45}
46
47/// Typed params for the actionable checksum-token request used by peer
48/// request/response terminal-flow fixtures.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
51#[serde(deny_unknown_fields)]
52pub struct CommsChecksumTokenParams {
53    pub subject: String,
54}
55
56/// Closed discriminator carried in [`CommsChecksumTokenResult`].
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59pub enum CommsChecksumTokenResultIntent {
60    #[serde(rename = "checksum_token")]
61    ChecksumToken,
62}
63
64/// Typed result for a checksum-token peer response.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67#[serde(deny_unknown_fields)]
68pub struct CommsChecksumTokenResult {
69    pub request_intent: CommsChecksumTokenResultIntent,
70    pub request_subject: String,
71    pub token: String,
72}
73
74/// Typed params for public `peer_request`.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77#[serde(untagged)]
78pub enum CommsPeerRequestParams {
79    SupervisorBridge(Box<BridgeCommand>),
80    ChecksumToken(CommsChecksumTokenParams),
81}
82
83impl CommsPeerRequestParams {
84    fn matches_intent(&self, intent: &CommsPeerRequestIntent) -> bool {
85        matches!(
86            (intent, self),
87            (
88                CommsPeerRequestIntent::SupervisorBridge,
89                Self::SupervisorBridge(_)
90            ) | (
91                CommsPeerRequestIntent::ChecksumToken,
92                Self::ChecksumToken(_)
93            )
94        )
95    }
96
97    fn into_json_value(self) -> Result<serde_json::Value, serde_json::Error> {
98        match self {
99            Self::SupervisorBridge(params) => serde_json::to_value(params),
100            Self::ChecksumToken(params) => serde_json::to_value(params),
101        }
102    }
103}
104
105/// Typed result payload for public `peer_response`.
106///
107/// Compatibility JSON is intentionally not accepted here. Callers that include
108/// a `result` field must provide a typed bridge reply shape.
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111#[serde(untagged)]
112pub enum CommsPeerResponseResult {
113    SupervisorBridge(BridgeReply),
114    ChecksumToken(CommsChecksumTokenResult),
115}
116
117impl CommsPeerResponseResult {
118    fn into_json_value(self) -> Result<serde_json::Value, serde_json::Error> {
119        match self {
120            Self::SupervisorBridge(result) => serde_json::to_value(result),
121            Self::ChecksumToken(result) => serde_json::to_value(result),
122        }
123    }
124}
125
126impl From<BridgeReply> for CommsPeerResponseResult {
127    fn from(reply: BridgeReply) -> Self {
128        Self::SupervisorBridge(reply)
129    }
130}
131
132/// Failure projecting the typed public comms contract into the core
133/// compatibility command envelope.
134#[derive(Debug, thiserror::Error)]
135pub enum CommsCommandProjectionError {
136    #[error(transparent)]
137    Command(#[from] CommsCommandError),
138    #[error("peer_request params do not match typed intent {intent}")]
139    IntentParamsMismatch { intent: &'static str },
140    #[error("failed to project typed comms {field} to compatibility JSON: {source}")]
141    CompatibilityJson {
142        field: &'static str,
143        #[source]
144        source: serde_json::Error,
145    },
146}
147
148impl CommsCommandProjectionError {
149    fn compatibility_json(field: &'static str, source: serde_json::Error) -> Self {
150        Self::CompatibilityJson { field, source }
151    }
152}
153
154/// Project the canonical `body` text from the single content authority.
155///
156/// When a comms command carries structured `blocks`, those blocks are the sole
157/// content authority and `body` is a deterministic projection of their text
158/// blocks (text-block contents joined with newlines; non-text blocks such as
159/// images contribute no body text). This guarantees `body` cannot diverge from
160/// the block content it mirrors. With no blocks, the caller-supplied `body`
161/// stands alone as the sole content owner.
162///
163/// This mirrors the single-content-authority contract already enforced by the
164/// comms tool surface (`project_body_from_blocks`); pulling the projection into
165/// the wire boundary means every surface that deserializes the public comms
166/// contract gets the same non-divergent guarantee for free.
167fn single_authority_body(body: String, blocks: &Option<Vec<meerkat_core::ContentBlock>>) -> String {
168    match blocks {
169        Some(blocks) => blocks
170            .iter()
171            .filter_map(|block| match block {
172                meerkat_core::ContentBlock::Text { text } => Some(text.as_str()),
173                _ => None,
174            })
175            .collect::<Vec<_>>()
176            .join("\n"),
177        None => body,
178    }
179}
180
181/// Request command carried inside public `comms/send` surfaces.
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
185pub enum CommsCommandRequest {
186    Input {
187        body: String,
188        #[serde(default, skip_serializing_if = "Option::is_none")]
189        blocks: Option<Vec<meerkat_core::ContentBlock>>,
190        #[serde(default, skip_serializing_if = "Option::is_none")]
191        source: Option<InputSource>,
192        #[serde(default, skip_serializing_if = "Option::is_none")]
193        stream: Option<InputStreamMode>,
194        #[serde(default, skip_serializing_if = "Option::is_none")]
195        handling_mode: Option<HandlingMode>,
196        #[serde(default, skip_serializing_if = "Option::is_none")]
197        allow_self_session: Option<bool>,
198    },
199    PeerMessage {
200        to: PeerId,
201        body: String,
202        #[serde(default, skip_serializing_if = "Option::is_none")]
203        blocks: Option<Vec<meerkat_core::ContentBlock>>,
204        /// Per-send tri-state taint override: absent inherits the
205        /// runtime-level outbound declaration.
206        #[serde(default, skip_serializing_if = "Option::is_none")]
207        content_taint: Option<SendTaintOverride>,
208        #[serde(default, skip_serializing_if = "Option::is_none")]
209        handling_mode: Option<HandlingMode>,
210    },
211    PeerLifecycle {
212        to: PeerId,
213        lifecycle_kind: PeerLifecycleKind,
214        params: CommsPeerLifecycleParams,
215    },
216    PeerRequest {
217        to: PeerId,
218        intent: CommsPeerRequestIntent,
219        params: CommsPeerRequestParams,
220        #[serde(default, skip_serializing_if = "Option::is_none")]
221        blocks: Option<Vec<meerkat_core::ContentBlock>>,
222        /// Per-send tri-state taint override: absent inherits the
223        /// runtime-level outbound declaration.
224        #[serde(default, skip_serializing_if = "Option::is_none")]
225        content_taint: Option<SendTaintOverride>,
226        #[serde(default, skip_serializing_if = "Option::is_none")]
227        handling_mode: Option<HandlingMode>,
228        #[serde(default, skip_serializing_if = "Option::is_none")]
229        stream: Option<InputStreamMode>,
230    },
231    PeerResponse {
232        to: PeerId,
233        in_reply_to: meerkat_core::interaction::InteractionId,
234        status: ResponseStatus,
235        #[serde(default, skip_serializing_if = "Option::is_none")]
236        result: Option<CommsPeerResponseResult>,
237        #[serde(default, skip_serializing_if = "Option::is_none")]
238        blocks: Option<Vec<meerkat_core::ContentBlock>>,
239        /// Per-send tri-state taint override: absent inherits the
240        /// runtime-level outbound declaration.
241        #[serde(default, skip_serializing_if = "Option::is_none")]
242        content_taint: Option<SendTaintOverride>,
243        #[serde(default, skip_serializing_if = "Option::is_none")]
244        handling_mode: Option<HandlingMode>,
245    },
246}
247
248impl CommsCommandRequest {
249    pub fn peer_label(&self) -> Option<String> {
250        match self {
251            Self::Input { .. } => None,
252            Self::PeerMessage { to, .. }
253            | Self::PeerLifecycle { to, .. }
254            | Self::PeerRequest { to, .. }
255            | Self::PeerResponse { to, .. } => Some(to.to_string()),
256        }
257    }
258
259    pub fn into_core_request(
260        self,
261    ) -> Result<meerkat_core::comms::CommsCommandRequest, CommsCommandProjectionError> {
262        Ok(match self {
263            Self::Input {
264                body,
265                blocks,
266                source,
267                stream,
268                handling_mode,
269                allow_self_session,
270            } => meerkat_core::comms::CommsCommandRequest::Input {
271                body: single_authority_body(body, &blocks),
272                blocks,
273                source,
274                stream,
275                handling_mode,
276                allow_self_session,
277            },
278            Self::PeerMessage {
279                to,
280                body,
281                blocks,
282                content_taint,
283                handling_mode,
284            } => meerkat_core::comms::CommsCommandRequest::PeerMessage {
285                to,
286                body: single_authority_body(body, &blocks),
287                blocks,
288                content_taint,
289                handling_mode,
290            },
291            Self::PeerLifecycle {
292                to,
293                lifecycle_kind,
294                params,
295            } => meerkat_core::comms::CommsCommandRequest::PeerLifecycle {
296                to,
297                lifecycle_kind,
298                params: params.into_json_value().map_err(|source| {
299                    CommsCommandProjectionError::compatibility_json("peer_lifecycle.params", source)
300                })?,
301            },
302            Self::PeerRequest {
303                to,
304                intent,
305                params,
306                blocks,
307                content_taint,
308                handling_mode,
309                stream,
310            } => {
311                if !params.matches_intent(&intent) {
312                    return Err(CommsCommandProjectionError::IntentParamsMismatch {
313                        intent: intent.as_str(),
314                    });
315                }
316                meerkat_core::comms::CommsCommandRequest::PeerRequest {
317                    to,
318                    // The closed intent is now the same core-owned type on both
319                    // sides of this seam, so it passes through typed — no
320                    // string downgrade at the public-wire -> core projection.
321                    // `params` cannot follow yet: its `supervisor.bridge`
322                    // variant wraps the contracts-owned `BridgeCommand`, which
323                    // core cannot reference, so the structurally-validated
324                    // params still serialize to compatibility JSON here.
325                    intent,
326                    params: params.into_json_value().map_err(|source| {
327                        CommsCommandProjectionError::compatibility_json(
328                            "peer_request.params",
329                            source,
330                        )
331                    })?,
332                    blocks,
333                    content_taint,
334                    handling_mode,
335                    stream,
336                }
337            }
338            Self::PeerResponse {
339                to,
340                in_reply_to,
341                status,
342                result,
343                blocks,
344                content_taint,
345                handling_mode,
346            } => meerkat_core::comms::CommsCommandRequest::PeerResponse {
347                to,
348                in_reply_to,
349                status,
350                result: match result {
351                    Some(result) => result.into_json_value().map_err(|source| {
352                        CommsCommandProjectionError::compatibility_json(
353                            "peer_response.result",
354                            source,
355                        )
356                    })?,
357                    None => serde_json::Value::Null,
358                },
359                blocks,
360                content_taint,
361                handling_mode,
362            },
363        })
364    }
365
366    pub fn into_command(
367        self,
368        session_id: &meerkat_core::types::SessionId,
369    ) -> Result<meerkat_core::comms::CommsCommand, CommsCommandProjectionError> {
370        Ok(self.into_core_request()?.into_command(session_id)?)
371    }
372}
373
374/// Request payload for `comms/send`.
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
376#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
377#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
378pub enum CommsSendParams {
379    Input {
380        session_id: String,
381        body: String,
382        #[serde(default, skip_serializing_if = "Option::is_none")]
383        blocks: Option<Vec<meerkat_core::ContentBlock>>,
384        #[serde(default, skip_serializing_if = "Option::is_none")]
385        source: Option<InputSource>,
386        #[serde(default, skip_serializing_if = "Option::is_none")]
387        stream: Option<InputStreamMode>,
388        #[serde(default, skip_serializing_if = "Option::is_none")]
389        handling_mode: Option<HandlingMode>,
390        #[serde(default, skip_serializing_if = "Option::is_none")]
391        allow_self_session: Option<bool>,
392    },
393    PeerMessage {
394        session_id: String,
395        to: PeerId,
396        body: String,
397        #[serde(default, skip_serializing_if = "Option::is_none")]
398        blocks: Option<Vec<meerkat_core::ContentBlock>>,
399        /// Per-send tri-state taint override: absent inherits the
400        /// runtime-level outbound declaration.
401        #[serde(default, skip_serializing_if = "Option::is_none")]
402        content_taint: Option<SendTaintOverride>,
403        #[serde(default, skip_serializing_if = "Option::is_none")]
404        handling_mode: Option<HandlingMode>,
405    },
406    PeerLifecycle {
407        session_id: String,
408        to: PeerId,
409        lifecycle_kind: PeerLifecycleKind,
410        params: CommsPeerLifecycleParams,
411    },
412    PeerRequest {
413        session_id: String,
414        to: PeerId,
415        intent: CommsPeerRequestIntent,
416        params: CommsPeerRequestParams,
417        #[serde(default, skip_serializing_if = "Option::is_none")]
418        blocks: Option<Vec<meerkat_core::ContentBlock>>,
419        /// Per-send tri-state taint override: absent inherits the
420        /// runtime-level outbound declaration.
421        #[serde(default, skip_serializing_if = "Option::is_none")]
422        content_taint: Option<SendTaintOverride>,
423        #[serde(default, skip_serializing_if = "Option::is_none")]
424        handling_mode: Option<HandlingMode>,
425        #[serde(default, skip_serializing_if = "Option::is_none")]
426        stream: Option<InputStreamMode>,
427    },
428    PeerResponse {
429        session_id: String,
430        to: PeerId,
431        in_reply_to: meerkat_core::interaction::InteractionId,
432        status: ResponseStatus,
433        #[serde(default, skip_serializing_if = "Option::is_none")]
434        result: Option<CommsPeerResponseResult>,
435        #[serde(default, skip_serializing_if = "Option::is_none")]
436        blocks: Option<Vec<meerkat_core::ContentBlock>>,
437        /// Per-send tri-state taint override: absent inherits the
438        /// runtime-level outbound declaration.
439        #[serde(default, skip_serializing_if = "Option::is_none")]
440        content_taint: Option<SendTaintOverride>,
441        #[serde(default, skip_serializing_if = "Option::is_none")]
442        handling_mode: Option<HandlingMode>,
443    },
444}
445
446impl CommsSendParams {
447    pub fn session_id(&self) -> &str {
448        match self {
449            Self::Input { session_id, .. }
450            | Self::PeerMessage { session_id, .. }
451            | Self::PeerLifecycle { session_id, .. }
452            | Self::PeerRequest { session_id, .. }
453            | Self::PeerResponse { session_id, .. } => session_id,
454        }
455    }
456
457    /// Recipient peer id for error normalization, if the command targets one.
458    pub fn peer_label(&self) -> Option<String> {
459        match self {
460            Self::Input { .. } => None,
461            Self::PeerMessage { to, .. }
462            | Self::PeerLifecycle { to, .. }
463            | Self::PeerRequest { to, .. }
464            | Self::PeerResponse { to, .. } => Some(to.to_string()),
465        }
466    }
467
468    pub fn into_command(self) -> CommsCommandRequest {
469        match self {
470            Self::Input {
471                body,
472                blocks,
473                source,
474                stream,
475                handling_mode,
476                allow_self_session,
477                ..
478            } => CommsCommandRequest::Input {
479                body,
480                blocks,
481                source,
482                stream,
483                handling_mode,
484                allow_self_session,
485            },
486            Self::PeerMessage {
487                to,
488                body,
489                blocks,
490                content_taint,
491                handling_mode,
492                ..
493            } => CommsCommandRequest::PeerMessage {
494                to,
495                body,
496                blocks,
497                content_taint,
498                handling_mode,
499            },
500            Self::PeerLifecycle {
501                to,
502                lifecycle_kind,
503                params,
504                ..
505            } => CommsCommandRequest::PeerLifecycle {
506                to,
507                lifecycle_kind,
508                params,
509            },
510            Self::PeerRequest {
511                to,
512                intent,
513                params,
514                blocks,
515                content_taint,
516                handling_mode,
517                stream,
518                ..
519            } => CommsCommandRequest::PeerRequest {
520                to,
521                intent,
522                params,
523                blocks,
524                content_taint,
525                handling_mode,
526                stream,
527            },
528            Self::PeerResponse {
529                to,
530                in_reply_to,
531                status,
532                result,
533                blocks,
534                content_taint,
535                handling_mode,
536                ..
537            } => CommsCommandRequest::PeerResponse {
538                to,
539                in_reply_to,
540                status,
541                result,
542                blocks,
543                content_taint,
544                handling_mode,
545            },
546        }
547    }
548}
549
550/// Request payload for `comms/peers`.
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
553#[serde(deny_unknown_fields)]
554pub struct CommsPeersParams {
555    pub session_id: String,
556}
557
558/// Response payload for `comms/send`.
559#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
561#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
562pub enum CommsSendResult {
563    InputAccepted {
564        interaction_id: String,
565        stream_reserved: bool,
566    },
567    PeerMessageSent {
568        envelope_id: String,
569        delivery: meerkat_core::comms::PeerDeliveryOutcome,
570    },
571    PeerLifecycleSent {
572        envelope_id: String,
573    },
574    PeerRequestSent {
575        envelope_id: String,
576        interaction_id: String,
577        request_id: String,
578        stream_reserved: bool,
579    },
580    PeerResponseSent {
581        envelope_id: String,
582        in_reply_to: String,
583    },
584}
585
586impl From<meerkat_core::comms::SendReceipt> for CommsSendResult {
587    fn from(receipt: meerkat_core::comms::SendReceipt) -> Self {
588        match receipt {
589            meerkat_core::comms::SendReceipt::InputAccepted {
590                interaction_id,
591                stream_reserved,
592            } => Self::InputAccepted {
593                interaction_id: interaction_id.0.to_string(),
594                stream_reserved,
595            },
596            meerkat_core::comms::SendReceipt::PeerMessageSent {
597                envelope_id,
598                delivery,
599            } => Self::PeerMessageSent {
600                envelope_id: envelope_id.to_string(),
601                delivery,
602            },
603            meerkat_core::comms::SendReceipt::PeerLifecycleSent { envelope_id } => {
604                Self::PeerLifecycleSent {
605                    envelope_id: envelope_id.to_string(),
606                }
607            }
608            meerkat_core::comms::SendReceipt::PeerRequestSent {
609                envelope_id,
610                interaction_id,
611                stream_reserved,
612            } => {
613                let envelope_id = envelope_id.to_string();
614                Self::PeerRequestSent {
615                    request_id: envelope_id.clone(),
616                    envelope_id,
617                    interaction_id: interaction_id.0.to_string(),
618                    stream_reserved,
619                }
620            }
621            meerkat_core::comms::SendReceipt::PeerResponseSent {
622                envelope_id,
623                in_reply_to,
624            } => Self::PeerResponseSent {
625                envelope_id: envelope_id.to_string(),
626                in_reply_to: in_reply_to.0.to_string(),
627            },
628        }
629    }
630}
631
632/// Why a peer was unreachable for a `comms/send` dispatch.
633#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
634#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
635#[serde(rename_all = "snake_case")]
636pub enum CommsPeerUnreachableReason {
637    OfflineOrNoAck,
638    TransportError,
639}
640
641/// Typed error data for `comms/send` failures.
642///
643/// The error taxonomy is owned here (K17): surfaces serialize this contract
644/// as JSON-RPC `error.data` instead of hand-shaping per-surface payloads.
645#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
646#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
647#[serde(tag = "code", rename_all = "snake_case")]
648pub enum CommsSendErrorData {
649    PeerNotFoundOrNotTrusted {
650        peer: String,
651        message: String,
652    },
653    PeerUnreachable {
654        peer: String,
655        reason: CommsPeerUnreachableReason,
656        message: String,
657        #[serde(default, skip_serializing_if = "Option::is_none")]
658        details: Option<String>,
659    },
660    PeerAdmissionDropped {
661        peer: String,
662        reason: meerkat_core::comms::AdmissionDropReason,
663        message: String,
664    },
665    SendFailed {
666        message: String,
667    },
668    InvalidCommand {
669        message: String,
670    },
671}
672
673impl CommsSendErrorData {
674    /// Human-readable message carried by every variant.
675    #[must_use]
676    pub fn message(&self) -> &str {
677        match self {
678            Self::PeerNotFoundOrNotTrusted { message, .. }
679            | Self::PeerUnreachable { message, .. }
680            | Self::PeerAdmissionDropped { message, .. }
681            | Self::SendFailed { message }
682            | Self::InvalidCommand { message } => message,
683        }
684    }
685}
686
687pub type CommsPeerEntry = PeerDirectoryEntry;
688
689/// Response payload for `comms/peers`.
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
692pub struct CommsPeersResult {
693    pub peers: Vec<PeerDirectoryEntry>,
694}
695
696impl CommsPeersResult {
697    pub fn from_entries(entries: &[meerkat_core::comms::PeerDirectoryEntry]) -> Self {
698        Self {
699            peers: entries.to_vec(),
700        }
701    }
702}
703
704impl From<PeerDirectoryListing> for CommsPeersResult {
705    fn from(listing: PeerDirectoryListing) -> Self {
706        Self {
707            peers: listing.peers,
708        }
709    }
710}
711
712#[cfg(test)]
713#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
714mod tests {
715    use super::super::supervisor_bridge::SUPERVISOR_BRIDGE_INTENT;
716    use super::*;
717    use serde_json::json;
718
719    fn peer_id() -> PeerId {
720        PeerId::new()
721    }
722
723    fn bridge_peer_spec() -> serde_json::Value {
724        let pubkey = [7u8; 32];
725        json!({
726            "name": "supervisor",
727            "peer_id": PeerId::from_ed25519_pubkey(&pubkey).to_string(),
728            "address": "inproc://supervisor",
729            "pubkey": pubkey,
730        })
731    }
732
733    fn supervisor_bridge_params() -> serde_json::Value {
734        json!({
735            "command": "observe_member",
736            "supervisor": bridge_peer_spec(),
737            "epoch": 1,
738            "protocol_version": 2,
739        })
740    }
741
742    #[test]
743    fn peer_request_unknown_intent_fails_closed() {
744        let err = serde_json::from_value::<CommsSendParams>(json!({
745            "session_id": "sid_1",
746            "kind": "peer_request",
747            "to": peer_id().to_string(),
748            "intent": "local.default",
749            "params": {}
750        }))
751        .expect_err("unknown public comms intent must fail at serde boundary");
752
753        let message = err.to_string();
754        assert!(
755            message.contains("local.default") || message.contains("variant"),
756            "error should mention the rejected intent, got: {message}"
757        );
758    }
759
760    #[test]
761    fn peer_request_malformed_params_cannot_be_typed_success() {
762        let err = serde_json::from_value::<CommsSendParams>(json!({
763            "session_id": "sid_1",
764            "kind": "peer_request",
765            "to": peer_id().to_string(),
766            "intent": "supervisor.bridge",
767            "params": "not-an-object"
768        }))
769        .expect_err("malformed supervisor bridge params must not deserialize");
770
771        let message = err.to_string();
772        assert!(
773            message.contains("invalid type")
774                || message.contains("params")
775                || message.contains("did not match any variant"),
776            "expected typed params error, got: {message}"
777        );
778    }
779
780    #[test]
781    fn peer_response_malformed_result_cannot_be_typed_success() {
782        let err = serde_json::from_value::<CommsSendParams>(json!({
783            "session_id": "sid_1",
784            "kind": "peer_response",
785            "to": peer_id().to_string(),
786            "in_reply_to": uuid::Uuid::new_v4().to_string(),
787            "status": "completed",
788            "result": {
789                "result": "ack",
790                "ok": "yes"
791            }
792        }))
793        .expect_err("malformed typed result must not deserialize");
794
795        let message = err.to_string();
796        assert!(
797            message.contains("ok")
798                || message.contains("invalid type")
799                || message.contains("did not match any variant"),
800            "expected typed result error, got: {message}"
801        );
802    }
803
804    #[test]
805    fn comms_send_result_unknown_field_fails_closed() {
806        let err = serde_json::from_value::<CommsSendResult>(json!({
807            "kind": "peer_request_sent",
808            "envelope_id": uuid::Uuid::new_v4().to_string(),
809            "interaction_id": uuid::Uuid::new_v4().to_string(),
810            "request_id": uuid::Uuid::new_v4().to_string(),
811            "stream_reserved": true,
812            "extra_behavior": true
813        }))
814        .expect_err("unknown result fields must fail at serde boundary");
815
816        let message = err.to_string();
817        assert!(
818            message.contains("extra_behavior") || message.contains("unknown field"),
819            "expected unknown result field error, got: {message}"
820        );
821    }
822
823    #[test]
824    fn comms_send_error_data_preserves_admission_drop_reason() {
825        let value = serde_json::to_value(CommsSendErrorData::PeerAdmissionDropped {
826            peer: "peer-a".to_string(),
827            reason: meerkat_core::comms::AdmissionDropReason::InboxFull,
828            message: "peer 'peer-a' rejected envelope at ingress: inbox_full".to_string(),
829        })
830        .expect("admission drop error data should serialize");
831
832        assert_eq!(value["code"], "peer_admission_dropped");
833        assert_eq!(value["peer"], "peer-a");
834        assert_eq!(value["reason"], "inbox_full");
835
836        let roundtrip: CommsSendErrorData =
837            serde_json::from_value(value).expect("admission drop error data should deserialize");
838        assert!(matches!(
839            roundtrip,
840            CommsSendErrorData::PeerAdmissionDropped {
841                reason: meerkat_core::comms::AdmissionDropReason::InboxFull,
842                ..
843            }
844        ));
845    }
846
847    #[test]
848    fn public_peer_request_projects_typed_intent_and_params_to_core() {
849        let params = serde_json::from_value::<CommsSendParams>(json!({
850            "session_id": "sid_1",
851            "kind": "peer_request",
852            "to": peer_id().to_string(),
853            "intent": "supervisor.bridge",
854            "params": supervisor_bridge_params(),
855            "handling_mode": "queue",
856            "stream": "reserve_interaction"
857        }))
858        .expect("typed supervisor bridge request should deserialize");
859
860        let session_id = meerkat_core::types::SessionId::new();
861        let command = params
862            .into_command()
863            .into_command(&session_id)
864            .expect("typed comms request should project to core command");
865
866        let meerkat_core::comms::CommsCommand::PeerRequest {
867            intent,
868            params,
869            handling_mode,
870            stream,
871            ..
872        } = command
873        else {
874            panic!("expected core peer request");
875        };
876
877        assert_eq!(intent, SUPERVISOR_BRIDGE_INTENT);
878        assert_eq!(params["command"], "observe_member");
879        assert_eq!(handling_mode, HandlingMode::Queue);
880        assert_eq!(stream, InputStreamMode::ReserveInteraction);
881    }
882
883    #[test]
884    fn public_checksum_token_request_projects_typed_intent_and_params_to_core() {
885        let params = serde_json::from_value::<CommsSendParams>(json!({
886            "session_id": "sid_1",
887            "kind": "peer_request",
888            "to": peer_id().to_string(),
889            "intent": "checksum_token",
890            "params": {
891                "subject": "alpha beta gamma"
892            },
893            "handling_mode": "steer",
894            "stream": "reserve_interaction"
895        }))
896        .expect("typed checksum token request should deserialize");
897
898        let session_id = meerkat_core::types::SessionId::new();
899        let command = params
900            .into_command()
901            .into_command(&session_id)
902            .expect("typed checksum token request should project to core command");
903
904        let meerkat_core::comms::CommsCommand::PeerRequest {
905            intent,
906            params,
907            handling_mode,
908            stream,
909            ..
910        } = command
911        else {
912            panic!("expected core peer request");
913        };
914
915        assert_eq!(intent, "checksum_token");
916        assert_eq!(params["subject"], "alpha beta gamma");
917        assert_eq!(handling_mode, HandlingMode::Steer);
918        assert_eq!(stream, InputStreamMode::ReserveInteraction);
919    }
920
921    #[test]
922    fn public_peer_request_rejects_intent_params_mismatch_before_dispatch() {
923        let params = serde_json::from_value::<CommsSendParams>(json!({
924            "session_id": "sid_1",
925            "kind": "peer_request",
926            "to": peer_id().to_string(),
927            "intent": "checksum_token",
928            "params": supervisor_bridge_params()
929        }))
930        .expect("mismatched typed params can deserialize but must not project");
931
932        let session_id = meerkat_core::types::SessionId::new();
933        let err = params
934            .into_command()
935            .into_command(&session_id)
936            .expect_err("intent/params mismatch must not become a core command");
937
938        assert!(
939            err.to_string().contains("checksum_token"),
940            "expected mismatch error to name intent, got: {err}"
941        );
942    }
943
944    #[test]
945    fn public_peer_response_result_projects_typed_bridge_reply_to_core() {
946        let params = serde_json::from_value::<CommsSendParams>(json!({
947            "session_id": "sid_1",
948            "kind": "peer_response",
949            "to": peer_id().to_string(),
950            "in_reply_to": uuid::Uuid::new_v4().to_string(),
951            "status": "completed",
952            "result": {
953                "result": "ack",
954                "ok": true
955            }
956        }))
957        .expect("typed bridge reply should deserialize");
958
959        let session_id = meerkat_core::types::SessionId::new();
960        let command = params
961            .into_command()
962            .into_command(&session_id)
963            .expect("typed comms response should project to core command");
964
965        let meerkat_core::comms::CommsCommand::PeerResponse { result, .. } = command else {
966            panic!("expected core peer response");
967        };
968
969        assert_eq!(result["result"], "ack");
970        assert_eq!(result["ok"], true);
971    }
972
973    #[test]
974    fn public_peer_response_result_projects_typed_checksum_token_to_core() {
975        let params = serde_json::from_value::<CommsSendParams>(json!({
976            "session_id": "sid_1",
977            "kind": "peer_response",
978            "to": peer_id().to_string(),
979            "in_reply_to": uuid::Uuid::new_v4().to_string(),
980            "status": "completed",
981            "result": {
982                "request_intent": "checksum_token",
983                "request_subject": "alpha beta gamma",
984                "token": "birch seventeen"
985            }
986        }))
987        .expect("typed checksum token reply should deserialize");
988
989        let session_id = meerkat_core::types::SessionId::new();
990        let command = params
991            .into_command()
992            .into_command(&session_id)
993            .expect("typed checksum token reply should project to core command");
994
995        let meerkat_core::comms::CommsCommand::PeerResponse { result, .. } = command else {
996            panic!("expected core peer response");
997        };
998
999        assert_eq!(result["request_intent"], "checksum_token");
1000        assert_eq!(result["request_subject"], "alpha beta gamma");
1001        assert_eq!(result["token"], "birch seventeen");
1002    }
1003
1004    #[test]
1005    fn input_body_is_projected_from_blocks_single_authority() {
1006        // A divergent caller-supplied body must NOT survive projection: when
1007        // blocks are present they are the single content authority and body is
1008        // derived from their text blocks.
1009        let request = CommsCommandRequest::Input {
1010            body: "diverged caller body".to_string(),
1011            blocks: Some(vec![
1012                meerkat_core::ContentBlock::Text {
1013                    text: "authoritative line one".to_string(),
1014                },
1015                meerkat_core::ContentBlock::Text {
1016                    text: "authoritative line two".to_string(),
1017                },
1018            ]),
1019            source: None,
1020            stream: None,
1021            handling_mode: None,
1022            allow_self_session: None,
1023        };
1024
1025        let core = request
1026            .into_core_request()
1027            .expect("input request should project to core");
1028
1029        let meerkat_core::comms::CommsCommandRequest::Input { body, .. } = core else {
1030            panic!("expected core input request");
1031        };
1032        assert_eq!(body, "authoritative line one\nauthoritative line two");
1033    }
1034
1035    #[test]
1036    fn peer_message_body_stands_alone_when_no_blocks() {
1037        // With no blocks, the caller-supplied body is the sole content owner and
1038        // is preserved verbatim.
1039        let request = CommsCommandRequest::PeerMessage {
1040            to: peer_id(),
1041            body: "lone body".to_string(),
1042            blocks: None,
1043            content_taint: None,
1044            handling_mode: None,
1045        };
1046
1047        let core = request
1048            .into_core_request()
1049            .expect("peer message should project to core");
1050
1051        let meerkat_core::comms::CommsCommandRequest::PeerMessage { body, .. } = core else {
1052            panic!("expected core peer message");
1053        };
1054        assert_eq!(body, "lone body");
1055    }
1056
1057    #[test]
1058    fn peer_message_content_taint_defaults_absent_and_threads_to_core() {
1059        // Absent on the wire => `None` (inherit the runtime declaration) — a
1060        // different fact from an explicit `undeclared` strip or a declared
1061        // taint state.
1062        let inherited = serde_json::from_value::<CommsSendParams>(json!({
1063            "session_id": "sid_1",
1064            "kind": "peer_message",
1065            "to": peer_id().to_string(),
1066            "body": "hello"
1067        }))
1068        .expect("peer message without content_taint should deserialize");
1069        let command = inherited
1070            .into_command()
1071            .into_command(&meerkat_core::types::SessionId::new())
1072            .expect("peer message should project to core command");
1073        let meerkat_core::comms::CommsCommand::PeerMessage { content_taint, .. } = command else {
1074            panic!("expected core peer message");
1075        };
1076        assert_eq!(content_taint, None);
1077
1078        for (wire, expected) in [
1079            (
1080                json!({"declare": "tainted"}),
1081                SendTaintOverride::Declare(SenderContentTaint::Tainted),
1082            ),
1083            (
1084                json!({"declare": "clean"}),
1085                SendTaintOverride::Declare(SenderContentTaint::Clean),
1086            ),
1087            (json!("undeclared"), SendTaintOverride::Undeclared),
1088        ] {
1089            let params = serde_json::from_value::<CommsSendParams>(json!({
1090                "session_id": "sid_1",
1091                "kind": "peer_message",
1092                "to": peer_id().to_string(),
1093                "body": "hello",
1094                "content_taint": wire
1095            }))
1096            .expect("peer message with content_taint should deserialize");
1097            let command = params
1098                .into_command()
1099                .into_command(&meerkat_core::types::SessionId::new())
1100                .expect("peer message should project to core command");
1101            let meerkat_core::comms::CommsCommand::PeerMessage { content_taint, .. } = command
1102            else {
1103                panic!("expected core peer message");
1104            };
1105            assert_eq!(content_taint, Some(expected));
1106        }
1107    }
1108
1109    #[test]
1110    fn public_peer_response_result_rejects_checksum_token_without_subject() {
1111        serde_json::from_value::<CommsSendParams>(json!({
1112            "session_id": "sid_1",
1113            "kind": "peer_response",
1114            "to": peer_id().to_string(),
1115            "in_reply_to": uuid::Uuid::new_v4().to_string(),
1116            "status": "completed",
1117            "result": {
1118                "request_intent": "checksum_token",
1119                "token": "birch seventeen"
1120            }
1121        }))
1122        .expect_err("checksum token replies must carry the request subject discriminator");
1123    }
1124}