1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::BTreeSet;
10use uuid::Uuid;
11
12use crate::comms::{
13 PeerId, PeerLifecycleKind, PeerName, PeerRoute, SUPERVISOR_BRIDGE_INTENT, TrustedPeerDescriptor,
14};
15use crate::types::{ContentBlock, HandlingMode, RenderMetadata};
16
17#[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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ResponseStatus {
35 Accepted,
36 Completed,
37 Failed,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub enum TerminalityClass {
49 Progress,
50 Terminal { disposition: TerminalDisposition },
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54#[non_exhaustive]
55pub enum TerminalDisposition {
56 Completed,
57 Failed,
58}
59
60pub fn classify_response_terminality(status: ResponseStatus) -> TerminalityClass {
62 match status {
63 ResponseStatus::Accepted => TerminalityClass::Progress,
64 ResponseStatus::Completed => TerminalityClass::Terminal {
65 disposition: TerminalDisposition::Completed,
66 },
67 ResponseStatus::Failed => TerminalityClass::Terminal {
68 disposition: TerminalDisposition::Failed,
69 },
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(tag = "type", rename_all = "snake_case")]
79pub enum InteractionContent {
80 Message {
82 body: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 blocks: Option<Vec<ContentBlock>>,
86 },
87 Request {
89 intent: String,
90 params: Value,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 blocks: Option<Vec<ContentBlock>>,
93 },
94 Response {
96 in_reply_to: InteractionId,
97 status: ResponseStatus,
98 result: Value,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 blocks: Option<Vec<ContentBlock>>,
101 },
102}
103
104#[derive(Debug, Clone)]
106pub struct InboxInteraction {
107 pub id: InteractionId,
109 pub from_route: Option<PeerId>,
112 pub from: String,
114 pub content: InteractionContent,
116 pub rendered_text: String,
118 pub handling_mode: HandlingMode,
120 pub render_metadata: Option<RenderMetadata>,
122}
123
124pub fn format_external_event_projection(source_name: &str, body: Option<&str>) -> String {
130 let label = format!("External event via {source_name}");
131 let body = body.map(str::trim).filter(|body| !body.is_empty());
132
133 match body {
134 Some(body) => format!("{label}: {body}"),
135 None => label,
136 }
137}
138
139pub fn format_peer_message_projection(from_peer: &str, body: &str) -> String {
141 format!("Peer message from {from_peer}:\n{body}")
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct SendResponseCallProjection {
151 pub peer_id: PeerId,
152 pub display_name: Option<String>,
153 pub in_reply_to: String,
154}
155
156impl SendResponseCallProjection {
157 pub const TOOL_NAME: &'static str = "send_response";
158 pub const PEER_ID_FIELD: &'static str = "peer_id";
159 pub const DISPLAY_NAME_FIELD: &'static str = "display_name";
160 pub const IN_REPLY_TO_FIELD: &'static str = "in_reply_to";
161 pub const STATUS_FIELD: &'static str = "status";
162 pub const RESULT_FIELD: &'static str = "result";
163
164 pub fn new(
165 peer_id: PeerId,
166 display_name: Option<&str>,
167 in_reply_to: impl Into<String>,
168 ) -> Self {
169 Self {
170 peer_id,
171 display_name: display_name
172 .map(str::trim)
173 .filter(|name| !name.is_empty())
174 .map(ToOwned::to_owned),
175 in_reply_to: in_reply_to.into(),
176 }
177 }
178
179 pub fn completed_example_args(&self) -> Value {
185 let mut args = serde_json::Map::new();
186 args.insert(
187 Self::PEER_ID_FIELD.to_string(),
188 Value::String(self.peer_id.to_string()),
189 );
190 if let Some(display_name) = &self.display_name {
191 args.insert(
192 Self::DISPLAY_NAME_FIELD.to_string(),
193 Value::String(display_name.clone()),
194 );
195 }
196 args.insert(
197 Self::IN_REPLY_TO_FIELD.to_string(),
198 Value::String(self.in_reply_to.clone()),
199 );
200 args.insert(
201 Self::STATUS_FIELD.to_string(),
202 Value::String("completed".to_string()),
203 );
204 Value::Object(args)
205 }
206
207 pub fn instruction_text(&self) -> String {
208 let args = serde_json::to_string(&self.completed_example_args())
209 .unwrap_or_else(|_| "{}".to_string());
210 format!(
211 "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.",
212 Self::TOOL_NAME
213 )
214 }
215}
216
217pub fn format_peer_request_projection(
219 from_peer_id: PeerId,
220 display_name: Option<&str>,
221 request_id: impl std::fmt::Display,
222 intent: &str,
223 params: &Value,
224) -> String {
225 let params_str = if params.is_null() || matches!(params, Value::Object(map) if map.is_empty()) {
226 String::new()
227 } else {
228 format!(
229 "\nParams: {}",
230 serde_json::to_string_pretty(params).unwrap_or_default()
231 )
232 };
233 let request_id = request_id.to_string();
234 let display_suffix = display_name
235 .map(str::trim)
236 .filter(|name| !name.is_empty())
237 .map(|name| format!(" (display_name: {name})"))
238 .unwrap_or_default();
239 let response_call =
240 SendResponseCallProjection::new(from_peer_id, display_name, request_id.clone());
241
242 format!(
243 "Peer request from peer_id {from_peer_id}{display_suffix} (id: {request_id})\n\
244 Intent: {intent}{params_str}\n\
245 Request ID: {request_id}\n\
246 \n\
247 This is a correlated peer request. {} \
248 Do not answer this request with send_message.",
249 response_call.instruction_text()
250 )
251}
252
253pub fn format_peer_response_projection(
255 from_peer: &str,
256 in_reply_to: impl std::fmt::Display,
257 status: ResponseStatus,
258 result: &Value,
259) -> String {
260 let status_str = match status {
261 ResponseStatus::Accepted => "accepted",
262 ResponseStatus::Completed => "completed",
263 ResponseStatus::Failed => "failed",
264 };
265 let result_str = if result.is_null() || matches!(result, Value::Object(map) if map.is_empty()) {
266 String::new()
267 } else {
268 format!(
269 "\nResult: {}",
270 serde_json::to_string_pretty(result).unwrap_or_default()
271 )
272 };
273
274 format!(
275 "Peer response from {from_peer} (to request: {in_reply_to})\n\
276 Status: {status_str}{result_str}"
277 )
278}
279
280pub fn format_peer_ack_projection(from_peer: &str, in_reply_to: impl std::fmt::Display) -> String {
282 format!("Peer ack from {from_peer} (to request: {in_reply_to})")
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum PeerInputClass {
291 ActionableMessage,
293 ActionableRequest,
295 ResponseProgress,
297 ResponseTerminal,
299 PeerLifecycleAdded,
301 PeerLifecycleRetired,
303 PeerLifecycleUnwired,
305 PeerLifecycleKickoffFailed,
307 PeerLifecycleKickoffCancelled,
309 SilentRequest,
311 Ack,
313 PlainEvent,
315}
316
317impl PeerInputClass {
318 pub fn is_actionable(&self) -> bool {
320 matches!(
321 self,
322 Self::ActionableMessage
323 | Self::ActionableRequest
324 | Self::ResponseProgress
325 | Self::ResponseTerminal
326 | Self::PlainEvent
327 | Self::PeerLifecycleKickoffFailed
328 | Self::PeerLifecycleKickoffCancelled
329 )
330 }
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
335pub enum PeerIngressAuthExemption {
336 SupervisorBridge,
338}
339
340impl PeerIngressAuthExemption {
341 pub const fn intent(self) -> &'static str {
342 match self {
343 Self::SupervisorBridge => SUPERVISOR_BRIDGE_INTENT,
344 }
345 }
346
347 pub fn matches_intent(self, intent: &str) -> bool {
348 self.intent() == intent
349 }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
354pub enum PeerIngressAuthDecision {
355 Required,
357 Exempt(PeerIngressAuthExemption),
359}
360
361impl PeerIngressAuthDecision {
362 pub const fn is_exempt(self) -> bool {
363 matches!(self, Self::Exempt(_))
364 }
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum PeerIngressConvention {
374 Message,
375 Request {
376 request_id: String,
377 intent: String,
378 },
379 Response {
380 in_reply_to: InteractionId,
381 status: ResponseStatus,
382 },
383 Ack {
384 in_reply_to: InteractionId,
385 },
386 Lifecycle {
387 kind: PeerLifecycleKind,
388 peer: String,
389 },
390 PlainEvent {
391 source_name: String,
392 },
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct PeerIngressFact {
402 pub interaction_id: InteractionId,
404 pub class: PeerInputClass,
406 pub kind: PeerIngressKind,
408 pub canonical_peer_id: Option<PeerId>,
410 pub display_name: Option<PeerName>,
412 pub signing_pubkey: Option<[u8; 32]>,
414 pub route: Option<PeerRoute>,
416 pub auth: Option<PeerIngressAuthDecision>,
418 pub convention: PeerIngressConvention,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct PeerIngressIdentity {
425 pub canonical_peer_id: PeerId,
426 pub display_label: String,
427 pub signing_pubkey: Option<[u8; 32]>,
428 pub convention: PeerIngressConvention,
429}
430
431impl PeerIngressIdentity {
432 pub fn new(
433 canonical_peer_id: PeerId,
434 display_label: impl Into<String>,
435 convention: PeerIngressConvention,
436 ) -> Self {
437 Self {
438 canonical_peer_id,
439 display_label: display_label.into(),
440 signing_pubkey: None,
441 convention,
442 }
443 }
444
445 pub fn with_signing_pubkey(mut self, signing_pubkey: [u8; 32]) -> Self {
446 self.signing_pubkey = Some(signing_pubkey);
447 self
448 }
449}
450
451impl PeerIngressFact {
452 pub fn peer(
453 interaction_id: InteractionId,
454 class: PeerInputClass,
455 kind: PeerIngressKind,
456 auth: Option<PeerIngressAuthDecision>,
457 identity: PeerIngressIdentity,
458 ) -> Self {
459 let PeerIngressIdentity {
460 canonical_peer_id,
461 display_label,
462 signing_pubkey,
463 convention,
464 } = identity;
465 let display_name = PeerName::new(display_label).ok();
466 let route = Some(match &display_name {
467 Some(name) => PeerRoute::with_display_name(canonical_peer_id, name.clone()),
468 None => PeerRoute::new(canonical_peer_id),
469 });
470 Self {
471 interaction_id,
472 class,
473 kind,
474 canonical_peer_id: Some(canonical_peer_id),
475 display_name,
476 signing_pubkey,
477 route,
478 auth,
479 convention,
480 }
481 }
482
483 pub fn plain_event(
484 interaction_id: InteractionId,
485 source_name: impl Into<String>,
486 class: PeerInputClass,
487 kind: PeerIngressKind,
488 ) -> Self {
489 let source_name = source_name.into();
490 Self {
491 interaction_id,
492 class,
493 kind,
494 canonical_peer_id: None,
495 display_name: None,
496 signing_pubkey: None,
497 route: None,
498 auth: None,
499 convention: PeerIngressConvention::PlainEvent { source_name },
500 }
501 }
502
503 pub fn legacy_peer_label(
508 interaction_id: InteractionId,
509 label: impl Into<String>,
510 class: PeerInputClass,
511 kind: PeerIngressKind,
512 auth: Option<PeerIngressAuthDecision>,
513 convention: PeerIngressConvention,
514 ) -> Self {
515 let label = label.into();
516 let canonical_peer_id = PeerId::parse(&label).ok();
517 let display_name = PeerName::new(label).ok();
518 let route = canonical_peer_id.map(|peer_id| match &display_name {
519 Some(name) => PeerRoute::with_display_name(peer_id, name.clone()),
520 None => PeerRoute::new(peer_id),
521 });
522 Self {
523 interaction_id,
524 class,
525 kind,
526 canonical_peer_id,
527 display_name,
528 signing_pubkey: None,
529 route,
530 auth,
531 convention,
532 }
533 }
534
535 pub fn canonical_peer_id_string(&self) -> Option<String> {
536 self.canonical_peer_id.map(|peer_id| peer_id.as_str())
537 }
538
539 pub fn display_label(&self) -> Option<String> {
540 self.display_name.as_ref().map(PeerName::as_string)
541 }
542
543 pub fn diagnostic_label(&self) -> String {
544 self.display_label()
545 .or_else(|| self.canonical_peer_id_string())
546 .unwrap_or_else(|| "<unknown-peer-ingress>".to_string())
547 }
548
549 pub fn plain_event_source_name(&self) -> Option<&str> {
550 match &self.convention {
551 PeerIngressConvention::PlainEvent { source_name } => Some(source_name.as_str()),
552 _ => None,
553 }
554 }
555}
556
557#[derive(Debug, Clone, PartialEq, Eq)]
559pub struct PeerIngressClassification {
560 pub class: PeerInputClass,
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 kind,
572 auth: PeerIngressAuthDecision::Required,
573 lifecycle_kind: None,
574 response_terminality: None,
575 }
576 }
577
578 pub const fn lifecycle(kind: PeerLifecycleKind) -> Self {
579 let class = match kind {
580 PeerLifecycleKind::PeerAdded => PeerInputClass::PeerLifecycleAdded,
581 PeerLifecycleKind::PeerRetired => PeerInputClass::PeerLifecycleRetired,
582 PeerLifecycleKind::PeerUnwired => PeerInputClass::PeerLifecycleUnwired,
583 };
584 Self {
585 class,
586 kind: PeerIngressKind::Request,
587 auth: PeerIngressAuthDecision::Required,
588 lifecycle_kind: Some(kind),
589 response_terminality: None,
590 }
591 }
592}
593
594#[derive(Debug, Clone, PartialEq)]
600pub struct PeerIngressEnvelopeFacts {
601 pub item_id: String,
602 pub from_peer: String,
603 pub from_peer_id: PeerId,
604 pub kind: PeerIngressEnvelopeKind,
605}
606
607#[derive(Debug, Clone, PartialEq)]
608pub enum PeerIngressEnvelopeKind {
609 Message {
610 body: String,
611 },
612 Request {
613 intent: String,
614 params: Value,
615 },
616 Lifecycle {
617 kind: PeerLifecycleKind,
618 params: Value,
619 },
620 Response {
621 in_reply_to: String,
622 status: ResponseStatus,
623 result: Value,
624 },
625 Ack {
626 in_reply_to: String,
627 },
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
632pub struct PeerIngressPlainEventFacts {
633 pub source_name: String,
634 pub body: String,
635}
636
637#[derive(Debug, Clone, PartialEq, Eq)]
639pub struct PeerIngressAdmission {
640 pub classification: PeerIngressClassification,
641 pub lifecycle_peer: Option<String>,
642 pub request_id: Option<String>,
643 pub rendered_text: String,
644}
645
646#[derive(Debug, Clone, PartialEq, Eq)]
655pub struct PeerIngressMachinePolicy {
656 silent_request_intents: BTreeSet<String>,
657 auth_exemptions: BTreeSet<PeerIngressAuthExemption>,
658}
659
660impl Default for PeerIngressMachinePolicy {
661 fn default() -> Self {
662 Self::from_silent_intents(std::iter::empty::<String>())
663 }
664}
665
666impl PeerIngressMachinePolicy {
667 pub fn from_silent_intents<I, S>(silent_intents: I) -> Self
668 where
669 I: IntoIterator<Item = S>,
670 S: Into<String>,
671 {
672 Self {
673 silent_request_intents: silent_intents.into_iter().map(Into::into).collect(),
674 auth_exemptions: BTreeSet::from([PeerIngressAuthExemption::SupervisorBridge]),
675 }
676 }
677
678 pub fn classify_message(&self) -> PeerIngressClassification {
679 PeerIngressClassification::required(
680 PeerInputClass::ActionableMessage,
681 PeerIngressKind::Message,
682 )
683 }
684
685 pub fn classify_request_intent(&self, intent: &str) -> PeerIngressClassification {
686 let auth = self
687 .auth_exemptions
688 .iter()
689 .copied()
690 .find(|exemption| exemption.matches_intent(intent))
691 .map(PeerIngressAuthDecision::Exempt)
692 .unwrap_or(PeerIngressAuthDecision::Required);
693
694 let mut classification = if let Some(kind) = classify_lifecycle_intent(intent) {
695 PeerIngressClassification::lifecycle(kind)
696 } else if self.silent_request_intents.contains(intent) {
697 PeerIngressClassification::required(
698 PeerInputClass::SilentRequest,
699 PeerIngressKind::Request,
700 )
701 } else {
702 PeerIngressClassification::required(
703 PeerInputClass::ActionableRequest,
704 PeerIngressKind::Request,
705 )
706 };
707 classification.auth = auth;
708 classification
709 }
710
711 pub fn classify_lifecycle(&self, kind: PeerLifecycleKind) -> PeerIngressClassification {
712 PeerIngressClassification::lifecycle(kind)
713 }
714
715 pub fn classify_response(&self, status: ResponseStatus) -> PeerIngressClassification {
716 let terminality = classify_response_terminality(status);
717 let class = match terminality {
718 TerminalityClass::Progress => PeerInputClass::ResponseProgress,
719 TerminalityClass::Terminal { .. } => PeerInputClass::ResponseTerminal,
720 };
721 let mut classification =
722 PeerIngressClassification::required(class, PeerIngressKind::Response);
723 classification.response_terminality = Some(terminality);
724 classification
725 }
726
727 pub fn classify_ack(&self) -> PeerIngressClassification {
728 PeerIngressClassification::required(PeerInputClass::Ack, PeerIngressKind::Ack)
729 }
730
731 pub fn classify_plain_event(&self) -> PeerIngressClassification {
732 PeerIngressClassification::required(PeerInputClass::PlainEvent, PeerIngressKind::PlainEvent)
733 }
734
735 pub fn classify_external_envelope(
736 &self,
737 facts: &PeerIngressEnvelopeFacts,
738 ) -> PeerIngressAdmission {
739 match &facts.kind {
740 PeerIngressEnvelopeKind::Message { .. } => {
741 let classification = self.classify_message();
742 PeerIngressAdmission {
743 rendered_text: render_peer_ingress_admitted_text(facts, &classification),
744 classification,
745 lifecycle_peer: None,
746 request_id: None,
747 }
748 }
749 PeerIngressEnvelopeKind::Request { intent, params } => {
750 let classification = self.classify_request_intent(intent);
751 let lifecycle_peer = classification
752 .lifecycle_kind
753 .map(|_| peer_lifecycle_subject(params, facts.from_peer.as_str()));
754 let rendered_text = render_peer_ingress_admitted_text(facts, &classification);
755 PeerIngressAdmission {
756 classification,
757 lifecycle_peer,
758 request_id: Some(facts.item_id.clone()),
759 rendered_text,
760 }
761 }
762 PeerIngressEnvelopeKind::Lifecycle { kind, params } => {
763 let classification = self.classify_lifecycle(*kind);
764 PeerIngressAdmission {
765 rendered_text: render_peer_ingress_admitted_text(facts, &classification),
766 classification,
767 lifecycle_peer: Some(peer_lifecycle_subject(params, facts.from_peer.as_str())),
768 request_id: None,
769 }
770 }
771 PeerIngressEnvelopeKind::Response {
772 in_reply_to,
773 status,
774 ..
775 } => {
776 let classification = self.classify_response(*status);
777 PeerIngressAdmission {
778 rendered_text: render_peer_ingress_admitted_text(facts, &classification),
779 classification,
780 lifecycle_peer: None,
781 request_id: Some(in_reply_to.clone()),
782 }
783 }
784 PeerIngressEnvelopeKind::Ack { in_reply_to } => {
785 let classification = self.classify_ack();
786 PeerIngressAdmission {
787 rendered_text: render_peer_ingress_admitted_text(facts, &classification),
788 classification,
789 lifecycle_peer: None,
790 request_id: Some(in_reply_to.clone()),
791 }
792 }
793 }
794 }
795
796 pub fn classify_plain_event_facts(
797 &self,
798 facts: &PeerIngressPlainEventFacts,
799 ) -> PeerIngressAdmission {
800 PeerIngressAdmission {
801 classification: self.classify_plain_event(),
802 lifecycle_peer: None,
803 request_id: None,
804 rendered_text: format_external_event_projection(&facts.source_name, Some(&facts.body)),
805 }
806 }
807}
808
809pub fn render_peer_ingress_admitted_text(
815 facts: &PeerIngressEnvelopeFacts,
816 classification: &PeerIngressClassification,
817) -> String {
818 match &facts.kind {
819 PeerIngressEnvelopeKind::Message { body } => {
820 format_peer_message_projection(&facts.from_peer, body)
821 }
822 PeerIngressEnvelopeKind::Request { intent, params } => {
823 if classification.lifecycle_kind.is_some() {
824 String::new()
825 } else {
826 format_peer_request_projection(
827 facts.from_peer_id,
828 Some(&facts.from_peer),
829 facts.item_id.as_str(),
830 intent,
831 params,
832 )
833 }
834 }
835 PeerIngressEnvelopeKind::Lifecycle { .. } => String::new(),
836 PeerIngressEnvelopeKind::Response {
837 in_reply_to,
838 status,
839 result,
840 } => format_peer_response_projection(&facts.from_peer, in_reply_to, *status, result),
841 PeerIngressEnvelopeKind::Ack { in_reply_to } => {
842 format_peer_ack_projection(&facts.from_peer, in_reply_to)
843 }
844 }
845}
846
847pub fn peer_lifecycle_subject(params: &Value, fallback_peer: &str) -> String {
849 params
850 .get("peer")
851 .and_then(Value::as_str)
852 .filter(|peer| !peer.is_empty())
853 .unwrap_or(fallback_peer)
854 .to_string()
855}
856
857fn classify_lifecycle_intent(intent: &str) -> Option<PeerLifecycleKind> {
858 if intent == PeerLifecycleKind::PeerAdded.as_str() {
859 Some(PeerLifecycleKind::PeerAdded)
860 } else if intent == PeerLifecycleKind::PeerRetired.as_str() {
861 Some(PeerLifecycleKind::PeerRetired)
862 } else if intent == PeerLifecycleKind::PeerUnwired.as_str() {
863 Some(PeerLifecycleKind::PeerUnwired)
864 } else {
865 None
866 }
867}
868
869#[derive(Debug, Clone)]
875pub struct PeerInputCandidate {
876 pub interaction: InboxInteraction,
878 pub ingress: PeerIngressFact,
881 pub lifecycle_peer: Option<String>,
883 pub response_terminality: Option<TerminalityClass>,
885}
886
887impl PeerInputCandidate {
888 pub fn new(
889 interaction: InboxInteraction,
890 ingress: PeerIngressFact,
891 lifecycle_peer: Option<String>,
892 ) -> Self {
893 Self {
894 interaction,
895 ingress,
896 lifecycle_peer,
897 response_terminality: None,
898 }
899 }
900
901 pub fn class(&self) -> PeerInputClass {
902 self.ingress.class
903 }
904
905 pub fn kind(&self) -> PeerIngressKind {
906 self.ingress.kind
907 }
908
909 pub fn auth(&self) -> Option<PeerIngressAuthDecision> {
910 self.ingress.auth
911 }
912}
913
914pub type ClassifiedInboxInteraction = PeerInputCandidate;
916
917#[derive(Debug, Clone, Copy, PartialEq, Eq)]
922pub enum PeerIngressKind {
923 Message,
924 Request,
925 Response,
926 Ack,
927 PlainEvent,
928}
929
930#[derive(Debug, Clone, PartialEq, Eq)]
937pub struct PeerIngressDiagnosticDisplay(String);
938
939impl PeerIngressDiagnosticDisplay {
940 pub fn new(value: impl Into<String>) -> Self {
941 Self(value.into())
942 }
943
944 pub fn as_str(&self) -> &str {
945 &self.0
946 }
947}
948
949impl std::fmt::Display for PeerIngressDiagnosticDisplay {
950 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
951 self.0.fmt(f)
952 }
953}
954
955#[derive(Debug, Clone, Copy, PartialEq, Eq)]
961pub enum PeerIngressAdmissionDiagnostic {
962 TrustedAtAdmission,
963 UntrustedAtAdmission,
964}
965
966impl PeerIngressAdmissionDiagnostic {
967 pub const fn from_trusted(trusted: bool) -> Self {
968 if trusted {
969 Self::TrustedAtAdmission
970 } else {
971 Self::UntrustedAtAdmission
972 }
973 }
974
975 pub const fn trusted_at_admission(self) -> bool {
976 matches!(self, Self::TrustedAtAdmission)
977 }
978}
979
980#[derive(Debug, Clone, PartialEq, Eq)]
987pub struct PeerIngressEntrySnapshot {
988 pub raw_item_id: InteractionId,
990 pub interaction_id: Option<InteractionId>,
992 pub class: PeerInputClass,
994 pub kind: PeerIngressKind,
996 pub from_peer_display: Option<PeerIngressDiagnosticDisplay>,
998 pub canonical_peer_id: Option<PeerId>,
1000 pub display_name: Option<PeerName>,
1002 pub signing_pubkey: Option<[u8; 32]>,
1004 pub route: Option<PeerRoute>,
1006 pub lifecycle_peer_display: Option<PeerIngressDiagnosticDisplay>,
1008 pub request_correlation_id: Option<InteractionId>,
1010 pub auth: Option<PeerIngressAuthDecision>,
1013 pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
1016 pub response_terminality: Option<TerminalityClass>,
1019}
1020
1021#[derive(Debug, Clone, PartialEq, Eq, Default)]
1027pub struct PeerIngressQueueSnapshot {
1028 pub total_count: usize,
1029 pub actionable_count: usize,
1030 pub response_count: usize,
1031 pub lifecycle_count: usize,
1032 pub silent_request_count: usize,
1033 pub ack_count: usize,
1034 pub plain_event_count: usize,
1035 pub queued_entries: Vec<PeerIngressEntrySnapshot>,
1036}
1037
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1043pub enum PeerIngressAuthorityPhase {
1044 #[default]
1045 Absent,
1046 Received,
1047 Dropped,
1048 Delivered,
1049}
1050
1051#[derive(Debug, Clone, PartialEq, Eq)]
1056pub struct PeerIngressRuntimeSnapshot {
1057 pub self_peer_id: crate::comms::PeerId,
1059 pub auth_required: bool,
1061 pub authority_phase: PeerIngressAuthorityPhase,
1063 pub trusted_peers: Vec<TrustedPeerDescriptor>,
1065 pub submission_queue_len: usize,
1067 pub queue: PeerIngressQueueSnapshot,
1069}
1070
1071#[cfg(test)]
1072#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1073mod tests {
1074 use super::*;
1075
1076 #[test]
1077 fn interaction_id_json_roundtrip() {
1078 let id = InteractionId(Uuid::new_v4());
1079 let json = serde_json::to_string(&id).unwrap();
1080 let parsed: InteractionId = serde_json::from_str(&json).unwrap();
1081 assert_eq!(id, parsed);
1082 }
1083
1084 #[test]
1085 fn interaction_content_message_json_roundtrip() {
1086 let content = InteractionContent::Message {
1087 body: "hello".to_string(),
1088 blocks: None,
1089 };
1090 let json = serde_json::to_value(&content).unwrap();
1091 assert_eq!(json["type"], "message");
1092 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1093 assert_eq!(content, parsed);
1094 }
1095
1096 #[test]
1097 fn interaction_content_request_json_roundtrip() {
1098 let content = InteractionContent::Request {
1099 intent: "review".to_string(),
1100 params: serde_json::json!({"pr": 42}),
1101 blocks: None,
1102 };
1103 let json = serde_json::to_value(&content).unwrap();
1104 assert_eq!(json["type"], "request");
1105 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1106 assert_eq!(content, parsed);
1107 }
1108
1109 #[test]
1110 fn interaction_content_response_json_roundtrip() {
1111 let id = InteractionId(Uuid::new_v4());
1112 let content = InteractionContent::Response {
1113 in_reply_to: id,
1114 status: ResponseStatus::Completed,
1115 result: serde_json::json!({"ok": true}),
1116 blocks: None,
1117 };
1118 let json = serde_json::to_value(&content).unwrap();
1119 assert_eq!(json["type"], "response");
1120 assert_eq!(json["status"], "completed");
1121 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1122 assert_eq!(content, parsed);
1123 }
1124
1125 #[test]
1126 fn response_status_json_roundtrip_all_variants() {
1127 for (variant, expected_str) in [
1128 (ResponseStatus::Accepted, "accepted"),
1129 (ResponseStatus::Completed, "completed"),
1130 (ResponseStatus::Failed, "failed"),
1131 ] {
1132 let json = serde_json::to_value(variant).unwrap();
1133 assert_eq!(json, expected_str);
1134 let parsed: ResponseStatus = serde_json::from_value(json).unwrap();
1135 assert_eq!(variant, parsed);
1136 }
1137 }
1138
1139 #[test]
1140 fn classify_response_terminality_covers_all_variants() {
1141 assert_eq!(
1142 classify_response_terminality(ResponseStatus::Accepted),
1143 TerminalityClass::Progress
1144 );
1145 assert_eq!(
1146 classify_response_terminality(ResponseStatus::Completed),
1147 TerminalityClass::Terminal {
1148 disposition: TerminalDisposition::Completed
1149 }
1150 );
1151 assert_eq!(
1152 classify_response_terminality(ResponseStatus::Failed),
1153 TerminalityClass::Terminal {
1154 disposition: TerminalDisposition::Failed
1155 }
1156 );
1157 }
1158
1159 #[test]
1160 fn peer_ingress_policy_owns_response_terminal_classification() {
1161 let policy = PeerIngressMachinePolicy::default();
1162
1163 assert_eq!(
1164 policy.classify_response(ResponseStatus::Accepted).class,
1165 PeerInputClass::ResponseProgress
1166 );
1167 assert_eq!(
1168 policy.classify_response(ResponseStatus::Completed).class,
1169 PeerInputClass::ResponseTerminal
1170 );
1171 assert_eq!(
1172 policy.classify_response(ResponseStatus::Failed).class,
1173 PeerInputClass::ResponseTerminal
1174 );
1175 }
1176
1177 #[test]
1178 fn peer_ingress_policy_auth_exempts_supervisor_bridge() {
1179 let policy = PeerIngressMachinePolicy::default();
1180 let classification = policy.classify_request_intent(crate::SUPERVISOR_BRIDGE_INTENT);
1181
1182 assert_eq!(classification.class, PeerInputClass::ActionableRequest);
1183 assert_eq!(
1184 classification.auth,
1185 PeerIngressAuthDecision::Exempt(PeerIngressAuthExemption::SupervisorBridge)
1186 );
1187 }
1188
1189 #[test]
1190 fn peer_ingress_policy_owns_lifecycle_and_silent_routing() {
1191 let policy = PeerIngressMachinePolicy::from_silent_intents(["probe.silent"]);
1192
1193 let lifecycle = policy.classify_request_intent(PeerLifecycleKind::PeerUnwired.as_str());
1194 assert_eq!(lifecycle.class, PeerInputClass::PeerLifecycleUnwired);
1195 assert_eq!(
1196 lifecycle.lifecycle_kind,
1197 Some(PeerLifecycleKind::PeerUnwired)
1198 );
1199
1200 let silent = policy.classify_request_intent("probe.silent");
1201 assert_eq!(silent.class, PeerInputClass::SilentRequest);
1202 assert_eq!(silent.auth, PeerIngressAuthDecision::Required);
1203 }
1204
1205 #[test]
1206 fn interaction_message_with_blocks_roundtrip() {
1207 let content = InteractionContent::Message {
1208 body: "hello".to_string(),
1209 blocks: Some(vec![
1210 ContentBlock::Text {
1211 text: "hello".to_string(),
1212 },
1213 ContentBlock::Image {
1214 media_type: "image/png".to_string(),
1215 data: "iVBORw0KGgo=".into(),
1216 },
1217 ]),
1218 };
1219 let json = serde_json::to_value(&content).unwrap();
1220 assert_eq!(json["type"], "message");
1221 assert!(json["blocks"].is_array());
1222 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1223 assert_eq!(content, parsed);
1224 }
1225
1226 #[test]
1227 fn inbox_interaction_preserves_runtime_hints() {
1228 let interaction = InboxInteraction {
1229 id: InteractionId(Uuid::new_v4()),
1230 from_route: None,
1231 from: "event:webhook".into(),
1232 content: InteractionContent::Message {
1233 body: "hello".into(),
1234 blocks: None,
1235 },
1236 rendered_text: "External event via webhook: hello".into(),
1237 handling_mode: HandlingMode::Steer,
1238 render_metadata: Some(RenderMetadata {
1239 class: crate::types::RenderClass::SystemNotice,
1240 salience: crate::types::RenderSalience::Urgent,
1241 }),
1242 };
1243
1244 assert_eq!(interaction.handling_mode, HandlingMode::Steer);
1245 assert!(interaction.render_metadata.is_some());
1246 }
1247
1248 #[test]
1249 fn interaction_message_without_blocks_compat() {
1250 let old_json = r#"{"type":"message","body":"hello"}"#;
1252 let parsed: InteractionContent = serde_json::from_str(old_json).unwrap();
1253 match parsed {
1254 InteractionContent::Message { body, blocks } => {
1255 assert_eq!(body, "hello");
1256 assert_eq!(blocks, None);
1257 }
1258 other => panic!("Expected Message, got {other:?}"),
1259 }
1260
1261 let content = InteractionContent::Message {
1263 body: "test".to_string(),
1264 blocks: None,
1265 };
1266 let json = serde_json::to_string(&content).unwrap();
1267 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1268 assert!(
1269 value.get("blocks").is_none(),
1270 "blocks: None should not appear in JSON"
1271 );
1272 }
1273}