Skip to main content

meerkat_contracts/wire/
mob.rs

1//! Mob RPC wire contracts.
2
3use super::connection::WireAuthBindingRef;
4use super::runtime::WireTurnMetadataOverride;
5use super::session::WireContentInput;
6use super::supervisor_bridge::BridgeBootstrapToken;
7use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
8use meerkat_core::OutputSchema;
9use meerkat_core::{
10    HandlingMode,
11    types::{RenderClass, RenderMetadata, RenderSalience},
12};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::BTreeMap;
16
17use meerkat_core::{SurfaceMetadata, SurfaceMetadataError};
18
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(rename_all = "snake_case")]
22pub enum WireMobBackendKind {
23    #[default]
24    Session,
25    External,
26}
27
28/// Runtime binding for spawn requests.
29///
30/// First step toward identity-first mobs. Carries backend-specific binding
31/// details at spawn time. `External` requires typed process identity; callers
32/// do not supply raw comms peer IDs.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
36pub enum WireRuntimeBinding {
37    Session,
38    External {
39        address: String,
40        #[serde(default, skip_serializing_if = "Option::is_none")]
41        bootstrap_token: Option<BridgeBootstrapToken>,
42        /// Typed Ed25519 signing identity for the external process. The
43        /// canonical comms `PeerId` is derived from this key after the wire
44        /// boundary, so callers cannot spoof an unrelated raw peer id.
45        identity: WireTrustedPeerIdentity,
46    },
47}
48
49#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
51#[serde(rename_all = "snake_case")]
52pub enum WireMobRuntimeMode {
53    #[default]
54    AutonomousHost,
55    TurnDriven,
56}
57
58/// How a mob member should be launched by `mob/spawn`.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[serde(tag = "mode", rename_all = "snake_case")]
62pub enum WireMemberLaunchMode {
63    Fresh,
64    Resume {
65        bridge_session_id: String,
66    },
67    Fork {
68        source_member_id: String,
69        #[serde(default)]
70        fork_context: WireForkContext,
71    },
72}
73
74/// Conversation history scope used when forking a mob member.
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77#[serde(tag = "type", rename_all = "snake_case")]
78pub enum WireForkContext {
79    #[default]
80    FullHistory,
81    LastMessages {
82        count: u32,
83    },
84}
85
86/// Public tool access policy for a spawned member or delegated session fork.
87#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89#[serde(tag = "type", content = "value", rename_all = "snake_case")]
90pub enum WireToolAccessPolicy {
91    #[default]
92    Inherit,
93    AllowList(Vec<String>),
94    DenyList(Vec<String>),
95}
96
97impl WireToolAccessPolicy {
98    /// Lower the closed public wire vocabulary into the core session policy.
99    ///
100    /// Keeping this conversion at the contract boundary lets schemas and
101    /// generated SDKs retain the discriminated union instead of widening the
102    /// fork request field to an untyped JSON object.
103    #[must_use]
104    pub fn into_core(self) -> meerkat_core::ops::ToolAccessPolicy {
105        match self {
106            Self::Inherit => meerkat_core::ops::ToolAccessPolicy::Inherit,
107            Self::AllowList(names) => {
108                meerkat_core::ops::ToolAccessPolicy::AllowList(names.into_iter().collect())
109            }
110            Self::DenyList(names) => {
111                meerkat_core::ops::ToolAccessPolicy::DenyList(names.into_iter().collect())
112            }
113        }
114    }
115}
116
117/// Pre-resolved tool filter inherited by a spawned mob member.
118#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub enum WireToolFilter {
121    #[default]
122    All,
123    Allow(Vec<String>),
124    Deny(Vec<String>),
125}
126
127/// Tool configuration embedded in a wire mob profile override.
128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130#[serde(deny_unknown_fields)]
131pub struct WireMobToolConfig {
132    #[serde(default)]
133    pub builtins: bool,
134    #[serde(default)]
135    pub shell: bool,
136    #[serde(default)]
137    pub comms: bool,
138    #[serde(default)]
139    pub memory: bool,
140    #[serde(default)]
141    pub workgraph: bool,
142    #[serde(default)]
143    pub mob: bool,
144    #[serde(default)]
145    pub schedule: bool,
146    #[serde(default)]
147    pub image_generation: bool,
148    #[serde(default)]
149    pub mcp: Vec<String>,
150}
151
152/// Profile fields that win over durable session metadata on resume.
153///
154/// Wire twin of `meerkat_mob::ResumeOverrideField`; closed snake_case
155/// vocabulary, parsed fail-closed at the wire boundary.
156#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158#[serde(rename_all = "snake_case")]
159pub enum WireMobResumeOverrideField {
160    Model,
161    Provider,
162    ProviderParams,
163}
164
165/// Profile override for `mob/spawn`.
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
168#[serde(deny_unknown_fields)]
169pub struct WireMobProfile {
170    pub model: String,
171    /// Explicit typed provider for the profile model (closed vocabulary,
172    /// fail-closed at the wire boundary).
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub provider: Option<meerkat_core::Provider>,
175    /// Durable self-hosted server binding for configured self-hosted aliases.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub self_hosted_server_id: Option<String>,
178    /// Configured default provider for `Auto` image-generation targets.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub image_generation_provider: Option<meerkat_core::Provider>,
181    /// Per-profile auto-compaction threshold override (tokens, non-zero).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub auto_compact_threshold: Option<std::num::NonZeroU64>,
184    /// Profile fields that win over durable session metadata on resume.
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub resume_overrides: Vec<WireMobResumeOverrideField>,
187    #[serde(default)]
188    pub skills: Vec<String>,
189    #[serde(default)]
190    pub tools: WireMobToolConfig,
191    #[serde(default)]
192    pub peer_description: String,
193    #[serde(default)]
194    pub external_addressable: bool,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub backend: Option<WireMobBackendKind>,
197    #[serde(default)]
198    pub runtime_mode: WireMobRuntimeMode,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub max_inline_peer_notifications: Option<i32>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub output_schema: Option<Value>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub provider_params: Option<crate::wire::runtime::WireProviderParamsOverride>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
209#[serde(deny_unknown_fields)]
210pub struct MobOrchestratorInput {
211    pub profile: String,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
216#[serde(tag = "source", rename_all = "snake_case")]
217pub enum MobSkillSourceInput {
218    Inline { content: String },
219    Path { path: String },
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224#[serde(deny_unknown_fields)]
225pub struct MobRoleWiringRuleInput {
226    pub a: String,
227    pub b: String,
228}
229
230#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232#[serde(deny_unknown_fields)]
233pub struct MobWiringRulesInput {
234    #[serde(default)]
235    pub auto_wire_orchestrator: bool,
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub role_wiring: Vec<MobRoleWiringRuleInput>,
238}
239
240#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242#[serde(deny_unknown_fields)]
243pub struct MobToolConfigInput {
244    #[serde(default)]
245    pub builtins: bool,
246    #[serde(default)]
247    pub shell: bool,
248    #[serde(default)]
249    pub comms: bool,
250    #[serde(default)]
251    pub memory: bool,
252    #[serde(default)]
253    pub workgraph: bool,
254    #[serde(default)]
255    pub mob: bool,
256    #[serde(default)]
257    pub schedule: bool,
258    #[serde(default)]
259    pub image_generation: bool,
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub mcp: Vec<String>,
262}
263
264/// Profile binding input: either an inline profile or a realm profile reference.
265///
266/// Not `Eq`: `Inline(MobProfileInput)` transitively carries float provider
267/// params (`temperature`, `top_p`) so `Eq` cannot be derived without
268/// losing fidelity.
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
270#[allow(clippy::large_enum_variant)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[serde(untagged)]
273pub enum MobProfileBindingInput {
274    /// Reference to a realm-scoped profile.
275    RealmRef {
276        /// Name of the realm profile.
277        realm_profile: String,
278    },
279    /// Inline profile definition.
280    Inline(MobProfileInput),
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
285#[serde(deny_unknown_fields)]
286pub struct MobProfileInput {
287    pub model: String,
288    /// Explicit typed provider for the profile model (closed vocabulary,
289    /// fail-closed at the wire boundary).
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub provider: Option<meerkat_core::Provider>,
292    /// Durable self-hosted server binding for configured self-hosted aliases.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub self_hosted_server_id: Option<String>,
295    /// Configured default provider for `Auto` image-generation targets.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub image_generation_provider: Option<meerkat_core::Provider>,
298    /// Per-profile auto-compaction threshold override (tokens, non-zero).
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub auto_compact_threshold: Option<std::num::NonZeroU64>,
301    /// Profile fields that win over durable session metadata on resume.
302    #[serde(default, skip_serializing_if = "Vec::is_empty")]
303    pub resume_overrides: Vec<WireMobResumeOverrideField>,
304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
305    pub skills: Vec<String>,
306    #[serde(default)]
307    pub tools: MobToolConfigInput,
308    #[serde(default, skip_serializing_if = "String::is_empty")]
309    pub peer_description: String,
310    #[serde(default)]
311    pub external_addressable: bool,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub backend: Option<WireMobBackendKind>,
314    #[serde(default)]
315    pub runtime_mode: WireMobRuntimeMode,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub max_inline_peer_notifications: Option<i32>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub output_schema: Option<OutputSchema>,
320    /// Non-`Eq` field: `WireProviderParamsOverride` contains float scalars
321    /// (`temperature`, `top_p`) so the struct can't derive `Eq` without
322    /// losing fidelity.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub provider_params: Option<crate::wire::runtime::WireProviderParamsOverride>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
328#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
329#[serde(deny_unknown_fields)]
330pub struct MobExternalBackendConfigInput {
331    pub address_base: String,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub supervisor_bridge: Option<MobSupervisorBridgeEndpointConfigInput>,
334}
335
336#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
337#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
338#[serde(deny_unknown_fields)]
339pub struct MobSupervisorBridgeEndpointConfigInput {
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub bind_address: Option<String>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub advertised_address: Option<String>,
344}
345
346#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
347#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
348#[serde(deny_unknown_fields)]
349pub struct MobBackendConfigInput {
350    #[serde(default)]
351    pub default: WireMobBackendKind,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub external: Option<MobExternalBackendConfigInput>,
354}
355
356#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
357#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
358#[serde(rename_all = "snake_case")]
359pub enum MobDispatchModeInput {
360    #[default]
361    FanOut,
362    OneToOne,
363    FanIn,
364}
365
366#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
367#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
368#[serde(tag = "type", rename_all = "snake_case")]
369pub enum MobCollectionPolicyInput {
370    #[default]
371    All,
372    Any,
373    Quorum {
374        n: u8,
375    },
376}
377
378#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
380#[serde(rename_all = "snake_case")]
381pub enum MobDependencyModeInput {
382    #[default]
383    All,
384    Any,
385}
386
387/// Explicit step output format. Omitting `output_format` on a step is
388/// meaningful — the definition layer resolves a schema-aware default (`json`
389/// when the step declares `expected_schema_ref`, `text` otherwise) — so the
390/// wire shape keeps "omitted" representable instead of baking in a default.
391#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
392#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
393#[serde(rename_all = "snake_case")]
394pub enum MobStepOutputFormatInput {
395    Json,
396    Text,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
400#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
401#[serde(tag = "op", rename_all = "snake_case")]
402pub enum MobConditionExprInput {
403    Eq { path: String, value: Value },
404    In { path: String, values: Vec<Value> },
405    Gt { path: String, value: Value },
406    Lt { path: String, value: Value },
407    And { exprs: Vec<MobConditionExprInput> },
408    Or { exprs: Vec<MobConditionExprInput> },
409    Not { expr: Box<MobConditionExprInput> },
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
413#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
414#[serde(deny_unknown_fields)]
415pub struct MobFrameSpecInput {
416    pub nodes: BTreeMap<String, MobFlowNodeInput>,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
420#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
421#[serde(tag = "kind", rename_all = "snake_case")]
422pub enum MobFlowNodeInput {
423    Step(MobFrameStepInput),
424    RepeatUntil(MobRepeatUntilInput),
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
428#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
429#[serde(deny_unknown_fields)]
430pub struct MobFrameStepInput {
431    pub step_id: String,
432    #[serde(default, skip_serializing_if = "Vec::is_empty")]
433    pub depends_on: Vec<String>,
434    #[serde(default)]
435    pub depends_on_mode: MobDependencyModeInput,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub branch: Option<String>,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
442#[serde(deny_unknown_fields)]
443pub struct MobRepeatUntilInput {
444    pub loop_id: String,
445    #[serde(default, skip_serializing_if = "Vec::is_empty")]
446    pub depends_on: Vec<String>,
447    #[serde(default)]
448    pub depends_on_mode: MobDependencyModeInput,
449    pub body: MobFrameSpecInput,
450    pub until: MobConditionExprInput,
451    pub max_iterations: u32,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
455#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
456#[serde(deny_unknown_fields)]
457pub struct MobFlowStepInput {
458    pub role: String,
459    pub message: WireContentInput,
460    #[serde(default, skip_serializing_if = "Vec::is_empty")]
461    pub depends_on: Vec<String>,
462    #[serde(default)]
463    pub dispatch_mode: MobDispatchModeInput,
464    #[serde(default)]
465    pub collection_policy: MobCollectionPolicyInput,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub condition: Option<MobConditionExprInput>,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub timeout_ms: Option<u64>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub expected_schema_ref: Option<String>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub branch: Option<String>,
474    #[serde(default)]
475    pub depends_on_mode: MobDependencyModeInput,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub allowed_tools: Option<Vec<String>>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub blocked_tools: Option<Vec<String>>,
480    /// Explicit output format; omitted resolves schema-aware at the
481    /// definition layer (`json` with `expected_schema_ref`, `text` without).
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub output_format: Option<MobStepOutputFormatInput>,
484}
485
486#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
487#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
488#[serde(deny_unknown_fields)]
489pub struct MobFlowSpecInput {
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub description: Option<String>,
492    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
493    pub steps: BTreeMap<String, MobFlowStepInput>,
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub root: Option<MobFrameSpecInput>,
496}
497
498#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
499#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
500#[serde(rename_all = "snake_case")]
501pub enum MobPolicyModeInput {
502    #[default]
503    Advisory,
504    Strict,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
508#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
509#[serde(deny_unknown_fields)]
510pub struct MobTopologyRuleInput {
511    pub from_role: String,
512    pub to_role: String,
513    pub allowed: bool,
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
517#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
518#[serde(deny_unknown_fields)]
519pub struct MobTopologySpecInput {
520    pub mode: MobPolicyModeInput,
521    pub rules: Vec<MobTopologyRuleInput>,
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
525#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
526#[serde(deny_unknown_fields)]
527pub struct MobSupervisorSpecInput {
528    pub role: String,
529    pub escalation_threshold: u32,
530    /// Declared escalation turn timeout in milliseconds. Absent means the
531    /// runtime default applies (mirrors the domain `SupervisorSpec` owner).
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub escalation_turn_timeout_ms: Option<u64>,
534}
535
536#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
537#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
538#[serde(deny_unknown_fields)]
539pub struct MobLimitsSpecInput {
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub max_flow_duration_ms: Option<u64>,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub max_step_retries: Option<u32>,
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub max_orphaned_turns: Option<u32>,
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub cancel_grace_timeout_ms: Option<u64>,
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub max_active_nodes: Option<u64>,
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub max_active_frames: Option<u64>,
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub max_frame_depth: Option<u64>,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
557#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
558#[serde(tag = "mode", rename_all = "snake_case")]
559pub enum MobSpawnPolicyInput {
560    None,
561    Auto {
562        profile_map: BTreeMap<String, String>,
563    },
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
567#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
568#[serde(deny_unknown_fields)]
569pub struct MobEventRouterConfigInput {
570    #[serde(default = "default_event_router_buffer_size")]
571    pub buffer_size: usize,
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub include_patterns: Option<Vec<String>>,
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub exclude_patterns: Option<Vec<String>>,
576}
577
578const fn default_event_router_buffer_size() -> usize {
579    256
580}
581
582/// Public mob definition input for `mob/create`.
583///
584/// This mirrors the public creation contract shape. Runtime-owned lifecycle and
585/// bookkeeping fields such as internal owner/runtime bindings,
586/// `session_cleanup_policy`, `is_implicit`, and internal-only profile tool
587/// bundles are intentionally not part of this schema.
588///
589/// Not `Eq`: `profiles` transitively carries float provider params.
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
591#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
592#[serde(deny_unknown_fields)]
593pub struct MobDefinitionInput {
594    pub id: String,
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub orchestrator: Option<MobOrchestratorInput>,
597    pub profiles: BTreeMap<String, MobProfileBindingInput>,
598    /// Mob-scoped custom model registry entries (`[models.<id>]`). Reuses the
599    /// typed config owner so one definition feeds provider inference,
600    /// compaction scaling, capability gates, and call timeouts.
601    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
602    pub models: BTreeMap<String, meerkat_core::config::CustomModelConfig>,
603    /// Mob-level default provider for `Auto` image-generation targets.
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub image_generation_provider: Option<meerkat_core::Provider>,
606    #[serde(default)]
607    pub wiring: MobWiringRulesInput,
608    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
609    pub skills: BTreeMap<String, MobSkillSourceInput>,
610    #[serde(default)]
611    pub backend: MobBackendConfigInput,
612    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
613    pub flows: BTreeMap<String, MobFlowSpecInput>,
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub topology: Option<MobTopologySpecInput>,
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub supervisor: Option<MobSupervisorSpecInput>,
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub limits: Option<MobLimitsSpecInput>,
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub spawn_policy: Option<MobSpawnPolicyInput>,
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub event_router: Option<MobEventRouterConfigInput>,
624}
625
626/// Request payload for `mob/create`.
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
628#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
629#[serde(deny_unknown_fields)]
630pub struct MobCreateParams {
631    pub definition: MobDefinitionInput,
632}
633
634/// Response payload for `mob/create`.
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
637pub struct MobCreateResult {
638    pub mob_id: String,
639}
640
641/// Shared request payload for mob methods that address a mob by id.
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
643#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
644#[serde(deny_unknown_fields)]
645pub struct MobIdParams {
646    pub mob_id: String,
647}
648
649/// Shared request payload for mob methods that address one member by identity.
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
651#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
652#[serde(deny_unknown_fields)]
653pub struct MobMemberParams {
654    pub mob_id: String,
655    pub agent_identity: String,
656}
657
658/// Lifecycle status of a mob on the wire. Mirrors
659/// `meerkat_mob::runtime::MobState` so surfaces report mob lifecycle through a
660/// closed type rather than re-deriving meaning from free-form status text.
661///
662/// Variants serialize to their PascalCase names (`"Creating"`, `"Running"`,
663/// ...) to match the canonical `MobState::as_str()` projection that producers
664/// emit on the wire.
665#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
666#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
667pub enum WireMobLifecycleStatus {
668    Creating,
669    Running,
670    Stopped,
671    Completed,
672    Destroyed,
673}
674
675/// One active mob row returned by `mob/list`.
676#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
677#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
678pub struct MobStatusResult {
679    pub mob_id: String,
680    pub status: WireMobLifecycleStatus,
681}
682
683/// Response payload for `mob/list`.
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
685#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
686pub struct MobListResult {
687    pub mobs: Vec<MobStatusResult>,
688}
689
690/// Request payload for `mob/spawn`.
691#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
692#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
693#[serde(deny_unknown_fields)]
694pub struct MobSpawnParams {
695    pub mob_id: String,
696    pub profile: String,
697    pub agent_identity: String,
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub initial_message: Option<WireContentInput>,
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub runtime_mode: Option<WireMobRuntimeMode>,
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub backend: Option<WireMobBackendKind>,
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub labels: Option<BTreeMap<String, String>>,
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub context: Option<Value>,
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub additional_instructions: Option<Vec<String>>,
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub binding: Option<WireRuntimeBinding>,
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub shell_env: Option<BTreeMap<String, String>>,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub auto_wire_parent: Option<bool>,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub launch_mode: Option<WireMemberLaunchMode>,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub tool_access_policy: Option<WireToolAccessPolicy>,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub inherited_tool_filter: Option<WireToolFilter>,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub override_profile: Option<WireMobProfile>,
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub model_override: Option<String>,
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub auth_binding: Option<WireAuthBindingRef>,
728    /// Requested placement host ref (comms `PeerId` string — the
729    /// `MemberOperatorSpawnSpec.placement` representation); `None` places
730    /// on the controlling host (§7.3 default). Admission is machine-owned
731    /// (`ResolveSpawnMemberAdmission` host-bound/capability arms), never a
732    /// surface-side check.
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub placement: Option<String>,
735}
736
737/// Response payload for `mob/spawn`.
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
739#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
740pub struct MobSpawnResult {
741    pub mob_id: String,
742    pub agent_identity: String,
743    pub member_ref: WireMemberRef,
744}
745
746/// Per-member request payload inside `mob/spawn_many`.
747#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
748#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
749#[serde(deny_unknown_fields)]
750pub struct MobSpawnSpecParams {
751    pub profile: String,
752    pub agent_identity: String,
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub initial_message: Option<WireContentInput>,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub runtime_mode: Option<WireMobRuntimeMode>,
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub backend: Option<WireMobBackendKind>,
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub labels: Option<BTreeMap<String, String>>,
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub context: Option<Value>,
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub additional_instructions: Option<Vec<String>>,
765    /// Bound host peer ID for placed execution; omit for the controlling host.
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub placement: Option<WireHostRef>,
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub model_override: Option<String>,
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub auth_binding: Option<WireAuthBindingRef>,
772}
773
774/// Request payload for `mob/spawn_many`.
775#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
776#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
777#[serde(deny_unknown_fields)]
778pub struct MobSpawnManyParams {
779    pub mob_id: String,
780    pub specs: Vec<MobSpawnSpecParams>,
781}
782
783/// Typed status for one `mob/spawn_many` row.
784#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
785#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
786#[serde(rename_all = "snake_case")]
787pub enum MobSpawnManyResultStatus {
788    Spawned,
789    Failed,
790}
791
792/// Successful per-member `mob/spawn_many` result payload.
793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
794#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
795#[serde(deny_unknown_fields)]
796pub struct MobSpawnManySpawnedResult {
797    pub agent_identity: String,
798    pub member_ref: WireMemberRef,
799}
800
801/// Typed failure cause for one failed `mob/spawn_many` member row.
802#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
803#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
804#[serde(rename_all = "snake_case")]
805pub enum MobSpawnManyFailureCause {
806    ProfileNotFound,
807    MemberNotFound,
808    MemberAlreadyExists,
809    NotExternallyAddressable,
810    InvalidTransition,
811    WiringError,
812    BridgeCommandRejected,
813    MemberRestoreFailed,
814    KickoffWaitTimedOut,
815    ReadyWaitTimedOut,
816    DefinitionError,
817    FlowNotFound,
818    FlowFailed,
819    RunNotFound,
820    RunCanceled,
821    FlowTurnTimedOut,
822    FrameDepthLimitExceeded,
823    FrameAtomicPersistenceUnavailable,
824    SpecRevisionConflict,
825    SchemaValidation,
826    InsufficientTargets,
827    TopologyViolation,
828    BridgeDeliveryRejected,
829    SupervisorEscalation,
830    UnsupportedForMode,
831    MissingMemberCapability,
832    ResetBarrier,
833    StorageError,
834    SessionError,
835    CommsError,
836    CallbackPending,
837    StaleFenceToken,
838    StaleEventCursor,
839    WorkNotFound,
840    Internal,
841}
842
843/// Failed per-member `mob/spawn_many` result payload.
844#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
845#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
846#[serde(deny_unknown_fields)]
847pub struct MobSpawnManyFailedResult {
848    pub cause: MobSpawnManyFailureCause,
849    pub message: String,
850    #[serde(default, skip_serializing_if = "Option::is_none")]
851    pub structured_data: Option<Value>,
852}
853
854/// Typed payload for one `mob/spawn_many` row.
855#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
856#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
857#[serde(untagged)]
858pub enum MobSpawnManyResultPayload {
859    Spawned(MobSpawnManySpawnedResult),
860    Failed(MobSpawnManyFailedResult),
861}
862
863/// One typed result entry in a `mob/spawn_many` response.
864#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
865#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
866#[serde(try_from = "MobSpawnManyResultEntryRaw")]
867pub struct MobSpawnManyResultEntry {
868    pub status: MobSpawnManyResultStatus,
869    pub result: MobSpawnManyResultPayload,
870}
871
872#[derive(Debug, Deserialize)]
873#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
874#[serde(deny_unknown_fields)]
875struct MobSpawnManyResultEntryRaw {
876    status: MobSpawnManyResultStatus,
877    result: MobSpawnManyResultPayload,
878}
879
880impl TryFrom<MobSpawnManyResultEntryRaw> for MobSpawnManyResultEntry {
881    type Error = String;
882
883    fn try_from(raw: MobSpawnManyResultEntryRaw) -> Result<Self, Self::Error> {
884        let entry = Self {
885            status: raw.status,
886            result: raw.result,
887        };
888        entry.validate().map_err(str::to_owned)?;
889        Ok(entry)
890    }
891}
892
893impl MobSpawnManyResultEntry {
894    pub fn spawned(agent_identity: impl Into<String>, member_ref: WireMemberRef) -> Self {
895        Self {
896            status: MobSpawnManyResultStatus::Spawned,
897            result: MobSpawnManyResultPayload::Spawned(MobSpawnManySpawnedResult {
898                agent_identity: agent_identity.into(),
899                member_ref,
900            }),
901        }
902    }
903
904    pub fn failed(cause: MobSpawnManyFailureCause, message: impl Into<String>) -> Self {
905        Self::failed_with_structured_data(cause, message, None)
906    }
907
908    pub fn failed_with_structured_data(
909        cause: MobSpawnManyFailureCause,
910        message: impl Into<String>,
911        structured_data: Option<Value>,
912    ) -> Self {
913        Self {
914            status: MobSpawnManyResultStatus::Failed,
915            result: MobSpawnManyResultPayload::Failed(MobSpawnManyFailedResult {
916                cause,
917                message: message.into(),
918                structured_data,
919            }),
920        }
921    }
922
923    pub fn validate(&self) -> Result<(), &'static str> {
924        match (&self.status, &self.result) {
925            (MobSpawnManyResultStatus::Spawned, MobSpawnManyResultPayload::Spawned(_))
926            | (MobSpawnManyResultStatus::Failed, MobSpawnManyResultPayload::Failed(_)) => Ok(()),
927            (MobSpawnManyResultStatus::Spawned, MobSpawnManyResultPayload::Failed(_)) => {
928                Err("mob spawn_many result status spawned requires spawned result")
929            }
930            (MobSpawnManyResultStatus::Failed, MobSpawnManyResultPayload::Spawned(_)) => {
931                Err("mob spawn_many result status failed requires failed result")
932            }
933        }
934    }
935}
936
937/// Response payload for `mob/spawn_many`.
938#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
939#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
940pub struct MobSpawnManyResult {
941    pub results: Vec<MobSpawnManyResultEntry>,
942}
943
944/// Response payload for `mob/retire`.
945#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
946#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
947pub struct MobRetireResult {
948    pub retired: bool,
949}
950
951/// Request payload for `mob/respawn`.
952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
953#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
954#[serde(deny_unknown_fields)]
955pub struct MobRespawnParams {
956    pub mob_id: String,
957    pub agent_identity: String,
958    #[serde(default, skip_serializing_if = "Option::is_none")]
959    pub initial_message: Option<WireContentInput>,
960}
961
962/// Identity-native respawn receipt returned inside `MobRespawnResult`.
963#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
964#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
965pub struct MobRespawnReceipt {
966    pub identity: String,
967    pub member_ref: WireMemberRef,
968}
969
970/// Outcome of a `mob/respawn` call. Mirrors the success vs
971/// `MobRespawnError::TopologyRestoreFailed` distinction as a closed type so SDK
972/// consumers branch on a typed variant instead of re-deriving meaning from a
973/// free-form status string.
974#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
975#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
976#[serde(rename_all = "snake_case")]
977pub enum WireMobRespawnOutcome {
978    Completed,
979    TopologyRestoreFailed,
980}
981
982/// Response payload for `mob/respawn`.
983#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
984#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
985pub struct MobRespawnResult {
986    pub status: WireMobRespawnOutcome,
987    pub receipt: MobRespawnReceipt,
988    #[serde(default, skip_serializing_if = "Vec::is_empty")]
989    pub failed_peer_ids: Vec<String>,
990}
991
992/// Response payload for `mob/members`.
993#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
994#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
995pub struct MobMembersResult {
996    pub mob_id: String,
997    pub members: Vec<MobMemberListEntryWire>,
998}
999
1000/// Request payload for `mob/events`.
1001#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1002#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1003#[serde(deny_unknown_fields)]
1004pub struct MobEventsParams {
1005    pub mob_id: String,
1006    #[serde(default)]
1007    pub after_cursor: u64,
1008    #[serde(default = "default_mob_events_limit")]
1009    pub limit: usize,
1010    #[serde(default)]
1011    pub strict: bool,
1012}
1013
1014const fn default_mob_events_limit() -> usize {
1015    100
1016}
1017
1018/// Response payload for `mob/events`.
1019#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1020#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1021pub struct MobEventsResult {
1022    pub events: Vec<Value>,
1023}
1024
1025/// Typed external peer identity for public mob wiring surfaces.
1026#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1027#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1028#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1029pub enum WireTrustedPeerIdentity {
1030    /// Recoverable Ed25519 public key string in `ed25519:<base64>` form.
1031    Ed25519PublicKey { public_key: String },
1032}
1033
1034/// Resolved external peer identity atoms used after the wire boundary.
1035#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1036pub struct ResolvedWireTrustedPeerIdentity {
1037    pub peer_id: meerkat_core::comms::PeerId,
1038    pub pubkey: [u8; 32],
1039}
1040
1041/// Failure modes for resolving a typed external peer identity.
1042#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
1043pub enum WireTrustedPeerIdentityError {
1044    #[error("external peer identity public_key must start with 'ed25519:'")]
1045    MissingEd25519Prefix,
1046    #[error("external peer identity public_key is not valid base64: {0}")]
1047    InvalidBase64(String),
1048    #[error("external peer identity public_key must decode to 32 bytes, got {actual}")]
1049    InvalidLength { actual: usize },
1050    #[error("external peer identity public_key must be non-zero")]
1051    ZeroPublicKey,
1052}
1053
1054impl WireTrustedPeerIdentity {
1055    pub fn resolve(&self) -> Result<ResolvedWireTrustedPeerIdentity, WireTrustedPeerIdentityError> {
1056        match self {
1057            Self::Ed25519PublicKey { public_key } => {
1058                let pubkey = parse_ed25519_public_key(public_key)?;
1059                if pubkey == [0u8; 32] {
1060                    return Err(WireTrustedPeerIdentityError::ZeroPublicKey);
1061                }
1062                Ok(ResolvedWireTrustedPeerIdentity {
1063                    peer_id: meerkat_core::comms::PeerId::from_ed25519_pubkey(&pubkey),
1064                    pubkey,
1065                })
1066            }
1067        }
1068    }
1069}
1070
1071fn parse_ed25519_public_key(raw: &str) -> Result<[u8; 32], WireTrustedPeerIdentityError> {
1072    const PREFIX: &str = "ed25519:";
1073    let encoded = raw
1074        .strip_prefix(PREFIX)
1075        .ok_or(WireTrustedPeerIdentityError::MissingEd25519Prefix)?;
1076    let bytes = BASE64
1077        .decode(encoded)
1078        .map_err(|err| WireTrustedPeerIdentityError::InvalidBase64(err.to_string()))?;
1079    let actual = bytes.len();
1080    let pubkey: [u8; 32] = bytes
1081        .try_into()
1082        .map_err(|_| WireTrustedPeerIdentityError::InvalidLength { actual })?;
1083    Ok(pubkey)
1084}
1085
1086/// Minimal trusted peer spec for public mob wiring surfaces.
1087///
1088/// `identity` is required and resolves to the Ed25519 signing public key
1089/// plus the canonical comms `PeerId` derived from that key. MCP callers do
1090/// not provide raw peer IDs, and missing key material fails at the boundary.
1091#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1092#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1093#[serde(deny_unknown_fields)]
1094pub struct WireTrustedPeerSpec {
1095    pub name: String,
1096    pub address: String,
1097    pub identity: WireTrustedPeerIdentity,
1098}
1099
1100/// Target for a mob wire/unwire call.
1101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1103#[serde(rename_all = "snake_case")]
1104pub enum MobPeerTarget {
1105    Local(String),
1106    External(WireTrustedPeerSpec),
1107}
1108
1109/// Request payload for `mob/wire`.
1110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1112#[serde(deny_unknown_fields)]
1113pub struct MobWireParams {
1114    pub mob_id: String,
1115    pub member: String,
1116    pub peer: MobPeerTarget,
1117}
1118
1119/// Response payload for `mob/wire`.
1120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1122pub struct MobWireResult {
1123    pub wired: bool,
1124}
1125
1126/// One local-member edge in `mob/wire_members_batch`.
1127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1129#[serde(deny_unknown_fields)]
1130pub struct MobWireMembersBatchEdge {
1131    pub a: String,
1132    pub b: String,
1133}
1134
1135/// Request payload for `mob/wire_members_batch`.
1136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1138#[serde(deny_unknown_fields)]
1139pub struct MobWireMembersBatchParams {
1140    pub mob_id: String,
1141    pub edges: Vec<MobWireMembersBatchEdge>,
1142}
1143
1144/// Response payload for `mob/wire_members_batch`.
1145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1147pub struct MobWireMembersBatchResult {
1148    pub requested: usize,
1149    pub wired: Vec<MobWireMembersBatchEdge>,
1150    pub already_wired: Vec<MobWireMembersBatchEdge>,
1151}
1152
1153/// Request payload for `mob/unwire`.
1154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1156#[serde(deny_unknown_fields)]
1157pub struct MobUnwireParams {
1158    pub mob_id: String,
1159    pub member: String,
1160    pub peer: MobPeerTarget,
1161}
1162
1163/// Response payload for `mob/unwire`.
1164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1166pub struct MobUnwireResult {
1167    pub unwired: bool,
1168}
1169
1170/// Request payload for host-side mob member delivery.
1171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1173#[serde(deny_unknown_fields)]
1174pub struct MobMemberSendParams {
1175    pub mob_id: String,
1176    pub agent_identity: String,
1177    pub content: WireContentInput,
1178    #[serde(default)]
1179    pub handling_mode: WireHandlingMode,
1180    #[serde(default, skip_serializing_if = "Option::is_none")]
1181    pub render_metadata: Option<WireRenderMetadata>,
1182}
1183
1184/// Response payload for host-side mob member delivery.
1185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1187pub struct WireAgentRuntimeId {
1188    pub identity: String,
1189    pub generation: u64,
1190}
1191
1192/// Response payload for host-side mob member delivery.
1193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1195pub struct MobMemberSendResult {
1196    pub mob_id: String,
1197    /// Identity-native member identity (0.6).
1198    pub agent_identity: String,
1199    /// Server-resolved opaque handle for subsequent member-targeted calls.
1200    /// App code routes through `member_ref`; the binding-era
1201    /// `{identity, generation}` pair carried by `WireAgentRuntimeId` is
1202    /// retired from app-facing responses per dogma #10.
1203    pub member_ref: WireMemberRef,
1204    pub handling_mode: WireHandlingMode,
1205}
1206
1207/// Request payload for `mob/ingress_interaction`.
1208///
1209/// This is the ergonomic "ensure an ingress member, then deliver user input"
1210/// path. It composes the existing declarative roster and member-send
1211/// semantics without introducing a separate thread/project runtime.
1212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1214#[serde(deny_unknown_fields)]
1215pub struct MobIngressInteractionParams {
1216    pub mob_id: String,
1217    pub spec: MobMemberSpecWire,
1218    pub content: WireContentInput,
1219    #[serde(default)]
1220    pub handling_mode: WireHandlingMode,
1221    #[serde(default, skip_serializing_if = "Option::is_none")]
1222    pub render_metadata: Option<WireRenderMetadata>,
1223}
1224
1225/// Response payload for `mob/ingress_interaction`.
1226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1227#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1228pub struct MobIngressInteractionResult {
1229    pub mob_id: String,
1230    pub agent_identity: String,
1231    pub member_ref: WireMemberRef,
1232    pub ensure_outcome: MobEnsureMemberOutcomeWire,
1233    pub delivery: MobMemberSendResult,
1234    /// Cursor observed immediately before the ensure/send composition.
1235    pub events_after_cursor: u64,
1236    /// Cursor observed after delivery was accepted.
1237    pub latest_event_cursor: u64,
1238}
1239
1240/// Public handling mode for mob member delivery.
1241#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1242#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1243#[serde(rename_all = "snake_case")]
1244pub enum WireHandlingMode {
1245    #[default]
1246    Queue,
1247    Steer,
1248}
1249
1250impl From<WireHandlingMode> for HandlingMode {
1251    fn from(mode: WireHandlingMode) -> Self {
1252        match mode {
1253            WireHandlingMode::Queue => HandlingMode::Queue,
1254            WireHandlingMode::Steer => HandlingMode::Steer,
1255        }
1256    }
1257}
1258
1259impl From<HandlingMode> for WireHandlingMode {
1260    fn from(mode: HandlingMode) -> Self {
1261        match mode {
1262            HandlingMode::Queue => WireHandlingMode::Queue,
1263            HandlingMode::Steer => WireHandlingMode::Steer,
1264        }
1265    }
1266}
1267
1268/// Public render class contract for mob member delivery.
1269#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1270#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1271#[serde(rename_all = "snake_case")]
1272pub enum WireRenderClass {
1273    UserPrompt,
1274    PeerMessage,
1275    PeerRequest,
1276    PeerResponse,
1277    ExternalEvent,
1278    FlowStep,
1279    Continuation,
1280    SystemNotice,
1281    ToolScopeNotice,
1282    OpsProgress,
1283}
1284
1285impl From<WireRenderClass> for RenderClass {
1286    fn from(class: WireRenderClass) -> Self {
1287        match class {
1288            WireRenderClass::UserPrompt => RenderClass::UserPrompt,
1289            WireRenderClass::PeerMessage => RenderClass::PeerMessage,
1290            WireRenderClass::PeerRequest => RenderClass::PeerRequest,
1291            WireRenderClass::PeerResponse => RenderClass::PeerResponse,
1292            WireRenderClass::ExternalEvent => RenderClass::ExternalEvent,
1293            WireRenderClass::FlowStep => RenderClass::FlowStep,
1294            WireRenderClass::Continuation => RenderClass::Continuation,
1295            WireRenderClass::SystemNotice => RenderClass::SystemNotice,
1296            WireRenderClass::ToolScopeNotice => RenderClass::ToolScopeNotice,
1297            WireRenderClass::OpsProgress => RenderClass::OpsProgress,
1298        }
1299    }
1300}
1301
1302impl From<RenderClass> for WireRenderClass {
1303    fn from(class: RenderClass) -> Self {
1304        match class {
1305            RenderClass::UserPrompt => WireRenderClass::UserPrompt,
1306            RenderClass::PeerMessage => WireRenderClass::PeerMessage,
1307            RenderClass::PeerRequest => WireRenderClass::PeerRequest,
1308            RenderClass::PeerResponse => WireRenderClass::PeerResponse,
1309            RenderClass::ExternalEvent => WireRenderClass::ExternalEvent,
1310            RenderClass::FlowStep => WireRenderClass::FlowStep,
1311            RenderClass::Continuation => WireRenderClass::Continuation,
1312            RenderClass::SystemNotice => WireRenderClass::SystemNotice,
1313            RenderClass::ToolScopeNotice => WireRenderClass::ToolScopeNotice,
1314            RenderClass::OpsProgress => WireRenderClass::OpsProgress,
1315        }
1316    }
1317}
1318
1319/// Public render salience contract for mob member delivery.
1320#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1322#[serde(rename_all = "snake_case")]
1323pub enum WireRenderSalience {
1324    Background,
1325    Normal,
1326    Important,
1327    Urgent,
1328}
1329
1330impl From<WireRenderSalience> for RenderSalience {
1331    fn from(salience: WireRenderSalience) -> Self {
1332        match salience {
1333            WireRenderSalience::Background => RenderSalience::Background,
1334            WireRenderSalience::Normal => RenderSalience::Normal,
1335            WireRenderSalience::Important => RenderSalience::Important,
1336            WireRenderSalience::Urgent => RenderSalience::Urgent,
1337        }
1338    }
1339}
1340
1341impl From<RenderSalience> for WireRenderSalience {
1342    fn from(salience: RenderSalience) -> Self {
1343        match salience {
1344            RenderSalience::Background => WireRenderSalience::Background,
1345            RenderSalience::Normal => WireRenderSalience::Normal,
1346            RenderSalience::Important => WireRenderSalience::Important,
1347            RenderSalience::Urgent => WireRenderSalience::Urgent,
1348        }
1349    }
1350}
1351
1352/// Public render metadata contract for mob member delivery.
1353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1354#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1355pub struct WireRenderMetadata {
1356    pub class: WireRenderClass,
1357    #[serde(default, skip_serializing_if = "Option::is_none")]
1358    pub salience: Option<WireRenderSalience>,
1359}
1360
1361impl From<WireRenderMetadata> for RenderMetadata {
1362    fn from(metadata: WireRenderMetadata) -> Self {
1363        Self {
1364            class: metadata.class.into(),
1365            salience: metadata
1366                .salience
1367                .unwrap_or(WireRenderSalience::Normal)
1368                .into(),
1369        }
1370    }
1371}
1372
1373impl From<RenderMetadata> for WireRenderMetadata {
1374    fn from(metadata: RenderMetadata) -> Self {
1375        Self {
1376            class: metadata.class.into(),
1377            salience: Some(metadata.salience.into()),
1378        }
1379    }
1380}
1381
1382// ---------------------------------------------------------------------------
1383// Declarative roster API (`mob/ensure_member`, `mob/reconcile`,
1384// `mob/list_members_matching`). These methods compose over spawn / retire /
1385// list_members; they introduce no new lifecycle.
1386// ---------------------------------------------------------------------------
1387
1388/// Per-member spec for `mob/ensure_member` and the `desired` entries of
1389/// `mob/reconcile`.
1390///
1391/// Mirrors the essential, codegen-friendly fields of
1392/// [`meerkat_mob::SpawnMemberSpec`]. Complex sub-types (tool access policy,
1393/// budget split, inherited tool filter, override profile) are not on this
1394/// wire surface — callers that need that parity should use the non-declarative
1395/// `mob/spawn` method.
1396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1397#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1398pub struct MobMemberSpecWire {
1399    /// Profile name (role) in the mob definition.
1400    pub profile: String,
1401    /// Stable member identity within the mob.
1402    pub agent_identity: String,
1403    #[serde(default, skip_serializing_if = "Option::is_none")]
1404    pub initial_message: Option<WireContentInput>,
1405    #[serde(default, skip_serializing_if = "Option::is_none")]
1406    pub runtime_mode: Option<WireMobRuntimeMode>,
1407    #[serde(default, skip_serializing_if = "Option::is_none")]
1408    pub backend: Option<WireMobBackendKind>,
1409    /// Bound host peer ID for placed execution; omit for the controlling host.
1410    #[serde(default, skip_serializing_if = "Option::is_none")]
1411    pub placement: Option<WireHostRef>,
1412    #[serde(default, skip_serializing_if = "Option::is_none")]
1413    pub binding: Option<WireRuntimeBinding>,
1414    #[serde(default, skip_serializing_if = "Option::is_none")]
1415    pub context: Option<Value>,
1416    #[serde(default, skip_serializing_if = "Option::is_none")]
1417    pub labels: Option<BTreeMap<String, String>>,
1418    #[serde(default, skip_serializing_if = "Option::is_none")]
1419    pub additional_instructions: Option<Vec<String>>,
1420    #[serde(default, skip_serializing_if = "Option::is_none")]
1421    pub auto_wire_parent: Option<bool>,
1422}
1423
1424impl MobMemberSpecWire {
1425    /// Compose the existing member `labels` and opaque `context` fields into
1426    /// the shared surface metadata contract without changing the JSON shape.
1427    #[must_use]
1428    pub fn surface_metadata(&self) -> SurfaceMetadata {
1429        SurfaceMetadata::from_optional_parts(self.labels.clone(), self.context.clone())
1430    }
1431
1432    /// Validate caller-supplied metadata for public member create surfaces.
1433    pub fn validate_public_surface_metadata(&self) -> Result<(), SurfaceMetadataError> {
1434        self.surface_metadata().validate_public()
1435    }
1436}
1437
1438/// Request payload for `mob/ensure_member`.
1439#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1440#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1441#[serde(deny_unknown_fields)]
1442pub struct MobEnsureMemberParams {
1443    pub mob_id: String,
1444    pub spec: MobMemberSpecWire,
1445}
1446
1447/// Server-resolved opaque handle for a mob member.
1448///
1449/// Encodes `{mob_id, agent_identity}` as a single base64url-encoded token
1450/// that callers treat as opaque. The server resolves the current
1451/// `AgentRuntimeId` and fence token against the live mob roster on every
1452/// dispatch — clients never reason about `generation` or `fence_token`
1453/// directly.
1454///
1455/// Use [`WireMemberRef::encode`] to produce a token and
1456/// [`WireMemberRef::decode`] inside an RPC handler to recover the
1457/// `(mob_id, agent_identity)` pair before resolving against the runtime.
1458#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1459#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1460#[serde(transparent)]
1461pub struct WireMemberRef(String);
1462
1463impl WireMemberRef {
1464    /// Construct a handle from its components. The `mob_id` and
1465    /// `agent_identity` together form the resolution key the server uses to
1466    /// look up the member's current incarnation.
1467    #[must_use]
1468    pub fn encode(mob_id: &str, agent_identity: &str) -> Self {
1469        // Single-letter keys keep the encoded payload short so the token
1470        // remains compact in URLs and JSON payloads.
1471        // `Value::to_string` on a two-field object is infallible.
1472        let payload = serde_json::json!({ "m": mob_id, "a": agent_identity });
1473        Self(base64_url_encode(payload.to_string().as_bytes()))
1474    }
1475
1476    /// Borrow the raw token string for transport.
1477    #[must_use]
1478    pub fn as_str(&self) -> &str {
1479        &self.0
1480    }
1481
1482    /// Construct a handle from a raw token string without validation. Used
1483    /// when forwarding an opaque token received from the wire.
1484    #[must_use]
1485    pub fn from_token(token: impl Into<String>) -> Self {
1486        Self(token.into())
1487    }
1488
1489    /// Decode the handle into `(mob_id, agent_identity)`. Returns `Err` when
1490    /// the token is malformed.
1491    pub fn decode(&self) -> Result<(String, String), WireMemberRefError> {
1492        let bytes = base64_url_decode(&self.0).map_err(|_| WireMemberRefError::Malformed)?;
1493        let value: Value =
1494            serde_json::from_slice(&bytes).map_err(|_| WireMemberRefError::Malformed)?;
1495        let mob_id = value
1496            .get("m")
1497            .and_then(Value::as_str)
1498            .ok_or(WireMemberRefError::Malformed)?;
1499        let agent_identity = value
1500            .get("a")
1501            .and_then(Value::as_str)
1502            .ok_or(WireMemberRefError::Malformed)?;
1503        Ok((mob_id.to_string(), agent_identity.to_string()))
1504    }
1505}
1506
1507/// Failure modes for [`WireMemberRef::decode`].
1508#[derive(Debug, thiserror::Error)]
1509pub enum WireMemberRefError {
1510    /// Token is not valid base64url or its decoded payload is not the
1511    /// expected `{m, a}` shape.
1512    #[error("malformed member ref token")]
1513    Malformed,
1514}
1515
1516fn base64_url_encode(bytes: &[u8]) -> String {
1517    use base64::Engine as _;
1518    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
1519}
1520
1521fn base64_url_decode(input: &str) -> Result<Vec<u8>, base64::DecodeError> {
1522    use base64::Engine as _;
1523    base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(input)
1524}
1525
1526/// Identity-native payload for `EnsureMemberOutcome::Spawned`.
1527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1528#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1529pub struct MobSpawnReceiptWire {
1530    pub agent_identity: String,
1531    /// Server-resolved opaque handle for subsequent member-targeted calls
1532    /// (work submission, cancellation, lifecycle). Replaces the binding-era
1533    /// `generation` / `fence_token` pair on app-facing surfaces.
1534    pub member_ref: WireMemberRef,
1535}
1536
1537/// Execution status mirroring `meerkat_mob::runtime::MobMemberStatus`.
1538#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1539#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1540#[serde(rename_all = "snake_case")]
1541pub enum WireMobMemberStatus {
1542    Active,
1543    Retiring,
1544    Broken,
1545    Completed,
1546    Unknown,
1547}
1548
1549/// Public roster entry returned by `mob/ensure_member`'s `Existed` outcome
1550/// (and other surfaces that want a typed snapshot of a single member). Mirrors
1551/// the public-facing fields of `meerkat_mob::runtime::MobMemberListEntry`
1552/// without leaking bridge-internal fields.
1553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1554#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1555pub struct MobMemberListEntryWire {
1556    pub agent_identity: String,
1557    pub member_ref: WireMemberRef,
1558    pub role: String,
1559    pub runtime_mode: WireMobRuntimeMode,
1560    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1561    pub wired_to: Vec<String>,
1562    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1563    pub labels: BTreeMap<String, String>,
1564    pub status: WireMobMemberStatus,
1565    #[serde(default, skip_serializing_if = "Option::is_none")]
1566    pub error: Option<String>,
1567    pub is_final: bool,
1568}
1569
1570/// Outcome of a `mob/ensure_member` call.
1571///
1572/// `Existed` returns the typed [`MobMemberListEntryWire`] roster snapshot so
1573/// public consumers do not need out-of-band knowledge of the Rust domain
1574/// `MobMemberListEntry` shape.
1575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1576#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1577pub enum MobEnsureMemberOutcomeWire {
1578    #[serde(rename = "spawned")]
1579    Spawned(MobSpawnReceiptWire),
1580    #[serde(rename = "existed")]
1581    Existed(MobMemberListEntryWire),
1582}
1583
1584/// Response payload for `mob/ensure_member`.
1585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1586#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1587pub struct MobEnsureMemberResult {
1588    pub outcome: MobEnsureMemberOutcomeWire,
1589}
1590
1591/// Options controlling a `mob/reconcile` pass.
1592#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1593#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1594#[serde(deny_unknown_fields)]
1595pub struct MobReconcileOptionsWire {
1596    /// When `true`, members on the roster whose identity is not in the
1597    /// `desired` set are retired.
1598    #[serde(default)]
1599    pub retire_stale: bool,
1600}
1601
1602/// Closed wire stage for a per-identity `mob/reconcile` failure.
1603#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1604#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1605#[serde(rename_all = "snake_case")]
1606pub enum WireMobReconcileStage {
1607    Spawn,
1608    Retire,
1609}
1610
1611/// Request payload for `mob/reconcile`.
1612#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1613#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1614#[serde(deny_unknown_fields)]
1615pub struct MobReconcileParams {
1616    pub mob_id: String,
1617    #[serde(default)]
1618    pub desired: Vec<MobMemberSpecWire>,
1619    #[serde(default)]
1620    pub options: MobReconcileOptionsWire,
1621}
1622
1623/// Typed mob error projection for wire surfaces. Carries the closed failure
1624/// class alongside the human-readable message so consumers branch on the typed
1625/// `code` rather than parsing the free-form `message`. Reuses
1626/// [`MobSpawnManyFailureCause`] as the canonical closed mob-error vocabulary.
1627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1628#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1629pub struct WireMobError {
1630    pub code: MobSpawnManyFailureCause,
1631    pub message: String,
1632}
1633
1634/// Per-identity failure in a `mob/reconcile` pass.
1635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1636#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1637pub struct MobReconcileFailureWire {
1638    pub agent_identity: String,
1639    pub stage: WireMobReconcileStage,
1640    /// Typed mob error: closed failure `code` plus human-readable `message`.
1641    pub error: WireMobError,
1642}
1643
1644/// Summary produced by a `mob/reconcile` pass.
1645#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1646#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1647pub struct MobReconcileReportWire {
1648    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1649    pub desired: Vec<String>,
1650    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1651    pub retained: Vec<String>,
1652    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1653    pub spawned: Vec<MobSpawnReceiptWire>,
1654    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1655    pub retired: Vec<String>,
1656    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1657    pub failures: Vec<MobReconcileFailureWire>,
1658}
1659
1660/// Response payload for `mob/reconcile`.
1661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1662#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1663pub struct MobReconcileResult {
1664    pub report: MobReconcileReportWire,
1665}
1666
1667/// Typed lifecycle action for `mob/lifecycle`. Replaces the prior
1668/// `action: String` discriminator with an exhaustive enum so callers and
1669/// handlers reason about lifecycle transitions through the type system
1670/// rather than string folklore.
1671#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1672#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1673#[serde(rename_all = "snake_case")]
1674pub enum WireMobLifecycleAction {
1675    Stop,
1676    Resume,
1677    Complete,
1678    Reset,
1679    Destroy,
1680}
1681
1682/// Typed wire/unwire action for the `mob_wire` agent tool. Replaces the prior
1683/// `action: String` discriminator with an exhaustive enum so the agent-tool
1684/// surface reasons about the wire/unwire distinction through the type system
1685/// rather than string folklore (mirrors [`WireMobLifecycleAction`]).
1686#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1687#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1688#[serde(rename_all = "snake_case")]
1689pub enum WireMobWireAction {
1690    Wire,
1691    Unwire,
1692}
1693
1694/// Request payload for `mob/lifecycle`.
1695#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1696#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1697#[serde(deny_unknown_fields)]
1698pub struct MobLifecycleParams {
1699    pub mob_id: String,
1700    pub action: WireMobLifecycleAction,
1701}
1702
1703/// Response payload for `mob/lifecycle`.
1704#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1705#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1706pub struct MobLifecycleResult {
1707    pub mob_id: String,
1708    pub action: WireMobLifecycleAction,
1709    pub ok: bool,
1710    #[serde(default, skip_serializing_if = "Option::is_none")]
1711    pub destroy_report: Option<Value>,
1712}
1713
1714/// Request payload for `mob/append_system_context`.
1715///
1716/// Appends one ordinary durable ordered System message to the member session
1717/// at the admitted transcript boundary. The text is preserved exactly.
1718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1719#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1720#[serde(deny_unknown_fields)]
1721pub struct MobAppendSystemContextParams {
1722    pub mob_id: String,
1723    pub agent_identity: String,
1724    pub text: String,
1725    #[serde(default, skip_serializing_if = "Option::is_none")]
1726    pub source: Option<String>,
1727    #[serde(default, skip_serializing_if = "Option::is_none")]
1728    pub idempotency_key: Option<String>,
1729}
1730
1731/// Outcome of a `mob/append_system_context` call on the wire. Mirrors
1732/// `meerkat_core::AppendSystemContextStatus` so consumers reason about the
1733/// applied/duplicate distinction through a closed type rather than a free-form
1734/// status string.
1735#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1736#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1737#[serde(rename_all = "snake_case")]
1738pub enum WireAppendSystemContextStatus {
1739    Applied,
1740    Duplicate,
1741}
1742
1743impl From<meerkat_core::AppendSystemContextStatus> for WireAppendSystemContextStatus {
1744    fn from(status: meerkat_core::AppendSystemContextStatus) -> Self {
1745        match status {
1746            meerkat_core::AppendSystemContextStatus::Applied => Self::Applied,
1747            meerkat_core::AppendSystemContextStatus::Duplicate => Self::Duplicate,
1748        }
1749    }
1750}
1751
1752/// Response payload for `mob/append_system_context`.
1753#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1754#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1755pub struct MobAppendSystemContextResult {
1756    pub mob_id: String,
1757    pub agent_identity: String,
1758    pub status: WireAppendSystemContextStatus,
1759}
1760
1761/// Response payload for `mob/flows`.
1762#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1763#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1764pub struct MobFlowsResult {
1765    pub mob_id: String,
1766    pub flows: Vec<String>,
1767}
1768
1769/// Request payload for `mob/flow_run`.
1770#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1771#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1772#[serde(deny_unknown_fields)]
1773pub struct MobFlowRunParams {
1774    pub mob_id: String,
1775    pub flow_id: String,
1776    #[serde(default)]
1777    pub params: Value,
1778}
1779
1780/// Request payload for `mob/run`.
1781///
1782/// Starts the pack's callable flow. `flow_id` defaults to `main`; `prompt` is
1783/// sugar for `params.prompt` when the caller does not provide that key.
1784#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1785#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1786#[serde(deny_unknown_fields)]
1787pub struct MobRunParams {
1788    pub mob_id: String,
1789    #[serde(default, skip_serializing_if = "Option::is_none")]
1790    pub flow_id: Option<String>,
1791    #[serde(default, skip_serializing_if = "Option::is_none")]
1792    pub prompt: Option<String>,
1793    #[serde(default)]
1794    pub params: Value,
1795}
1796
1797/// Response payload for `mob/flow_run`.
1798#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1799#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1800pub struct MobFlowRunResult {
1801    pub run_id: String,
1802}
1803
1804/// Request payload for `mob/flow_status`.
1805#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1806#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1807#[serde(deny_unknown_fields)]
1808pub struct MobFlowStatusParams {
1809    pub mob_id: String,
1810    pub run_id: String,
1811}
1812
1813/// Lifecycle status of a flow run on the wire. Mirrors
1814/// `meerkat_mob::MobRunStatus` so consumers branch on a closed type rather than
1815/// re-deriving meaning from a free-form status string.
1816#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1817#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1818#[serde(rename_all = "snake_case")]
1819pub enum WireMobRunStatus {
1820    Pending,
1821    Running,
1822    Completed,
1823    Failed,
1824    Canceled,
1825}
1826
1827/// Typed public projection of a single flow run for `mob/flow_status`.
1828///
1829/// The canonical identity and lifecycle fields (`run_id`, `mob_id`, `flow_id`,
1830/// `status`) are typed; the remaining kernel-owned step/loop projection rides
1831/// along as the `kernel` map. Producers project a domain `MobRun` into this
1832/// shape so consumers never re-derive run identity or lifecycle from a free
1833/// `serde_json::Value`.
1834#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1835#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1836pub struct WireMobRun {
1837    pub run_id: String,
1838    pub mob_id: String,
1839    pub flow_id: String,
1840    pub status: WireMobRunStatus,
1841    /// Remaining kernel-owned run projection (step ledger, frame/loop outputs,
1842    /// flow state) after the typed identity/lifecycle fields are lifted out.
1843    #[serde(flatten)]
1844    pub kernel: serde_json::Map<String, Value>,
1845}
1846
1847/// Response payload for `mob/flow_status`.
1848///
1849/// `run` is `None` when the requested run id has no persisted run.
1850#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1851#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1852pub struct MobFlowStatusResult {
1853    #[serde(default, skip_serializing_if = "Option::is_none")]
1854    pub run: Option<WireMobRun>,
1855}
1856
1857/// Request payload for `mob/run_result`.
1858#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1859#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1860#[serde(deny_unknown_fields)]
1861pub struct MobRunResultParams {
1862    pub mob_id: String,
1863    pub run_id: String,
1864}
1865
1866/// Typed output envelope for a completed or in-flight mob flow run.
1867#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1868#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1869#[serde(deny_unknown_fields)]
1870pub struct WireMobRunResultEnvelope {
1871    pub run_id: String,
1872    pub mob_id: String,
1873    pub flow_id: String,
1874    pub status: WireMobRunStatus,
1875    #[serde(default, skip_serializing_if = "Option::is_none")]
1876    pub result: Option<Value>,
1877    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1878    pub outputs: BTreeMap<String, Value>,
1879}
1880
1881/// Response payload for `mob/run_result`.
1882///
1883/// `run` is `None` when the requested run id has no persisted run.
1884#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1885#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1886pub struct MobRunResult {
1887    #[serde(default, skip_serializing_if = "Option::is_none")]
1888    pub run: Option<WireMobRunResultEnvelope>,
1889}
1890
1891/// Request payload for `mob/flow_cancel`.
1892#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1893#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1894#[serde(deny_unknown_fields)]
1895pub struct MobFlowCancelParams {
1896    pub mob_id: String,
1897    pub run_id: String,
1898}
1899
1900/// Response payload for `mob/flow_cancel`.
1901#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1902#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1903pub struct MobFlowCancelResult {
1904    pub canceled: bool,
1905}
1906
1907/// Request payload for `mob/spawn_helper`.
1908#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1909#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1910#[serde(deny_unknown_fields)]
1911pub struct MobSpawnHelperParams {
1912    pub mob_id: String,
1913    pub prompt: String,
1914    #[serde(default, skip_serializing_if = "Option::is_none")]
1915    pub agent_identity: Option<String>,
1916    #[serde(default, skip_serializing_if = "Option::is_none")]
1917    pub role_name: Option<String>,
1918    #[serde(default, skip_serializing_if = "Option::is_none")]
1919    pub model_override: Option<String>,
1920    #[serde(default, skip_serializing_if = "Option::is_none")]
1921    pub auth_binding: Option<WireAuthBindingRef>,
1922    #[serde(default, skip_serializing_if = "Option::is_none")]
1923    pub runtime_mode: Option<WireMobRuntimeMode>,
1924    #[serde(default, skip_serializing_if = "Option::is_none")]
1925    pub backend: Option<WireMobBackendKind>,
1926}
1927
1928/// Request payload for `mob/fork_helper`.
1929#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1930#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1931#[serde(deny_unknown_fields)]
1932pub struct MobForkHelperParams {
1933    pub mob_id: String,
1934    pub source_member_id: String,
1935    pub prompt: String,
1936    #[serde(default, skip_serializing_if = "Option::is_none")]
1937    pub agent_identity: Option<String>,
1938    #[serde(default, skip_serializing_if = "Option::is_none")]
1939    pub role_name: Option<String>,
1940    #[serde(default, skip_serializing_if = "Option::is_none")]
1941    pub model_override: Option<String>,
1942    #[serde(default, skip_serializing_if = "Option::is_none")]
1943    pub auth_binding: Option<WireAuthBindingRef>,
1944    #[serde(default, skip_serializing_if = "Option::is_none")]
1945    pub fork_context: Option<Value>,
1946    #[serde(default, skip_serializing_if = "Option::is_none")]
1947    pub runtime_mode: Option<WireMobRuntimeMode>,
1948    #[serde(default, skip_serializing_if = "Option::is_none")]
1949    pub backend: Option<WireMobBackendKind>,
1950}
1951
1952/// Response payload for `mob/spawn_helper` and `mob/fork_helper`.
1953#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1954#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1955pub struct MobHelperResult {
1956    #[serde(default, skip_serializing_if = "Option::is_none")]
1957    pub output: Option<String>,
1958    pub tokens_used: u64,
1959    pub agent_identity: String,
1960    pub member_ref: WireMemberRef,
1961}
1962
1963/// Response payload for `mob/force_cancel`.
1964#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1965#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1966pub struct MobForceCancelResult {
1967    pub cancelled: bool,
1968}
1969
1970/// Request payload for `mob/turn_start`.
1971///
1972/// `provider_params` and `auth_binding` carry the canonical Inherit/Set/Clear
1973/// tri-state via [`WireTurnMetadataOverride`]; unknown fields (including the
1974/// retired `clear_*` split wire form) fail closed at the serde boundary via
1975/// `deny_unknown_fields`, which also keeps the emitted JSON Schema's
1976/// `additionalProperties: false` aligned with the deserializer.
1977#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1978#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1979#[serde(deny_unknown_fields)]
1980pub struct MobTurnStartParams {
1981    pub mob_id: String,
1982    pub agent_identity: String,
1983    pub prompt: WireContentInput,
1984    #[serde(default, skip_serializing_if = "Option::is_none")]
1985    pub skill_refs: Option<Vec<meerkat_core::skills::SkillRef>>,
1986    #[serde(default, skip_serializing_if = "Option::is_none")]
1987    pub turn_tool_overlay: Option<meerkat_core::service::PublicTurnToolOverlay>,
1988    #[serde(default, skip_serializing_if = "Option::is_none")]
1989    pub additional_instructions: Option<Vec<String>>,
1990    #[serde(default, skip_serializing_if = "Option::is_none")]
1991    pub keep_alive: Option<bool>,
1992    #[serde(default, skip_serializing_if = "Option::is_none")]
1993    pub model: Option<String>,
1994    #[serde(default, skip_serializing_if = "Option::is_none")]
1995    pub provider: Option<String>,
1996    /// Exact configured local-server route for a self-hosted model.
1997    #[serde(default, skip_serializing_if = "Option::is_none")]
1998    pub self_hosted_server_id: Option<String>,
1999    #[serde(default, skip_serializing_if = "Option::is_none")]
2000    pub max_tokens: Option<u32>,
2001    #[serde(default, skip_serializing_if = "Option::is_none")]
2002    pub system_prompt: Option<String>,
2003    #[serde(default, skip_serializing_if = "Option::is_none")]
2004    pub output_schema: Option<Value>,
2005    #[serde(default, skip_serializing_if = "Option::is_none")]
2006    pub structured_output_retries: Option<u32>,
2007    #[serde(default, skip_serializing_if = "Option::is_none")]
2008    pub provider_params:
2009        Option<WireTurnMetadataOverride<crate::wire::runtime::WireProviderParamsOverride>>,
2010    #[serde(default, skip_serializing_if = "Option::is_none")]
2011    pub auth_binding: Option<WireTurnMetadataOverride<WireAuthBindingRef>>,
2012    /// Host-attached injected context for this turn. Each entry materializes
2013    /// as a separate typed injected-context transcript message immediately
2014    /// before the turn's user message, in order. `mob/turn_start` already
2015    /// rejects autonomous members, so this always rides a turn-driven turn.
2016    #[serde(default, skip_serializing_if = "Option::is_none")]
2017    pub injected_context: Option<Vec<WireContentInput>>,
2018    /// Host-regenerated request-only context for this member turn.
2019    #[serde(default, skip_serializing_if = "Option::is_none")]
2020    pub transient_turn_context: Option<meerkat_core::lifecycle::run_primitive::TurnRequestContext>,
2021}
2022
2023/// One currently wired peer that is known to be unreachable.
2024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2025#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2026pub struct WireUnreachablePeer {
2027    pub peer: String,
2028    #[serde(default, skip_serializing_if = "Option::is_none")]
2029    pub reason: Option<String>,
2030}
2031
2032/// Live connectivity summary for a member's currently wired peers. Mirrors
2033/// `meerkat_mob::MobPeerConnectivitySnapshot`.
2034#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2035#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2036pub struct WirePeerConnectivitySnapshot {
2037    pub reachable_peer_count: usize,
2038    pub unknown_peer_count: usize,
2039    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2040    pub unreachable_peers: Vec<WireUnreachablePeer>,
2041}
2042
2043/// Tri-state peer-connectivity projection for `mob/member_status`.
2044///
2045/// Distinguishes "connectivity is not applicable to this member" (no bridge
2046/// session backs the member) from "the live probe timed out" (the answer is
2047/// transiently unknown) from a resolved connectivity snapshot. The legacy
2048/// `Option<MobPeerConnectivitySnapshot>` projection collapsed both the
2049/// not-applicable and timed-out cases into `None`, laundering a transient
2050/// probe fault into the same shape as a structurally-absent binding.
2051#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2052#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2053#[serde(tag = "status", rename_all = "snake_case")]
2054pub enum WirePeerConnectivity {
2055    /// The member has no bridge session, so live peer connectivity is not a
2056    /// resolvable fact for it.
2057    NotApplicable,
2058    /// A live connectivity probe was attempted but did not resolve in time.
2059    ProbeTimedOut,
2060    /// A resolved connectivity snapshot.
2061    Known {
2062        snapshot: WirePeerConnectivitySnapshot,
2063    },
2064}
2065
2066/// Response payload for `mob/member_status`.
2067#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2068#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2069pub struct MobMemberStatusResult {
2070    pub status: WireMobMemberStatus,
2071    /// Server-resolved opaque handle for subsequent member-targeted calls.
2072    pub member_ref: WireMemberRef,
2073    #[serde(default, skip_serializing_if = "Option::is_none")]
2074    pub output_preview: Option<String>,
2075    #[serde(default, skip_serializing_if = "Option::is_none")]
2076    pub error: Option<String>,
2077    pub tokens_used: u64,
2078    pub is_final: bool,
2079    #[serde(default, skip_serializing_if = "Option::is_none")]
2080    pub current_session_id: Option<String>,
2081    #[serde(default, skip_serializing_if = "Option::is_none")]
2082    pub peer_connectivity: Option<WirePeerConnectivity>,
2083    #[serde(default, skip_serializing_if = "Option::is_none")]
2084    pub kickoff: Option<Value>,
2085    #[serde(default, skip_serializing_if = "Option::is_none")]
2086    pub external_member: Option<Value>,
2087    #[serde(default, skip_serializing_if = "Option::is_none")]
2088    pub resolved_capabilities: Option<crate::wire::WireResolvedModelCapabilities>,
2089    #[serde(default, skip_serializing_if = "Option::is_none")]
2090    pub progress: Option<WireMemberProgressSnapshot>,
2091    /// Execution activity is separate from member lifecycle status.
2092    #[serde(default, skip_serializing_if = "Option::is_none")]
2093    pub activity: Option<crate::wire::JobExecutionActivity>,
2094    /// Durable detached work owned by this member's current session.
2095    #[serde(default, skip_serializing_if = "Option::is_none")]
2096    pub detached_jobs: Option<crate::wire::DetachedJobsActivitySummary>,
2097    // Multi-host projections (SD-5): placement and reachability are TYPED
2098    // fields here — never smuggled inside the opaque `external_member`
2099    // value. All optional + absent-omitted for byte-compat with released
2100    // SDKs.
2101    /// Host the member is materialized on; `None` = controlling host.
2102    #[serde(default, skip_serializing_if = "Option::is_none")]
2103    pub placement: Option<WireHostRef>,
2104    /// Bridge control-plane reachability of the owning host/member.
2105    #[serde(default, skip_serializing_if = "Option::is_none")]
2106    pub control_reachability: Option<WireReachability>,
2107    /// Comms data-plane reachability of the member peer.
2108    #[serde(default, skip_serializing_if = "Option::is_none")]
2109    pub comms_reachability: Option<WireReachability>,
2110    /// Observer-local monotonic ms since last verified contact — never a
2111    /// remote wall-clock comparison.
2112    #[serde(default, skip_serializing_if = "Option::is_none")]
2113    pub last_seen_ms: Option<u64>,
2114    #[serde(default, skip_serializing_if = "Option::is_none")]
2115    pub freshness_reason: Option<String>,
2116    /// Lifecycle capability flags for this member's placement (§19.L7).
2117    #[serde(default, skip_serializing_if = "Option::is_none")]
2118    pub lifecycle_capabilities: Option<WireMemberLifecycleCapabilities>,
2119    /// Reserved portability projection; placed v1 members report an empty
2120    /// list because non-portable resources are rejected, never disabled.
2121    #[serde(default, skip_serializing_if = "Option::is_none")]
2122    pub non_portable_disabled: Option<Vec<super::portable_spec::WireNonPortableResourceKind>>,
2123}
2124
2125#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2127#[serde(rename_all = "snake_case")]
2128pub enum WireMemberRunState {
2129    Idle,
2130    RunOpen,
2131    Unknown,
2132}
2133
2134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2136#[serde(rename_all = "snake_case")]
2137pub enum WireMemberHealthClass {
2138    Healthy,
2139    Degraded,
2140    Wedged,
2141    Unknown,
2142}
2143
2144#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2146#[serde(rename_all = "snake_case")]
2147pub enum WireMemberProgressEvent {
2148    ExecutionAdvanced,
2149    BecameIdle,
2150    Unchanged,
2151}
2152
2153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2155pub struct WireMemberProgressSnapshot {
2156    pub run_state: WireMemberRunState,
2157    pub in_flight_work: u64,
2158    pub last_progress_at_ms: u64,
2159    pub last_progress_event: WireMemberProgressEvent,
2160    pub health: WireMemberHealthClass,
2161}
2162
2163/// Response payload for `mob/snapshot`.
2164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2166pub struct MobSnapshotResult {
2167    pub mob_id: String,
2168    pub status: WireMobLifecycleStatus,
2169    pub members: Vec<MobMemberListEntryWire>,
2170}
2171
2172#[cfg(test)]
2173mod member_status_capability_tests {
2174    use super::*;
2175
2176    #[test]
2177    fn member_status_result_round_trips_resolved_capabilities() -> Result<(), serde_json::Error> {
2178        let capabilities = crate::wire::WireResolvedModelCapabilities {
2179            vision: true,
2180            image_input: true,
2181            image_tool_results: false,
2182            inline_video: false,
2183            realtime: true,
2184            web_search: true,
2185            image_generation: true,
2186        };
2187        let result = MobMemberStatusResult {
2188            status: WireMobMemberStatus::Active,
2189            member_ref: WireMemberRef::encode("mob-1", "worker-1"),
2190            output_preview: None,
2191            error: None,
2192            tokens_used: 0,
2193            is_final: false,
2194            current_session_id: Some("session-1".to_string()),
2195            peer_connectivity: Some(WirePeerConnectivity::Known {
2196                snapshot: WirePeerConnectivitySnapshot {
2197                    reachable_peer_count: 1,
2198                    unknown_peer_count: 0,
2199                    unreachable_peers: Vec::new(),
2200                },
2201            }),
2202            kickoff: None,
2203            external_member: None,
2204            resolved_capabilities: Some(capabilities.clone()),
2205            progress: None,
2206            activity: None,
2207            detached_jobs: None,
2208            placement: None,
2209            control_reachability: None,
2210            comms_reachability: None,
2211            last_seen_ms: None,
2212            freshness_reason: None,
2213            lifecycle_capabilities: None,
2214            non_portable_disabled: None,
2215        };
2216
2217        let json = serde_json::to_string(&result)?;
2218        assert!(json.contains("\"resolved_capabilities\""));
2219        let parsed: MobMemberStatusResult = serde_json::from_str(&json)?;
2220        assert_eq!(parsed.resolved_capabilities, Some(capabilities));
2221        Ok(())
2222    }
2223}
2224
2225/// Response payload for `mob/destroy`.
2226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2227#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2228pub struct MobDestroyResult {
2229    pub mob_id: String,
2230    pub ok: bool,
2231    pub destroy_report: Value,
2232}
2233
2234/// Response payload for `mob/rotate_supervisor`.
2235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2236#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2237pub struct MobRotateSupervisorResult {
2238    pub mob_id: String,
2239    pub ok: bool,
2240    pub report: SupervisorRotationReportWire,
2241}
2242
2243/// Confirmed supervisor rotation report returned by `mob/rotate_supervisor`.
2244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2246pub struct SupervisorRotationReportWire {
2247    pub previous_epoch: u64,
2248    pub current_epoch: u64,
2249    pub public_peer_id: String,
2250}
2251
2252/// Discriminator kind for the supervisor-rotation-incomplete error details.
2253#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2254#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2255#[serde(rename_all = "snake_case")]
2256pub enum SupervisorRotationIncompleteKind {
2257    SupervisorRotationIncomplete,
2258}
2259
2260/// Which authority a supervisor-rotation retry validates against.
2261#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2262#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2263#[serde(rename_all = "snake_case")]
2264pub enum SupervisorRotationRetryAuthority {
2265    PendingRotation,
2266    PreRotation,
2267}
2268
2269/// Durability scope of a supervisor-rotation retry.
2270#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2272#[serde(rename_all = "snake_case")]
2273pub enum SupervisorRotationRetryScope {
2274    Durable,
2275    PreRotation,
2276}
2277
2278/// Typed details of `MobError::SupervisorRotationIncomplete` on the wire.
2279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2280#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2281#[serde(rename_all = "snake_case")]
2282pub struct SupervisorRotationIncompleteDetailsWire {
2283    pub kind: SupervisorRotationIncompleteKind,
2284    pub previous_epoch: u64,
2285    pub attempted_epoch: u64,
2286    pub attempted_public_peer_id: String,
2287    pub rotated_peer_count: usize,
2288    pub rollback_succeeded: bool,
2289    pub pending_authority_recorded: bool,
2290    #[serde(default, skip_serializing_if = "Option::is_none")]
2291    pub rollback_error: Option<String>,
2292    pub retry_authority: SupervisorRotationRetryAuthority,
2293    pub retry_scope: SupervisorRotationRetryScope,
2294}
2295
2296/// JSON-RPC `error.data` payload for an incomplete supervisor rotation.
2297#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2298#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2299#[serde(rename_all = "snake_case")]
2300pub struct SupervisorRotationIncompleteDataWire {
2301    pub code: String,
2302    pub message: String,
2303    pub details: SupervisorRotationIncompleteDetailsWire,
2304}
2305
2306/// Shared request payload for mob readiness waits.
2307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2308#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2309#[serde(deny_unknown_fields)]
2310pub struct MobWaitParams {
2311    pub mob_id: String,
2312    #[serde(default, skip_serializing_if = "Option::is_none")]
2313    pub member_ids: Option<Vec<String>>,
2314    #[serde(default, skip_serializing_if = "Option::is_none")]
2315    pub timeout_ms: Option<u64>,
2316}
2317
2318/// Response payload for `mob/wait_kickoff` and `mob/wait_ready`.
2319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2320#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2321pub struct MobWaitMembersResult {
2322    pub members: Vec<Value>,
2323}
2324
2325/// Response payload for `mob/cancel_work`.
2326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2327#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2328pub struct MobCancelWorkResult {
2329    pub mob_id: String,
2330    pub ok: bool,
2331}
2332
2333/// Response payload for `mob/cancel_all_work`.
2334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2335#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2336pub struct MobCancelAllWorkResult {
2337    pub mob_id: String,
2338    pub ok: bool,
2339}
2340
2341/// Request payload for `mob/profile/create`.
2342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2343#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2344#[serde(deny_unknown_fields)]
2345pub struct MobProfileCreateParams {
2346    pub name: String,
2347    pub profile: MobProfileInput,
2348}
2349
2350/// Request payload for `mob/profile/get`.
2351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2352#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2353#[serde(deny_unknown_fields)]
2354pub struct MobProfileNameParams {
2355    pub name: String,
2356}
2357
2358/// Request payload for `mob/profile/update`.
2359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2361#[serde(deny_unknown_fields)]
2362pub struct MobProfileUpdateParams {
2363    pub name: String,
2364    pub profile: MobProfileInput,
2365    pub expected_revision: u64,
2366}
2367
2368/// Request payload for `mob/profile/delete`.
2369#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2370#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2371#[serde(deny_unknown_fields)]
2372pub struct MobProfileDeleteParams {
2373    pub name: String,
2374    pub expected_revision: u64,
2375}
2376
2377/// Stored realm profile projection returned by `mob/profile/*`.
2378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2380pub struct MobProfileLookupResult {
2381    #[serde(default)]
2382    pub not_found: bool,
2383    pub name: String,
2384    #[serde(default, skip_serializing_if = "Option::is_none")]
2385    pub profile: Option<WireMobProfile>,
2386    #[serde(default, skip_serializing_if = "Option::is_none")]
2387    pub revision: Option<u64>,
2388    #[serde(default, skip_serializing_if = "Option::is_none")]
2389    pub created_at: Option<String>,
2390    #[serde(default, skip_serializing_if = "Option::is_none")]
2391    pub updated_at: Option<String>,
2392}
2393
2394/// Response payload for `mob/profile/list`.
2395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2396#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2397pub struct MobProfileListResult {
2398    pub profiles: Vec<MobProfileLookupResult>,
2399}
2400
2401/// Response payload for `mob/profile/delete`.
2402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2403#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2404pub struct MobProfileDeleteResult {
2405    pub name: String,
2406    pub deleted_revision: u64,
2407}
2408
2409/// Request payload for `mob/stream_open`.
2410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2411#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2412#[serde(deny_unknown_fields)]
2413pub struct MobStreamOpenParams {
2414    pub mob_id: String,
2415    #[serde(default, skip_serializing_if = "Option::is_none")]
2416    pub agent_identity: Option<String>,
2417}
2418
2419/// Response payload for `mob/stream_open`.
2420#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2421#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2422pub struct MobStreamOpenResult {
2423    pub stream_id: String,
2424    pub opened: bool,
2425}
2426
2427/// Request payload for `mob/stream_close`.
2428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2429#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2430#[serde(deny_unknown_fields)]
2431pub struct MobStreamCloseParams {
2432    pub stream_id: String,
2433}
2434
2435/// Response payload for `mob/stream_close`.
2436#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2437#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2438pub struct MobStreamCloseResult {
2439    pub stream_id: String,
2440    pub closed: bool,
2441    pub already_closed: bool,
2442}
2443
2444/// Origin for `MobSubmitWorkParams`. Replaces the prior free-form
2445/// `origin: Option<String>` shape.
2446#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2447#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2448#[serde(rename_all = "snake_case")]
2449pub enum WireWorkOrigin {
2450    #[default]
2451    External,
2452    Internal,
2453}
2454
2455/// Request payload for `mob/submit_work`.
2456///
2457/// Identifies the member through the opaque [`WireMemberRef`] handle the
2458/// server resolves against the live roster — callers do not pass
2459/// `generation` or `fence_token`.
2460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2461#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2462#[serde(deny_unknown_fields)]
2463pub struct MobSubmitWorkParams {
2464    pub member_ref: WireMemberRef,
2465    /// Optional caller-supplied work reference. When absent the server
2466    /// generates a fresh UUID.
2467    #[serde(default, skip_serializing_if = "Option::is_none")]
2468    pub work_ref: Option<String>,
2469    pub content: WireContentInput,
2470    #[serde(default)]
2471    pub origin: WireWorkOrigin,
2472    /// Host-attached injected context delivered alongside the work content.
2473    /// Each entry materializes on the member as a separate typed
2474    /// injected-context transcript message immediately before the work
2475    /// content, in order. Deliverable to queue-mode turn-driven members;
2476    /// autonomous inbox delivery rejects it with a typed error.
2477    #[serde(default, skip_serializing_if = "Option::is_none")]
2478    pub injected_context: Option<Vec<WireContentInput>>,
2479    /// Host-regenerated request-only context for this work turn.
2480    #[serde(default, skip_serializing_if = "Option::is_none")]
2481    pub transient_turn_context: Option<meerkat_core::lifecycle::run_primitive::TurnRequestContext>,
2482    /// Durable kickoff objective correlation to stamp onto this delegated turn.
2483    #[serde(default, skip_serializing_if = "Option::is_none")]
2484    pub objective_id: Option<String>,
2485}
2486
2487/// Response payload for `mob/submit_work`.
2488#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2489#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2490pub struct MobSubmitWorkResult {
2491    pub mob_id: String,
2492    pub work_ref: String,
2493    pub member_ref: WireMemberRef,
2494    #[serde(default, skip_serializing_if = "Option::is_none")]
2495    pub objective_id: Option<String>,
2496}
2497
2498/// Explicitly concludes one machine-owned kickoff objective.
2499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2500#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2501#[serde(deny_unknown_fields)]
2502pub struct MobConcludeObjectiveParams {
2503    pub member_ref: WireMemberRef,
2504    pub objective_id: String,
2505    pub outcome: String,
2506}
2507
2508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2509#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2510pub struct MobConcludeObjectiveResult {
2511    pub member_ref: WireMemberRef,
2512    pub objective_id: String,
2513    pub concluded: bool,
2514}
2515
2516/// Request payload for `mob/cancel_work`.
2517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2518#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2519#[serde(deny_unknown_fields)]
2520pub struct MobCancelWorkParams {
2521    pub mob_id: String,
2522    pub work_ref: String,
2523}
2524
2525/// Request payload for `mob/cancel_all_work`.
2526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2527#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2528#[serde(deny_unknown_fields)]
2529pub struct MobCancelAllWorkParams {
2530    pub member_ref: WireMemberRef,
2531}
2532
2533/// Filter for `mob/list_members_matching`. Non-empty / `Some` fields are
2534/// combined conjunctively; an empty filter matches every member.
2535#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2536#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2537#[serde(deny_unknown_fields)]
2538pub struct MobMemberFilterWire {
2539    /// Required exact matches on member labels.
2540    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2541    pub labels: BTreeMap<String, String>,
2542    /// Required profile name (role).
2543    #[serde(default, skip_serializing_if = "Option::is_none")]
2544    pub role: Option<String>,
2545    /// Required canonical machine-projected member status.
2546    #[serde(default, skip_serializing_if = "Option::is_none")]
2547    pub status: Option<WireMobMemberStatus>,
2548}
2549
2550/// Request payload for `mob/list_members_matching`.
2551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2553#[serde(deny_unknown_fields)]
2554pub struct MobListMembersMatchingParams {
2555    pub mob_id: String,
2556    #[serde(default)]
2557    pub filter: MobMemberFilterWire,
2558}
2559
2560/// Response payload for `mob/list_members_matching`. Each member is the raw
2561/// roster entry JSON.
2562#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2563#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2564pub struct MobListMembersMatchingResult {
2565    #[serde(default)]
2566    pub members: Vec<Value>,
2567}
2568
2569// ---------------------------------------------------------------------------
2570// Multi-host mob DTOs (V4): control scopes, host roster, remote history,
2571// grants, member live console. Types only — RPC catalog entries land with
2572// the surface phases.
2573// ---------------------------------------------------------------------------
2574
2575/// Closed control-plane scope vocabulary (A9). Grants and bridge scope
2576/// denials speak exactly this set.
2577#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
2578#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2579#[serde(rename_all = "snake_case")]
2580pub enum WireControlScope {
2581    List,
2582    ReadHistory,
2583    SubscribeEvents,
2584    SendCommand,
2585    Cancel,
2586    Retire,
2587    WireTopology,
2588    Live,
2589    AdminHost,
2590    AdminGrants,
2591}
2592
2593/// Observer-computed reachability class (§7.5). A projection from typed
2594/// bridge/pump outcomes — never a membership fact, and a DIFFERENT fact
2595/// from the bridge's own `BridgePeerConnectivity`.
2596#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2598#[serde(rename_all = "snake_case")]
2599pub enum WireReachability {
2600    Reachable,
2601    Stale,
2602    Unreachable,
2603    Unknown,
2604}
2605
2606/// Opaque host reference: the host's canonical comms `PeerId` string.
2607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
2608#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2609#[serde(transparent)]
2610pub struct WireHostRef(pub String);
2611
2612/// Lifecycle capabilities available for a member at its placement (§19.L7).
2613#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2614#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2615#[serde(deny_unknown_fields)]
2616pub struct WireMemberLifecycleCapabilities {
2617    pub transcript_edits: bool,
2618    pub revisions: bool,
2619    pub resume_after_restart: bool,
2620}
2621
2622/// Host bind lifecycle phase as recorded by the controlling machine.
2623#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2624#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2625#[serde(rename_all = "snake_case")]
2626pub enum WireHostBindPhase {
2627    Requested,
2628    Bound,
2629}
2630
2631/// Wire mirror of the DSL `HostCapabilityFlags` single enumeration (§6.1) —
2632/// the machine owns the fact; this is its console projection.
2633///
2634/// Field vocabulary matches the machine maps and the domain
2635/// `HostCapabilityReport` exactly (ADJ-P7-1, FLAG-A2): `u64` protocol bounds
2636/// and an OPEN `BTreeSet<String>` provider vocabulary — a newer member host
2637/// advertising a provider this build's enum lacks must stay representable
2638/// (no silent caps).
2639#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2640#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2641#[serde(deny_unknown_fields)]
2642pub struct WireHostCapabilityFlags {
2643    pub protocol_min: u64,
2644    pub protocol_max: u64,
2645    pub engine_version: String,
2646    pub durable_sessions: bool,
2647    pub autonomous_members: bool,
2648    pub hard_cancel_member: bool,
2649    #[serde(default)]
2650    pub tracked_input_cancel: bool,
2651    pub memory_store: bool,
2652    pub mcp: bool,
2653    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
2654    pub resolvable_providers: std::collections::BTreeSet<String>,
2655    pub approval_forwarding: bool,
2656    #[serde(default, skip_serializing_if = "Option::is_none")]
2657    pub live_endpoint: Option<String>,
2658}
2659
2660/// One tracked host row for `mob/hosts` (A13).
2661///
2662/// `endpoint`, `authority_epoch`, and `capabilities` are the CommitHostBind
2663/// facts — present for `Bound` hosts, typed-absent for a `Requested`-phase
2664/// host (an open or failed bind window commits nothing; fabricating empty
2665/// values would launder ceremony state into committed facts).
2666///
2667/// `control_reachability`/`last_seen_ms`/`freshness_reason` are fed by the
2668/// observer-local periodic `HostStatus` driver shared with orphan
2669/// reconciliation. They remain typed-absent until the first observation and
2670/// never become durable membership facts.
2671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2672#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2673pub struct MobHostStatus {
2674    pub host_id: WireHostRef,
2675    #[serde(default, skip_serializing_if = "Option::is_none")]
2676    pub endpoint: Option<String>,
2677    pub bind_phase: WireHostBindPhase,
2678    #[serde(default, skip_serializing_if = "Option::is_none")]
2679    pub authority_epoch: Option<u64>,
2680    #[serde(default, skip_serializing_if = "Option::is_none")]
2681    pub capabilities: Option<WireHostCapabilityFlags>,
2682    #[serde(default, skip_serializing_if = "Option::is_none")]
2683    pub control_reachability: Option<WireReachability>,
2684    /// Observer-local monotonic ms since last verified contact.
2685    #[serde(default, skip_serializing_if = "Option::is_none")]
2686    pub last_seen_ms: Option<u64>,
2687    #[serde(default, skip_serializing_if = "Option::is_none")]
2688    pub freshness_reason: Option<String>,
2689    pub materialized_member_count: u64,
2690}
2691
2692/// Response payload for `mob/hosts`.
2693#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2694#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2695pub struct MobHostsResult {
2696    pub hosts: Vec<MobHostStatus>,
2697}
2698
2699/// One outstanding cross-host route-install obligation.
2700#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2701#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2702pub struct WireRouteInstallObligation {
2703    pub edge_a: String,
2704    pub edge_b: String,
2705    pub host: WireHostRef,
2706}
2707
2708/// Response payload for the route-install status projection.
2709#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2710#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2711pub struct MobRouteInstallsResult {
2712    pub outstanding: Vec<WireRouteInstallObligation>,
2713    pub complete: bool,
2714}
2715
2716/// Who attests a remotely-served projection (§7/§20): `HostClaimed` facts
2717/// are only what the owning host reports; `ControllingHostVerified` facts
2718/// were checked against controlling-machine records.
2719#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2720#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2721#[serde(rename_all = "snake_case")]
2722pub enum WireProjectionProvenance {
2723    HostClaimed,
2724    ControllingHostVerified,
2725}
2726
2727/// Equality adapter over a canonical wire transcript row.
2728///
2729/// `WireSessionMessage` deliberately derives no `PartialEq` (opaque
2730/// tool-call args ride `RawValue`), but the bridge reply chain that
2731/// carries history pages must be `Eq` (the comms envelope enums derive
2732/// it). Equality here is semantic-JSON equality of the serialized wire
2733/// form — exactly the fact reply comparison needs. Transparent: the wire
2734/// shape stays the raw row object.
2735#[derive(Debug, Clone, Serialize, Deserialize)]
2736#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2737#[serde(transparent)]
2738pub struct WireHistoryRow(pub super::session::WireSessionMessage);
2739
2740impl PartialEq for WireHistoryRow {
2741    fn eq(&self, other: &Self) -> bool {
2742        match (
2743            serde_json::to_value(&self.0),
2744            serde_json::to_value(&other.0),
2745        ) {
2746            (Ok(a), Ok(b)) => a == b,
2747            // Unreachable for transcript rows (their serialization is
2748            // infallible); kept fail-closed rather than laundering a
2749            // serialize error into equality.
2750            _ => false,
2751        }
2752    }
2753}
2754
2755impl Eq for WireHistoryRow {}
2756
2757/// Shared transcript page body used by both the bridge
2758/// `MemberHistoryPage` reply and the console `mob/member_history` result —
2759/// same page shape for local and remote members.
2760#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2761#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2762#[serde(deny_unknown_fields)]
2763pub struct WireMemberHistoryPageBody {
2764    pub from_index: u64,
2765    pub messages: Vec<WireHistoryRow>,
2766    /// Total transcript length — carried so offset math (e.g. fork
2767    /// `LastMessages`) needs no extra round-trip.
2768    pub message_count: u64,
2769    #[serde(default, skip_serializing_if = "Option::is_none")]
2770    pub next_index: Option<u64>,
2771    pub complete: bool,
2772}
2773
2774impl WireMemberHistoryPageBody {
2775    /// THE page-shape projection (multi-host mobs DEC-P6E-6): the member
2776    /// host's `ReadMemberHistory` arm AND the controlling host's local
2777    /// history branch both call this, so "remote page read == local page
2778    /// shape" holds by construction, not by test luck.
2779    pub fn try_from_history_page(
2780        page: &meerkat_core::service::SessionHistoryPage,
2781    ) -> Result<Self, super::error::WireConversionError> {
2782        let invalid =
2783            |reason: String| super::error::WireConversionError::MemberHistoryPage { debug: reason };
2784        let message_count = u64::try_from(page.message_count).map_err(|_| {
2785            invalid(format!(
2786                "message_count {} exceeds the u64 wire domain",
2787                page.message_count
2788            ))
2789        })?;
2790        let from_index = u64::try_from(page.offset).map_err(|_| {
2791            invalid(format!(
2792                "offset {} exceeds the u64 wire domain",
2793                page.offset
2794            ))
2795        })?;
2796        let served = u64::try_from(page.messages.len()).map_err(|_| {
2797            invalid(format!(
2798                "served row count {} exceeds the u64 wire domain",
2799                page.messages.len()
2800            ))
2801        })?;
2802        let next_index = if page.has_more {
2803            if served == 0 {
2804                return Err(invalid(format!(
2805                    "page at offset {from_index} claims more rows but serves none"
2806                )));
2807            }
2808            Some(from_index.checked_add(served).ok_or_else(|| {
2809                invalid(format!(
2810                    "offset {from_index} plus served row count {served} exhausts the u64 cursor domain"
2811                ))
2812            })?)
2813        } else {
2814            None
2815        };
2816        Ok(Self {
2817            from_index,
2818            messages: page
2819                .messages
2820                .iter()
2821                .map(|message| {
2822                    WireHistoryRow(super::session::WireSessionMessage::from(message.clone()))
2823                })
2824                .collect(),
2825            message_count,
2826            next_index,
2827            complete: !page.has_more,
2828        })
2829    }
2830}
2831
2832/// Request payload for `mob/member_history`.
2833#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2834#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2835#[serde(deny_unknown_fields)]
2836pub struct MobMemberHistoryParams {
2837    pub mob_id: String,
2838    pub agent_identity: String,
2839    #[serde(default, skip_serializing_if = "Option::is_none")]
2840    pub from_index: Option<u64>,
2841    #[serde(default, skip_serializing_if = "Option::is_none")]
2842    pub limit: Option<u32>,
2843}
2844
2845/// Response payload for `mob/member_history`. Pagination facts live inside
2846/// `page` (one owner); this envelope adds the placement/provenance facts
2847/// only the controlling host knows.
2848#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2849#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2850pub struct MobMemberHistoryResult {
2851    pub page: WireMemberHistoryPageBody,
2852    pub generation: u64,
2853    #[serde(default, skip_serializing_if = "Option::is_none")]
2854    pub placement: Option<WireHostRef>,
2855    pub provenance: WireProjectionProvenance,
2856}
2857
2858/// Request payload for `mob/bind_host`.
2859#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2860#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2861#[serde(deny_unknown_fields)]
2862pub struct MobBindHostParams {
2863    pub mob_id: String,
2864    pub descriptor: super::supervisor_bridge::WireHostBindingDescriptor,
2865}
2866
2867/// Response payload for `mob/bind_host`.
2868#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2869#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2870pub struct MobBindHostResult {
2871    pub host_id: WireHostRef,
2872    pub capabilities: WireHostCapabilityFlags,
2873    pub authority_epoch: u64,
2874}
2875
2876/// Request payload for `mob/revoke_host`.
2877#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2878#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2879#[serde(deny_unknown_fields)]
2880pub struct MobRevokeHostParams {
2881    pub mob_id: String,
2882    pub host_id: WireHostRef,
2883}
2884
2885/// Response payload for `mob/revoke_host`.
2886#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2887#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2888pub struct MobRevokeHostResult {
2889    pub host_id: WireHostRef,
2890    /// Agent identities whose materializations were released by the
2891    /// revocation.
2892    pub released_members: Vec<String>,
2893}
2894
2895/// One control-plane grant record (A9).
2896#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2897#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2898pub struct WireGrantRecord {
2899    pub principal: String,
2900    pub scopes: Vec<WireControlScope>,
2901    #[serde(default, skip_serializing_if = "Option::is_none")]
2902    pub expires_at_ms: Option<u64>,
2903}
2904
2905/// Request payload for `mob/grant_scopes`.
2906#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2907#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2908#[serde(deny_unknown_fields)]
2909pub struct MobGrantScopesParams {
2910    pub mob_id: String,
2911    pub principal: String,
2912    pub scopes: Vec<WireControlScope>,
2913    #[serde(default, skip_serializing_if = "Option::is_none")]
2914    pub expires_at_ms: Option<u64>,
2915}
2916
2917/// Response payload for `mob/grant_scopes`.
2918#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2919#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2920pub struct MobGrantScopesResult {
2921    pub record: WireGrantRecord,
2922}
2923
2924/// Request payload for `mob/revoke_scopes`. `scopes: None` revokes the
2925/// principal's entire grant.
2926#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2927#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2928#[serde(deny_unknown_fields)]
2929pub struct MobRevokeScopesParams {
2930    pub mob_id: String,
2931    pub principal: String,
2932    #[serde(default, skip_serializing_if = "Option::is_none")]
2933    pub scopes: Option<Vec<WireControlScope>>,
2934}
2935
2936/// Response payload for `mob/revoke_scopes`.
2937#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2938#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2939pub struct MobRevokeScopesResult {
2940    pub removed: bool,
2941}
2942
2943/// Response payload for `mob/grants`.
2944#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2945#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2946pub struct MobGrantsResult {
2947    pub grants: Vec<WireGrantRecord>,
2948}
2949
2950/// Typed `details` payload for `ErrorCode::ScopeDenied` (§17.4). Every
2951/// console surface serializes exactly this struct into the wire error's
2952/// `details` carrier; the field shape mirrors
2953/// `BridgeRejectionCause::ScopeDenied` so bridge and console denials speak
2954/// one shape. `presented` is the denied caller's own effective (post-expiry)
2955/// scope set — never another principal's grants.
2956#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2957#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2958#[serde(deny_unknown_fields)]
2959pub struct WireScopeDeniedDetail {
2960    pub required: WireControlScope,
2961    pub presented: Vec<WireControlScope>,
2962}
2963
2964/// Request payload for `mob/member_live_open` (§16.4). Result reuses
2965/// `LiveOpenResult` verbatim.
2966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2967#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2968#[serde(deny_unknown_fields)]
2969pub struct MobMemberLiveOpenParams {
2970    pub mob_id: String,
2971    pub agent_identity: String,
2972    #[serde(default, skip_serializing_if = "Option::is_none")]
2973    pub turning_mode: Option<super::realtime::RealtimeTurningMode>,
2974    #[serde(default, skip_serializing_if = "Option::is_none")]
2975    pub transport: Option<super::live::LiveOpenTransport>,
2976}
2977
2978/// Request payload for `mob/member_live_close`. Close-what-you-name
2979/// (ADJ-P6B-15): `channel_id` is REQUIRED — a reconciling console can never
2980/// race-kill a channel a concurrent legitimate open just minted. The status
2981/// read has its own params type ([`MobMemberLiveStatusParams`]) because its
2982/// `channel_id` is optional by contract.
2983#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2984#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2985#[serde(deny_unknown_fields)]
2986pub struct MobMemberLiveChannelParams {
2987    pub mob_id: String,
2988    pub agent_identity: String,
2989    pub channel_id: String,
2990}
2991
2992/// Request payload for `mob/member_live_status` (§16.9, ADJ-P6B-2).
2993/// `channel_id: None` IS the reply-loss discovery primitive — it resolves
2994/// "the member's active channel" on the owning host, so an orphaned open's
2995/// id can be discovered and closed. A dedicated type (not
2996/// [`MobMemberLiveChannelParams`]) so the wire cannot amputate the
2997/// discovery read (DEC-P7A-2).
2998#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2999#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3000#[serde(deny_unknown_fields)]
3001pub struct MobMemberLiveStatusParams {
3002    pub mob_id: String,
3003    pub agent_identity: String,
3004    #[serde(default, skip_serializing_if = "Option::is_none")]
3005    pub channel_id: Option<String>,
3006}
3007
3008/// Request payload for `mob/hard_cancel_member` (DEC-P6E-8). `reason` is
3009/// REQUIRED: the handle verb demands one, and a handler-minted default
3010/// string would be handler-owned meaning (DEC-P7A-2).
3011#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3012#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3013#[serde(deny_unknown_fields)]
3014pub struct MobHardCancelParams {
3015    pub mob_id: String,
3016    pub agent_identity: String,
3017    pub reason: String,
3018}
3019
3020/// Response payload for `mob/hard_cancel_member`. A dedicated type (not
3021/// [`MobForceCancelResult`] reuse) so the hard/force distinction stays
3022/// legible in SDK type names (DEC-P7A-2).
3023#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3024#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3025pub struct MobHardCancelResult {
3026    pub cancelled: bool,
3027}
3028
3029/// Request payload for `mob/member_live_control`.
3030#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3031#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3032#[serde(deny_unknown_fields)]
3033pub struct MobMemberLiveControlParams {
3034    pub mob_id: String,
3035    pub agent_identity: String,
3036    pub channel_id: String,
3037    pub verb: super::supervisor_bridge::BridgeLiveControlVerb,
3038}
3039
3040#[cfg(test)]
3041#[allow(clippy::expect_used, clippy::panic)]
3042mod tests {
3043    use super::*;
3044
3045    #[test]
3046    fn mob_helper_params_carry_structural_auth_binding() {
3047        let parsed: MobSpawnHelperParams = serde_json::from_value(serde_json::json!({
3048            "mob_id": "mob-1",
3049            "prompt": "help",
3050            "agent_identity": "helper",
3051            "auth_binding": {
3052                "realm": "dev",
3053                "binding": "default_anthropic",
3054                "profile": "console"
3055            }
3056        }))
3057        .expect("spawn helper params parse");
3058        let auth_binding = parsed.auth_binding.expect("auth_binding should parse");
3059        assert_eq!(auth_binding.realm.as_str(), "dev");
3060        assert_eq!(auth_binding.binding.as_str(), "default_anthropic");
3061        assert_eq!(
3062            auth_binding
3063                .profile
3064                .as_ref()
3065                .map(|profile| profile.as_str()),
3066            Some("console")
3067        );
3068
3069        let parsed: MobForkHelperParams = serde_json::from_value(serde_json::json!({
3070            "mob_id": "mob-1",
3071            "source_member_id": "source",
3072            "prompt": "help",
3073            "agent_identity": "helper",
3074            "auth_binding": {
3075                "realm": "dev",
3076                "binding": "default_anthropic"
3077            }
3078        }))
3079        .expect("fork helper params parse");
3080        let auth_binding = parsed.auth_binding.expect("auth_binding should parse");
3081        assert_eq!(auth_binding.realm.as_str(), "dev");
3082        assert_eq!(auth_binding.binding.as_str(), "default_anthropic");
3083        assert!(auth_binding.profile.is_none());
3084    }
3085
3086    #[test]
3087    fn wire_mob_profile_parses_provider_fields_fail_closed() {
3088        // Minimal legacy payload (no new fields) still parses.
3089        let legacy: WireMobProfile =
3090            serde_json::from_str(r#"{"model":"claude-opus-4-8"}"#).expect("legacy profile parses");
3091        assert_eq!(legacy.provider, None);
3092        assert!(legacy.resume_overrides.is_empty());
3093
3094        // Typed provider + resume override vocabulary parse into closed enums.
3095        let full: WireMobProfile = serde_json::from_str(
3096            r#"{
3097                "model": "claude-internal-preview",
3098                "provider": "anthropic",
3099                "image_generation_provider": "gemini",
3100                "auto_compact_threshold": 60000,
3101                "resume_overrides": ["model", "provider"]
3102            }"#,
3103        )
3104        .expect("typed profile parses");
3105        assert_eq!(full.provider, Some(meerkat_core::Provider::Anthropic));
3106        assert_eq!(
3107            full.image_generation_provider,
3108            Some(meerkat_core::Provider::Gemini)
3109        );
3110        assert_eq!(
3111            full.resume_overrides,
3112            vec![
3113                WireMobResumeOverrideField::Model,
3114                WireMobResumeOverrideField::Provider
3115            ]
3116        );
3117
3118        // Fail-closed: unknown provider names and zero thresholds reject.
3119        assert!(
3120            serde_json::from_str::<WireMobProfile>(r#"{"model":"m","provider":"not-a-provider"}"#)
3121                .is_err(),
3122            "unknown provider names must fail closed at the wire boundary"
3123        );
3124        assert!(
3125            serde_json::from_str::<WireMobProfile>(r#"{"model":"m","auto_compact_threshold":0}"#)
3126                .is_err(),
3127            "zero auto_compact_threshold must fail closed at the wire boundary"
3128        );
3129        assert!(
3130            serde_json::from_str::<WireMobProfile>(
3131                r#"{"model":"m","resume_overrides":["everything"]}"#
3132            )
3133            .is_err(),
3134            "resume_overrides vocabulary is closed"
3135        );
3136    }
3137
3138    #[test]
3139    fn mob_definition_input_parses_custom_models() {
3140        let input: MobDefinitionInput = serde_json::from_str(
3141            r#"{
3142                "id": "m",
3143                "profiles": {"worker": {"model": "claude-internal-preview"}},
3144                "models": {
3145                    "claude-internal-preview": {
3146                        "provider": "anthropic",
3147                        "context_window": 500000,
3148                        "vision": true
3149                    }
3150                },
3151                "image_generation_provider": "openai"
3152            }"#,
3153        )
3154        .expect("definition with custom models parses");
3155        let model = input
3156            .models
3157            .get("claude-internal-preview")
3158            .expect("custom model present");
3159        assert_eq!(model.provider, meerkat_core::Provider::Anthropic);
3160        assert_eq!(model.context_window, Some(500_000));
3161        assert_eq!(model.vision, Some(true));
3162        assert_eq!(
3163            input.image_generation_provider,
3164            Some(meerkat_core::Provider::OpenAI)
3165        );
3166    }
3167
3168    #[test]
3169    fn wire_member_ref_round_trips_through_encode_decode() {
3170        let token = WireMemberRef::encode("mob-42", "worker-1");
3171        let (mob_id, agent_identity) = token.decode().expect("decode round-trips");
3172        assert_eq!(mob_id, "mob-42");
3173        assert_eq!(agent_identity, "worker-1");
3174    }
3175
3176    #[test]
3177    fn wire_member_ref_rejects_malformed_token() {
3178        let err = WireMemberRef::from_token("not-a-token-payload")
3179            .decode()
3180            .expect_err("malformed tokens must fail to decode");
3181        assert!(matches!(err, WireMemberRefError::Malformed));
3182    }
3183
3184    #[test]
3185    fn mob_spawn_many_spec_placement_is_optional_and_round_trips() {
3186        let placed: MobSpawnSpecParams = serde_json::from_value(serde_json::json!({
3187            "profile": "worker",
3188            "agent_identity": "w1",
3189            "placement": "host-b-peer"
3190        }))
3191        .expect("placed spawn-many spec parses");
3192        assert_eq!(
3193            placed.placement.as_ref().map(|host| host.0.as_str()),
3194            Some("host-b-peer")
3195        );
3196        assert_eq!(
3197            serde_json::to_value(&placed).expect("placed spawn-many spec serializes")["placement"],
3198            "host-b-peer"
3199        );
3200
3201        let local: MobSpawnSpecParams = serde_json::from_value(serde_json::json!({
3202            "profile": "worker",
3203            "agent_identity": "w2"
3204        }))
3205        .expect("local spawn-many spec parses");
3206        assert!(local.placement.is_none());
3207        assert!(
3208            serde_json::to_value(&local)
3209                .expect("local spawn-many spec serializes")
3210                .get("placement")
3211                .is_none(),
3212            "absent placement must remain omitted for source and wire compatibility"
3213        );
3214    }
3215
3216    #[test]
3217    fn mob_member_spec_placement_is_optional_and_round_trips() {
3218        let placed: MobMemberSpecWire = serde_json::from_value(serde_json::json!({
3219            "profile": "worker",
3220            "agent_identity": "w1",
3221            "placement": "host-b-peer"
3222        }))
3223        .expect("placed declarative member spec parses");
3224        assert_eq!(
3225            placed.placement.as_ref().map(|host| host.0.as_str()),
3226            Some("host-b-peer")
3227        );
3228
3229        let local: MobMemberSpecWire = serde_json::from_value(serde_json::json!({
3230            "profile": "worker",
3231            "agent_identity": "w2"
3232        }))
3233        .expect("local declarative member spec parses");
3234        assert!(local.placement.is_none());
3235    }
3236
3237    #[test]
3238    fn mob_member_spec_exposes_shared_surface_metadata() {
3239        let spec = MobMemberSpecWire {
3240            profile: "worker".into(),
3241            agent_identity: "w1".into(),
3242            initial_message: None,
3243            runtime_mode: None,
3244            backend: None,
3245            placement: None,
3246            binding: None,
3247            context: Some(serde_json::json!({"client_ref": "member-card"})),
3248            labels: Some(BTreeMap::from([("client.member_id".into(), "w1".into())])),
3249            additional_instructions: None,
3250            auto_wire_parent: None,
3251        };
3252
3253        let metadata = spec.surface_metadata();
3254        assert_eq!(
3255            metadata.labels.get("client.member_id").map(String::as_str),
3256            Some("w1")
3257        );
3258        assert_eq!(
3259            metadata.app_context,
3260            Some(serde_json::json!({"client_ref": "member-card"}))
3261        );
3262    }
3263
3264    #[test]
3265    fn mob_member_spec_surface_metadata_rejects_reserved_keys() {
3266        let spec = MobMemberSpecWire {
3267            profile: "worker".into(),
3268            agent_identity: "w1".into(),
3269            initial_message: None,
3270            runtime_mode: None,
3271            backend: None,
3272            placement: None,
3273            binding: None,
3274            context: None,
3275            labels: Some(BTreeMap::from([("mob_id".into(), "spoof".into())])),
3276            additional_instructions: None,
3277            auto_wire_parent: None,
3278        };
3279
3280        assert!(spec.validate_public_surface_metadata().is_err());
3281    }
3282
3283    #[test]
3284    fn mob_reconcile_failure_stage_is_typed_wire_enum() {
3285        let failure = MobReconcileFailureWire {
3286            agent_identity: "worker-1".into(),
3287            stage: WireMobReconcileStage::Spawn,
3288            error: WireMobError {
3289                code: MobSpawnManyFailureCause::ProfileNotFound,
3290                message: "spawn failed".into(),
3291            },
3292        };
3293
3294        let json = serde_json::to_value(&failure).expect("serialize failure");
3295        assert_eq!(json["stage"], "spawn");
3296        assert_eq!(json["error"]["code"], "profile_not_found");
3297        assert_eq!(json["error"]["message"], "spawn failed");
3298
3299        let round_trip: MobReconcileFailureWire =
3300            serde_json::from_value(json).expect("deserialize failure");
3301        assert_eq!(round_trip.stage, WireMobReconcileStage::Spawn);
3302        assert_eq!(
3303            round_trip.error.code,
3304            MobSpawnManyFailureCause::ProfileNotFound
3305        );
3306
3307        let err = serde_json::from_value::<MobReconcileFailureWire>(serde_json::json!({
3308            "agent_identity": "worker-1",
3309            "stage": "restart",
3310            "error": { "code": "profile_not_found", "message": "bad stage" }
3311        }))
3312        .expect_err("unknown reconcile stage must be rejected");
3313        assert!(err.to_string().contains("unknown variant"));
3314    }
3315
3316    #[test]
3317    fn mob_lifecycle_params_reject_unknown_action_string() {
3318        let err = serde_json::from_value::<MobLifecycleParams>(serde_json::json!({
3319            "mob_id": "mob-1",
3320            "action": "explode"
3321        }))
3322        .expect_err("unknown lifecycle actions must fail at the typed wire boundary");
3323
3324        assert!(
3325            err.to_string().contains("unknown variant"),
3326            "unexpected error: {err}"
3327        );
3328    }
3329
3330    #[test]
3331    fn mob_lifecycle_result_round_trips_typed_action() {
3332        let result = MobLifecycleResult {
3333            mob_id: "mob-1".into(),
3334            action: WireMobLifecycleAction::Complete,
3335            ok: true,
3336            destroy_report: None,
3337        };
3338
3339        let json = serde_json::to_value(&result).expect("serialize lifecycle result");
3340        assert_eq!(json["action"], "complete");
3341
3342        let round_trip: MobLifecycleResult =
3343            serde_json::from_value(json).expect("deserialize lifecycle result");
3344        assert_eq!(round_trip.action, WireMobLifecycleAction::Complete);
3345    }
3346
3347    #[test]
3348    fn mob_wire_members_batch_contract_is_local_edge_native() {
3349        let params: MobWireMembersBatchParams = serde_json::from_value(serde_json::json!({
3350            "mob_id": "mob-1",
3351            "edges": [
3352                { "a": "lead", "b": "worker-b" },
3353                { "a": "worker-a", "b": "lead" }
3354            ]
3355        }))
3356        .expect("batch wire params deserialize");
3357
3358        assert_eq!(params.mob_id, "mob-1");
3359        assert_eq!(params.edges.len(), 2);
3360        assert_eq!(params.edges[0].a, "lead");
3361        assert_eq!(params.edges[0].b, "worker-b");
3362
3363        let result = MobWireMembersBatchResult {
3364            requested: 2,
3365            wired: vec![MobWireMembersBatchEdge {
3366                a: "lead".into(),
3367                b: "worker-a".into(),
3368            }],
3369            already_wired: vec![MobWireMembersBatchEdge {
3370                a: "lead".into(),
3371                b: "worker-b".into(),
3372            }],
3373        };
3374        let json = serde_json::to_value(&result).expect("serialize batch wire result");
3375        assert_eq!(json["requested"], 2);
3376        assert_eq!(json["wired"][0]["a"], "lead");
3377        assert_eq!(json["already_wired"][0]["b"], "worker-b");
3378
3379        let err = serde_json::from_value::<MobWireMembersBatchParams>(serde_json::json!({
3380            "mob_id": "mob-1",
3381            "edges": [{ "member": "lead", "peer": "worker-a" }]
3382        }))
3383        .expect_err("mixed local/external mob/wire shape must not deserialize");
3384        let message = err.to_string();
3385        assert!(
3386            message.contains("unknown field `member`") || message.contains("missing field `a`"),
3387            "unexpected error: {message}"
3388        );
3389    }
3390
3391    #[test]
3392    fn mob_spawn_many_result_entry_uses_typed_status_result_envelope() {
3393        let member_ref = WireMemberRef::encode("mob-1", "worker-1");
3394        let entry = MobSpawnManyResultEntry::spawned("worker-1", member_ref.clone());
3395
3396        let json = serde_json::to_value(&entry).expect("serialize typed spawn_many row");
3397        assert_eq!(json["status"], "spawned");
3398        assert_eq!(json["result"]["agent_identity"], "worker-1");
3399        assert_eq!(json["result"]["member_ref"], member_ref.as_str());
3400        assert!(json.get("ok").is_none());
3401        assert!(json.get("error").is_none());
3402
3403        let round_trip: MobSpawnManyResultEntry =
3404            serde_json::from_value(json).expect("deserialize typed spawn_many row");
3405        assert_eq!(round_trip, entry);
3406
3407        let failed = MobSpawnManyResultEntry::failed(
3408            MobSpawnManyFailureCause::ProfileNotFound,
3409            "profile missing",
3410        );
3411        let json = serde_json::to_value(&failed).expect("serialize typed failed spawn_many row");
3412        assert_eq!(json["status"], "failed");
3413        assert_eq!(json["result"]["cause"], "profile_not_found");
3414        assert_eq!(json["result"]["message"], "profile missing");
3415        assert!(json["result"].get("structured_data").is_none());
3416        assert!(json.get("ok").is_none());
3417        assert!(json.get("error").is_none());
3418
3419        let round_trip: MobSpawnManyResultEntry =
3420            serde_json::from_value(json).expect("deserialize typed failed spawn_many row");
3421        assert_eq!(round_trip, failed);
3422
3423        let failed_with_data = MobSpawnManyResultEntry::failed_with_structured_data(
3424            MobSpawnManyFailureCause::SessionError,
3425            "provider authentication failed",
3426            Some(serde_json::json!({
3427                "cause": "provider_auth",
3428                "kind": "interactive_login_required",
3429                "provider": "openai"
3430            })),
3431        );
3432        let json = serde_json::to_value(&failed_with_data).expect("serialize failed row with data");
3433        assert_eq!(json["result"]["structured_data"]["cause"], "provider_auth");
3434        let round_trip: MobSpawnManyResultEntry =
3435            serde_json::from_value(json).expect("deserialize failed row with data");
3436        assert_eq!(round_trip, failed_with_data);
3437
3438        let legacy_without_data = serde_json::json!({
3439            "status": "failed",
3440            "result": {
3441                "cause": "profile_not_found",
3442                "message": "profile missing"
3443            }
3444        });
3445        let decoded: MobSpawnManyResultEntry =
3446            serde_json::from_value(legacy_without_data).expect("read pre-data failed row");
3447        assert_eq!(decoded, failed);
3448    }
3449
3450    #[test]
3451    fn mob_spawn_many_result_entry_rejects_legacy_or_malformed_envelopes() {
3452        let legacy = serde_json::json!({
3453            "ok": true,
3454            "agent_identity": "worker-1",
3455            "member_ref": WireMemberRef::encode("mob-1", "worker-1"),
3456        });
3457        let err = serde_json::from_value::<MobSpawnManyResultEntry>(legacy)
3458            .expect_err("legacy ok carrier must not deserialize");
3459        assert!(
3460            err.to_string().contains("missing field `status`")
3461                || err.to_string().contains("unknown field"),
3462            "unexpected error: {err}"
3463        );
3464
3465        let missing_result = serde_json::json!({
3466            "status": "spawned"
3467        });
3468        let err = serde_json::from_value::<MobSpawnManyResultEntry>(missing_result)
3469            .expect_err("missing typed result must fail closed");
3470        assert!(
3471            err.to_string().contains("missing field `result`"),
3472            "unexpected error: {err}"
3473        );
3474
3475        let unknown_status = serde_json::json!({
3476            "status": "ok",
3477            "result": {
3478                "agent_identity": "worker-1",
3479                "member_ref": WireMemberRef::encode("mob-1", "worker-1"),
3480            }
3481        });
3482        let err = serde_json::from_value::<MobSpawnManyResultEntry>(unknown_status)
3483            .expect_err("unknown typed status must fail closed");
3484        assert!(
3485            err.to_string().contains("unknown variant"),
3486            "unexpected error: {err}"
3487        );
3488
3489        let mismatched = serde_json::json!({
3490            "status": "spawned",
3491            "result": {
3492                "cause": "profile_not_found",
3493                "message": "profile missing"
3494            }
3495        });
3496        let err = serde_json::from_value::<MobSpawnManyResultEntry>(mismatched)
3497            .expect_err("status/result mismatch must fail closed");
3498        assert!(
3499            err.to_string()
3500                .contains("status spawned requires spawned result"),
3501            "unexpected error: {err}"
3502        );
3503
3504        let message_only_failure = serde_json::json!({
3505            "status": "failed",
3506            "result": {
3507                "message": "profile missing"
3508            }
3509        });
3510        let err = serde_json::from_value::<MobSpawnManyResultEntry>(message_only_failure)
3511            .expect_err("string-only failure result must fail closed");
3512        assert!(
3513            err.to_string().contains("data did not match any variant")
3514                || err.to_string().contains("missing field `cause`"),
3515            "unexpected error: {err}"
3516        );
3517
3518        let unknown_failure_cause = serde_json::json!({
3519            "status": "failed",
3520            "result": {
3521                "cause": "future_failure",
3522                "message": "future failure"
3523            }
3524        });
3525        let err = serde_json::from_value::<MobSpawnManyResultEntry>(unknown_failure_cause)
3526            .expect_err("unknown failure cause must fail closed");
3527        assert!(
3528            err.to_string().contains("data did not match any variant")
3529                || err.to_string().contains("unknown variant"),
3530            "unexpected error: {err}"
3531        );
3532    }
3533
3534    #[test]
3535    fn mob_wire_params_reject_legacy_local_target_shape() {
3536        let err = serde_json::from_value::<MobWireParams>(serde_json::json!({
3537            "mob_id": "mob-1",
3538            "local": "member-a",
3539            "target": { "local": "member-b" }
3540        }))
3541        .expect_err("legacy local/target shape must be rejected");
3542
3543        let msg = err.to_string();
3544        assert!(
3545            msg.contains("unknown field `local`") || msg.contains("missing field `member`"),
3546            "unexpected error: {msg}"
3547        );
3548    }
3549
3550    #[test]
3551    fn mob_wire_params_accept_canonical_external_peer_identity() {
3552        let params = serde_json::from_value::<MobWireParams>(serde_json::json!({
3553            "mob_id": "mob-1",
3554            "member": "member-a",
3555            "peer": {
3556                "external": {
3557                    "name": "external-worker",
3558                    "address": "inproc://external-worker",
3559                    "identity": {
3560                        "kind": "ed25519_public_key",
3561                        "public_key": "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
3562                    }
3563                }
3564            }
3565        }))
3566        .expect("canonical external peer identity should deserialize");
3567
3568        let MobPeerTarget::External(spec) = params.peer else {
3569            panic!("expected external peer target");
3570        };
3571        assert_eq!(spec.name, "external-worker");
3572    }
3573
3574    #[test]
3575    fn mob_wire_params_reject_raw_external_peer_id_shape() {
3576        let err = serde_json::from_value::<MobWireParams>(serde_json::json!({
3577            "mob_id": "mob-1",
3578            "member": "member-a",
3579            "peer": {
3580                "external": {
3581                    "name": "external-worker",
3582                    "peer_id": meerkat_core::comms::PeerId::from_ed25519_pubkey(&[7u8; 32]).to_string(),
3583                    "address": "inproc://external-worker",
3584                    "pubkey": vec![7u8; 32]
3585                }
3586            }
3587        }))
3588        .expect_err("raw peer_id/pubkey external peer shape must be rejected");
3589
3590        let msg = err.to_string();
3591        assert!(
3592            msg.contains("peer_id") || msg.contains("identity"),
3593            "unexpected error: {msg}"
3594        );
3595    }
3596
3597    #[test]
3598    fn mob_wire_params_reject_missing_external_peer_pubkey_material() {
3599        let err = serde_json::from_value::<MobWireParams>(serde_json::json!({
3600            "mob_id": "mob-1",
3601            "member": "member-a",
3602            "peer": {
3603                "external": {
3604                    "name": "external-worker",
3605                    "address": "inproc://external-worker",
3606                    "identity": {
3607                        "kind": "ed25519_public_key"
3608                    }
3609                }
3610            }
3611        }))
3612        .expect_err("missing external peer pubkey material must fail closed");
3613
3614        let msg = err.to_string();
3615        assert!(
3616            msg.contains("public_key") || msg.contains("identity"),
3617            "unexpected error: {msg}"
3618        );
3619    }
3620
3621    #[test]
3622    fn runtime_binding_accepts_canonical_external_peer_identity() {
3623        let binding = serde_json::from_value::<WireRuntimeBinding>(serde_json::json!({
3624            "kind": "external",
3625            "address": "inproc://external-worker",
3626            "identity": {
3627                "kind": "ed25519_public_key",
3628                "public_key": "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
3629            }
3630        }))
3631        .expect("canonical external runtime binding identity should deserialize");
3632
3633        let WireRuntimeBinding::External {
3634            identity, address, ..
3635        } = binding
3636        else {
3637            panic!("expected external runtime binding");
3638        };
3639        assert_eq!(address, "inproc://external-worker");
3640        assert_eq!(
3641            identity.resolve().expect("identity resolves").pubkey,
3642            [7u8; 32]
3643        );
3644    }
3645
3646    #[test]
3647    fn runtime_binding_rejects_raw_external_peer_id_shape() {
3648        let err = serde_json::from_value::<WireRuntimeBinding>(serde_json::json!({
3649            "kind": "external",
3650            "peer_id": meerkat_core::comms::PeerId::from_ed25519_pubkey(&[7u8; 32]).to_string(),
3651            "address": "inproc://external-worker",
3652            "pubkey": vec![7u8; 32]
3653        }))
3654        .expect_err("raw peer_id/pubkey external runtime binding shape must be rejected");
3655
3656        let msg = err.to_string();
3657        assert!(
3658            msg.contains("peer_id") || msg.contains("identity"),
3659            "unexpected error: {msg}"
3660        );
3661    }
3662
3663    #[test]
3664    fn runtime_binding_rejects_missing_external_peer_pubkey_material() {
3665        let err = serde_json::from_value::<WireRuntimeBinding>(serde_json::json!({
3666            "kind": "external",
3667            "address": "inproc://external-worker",
3668            "identity": {
3669                "kind": "ed25519_public_key"
3670            }
3671        }))
3672        .expect_err("missing external runtime binding pubkey material must fail closed");
3673
3674        let msg = err.to_string();
3675        assert!(
3676            msg.contains("public_key") || msg.contains("identity"),
3677            "unexpected error: {msg}"
3678        );
3679    }
3680
3681    #[test]
3682    fn mob_turn_start_params_capture_turn_override_fields() {
3683        let params = serde_json::from_value::<MobTurnStartParams>(serde_json::json!({
3684            "mob_id": "mob-1",
3685            "agent_identity": "worker",
3686            "prompt": "continue",
3687            "output_schema": { "type": "object" },
3688            "structured_output_retries": 2
3689        }))
3690        .expect("turn_start should accept explicit turn override fields");
3691
3692        assert_eq!(params.mob_id, "mob-1");
3693        assert_eq!(params.agent_identity, "worker");
3694        assert_eq!(params.prompt, WireContentInput::Text("continue".into()));
3695        assert_eq!(
3696            params.output_schema,
3697            Some(serde_json::json!({ "type": "object" }))
3698        );
3699        assert_eq!(params.structured_output_retries, Some(2));
3700
3701        let err = serde_json::from_value::<MobTurnStartParams>(serde_json::json!({
3702            "mob_id": "mob-1",
3703            "agent_identity": "worker",
3704            "prompt": "continue",
3705            "unknown_override": true
3706        }))
3707        .expect_err("turn_start must reject unknown override fields");
3708        assert!(
3709            err.to_string().contains("unknown field"),
3710            "unexpected error: {err}"
3711        );
3712    }
3713
3714    #[test]
3715    fn mob_create_params_reject_reserved_runtime_lifecycle_fields() {
3716        let err = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3717            "definition": {
3718                "id": "mob-1",
3719                "owner_runtime_binding": "runtime:worker:0",
3720                "profiles": {
3721                    "worker": { "model": "claude-sonnet-4-6" }
3722                }
3723            }
3724        }))
3725        .expect_err("reserved runtime lifecycle fields must be rejected");
3726
3727        assert!(
3728            err.to_string()
3729                .contains("unknown field `owner_runtime_binding`"),
3730            "unexpected error: {err}"
3731        );
3732    }
3733
3734    #[test]
3735    fn mob_create_params_reject_reserved_runtime_bridge_owner_field() {
3736        let err = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3737            "definition": {
3738                "id": "mob-1",
3739                "owner_transport_binding": "transport:worker:0",
3740                "profiles": {
3741                    "worker": { "model": "claude-sonnet-4-6" }
3742                }
3743            }
3744        }))
3745        .expect_err("reserved runtime bridge owner field must be rejected");
3746
3747        assert!(
3748            err.to_string()
3749                .contains("unknown field `owner_transport_binding`"),
3750            "unexpected error: {err}"
3751        );
3752    }
3753
3754    #[test]
3755    fn mob_create_params_reject_internal_profile_tool_bundles() {
3756        let err = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3757            "definition": {
3758                "id": "mob-1",
3759                "profiles": {
3760                    "worker": {
3761                        "model": "claude-sonnet-4-6",
3762                        "tools": {
3763                            "rust_bundles": ["internal-only"]
3764                        }
3765                    }
3766                }
3767            }
3768        }))
3769        .expect_err("internal rust tool bundles must be rejected");
3770
3771        // With untagged MobProfileBindingInput, the error message is about
3772        // no variant matching rather than the specific unknown field.
3773        assert!(
3774            err.to_string().contains("did not match any variant")
3775                || err.to_string().contains("unknown field `rust_bundles`"),
3776            "unexpected error: {err}"
3777        );
3778    }
3779
3780    #[test]
3781    fn mob_create_params_accept_typed_nested_flow_definition() {
3782        let params = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3783            "definition": {
3784                "id": "mob-1",
3785                "profiles": {
3786                    "worker": { "model": "claude-sonnet-4-6" }
3787                },
3788                "flows": {
3789                    "review": {
3790                        "description": "review flow",
3791                        "steps": {
3792                            "draft": {
3793                                "role": "worker",
3794                                "message": "draft it"
3795                            }
3796                        }
3797                    }
3798                }
3799            }
3800        }))
3801        .expect("typed nested flow definition should parse");
3802
3803        assert_eq!(
3804            params.definition.flows["review"].steps["draft"].role,
3805            "worker"
3806        );
3807    }
3808
3809    /// DEC-1 absence pin: `budget_split_policy` was deleted (functionally
3810    /// unconsumed; accepted-then-discarded budget instructions are a
3811    /// fail-quiet containment lie). A payload still carrying it FAILS
3812    /// decode — replaces the old parity fixtures.
3813    #[test]
3814    fn mob_spawn_params_reject_deleted_budget_split_policy() {
3815        let err = serde_json::from_value::<MobSpawnParams>(serde_json::json!({
3816            "mob_id": "mob-1",
3817            "profile": "worker",
3818            "agent_identity": "worker-1",
3819            "budget_split_policy": { "type": "equal" }
3820        }))
3821        .expect_err("deleted budget_split_policy must fail closed at the wire boundary");
3822        assert!(
3823            err.to_string()
3824                .contains("unknown field `budget_split_policy`"),
3825            "unexpected error: {err}"
3826        );
3827    }
3828
3829    /// ADJ-7 pin: `placement` is an optional comms `PeerId` string; absent
3830    /// stays `None` (byte-compat with pre-placement payloads) and `None`
3831    /// never serializes.
3832    #[test]
3833    fn mob_spawn_params_placement_round_trips_and_defaults_absent() {
3834        let params = serde_json::from_value::<MobSpawnParams>(serde_json::json!({
3835            "mob_id": "mob-1",
3836            "profile": "worker",
3837            "agent_identity": "worker-1"
3838        }))
3839        .expect("placement-less params must decode");
3840        assert_eq!(params.placement, None);
3841        let encoded = serde_json::to_value(&params).expect("serialize params");
3842        assert!(
3843            encoded.get("placement").is_none(),
3844            "absent placement must not serialize: {encoded}"
3845        );
3846
3847        let params = serde_json::from_value::<MobSpawnParams>(serde_json::json!({
3848            "mob_id": "mob-1",
3849            "profile": "worker",
3850            "agent_identity": "worker-1",
3851            "placement": "host-peer-b"
3852        }))
3853        .expect("placed params must decode");
3854        assert_eq!(params.placement.as_deref(), Some("host-peer-b"));
3855        let encoded = serde_json::to_value(&params).expect("serialize params");
3856        assert_eq!(encoded["placement"], serde_json::json!("host-peer-b"));
3857    }
3858
3859    fn minimal_member_status() -> MobMemberStatusResult {
3860        MobMemberStatusResult {
3861            status: WireMobMemberStatus::Active,
3862            member_ref: WireMemberRef::encode("mob-1", "worker-1"),
3863            output_preview: None,
3864            error: None,
3865            tokens_used: 0,
3866            is_final: false,
3867            current_session_id: None,
3868            peer_connectivity: None,
3869            kickoff: None,
3870            external_member: None,
3871            resolved_capabilities: None,
3872            progress: None,
3873            activity: None,
3874            detached_jobs: None,
3875            placement: None,
3876            control_reachability: None,
3877            comms_reachability: None,
3878            last_seen_ms: None,
3879            freshness_reason: None,
3880            lifecycle_capabilities: None,
3881            non_portable_disabled: None,
3882        }
3883    }
3884
3885    /// Byte-compat with released SDKs: every multi-host field skips when
3886    /// `None`, and a pre-field JSON payload still decodes.
3887    #[test]
3888    fn member_status_multi_host_fields_skip_when_absent_and_decode_legacy() {
3889        let value = serde_json::to_value(minimal_member_status()).expect("serialize member status");
3890        for absent in [
3891            "placement",
3892            "control_reachability",
3893            "comms_reachability",
3894            "last_seen_ms",
3895            "freshness_reason",
3896            "lifecycle_capabilities",
3897            "non_portable_disabled",
3898        ] {
3899            assert!(
3900                value.get(absent).is_none(),
3901                "absent {absent} must be omitted from the wire form: {value}"
3902            );
3903        }
3904
3905        // Pre-multi-host payload (as a released SDK would emit) decodes.
3906        let legacy = serde_json::json!({
3907            "status": "active",
3908            "member_ref": WireMemberRef::encode("mob-1", "worker-1"),
3909            "tokens_used": 3,
3910            "is_final": false,
3911        });
3912        let decoded: MobMemberStatusResult =
3913            serde_json::from_value(legacy).expect("legacy member status decodes");
3914        assert!(decoded.placement.is_none());
3915        assert!(decoded.lifecycle_capabilities.is_none());
3916    }
3917
3918    /// SD-5 pin: placement facts live ONLY at the typed keys — never
3919    /// inside the opaque `external_member` value.
3920    #[test]
3921    fn member_status_carries_placement_only_at_typed_keys() {
3922        let mut status = minimal_member_status();
3923        status.placement = Some(WireHostRef("host-b-peer".to_string()));
3924        status.control_reachability = Some(WireReachability::Stale);
3925        status.comms_reachability = Some(WireReachability::Reachable);
3926        status.last_seen_ms = Some(1_234);
3927        status.freshness_reason = Some("pump idle".to_string());
3928        status.lifecycle_capabilities = Some(WireMemberLifecycleCapabilities {
3929            transcript_edits: false,
3930            revisions: false,
3931            resume_after_restart: true,
3932        });
3933        status.non_portable_disabled = Some(vec![
3934            super::super::portable_spec::WireNonPortableResourceKind::WorkgraphTools,
3935        ]);
3936        status.external_member = Some(serde_json::json!({"endpoint": "tcp://10.0.0.2:7101"}));
3937
3938        let value = serde_json::to_value(&status).expect("serialize member status");
3939        assert_eq!(value["placement"], serde_json::json!("host-b-peer"));
3940        assert_eq!(value["control_reachability"], serde_json::json!("stale"));
3941        assert_eq!(
3942            value["non_portable_disabled"],
3943            serde_json::json!(["workgraph_tools"])
3944        );
3945        assert!(
3946            value["external_member"].get("placement").is_none(),
3947            "placement must not ride the opaque external_member value (SD-5)"
3948        );
3949
3950        let decoded: MobMemberStatusResult =
3951            serde_json::from_value(value).expect("decode member status");
3952        assert_eq!(decoded.placement, status.placement);
3953        assert_eq!(decoded.control_reachability, status.control_reachability);
3954    }
3955
3956    #[test]
3957    fn control_scope_and_reachability_round_trip_snake_case() {
3958        let scopes: &[(WireControlScope, &str)] = &[
3959            (WireControlScope::List, "list"),
3960            (WireControlScope::ReadHistory, "read_history"),
3961            (WireControlScope::SubscribeEvents, "subscribe_events"),
3962            (WireControlScope::SendCommand, "send_command"),
3963            (WireControlScope::Cancel, "cancel"),
3964            (WireControlScope::Retire, "retire"),
3965            (WireControlScope::WireTopology, "wire_topology"),
3966            (WireControlScope::Live, "live"),
3967            (WireControlScope::AdminHost, "admin_host"),
3968            (WireControlScope::AdminGrants, "admin_grants"),
3969        ];
3970        for (scope, expected) in scopes {
3971            let value = serde_json::to_value(scope).expect("serialize scope");
3972            assert_eq!(value, serde_json::json!(expected));
3973            let decoded: WireControlScope = serde_json::from_value(value).expect("decode scope");
3974            assert_eq!(decoded, *scope);
3975        }
3976        assert!(
3977            serde_json::from_value::<WireControlScope>(serde_json::json!("admin")).is_err(),
3978            "unknown scopes must fail decode (closed vocabulary)"
3979        );
3980
3981        let classes: &[(WireReachability, &str)] = &[
3982            (WireReachability::Reachable, "reachable"),
3983            (WireReachability::Stale, "stale"),
3984            (WireReachability::Unreachable, "unreachable"),
3985            (WireReachability::Unknown, "unknown"),
3986        ];
3987        for (class, expected) in classes {
3988            let value = serde_json::to_value(class).expect("serialize reachability");
3989            assert_eq!(value, serde_json::json!(expected));
3990            let decoded: WireReachability =
3991                serde_json::from_value(value).expect("decode reachability");
3992            assert_eq!(decoded, *class);
3993        }
3994    }
3995
3996    #[test]
3997    fn scope_denied_detail_round_trips_snake_case_and_denies_unknown_fields() {
3998        let detail = WireScopeDeniedDetail {
3999            required: WireControlScope::AdminGrants,
4000            presented: vec![WireControlScope::List, WireControlScope::SendCommand],
4001        };
4002        let value = serde_json::to_value(&detail).expect("serialize detail");
4003        assert_eq!(
4004            value,
4005            serde_json::json!({
4006                "required": "admin_grants",
4007                "presented": ["list", "send_command"],
4008            })
4009        );
4010        let decoded: WireScopeDeniedDetail = serde_json::from_value(value).expect("decode detail");
4011        assert_eq!(decoded, detail);
4012
4013        assert!(
4014            serde_json::from_value::<WireScopeDeniedDetail>(serde_json::json!({
4015                "required": "admin_grants",
4016                "presented": [],
4017                "reason": "extra",
4018            }))
4019            .is_err(),
4020            "unknown fields must be rejected (deny_unknown_fields)"
4021        );
4022    }
4023
4024    #[test]
4025    fn host_status_and_grants_round_trip() {
4026        let host = MobHostStatus {
4027            host_id: WireHostRef("host-b-peer".to_string()),
4028            endpoint: Some("tcp://10.0.0.2:7100".to_string()),
4029            bind_phase: WireHostBindPhase::Bound,
4030            authority_epoch: Some(4),
4031            capabilities: Some(WireHostCapabilityFlags {
4032                protocol_min: 2,
4033                protocol_max: 4,
4034                engine_version: "0.7.22".to_string(),
4035                durable_sessions: true,
4036                autonomous_members: true,
4037                hard_cancel_member: false,
4038                tracked_input_cancel: false,
4039                memory_store: false,
4040                mcp: true,
4041                resolvable_providers: std::collections::BTreeSet::from(["anthropic".to_string()]),
4042                approval_forwarding: false,
4043                live_endpoint: None,
4044            }),
4045            control_reachability: Some(WireReachability::Reachable),
4046            last_seen_ms: Some(250),
4047            freshness_reason: None,
4048            materialized_member_count: 2,
4049        };
4050        let result = MobHostsResult { hosts: vec![host] };
4051        let value = serde_json::to_value(&result).expect("serialize hosts");
4052        assert_eq!(value["hosts"][0]["bind_phase"], serde_json::json!("bound"));
4053        let decoded: MobHostsResult = serde_json::from_value(value).expect("decode hosts");
4054        assert_eq!(decoded, result);
4055
4056        // A Requested-phase host commits nothing: the ceremony facts are
4057        // typed-absent, never fabricated empties.
4058        let requested = MobHostStatus {
4059            host_id: WireHostRef("host-c-peer".to_string()),
4060            endpoint: None,
4061            bind_phase: WireHostBindPhase::Requested,
4062            authority_epoch: None,
4063            capabilities: None,
4064            control_reachability: None,
4065            last_seen_ms: None,
4066            freshness_reason: None,
4067            materialized_member_count: 0,
4068        };
4069        let value = serde_json::to_value(&requested).expect("serialize requested host");
4070        assert_eq!(value["bind_phase"], serde_json::json!("requested"));
4071        assert!(value.get("endpoint").is_none());
4072        assert!(value.get("authority_epoch").is_none());
4073        assert!(value.get("capabilities").is_none());
4074        let decoded: MobHostStatus = serde_json::from_value(value).expect("decode requested host");
4075        assert_eq!(decoded, requested);
4076
4077        let record = WireGrantRecord {
4078            principal: "console:luka".to_string(),
4079            scopes: vec![WireControlScope::List, WireControlScope::Live],
4080            expires_at_ms: None,
4081        };
4082        let value = serde_json::to_value(&record).expect("serialize grant");
4083        assert!(
4084            value.get("expires_at_ms").is_none(),
4085            "absent expiry must be omitted"
4086        );
4087        let decoded: WireGrantRecord = serde_json::from_value(value).expect("decode grant");
4088        assert_eq!(decoded, record);
4089    }
4090
4091    #[test]
4092    fn member_history_result_round_trips_with_provenance() {
4093        let result = MobMemberHistoryResult {
4094            page: WireMemberHistoryPageBody {
4095                from_index: 5,
4096                messages: Vec::new(),
4097                message_count: 12,
4098                next_index: Some(10),
4099                complete: false,
4100            },
4101            generation: 2,
4102            placement: Some(WireHostRef("host-b-peer".to_string())),
4103            provenance: WireProjectionProvenance::HostClaimed,
4104        };
4105        let value = serde_json::to_value(&result).expect("serialize history result");
4106        assert_eq!(value["provenance"], serde_json::json!("host_claimed"));
4107        assert_eq!(value["page"]["message_count"], serde_json::json!(12));
4108        let decoded: MobMemberHistoryResult =
4109            serde_json::from_value(value.clone()).expect("decode history result");
4110        let reencoded = serde_json::to_value(&decoded).expect("reserialize history result");
4111        assert_eq!(value, reencoded);
4112    }
4113
4114    #[test]
4115    fn member_history_projection_rejects_non_advancing_page() {
4116        let page = meerkat_core::service::SessionHistoryPage {
4117            session_id: meerkat_core::SessionId::new(),
4118            message_count: 1,
4119            offset: 0,
4120            limit: Some(1),
4121            has_more: true,
4122            messages: Vec::new(),
4123        };
4124        let error = WireMemberHistoryPageBody::try_from_history_page(&page)
4125            .expect_err("a page that claims more rows must advance its cursor");
4126        assert!(matches!(
4127            error,
4128            crate::wire::error::WireConversionError::MemberHistoryPage { debug }
4129                if debug.contains("serves none")
4130        ));
4131    }
4132
4133    #[cfg(target_pointer_width = "64")]
4134    #[test]
4135    fn member_history_projection_rejects_exhausted_cursor() {
4136        let page = meerkat_core::service::SessionHistoryPage {
4137            session_id: meerkat_core::SessionId::new(),
4138            message_count: usize::MAX,
4139            offset: usize::MAX,
4140            limit: Some(2),
4141            has_more: true,
4142            messages: vec![
4143                meerkat_core::types::Message::User(meerkat_core::types::UserMessage::text("first")),
4144                meerkat_core::types::Message::User(meerkat_core::types::UserMessage::text(
4145                    "second",
4146                )),
4147            ],
4148        };
4149        let error = WireMemberHistoryPageBody::try_from_history_page(&page)
4150            .expect_err("MAX has no representable member-history successor");
4151        assert!(matches!(
4152            error,
4153            crate::wire::error::WireConversionError::MemberHistoryPage { debug }
4154                if debug.contains("exhausts the u64 cursor domain")
4155        ));
4156    }
4157
4158    /// T-A1 (DEC-P7A-2): the two phase-7 params additions round-trip, fail
4159    /// closed on unknown fields, and the live-status discovery read stays
4160    /// expressible (`channel_id` absent ⇒ `None`) while close keeps its
4161    /// required id.
4162    #[test]
4163    fn hard_cancel_params_round_trip_and_reject_unknown_fields() {
4164        let params = MobHardCancelParams {
4165            mob_id: "mob-1".to_string(),
4166            agent_identity: "worker".to_string(),
4167            reason: "operator interrupt".to_string(),
4168        };
4169        let value = serde_json::to_value(&params).expect("serialize hard-cancel params");
4170        let decoded: MobHardCancelParams =
4171            serde_json::from_value(value).expect("decode hard-cancel params");
4172        assert_eq!(decoded, params);
4173
4174        serde_json::from_value::<MobHardCancelParams>(serde_json::json!({
4175            "mob_id": "mob-1",
4176            "agent_identity": "worker",
4177            "reason": "x",
4178            "force": true,
4179        }))
4180        .expect_err("unknown field must be rejected");
4181
4182        // `reason` is required — the handle verb demands one, and a
4183        // handler-minted default would be handler-owned meaning.
4184        serde_json::from_value::<MobHardCancelParams>(serde_json::json!({
4185            "mob_id": "mob-1",
4186            "agent_identity": "worker",
4187        }))
4188        .expect_err("missing reason must be rejected");
4189
4190        let result = MobHardCancelResult { cancelled: true };
4191        let value = serde_json::to_value(&result).expect("serialize hard-cancel result");
4192        assert_eq!(value, serde_json::json!({ "cancelled": true }));
4193    }
4194
4195    #[test]
4196    fn member_live_status_params_keep_the_discovery_read() {
4197        // Absent channel_id parses to None — the ADJ-P6B-2 reply-loss
4198        // discovery primitive stays expressible on the wire.
4199        let discovery: MobMemberLiveStatusParams = serde_json::from_value(serde_json::json!({
4200            "mob_id": "mob-1",
4201            "agent_identity": "worker",
4202        }))
4203        .expect("discovery status params parse");
4204        assert_eq!(discovery.channel_id, None);
4205        let value = serde_json::to_value(&discovery).expect("serialize discovery params");
4206        assert!(
4207            value.get("channel_id").is_none(),
4208            "absent channel_id must be omitted"
4209        );
4210
4211        let named: MobMemberLiveStatusParams = serde_json::from_value(serde_json::json!({
4212            "mob_id": "mob-1",
4213            "agent_identity": "worker",
4214            "channel_id": "chan-7",
4215        }))
4216        .expect("named status params parse");
4217        assert_eq!(named.channel_id.as_deref(), Some("chan-7"));
4218
4219        serde_json::from_value::<MobMemberLiveStatusParams>(serde_json::json!({
4220            "mob_id": "mob-1",
4221            "agent_identity": "worker",
4222            "chan": "chan-7",
4223        }))
4224        .expect_err("unknown field must be rejected");
4225
4226        // Close-what-you-name (ADJ-P6B-15): close still REQUIRES the id.
4227        serde_json::from_value::<MobMemberLiveChannelParams>(serde_json::json!({
4228            "mob_id": "mob-1",
4229            "agent_identity": "worker",
4230        }))
4231        .expect_err("close without channel_id must be rejected");
4232    }
4233}