1pub 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#[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#[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#[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#[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#[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#[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#[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
154fn 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#[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 #[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 #[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 #[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 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#[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 #[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 #[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 #[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 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#[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#[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#[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#[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 #[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#[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 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 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 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}