Skip to main content

meerkat_mobkit/unified_runtime/
cross_mob.rs

1//! Cross-mob communication — peering and messaging between members in different mobs.
2
3use meerkat_core::comms::TrustedPeerDescriptor;
4use meerkat_core::types::HandlingMode;
5use meerkat_mob::ids::AgentIdentity;
6use meerkat_mob::{MobHandle, PeerTarget};
7
8use crate::auth::peer_keys::GatewayPeerKeys;
9use crate::contact_directory::{ContactDirectory, ContactEntry, MobTransport};
10use crate::runtime::cross_mob_remote::{RemoteMobError, RemoteMobProxy};
11
12use super::UnifiedRuntime;
13
14/// Dispatch a cross-mob operation to either an in-process `MobHandle`
15/// (registered via `register_peer_mob`) or a [`RemoteMobProxy`] for
16/// peers reachable over TCP/UDS.
17///
18/// Phase 1 wires the structural seam — see `runtime/cross_mob_remote.rs`
19/// for the Phase 2 plan that fills in real cross-process control RPC.
20enum LocalOrRemote {
21    /// Same-process peer with an `Arc<MobHandle>` for direct dispatch.
22    Local(MobHandle),
23    /// Cross-process peer reachable via TCP/UDS.
24    Remote(RemoteMobProxy),
25}
26
27struct MemberPeerInfo {
28    peer_id: String,
29    comms_name: String,
30    pubkey: [u8; 32],
31}
32
33/// Errors from cross-mob operations.
34#[derive(Debug)]
35pub enum CrossMobError {
36    /// No contact directory configured on this runtime.
37    NoContactDirectory,
38    /// Mob ID not found in the contact directory.
39    UnknownMob(String),
40    /// No peer mob handle registered for this mob (required for inproc).
41    NoPeerHandle(String),
42    /// Caller asked to wire a non-inproc peer but did not supply (or
43    /// could not derive) a 32-byte Ed25519 pubkey. Mobkit refuses to
44    /// build an unsigned descriptor on real transports — meerkat-comms
45    /// would then admit any sender at ingress.
46    MissingPeerPubkey { mob_id: Option<String> },
47    /// Member not found in the target mob's roster.
48    MemberNotFound { member_id: String, mob_id: String },
49    /// Member has no comms runtime (not comms-enabled).
50    NoCommsInfo { member_id: String, mob_id: String },
51    /// The underlying mob operation failed.
52    Mob(meerkat_mob::MobError),
53    /// Failed to build a trusted peer spec.
54    PeerSpec(String),
55    /// A cross-process control-channel call failed. Phase 1 returns this
56    /// for any TCP/UDS contact entry that does not also have an
57    /// in-process `MobHandle` registered — the seam is laid out, the
58    /// real client lands in Phase 2.
59    Remote(RemoteMobError),
60}
61
62impl std::fmt::Display for CrossMobError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::NoContactDirectory => write!(f, "no contact directory configured"),
66            Self::UnknownMob(id) => write!(f, "unknown mob: {id}"),
67            Self::NoPeerHandle(id) => write!(f, "no peer mob handle registered for: {id}"),
68            Self::Remote(e) => write!(f, "cross-process cross-mob: {e}"),
69            Self::MemberNotFound { member_id, mob_id } => {
70                write!(f, "member '{member_id}' not found in mob '{mob_id}'")
71            }
72            Self::NoCommsInfo { member_id, mob_id } => {
73                write!(
74                    f,
75                    "member '{member_id}' in mob '{mob_id}' has no comms runtime"
76                )
77            }
78            Self::Mob(err) => write!(f, "mob error: {err}"),
79            Self::PeerSpec(reason) => write!(f, "peer spec error: {reason}"),
80            Self::MissingPeerPubkey { mob_id } => match mob_id {
81                Some(id) => write!(
82                    f,
83                    "non-inproc peer for mob '{id}' has no signing pubkey; \
84                     bootstrap via mobkit/peer_pubkey or populate the contact \
85                     directory's pubkey field before wiring"
86                ),
87                None => write!(
88                    f,
89                    "non-inproc peer has no signing pubkey; supply a 32-byte \
90                     Ed25519 pubkey or use inproc transport"
91                ),
92            },
93        }
94    }
95}
96
97impl std::error::Error for CrossMobError {}
98
99impl From<meerkat_mob::MobError> for CrossMobError {
100    fn from(err: meerkat_mob::MobError) -> Self {
101        Self::Mob(err)
102    }
103}
104
105impl From<RemoteMobError> for CrossMobError {
106    fn from(err: RemoteMobError) -> Self {
107        Self::Remote(err)
108    }
109}
110
111impl UnifiedRuntime {
112    /// Register an external mob's handle for same-process cross-mob communication.
113    pub async fn register_peer_mob(&self, mob_id: &str, handle: MobHandle) {
114        self.peer_mob_handles
115            .write()
116            .await
117            .insert(mob_id.to_string(), handle);
118    }
119
120    /// Set the contact directory for cross-mob address resolution.
121    pub fn set_contact_directory(&mut self, directory: ContactDirectory) {
122        self.contact_directory = Some(directory);
123    }
124
125    /// Install the long-lived Ed25519 keypair this gateway advertises via
126    /// `mobkit/peer_pubkey` and (when meerkat-comms grows out-of-process
127    /// transports) signs outbound envelopes with.
128    ///
129    /// Inproc-only deployments and most tests skip this — the in-process
130    /// router authorises by identity map and signature verification is
131    /// moot. Production gateways and any cross-process integration test
132    /// must call this.
133    pub fn set_gateway_peer_keys(&mut self, keys: GatewayPeerKeys) {
134        self.gateway_peer_keys = Some(keys);
135    }
136
137    /// Borrow the local gateway keypair if one was installed.
138    pub fn gateway_peer_keys(&self) -> Option<&GatewayPeerKeys> {
139        self.gateway_peer_keys.as_ref()
140    }
141
142    /// Wire a local member to a member in an external mob.
143    ///
144    /// Resolves both members' peer IDs from roster entries, builds peer specs
145    /// using the transport scheme advertised by the contact directory entry
146    /// (`inproc`, `tcp`, or `uds`), and registers the peer on both sides to
147    /// establish bidirectional trust.
148    ///
149    /// # Local vs remote dispatch
150    ///
151    /// The destination mob is dispatched as either [`LocalOrRemote::Local`]
152    /// (when an `Arc<MobHandle>` was registered via [`Self::register_peer_mob`])
153    /// or [`LocalOrRemote::Remote`] (when only a contact-directory TCP/UDS
154    /// entry exists). Phase 1 ships the structural seam; Phase 2 wires the
155    /// real cross-process control RPC — see
156    /// `runtime::cross_mob_remote::RemoteMobProxy`.
157    pub async fn wire_cross_mob(
158        &self,
159        local_member_id: &str,
160        remote_member_id: &str,
161        remote_mob_id: &str,
162    ) -> Result<(), CrossMobError> {
163        let entry = self.resolve_contact(remote_mob_id)?;
164        let remote = self.dispatch_for(&entry).await?;
165
166        let local_handle = self.mob_runtime.handle();
167        let local_mob_id = local_handle.mob_id().to_string();
168        // Cross-mob callers speak the public alias space (identity-first
169        // runtime ids like `rt:{identity}:{gen}` included); the mob roster
170        // holds comms-safe encoded ids, so encode at this boundary.
171        let local_mid = crate::member_comms_id::mob_member_id(local_member_id);
172
173        let local_info = self
174            .get_member_peer_info(&local_handle, &local_mid, &local_mob_id)
175            .await?;
176
177        match remote {
178            LocalOrRemote::Local(remote_handle) => {
179                let remote_mid = crate::member_comms_id::mob_member_id(remote_member_id);
180                let remote_info = self
181                    .get_member_peer_info(&remote_handle, &remote_mid, remote_mob_id)
182                    .await?;
183
184                let remote_spec = build_peer_spec(
185                    &remote_info.comms_name,
186                    &remote_info.peer_id,
187                    &entry.transport,
188                    Some(remote_info.pubkey),
189                )?;
190                let local_spec = build_peer_spec(
191                    &local_info.comms_name,
192                    &local_info.peer_id,
193                    &MobTransport::Inproc,
194                    Some(local_info.pubkey),
195                )?;
196
197                local_handle
198                    .wire(local_mid.clone(), PeerTarget::External(remote_spec))
199                    .await
200                    .map_err(CrossMobError::Mob)?;
201
202                if let Err(e) = remote_handle
203                    .wire(remote_mid.clone(), PeerTarget::External(local_spec))
204                    .await
205                {
206                    if let Ok(rollback_spec) = build_peer_spec(
207                        &remote_info.comms_name,
208                        &remote_info.peer_id,
209                        &entry.transport,
210                        Some(remote_info.pubkey),
211                    ) {
212                        let _ = local_handle
213                            .unwire(local_mid, PeerTarget::External(rollback_spec))
214                            .await;
215                    }
216                    return Err(CrossMobError::Mob(e));
217                }
218
219                Ok(())
220            }
221            LocalOrRemote::Remote(proxy) => {
222                // Cross-process bilateral wire:
223                // 1. Look up the remote member's peer info via control RPC.
224                // 2. Build a descriptor pointing to the remote member using
225                //    the contact-entry transport; wire locally first.
226                // 3. Send a `Wire` control request advertising our local
227                //    member's peer info; remote side wires its half.
228                // 4. On remote-side failure, roll back the local wire.
229                let (remote_peer_id, remote_comms_name) = proxy
230                    .lookup_member(remote_member_id)
231                    .await
232                    .map_err(CrossMobError::Remote)?;
233                let remote_spec = build_peer_spec(
234                    &remote_comms_name,
235                    &remote_peer_id,
236                    &entry.transport,
237                    entry.pubkey,
238                )?;
239
240                local_handle
241                    .wire(local_mid.clone(), PeerTarget::External(remote_spec))
242                    .await
243                    .map_err(CrossMobError::Mob)?;
244
245                // The remote side reaches us over the same transport scheme
246                // we use to reach it. The contact directory entry on the
247                // remote gateway will record our control endpoint; for the
248                // bilateral wire we advertise the same endpoint the
249                // ContactEntry currently encodes for *us*. Until we run
250                // a discovery RPC the other way, advertise an inproc
251                // back-pointer so trust is symmetric on the wire surface.
252                let pubkey_b64 = self
253                    .gateway_peer_keys
254                    .as_ref()
255                    .map(crate::auth::peer_keys::GatewayPeerKeys::pubkey_b64);
256                let local_spec_address = format!("inproc://{}", local_info.comms_name);
257                if let Err(remote_err) = proxy
258                    .wire_remote(
259                        remote_member_id,
260                        &local_spec_address,
261                        &local_info.comms_name,
262                        &local_info.peer_id,
263                        pubkey_b64,
264                    )
265                    .await
266                {
267                    let rollback_spec = build_peer_spec(
268                        &remote_comms_name,
269                        &remote_peer_id,
270                        &entry.transport,
271                        entry.pubkey,
272                    );
273                    if let Ok(spec) = rollback_spec {
274                        let _ = local_handle
275                            .unwire(local_mid, PeerTarget::External(spec))
276                            .await;
277                    }
278                    return Err(CrossMobError::Remote(remote_err));
279                }
280
281                Ok(())
282            }
283        }
284    }
285
286    /// Unwire a cross-mob peering.
287    ///
288    /// Best-effort on both sides — attempts to unwire both the local and
289    /// remote members. Partial cleanup is better than aborting after one
290    /// side fails, which would leave asymmetric peering.
291    pub async fn unwire_cross_mob(
292        &self,
293        local_member_id: &str,
294        remote_member_id: &str,
295        remote_mob_id: &str,
296    ) -> Result<(), CrossMobError> {
297        let entry = self.resolve_contact(remote_mob_id)?;
298        let remote = self.dispatch_for(&entry).await?;
299        let local_handle = self.mob_runtime.handle();
300        let local_mob_id = local_handle.mob_id().to_string();
301        let local_mid = crate::member_comms_id::mob_member_id(local_member_id);
302
303        let mut first_error: Option<CrossMobError> = None;
304
305        let local_info_opt = self
306            .get_member_peer_info(&local_handle, &local_mid, &local_mob_id)
307            .await
308            .ok();
309
310        match remote {
311            LocalOrRemote::Local(remote_handle) => {
312                let remote_mid = crate::member_comms_id::mob_member_id(remote_member_id);
313                if let Ok(remote_info) = self
314                    .get_member_peer_info(&remote_handle, &remote_mid, remote_mob_id)
315                    .await
316                    && let Ok(spec) = build_peer_spec(
317                        &remote_info.comms_name,
318                        &remote_info.peer_id,
319                        &entry.transport,
320                        Some(remote_info.pubkey),
321                    )
322                    && let Err(e) = local_handle
323                        .unwire(local_mid.clone(), PeerTarget::External(spec))
324                        .await
325                {
326                    first_error = Some(CrossMobError::Mob(e));
327                }
328
329                if let Some(local_info) = &local_info_opt
330                    && let Ok(spec) = build_peer_spec(
331                        &local_info.comms_name,
332                        &local_info.peer_id,
333                        &MobTransport::Inproc,
334                        Some(local_info.pubkey),
335                    )
336                    && let Err(e) = remote_handle
337                        .unwire(remote_mid.clone(), PeerTarget::External(spec))
338                        .await
339                    && first_error.is_none()
340                {
341                    first_error = Some(CrossMobError::Mob(e));
342                }
343            }
344            LocalOrRemote::Remote(proxy) => {
345                if let Ok((remote_peer_id, remote_comms_name)) =
346                    proxy.lookup_member(remote_member_id).await
347                    && let Ok(spec) = build_peer_spec(
348                        &remote_comms_name,
349                        &remote_peer_id,
350                        &entry.transport,
351                        entry.pubkey,
352                    )
353                    && let Err(e) = local_handle
354                        .unwire(local_mid.clone(), PeerTarget::External(spec))
355                        .await
356                {
357                    first_error = Some(CrossMobError::Mob(e));
358                }
359
360                if let Some(local_info) = &local_info_opt {
361                    let pubkey_b64 = self
362                        .gateway_peer_keys
363                        .as_ref()
364                        .map(crate::auth::peer_keys::GatewayPeerKeys::pubkey_b64);
365                    let local_spec_address = format!("inproc://{}", local_info.comms_name);
366                    if let Err(e) = proxy
367                        .unwire_remote(
368                            remote_member_id,
369                            &local_spec_address,
370                            &local_info.comms_name,
371                            &local_info.peer_id,
372                            pubkey_b64,
373                        )
374                        .await
375                        && first_error.is_none()
376                    {
377                        first_error = Some(CrossMobError::Remote(e));
378                    }
379                }
380            }
381        }
382
383        match first_error {
384            Some(e) => Err(e),
385            None => Ok(()),
386        }
387    }
388
389    /// Inject a message into a remote mob member's session.
390    ///
391    /// This is an **app-level injection** — the remote agent receives the
392    /// message as an external turn but does not know who sent it. For
393    /// agent-to-agent communication with sender identity and reply path,
394    /// use `wire_cross_mob` to set up peering, then agents communicate
395    /// directly via their comms `send` tool.
396    ///
397    /// `from_local_member` is recorded for audit/logging but does not
398    /// affect delivery — the message is injected via the remote mob handle.
399    pub async fn send_cross_mob(
400        &self,
401        from_local_member: &str,
402        remote_member_id: &str,
403        remote_mob_id: &str,
404        content: impl Into<meerkat_core::ContentInput>,
405    ) -> Result<String, CrossMobError> {
406        let entry = self.resolve_contact(remote_mob_id)?;
407        let remote = self.dispatch_for(&entry).await?;
408        let remote_mid = crate::member_comms_id::mob_member_id(remote_member_id);
409        let content = content.into();
410        let _ = from_local_member; // audit context; delivery is via remote handle
411
412        match remote {
413            LocalOrRemote::Local(remote_handle) => {
414                let _receipt = remote_handle
415                    .member(&remote_mid)
416                    .await
417                    .map_err(CrossMobError::Mob)?
418                    .send(content, HandlingMode::Queue)
419                    .await
420                    .map_err(CrossMobError::Mob)?;
421                // Meerkat 0.6: MemberDeliveryReceipt no longer carries
422                // session_id. Resolve the bridge session id from the
423                // remote mob handle.
424                let session_id = remote_handle
425                    .resolve_bridge_session_id(&remote_mid)
426                    .await
427                    .ok_or_else(|| CrossMobError::NoCommsInfo {
428                        member_id: remote_member_id.to_string(),
429                        mob_id: remote_mob_id.to_string(),
430                    })?;
431                Ok(session_id.to_string())
432            }
433            LocalOrRemote::Remote(proxy) => {
434                // Cross-process: serialize the content and ship it over
435                // the remote control channel. The peer gateway dispatches
436                // it against its local mob and returns the bridge session
437                // id that accepted the injection.
438                let content_json = serde_json::to_value(&content).map_err(|err| {
439                    CrossMobError::PeerSpec(format!(
440                        "failed to serialize content for remote inject: {err}"
441                    ))
442                })?;
443                let session_id = proxy
444                    .inject_message(remote_member_id, content_json)
445                    .await
446                    .map_err(CrossMobError::Remote)?;
447                Ok(session_id)
448            }
449        }
450    }
451
452    /// List external mobs from the contact directory.
453    pub fn list_external_mobs(&self) -> Vec<ContactEntry> {
454        self.contact_directory
455            .as_ref()
456            .map(|d| d.list().into_iter().cloned().collect())
457            .unwrap_or_default()
458    }
459
460    /// Whether a contact directory is configured (cross-mob operations available).
461    pub fn has_contact_directory(&self) -> bool {
462        self.contact_directory.is_some()
463    }
464
465    /// Whether any peer mob handles are registered (required for
466    /// high-level cross-mob wire/unwire/send).
467    pub async fn has_peer_mob_handles(&self) -> bool {
468        !self.peer_mob_handles.read().await.is_empty()
469    }
470
471    /// Whether the contact directory has any inproc entries.
472    pub fn has_inproc_contacts(&self) -> bool {
473        self.contact_directory.as_ref().is_some_and(|d| {
474            d.list()
475                .iter()
476                .any(|e| matches!(e.transport, MobTransport::Inproc))
477        })
478    }
479
480    /// Whether the contact directory has any cross-process (TCP/UDS) entries.
481    /// Useful for opting into the remote-mob proxy code path.
482    pub fn has_remote_contacts(&self) -> bool {
483        self.contact_directory.as_ref().is_some_and(|d| {
484            d.list()
485                .iter()
486                .any(|e| matches!(e.transport, MobTransport::Tcp(_) | MobTransport::Uds(_)))
487        })
488    }
489
490    /// Return the local mob's ID.
491    pub fn mob_id(&self) -> String {
492        self.mob_runtime.handle().mob_id().to_string()
493    }
494
495    /// Get comms peer info for a local member.
496    /// Returns `(peer_id, comms_name, address)` — the address is always
497    /// `inproc://{comms_name}` for local members. For cross-process peering,
498    /// the caller should replace the address with the remote gateway's
499    /// TCP/UDS endpoint.
500    pub async fn local_member_peer_info(
501        &self,
502        member_id: &str,
503    ) -> Result<(String, String, String), CrossMobError> {
504        let handle = self.mob_runtime.handle();
505        let mob_id = handle.mob_id().to_string();
506        let mid = crate::member_comms_id::mob_member_id(member_id);
507        let info = self.get_member_peer_info(&handle, &mid, &mob_id).await?;
508        let address = format!("inproc://{}", info.comms_name);
509        Ok((info.peer_id, info.comms_name, address))
510    }
511
512    /// Wire a local member to an external peer using provided comms info.
513    /// Only wires the local side — for the bidirectional wire, call this
514    /// on both gateways.
515    ///
516    /// `remote_address` is the comms transport address (e.g. `"inproc://name"`
517    /// for same-process, `"tcp://host:port"` for cross-process).
518    /// `remote_pubkey` is the peer gateway's 32-byte Ed25519 verifying
519    /// key. Inproc transports may pass `None` (the in-process router
520    /// authorises by identity map). Non-inproc transports MUST supply a
521    /// non-zero pubkey — this call fails closed with
522    /// [`CrossMobError::MissingPeerPubkey`] otherwise so unsigned
523    /// descriptors never reach a real transport.
524    pub async fn wire_local(
525        &self,
526        local_member_id: &str,
527        remote_comms_name: &str,
528        remote_peer_id: &str,
529        remote_address: &str,
530        remote_pubkey: Option<[u8; 32]>,
531    ) -> Result<(), CrossMobError> {
532        let spec = build_external_peer_spec(
533            remote_comms_name,
534            remote_peer_id,
535            remote_address,
536            remote_pubkey,
537        )?;
538        let local_mid = crate::member_comms_id::mob_member_id(local_member_id);
539        self.mob_runtime
540            .handle()
541            .wire(local_mid, PeerTarget::External(spec))
542            .await
543            .map_err(CrossMobError::Mob)
544    }
545
546    /// Undo a `wire_local` — unwire a local member from a previously wired peer.
547    /// Only affects the local side; the remote side is left unchanged.
548    pub async fn unwire_local(
549        &self,
550        local_member_id: &str,
551        remote_comms_name: &str,
552        remote_peer_id: &str,
553        remote_address: &str,
554        remote_pubkey: Option<[u8; 32]>,
555    ) -> Result<(), CrossMobError> {
556        let spec = build_external_peer_spec(
557            remote_comms_name,
558            remote_peer_id,
559            remote_address,
560            remote_pubkey,
561        )?;
562        let local_mid = crate::member_comms_id::mob_member_id(local_member_id);
563        self.mob_runtime
564            .handle()
565            .unwire(local_mid, PeerTarget::External(spec))
566            .await
567            .map_err(CrossMobError::Mob)
568    }
569
570    // -- internal helpers --
571
572    fn resolve_contact(&self, mob_id: &str) -> Result<ContactEntry, CrossMobError> {
573        let dir = self
574            .contact_directory
575            .as_ref()
576            .ok_or(CrossMobError::NoContactDirectory)?;
577        dir.get(mob_id)
578            .cloned()
579            .ok_or_else(|| CrossMobError::UnknownMob(mob_id.to_string()))
580    }
581
582    /// Pick the appropriate dispatch arm for a contact entry.
583    ///
584    /// Order of preference:
585    /// 1. **Local** — if a `MobHandle` was registered via
586    ///    [`Self::register_peer_mob`] for this mob_id, use it directly
587    ///    (covers the inproc + same-process-test paths).
588    /// 2. **Remote** — for TCP/UDS contact entries with no registered
589    ///    handle, build a [`RemoteMobProxy`].
590    /// 3. **Error** — inproc entry with no registered handle, or unknown
591    ///    transport.
592    async fn dispatch_for(&self, entry: &ContactEntry) -> Result<LocalOrRemote, CrossMobError> {
593        if let Some(handle) = self
594            .peer_mob_handles
595            .read()
596            .await
597            .get(&entry.mob_id)
598            .cloned()
599        {
600            return Ok(LocalOrRemote::Local(handle));
601        }
602        match RemoteMobProxy::from_entry(entry)? {
603            Some(proxy) => Ok(LocalOrRemote::Remote(proxy)),
604            None => Err(CrossMobError::NoPeerHandle(entry.mob_id.clone())),
605        }
606    }
607
608    /// Resolve a member's peer_id, comms name, and transport key from the roster entry.
609    ///
610    /// Returns peer id, comms name, and the member transport key. The comms
611    /// name is built through `meerkat_core::MemberCommsName::new`, the single
612    /// fail-closed owner meerkat-mob routes all such names through
613    /// (`render_member_comms_name`). It validates each of the three components
614    /// against the identifier-safe slug rule and renders `{mob_id}/{role}/{member}`.
615    /// Routing through the typed owner (rather than a raw `format!`) means a
616    /// slug-invalid `mob_id`/`role` is rejected here with a clear error instead
617    /// of minting a descriptor that silently fails to match at comms ingress.
618    async fn get_member_peer_info(
619        &self,
620        handle: &MobHandle,
621        meerkat_id: &AgentIdentity,
622        mob_id: &str,
623    ) -> Result<MemberPeerInfo, CrossMobError> {
624        let entry = handle
625            .get_member(meerkat_id)
626            .await
627            .map_err(|err| {
628                CrossMobError::PeerSpec(format!(
629                    "member lookup for '{meerkat_id}' in mob '{mob_id}' failed: {err}"
630                ))
631            })?
632            .ok_or_else(|| CrossMobError::MemberNotFound {
633                member_id: meerkat_id.to_string(),
634                mob_id: mob_id.to_string(),
635            })?;
636        let peer_id = entry
637            .peer_id()
638            .ok_or_else(|| CrossMobError::NoCommsInfo {
639                member_id: meerkat_id.to_string(),
640                mob_id: mob_id.to_string(),
641            })?
642            .to_string();
643        let pubkey_b64 = entry.transport_public_key().ok_or_else(|| {
644            CrossMobError::PeerSpec(format!(
645                "member '{meerkat_id}' in mob '{mob_id}' has no transport public key"
646            ))
647        })?;
648        let pubkey = crate::auth::peer_keys::decode_pubkey_b64(pubkey_b64).map_err(|err| {
649            CrossMobError::PeerSpec(format!(
650                "member '{meerkat_id}' in mob '{mob_id}' has invalid transport public key: {err}"
651            ))
652        })?;
653        let comms_name = meerkat_core::MemberCommsName::new(
654            mob_id,
655            entry.role.as_str(),
656            meerkat_id.as_str(),
657        )
658        .map_err(|err| {
659            CrossMobError::PeerSpec(format!(
660                "member '{meerkat_id}' in mob '{mob_id}' has an invalid comms name component: {err}"
661            ))
662        })?
663        .to_string();
664        Ok(MemberPeerInfo {
665            peer_id,
666            comms_name,
667            pubkey,
668        })
669    }
670}
671
672/// Build a `TrustedPeerDescriptor` whose address reflects the supplied
673/// transport. **Routes through [`build_external_peer_spec`] so the
674/// pubkey requirement is enforced**: inproc descriptors stay unsigned
675/// (the in-process router authorizes via its identity map), but TCP and
676/// UDS peers must have a non-zero 32-byte pubkey or the call fails
677/// closed with `CrossMobError::MissingPeerPubkey`.
678///
679/// `pubkey` is `None` for the local-half descriptor (always inproc) and
680/// `entry.pubkey` for the remote-half descriptor.
681fn build_peer_spec(
682    comms_name: &str,
683    peer_id: &str,
684    transport: &MobTransport,
685    pubkey: Option<[u8; 32]>,
686) -> Result<TrustedPeerDescriptor, CrossMobError> {
687    let address = match transport {
688        MobTransport::Inproc => format!("inproc://{comms_name}"),
689        MobTransport::Tcp(addr) => format!("tcp://{addr}"),
690        MobTransport::Uds(path) => format!("uds://{path}"),
691    };
692    build_external_peer_spec(comms_name, peer_id, &address, pubkey)
693}
694
695/// Build a [`TrustedPeerDescriptor`] for an external (non-inproc) peer.
696///
697/// The address scheme decides the policy:
698///
699/// * `inproc://...` — keep behaviour aligned with
700///   [`build_inproc_peer_spec`]: pubkey is optional and an unsigned
701///   descriptor is acceptable.
702/// * `tcp://...` / `uds://...` — fail closed unless a non-zero 32-byte
703///   pubkey is supplied. meerkat-comms keys its trust store by pubkey;
704///   admitting an all-zero pubkey would let any sender in.
705fn build_external_peer_spec(
706    comms_name: &str,
707    peer_id: &str,
708    address: &str,
709    pubkey: Option<[u8; 32]>,
710) -> Result<TrustedPeerDescriptor, CrossMobError> {
711    let is_inproc = address.starts_with("inproc://");
712    match (is_inproc, pubkey) {
713        (true, None) => TrustedPeerDescriptor::test_only_unsigned(comms_name, peer_id, address)
714            .map_err(CrossMobError::PeerSpec),
715        (true, Some(bytes)) => {
716            TrustedPeerDescriptor::unsigned_with_pubkey(comms_name, peer_id, bytes, address)
717                .map_err(CrossMobError::PeerSpec)
718        }
719        (false, None) => Err(CrossMobError::MissingPeerPubkey { mob_id: None }),
720        (false, Some(bytes)) => {
721            if bytes == [0u8; 32] {
722                return Err(CrossMobError::MissingPeerPubkey { mob_id: None });
723            }
724            TrustedPeerDescriptor::unsigned_with_pubkey(comms_name, peer_id, bytes, address)
725                .map_err(CrossMobError::PeerSpec)
726        }
727    }
728}
729
730/// Build a TCP peer descriptor.
731///
732/// Uses the comms-layer address scheme `tcp://host:port`. **Phase-1 seam**:
733/// goes through [`TrustedPeerDescriptor::test_only_unsigned`]. Callers
734/// that need a real signed descriptor (Ed25519-stamped) should construct
735/// it via [`build_external_peer_spec`] with an explicit pubkey instead.
736pub fn build_tcp_peer_spec(
737    comms_name: &str,
738    peer_id: &str,
739    address: &str,
740) -> Result<TrustedPeerDescriptor, CrossMobError> {
741    TrustedPeerDescriptor::test_only_unsigned(comms_name, peer_id, format!("tcp://{address}"))
742        .map_err(CrossMobError::PeerSpec)
743}
744
745/// Build a UDS peer descriptor.
746///
747/// Uses the comms-layer address scheme `uds:///path` (triple slash —
748/// `uds://` + absolute path). See [`build_tcp_peer_spec`] for the
749/// Phase-1 vs signed-descriptor seam note.
750pub fn build_uds_peer_spec(
751    comms_name: &str,
752    peer_id: &str,
753    path: &str,
754) -> Result<TrustedPeerDescriptor, CrossMobError> {
755    let normalized = if let Some(stripped) = path.strip_prefix('/') {
756        stripped
757    } else {
758        path
759    };
760    TrustedPeerDescriptor::test_only_unsigned(comms_name, peer_id, format!("uds:///{normalized}"))
761        .map_err(CrossMobError::PeerSpec)
762}
763
764#[cfg(test)]
765#[allow(clippy::unwrap_used, clippy::expect_used)]
766mod tests {
767    use super::*;
768
769    const TEST_PEER_ID: &str = "00000000-0000-4000-8000-000000000001";
770
771    /// Non-zero placeholder pubkey for the trust-required tests.
772    const TEST_PUBKEY: [u8; 32] = [42u8; 32];
773
774    /// `TrustedPeerDescriptor::validate_pubkey_for_peer_id` (post-LUC-*)
775    /// requires the descriptor's `peer_id` to be UUIDv5-derived from its
776    /// pubkey. Tests that pass a non-zero pubkey must therefore use the
777    /// derived id, not an arbitrary placeholder.
778    fn derived_peer_id() -> String {
779        meerkat_core::comms::PeerId::from_ed25519_pubkey(&TEST_PUBKEY).to_string()
780    }
781
782    #[test]
783    fn peer_spec_inproc_uses_comms_name_address() {
784        let spec = build_peer_spec(
785            "authors/coordinator/alice",
786            TEST_PEER_ID,
787            &MobTransport::Inproc,
788            None,
789        )
790        .expect("spec");
791        assert_eq!(spec.address.endpoint(), "authors/coordinator/alice");
792    }
793
794    #[test]
795    fn peer_spec_tcp_uses_tcp_scheme() {
796        let id = derived_peer_id();
797        let spec = build_peer_spec(
798            "authors/coordinator/alice",
799            &id,
800            &MobTransport::Tcp("127.0.0.1:9001".to_string()),
801            Some(TEST_PUBKEY),
802        )
803        .expect("spec");
804        assert_eq!(spec.address.endpoint(), "127.0.0.1:9001");
805    }
806
807    #[test]
808    fn peer_spec_uds_uses_uds_scheme() {
809        let id = derived_peer_id();
810        let spec = build_peer_spec(
811            "authors/coordinator/alice",
812            &id,
813            &MobTransport::Uds("/tmp/x.sock".to_string()),
814            Some(TEST_PUBKEY),
815        )
816        .expect("spec");
817        assert_eq!(spec.address.endpoint(), "/tmp/x.sock");
818    }
819
820    /// Regression: cross-mob TCP wires must fail closed without a pubkey.
821    /// Pre-fix `build_peer_spec` produced an unsigned descriptor for any
822    /// transport; meerkat-comms would have admitted any sender.
823    #[test]
824    fn peer_spec_tcp_without_pubkey_rejected() {
825        let result = build_peer_spec(
826            "authors/coordinator/alice",
827            TEST_PEER_ID,
828            &MobTransport::Tcp("127.0.0.1:9001".to_string()),
829            None,
830        );
831        assert!(
832            matches!(result, Err(CrossMobError::MissingPeerPubkey { .. })),
833            "TCP peer spec without pubkey must fail closed, got {result:?}"
834        );
835    }
836
837    /// Regression: cross-mob UDS wires must also fail closed without a pubkey.
838    #[test]
839    fn peer_spec_uds_without_pubkey_rejected() {
840        let result = build_peer_spec(
841            "authors/coordinator/alice",
842            TEST_PEER_ID,
843            &MobTransport::Uds("/tmp/x.sock".to_string()),
844            None,
845        );
846        assert!(
847            matches!(result, Err(CrossMobError::MissingPeerPubkey { .. })),
848            "UDS peer spec without pubkey must fail closed, got {result:?}"
849        );
850    }
851
852    #[test]
853    fn build_uds_peer_spec_handles_leading_slash() {
854        // Caller may pass the path with or without a leading slash; both
855        // produce the canonical `uds:///path` form.
856        let with = build_uds_peer_spec("a", "00000000-0000-4000-8000-000000000001", "/tmp/x.sock")
857            .expect("spec");
858        let without =
859            build_uds_peer_spec("a", "00000000-0000-4000-8000-000000000001", "tmp/x.sock")
860                .expect("spec");
861        assert_eq!(with.address.endpoint(), "/tmp/x.sock");
862        assert_eq!(without.address.endpoint(), "/tmp/x.sock");
863    }
864}