Skip to main content

meerkat_mobkit/
http_console.rs

1//! HTTP routes for the admin console REST API.
2
3use async_stream::stream;
4use axum::extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State};
5use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
6use axum::response::sse::{Event, KeepAlive, Sse};
7use axum::response::{IntoResponse, Redirect};
8use axum::routing::{get, post};
9use axum::{Json, Router};
10use base64::Engine;
11use futures::StreamExt;
12use futures::future::join_all;
13use meerkat_contracts::WireRuntimeBinding;
14use meerkat_core::ContentInput;
15use meerkat_core::comms::TrustedPeerDescriptor;
16use meerkat_core::event::agent_event_type;
17use meerkat_mob::MobState;
18use meerkat_mob::ids::{AgentIdentity, MobId};
19use meerkat_mob::launch::MemberLaunchMode;
20use meerkat_mob::runtime::reconcile::MemberFilter;
21use meerkat_mob::{
22    MobBackendKind, MobHandle, MobRuntimeMode, PeerTarget, ProfileName, SpawnMemberSpec,
23};
24
25use crate::mob_handle_runtime::{
26    is_recoverable_lifecycle_cleanup_error, member_entry_to_json,
27    model_capabilities_for_member_entry, model_capabilities_for_role, resolved_tools_for_member,
28    resolved_tools_for_session, topology_restore_failed_peer_ids, topology_restore_warning_json,
29};
30use serde_json::{Value, json};
31use std::collections::{BTreeMap, BTreeSet};
32use std::convert::Infallible;
33use std::sync::Arc;
34use std::time::{Duration, Instant};
35
36use crate::access::{
37    ACCESS_ACTIONS, ACTION_AGENT_MEMORY_DELETE, ACTION_AGENT_MEMORY_READ,
38    ACTION_AGENT_MEMORY_WRITE, ACTION_AGENT_RESET, ACTION_AGENT_RESPAWN, ACTION_AGENT_RETIRE,
39    ACTION_AGENT_SEND, ACTION_AGENT_SPAWN, ACTION_AGENT_VIEW, ACTION_GATING_DECIDE,
40    ACTION_GATING_VIEW, ACTION_MEMORY_QUARANTINE_REVIEW, ACTION_MOB_MEMORY_READ,
41    ACTION_MOB_OBSERVE, ACTION_OPERATOR_MEMORY_READ, ACTION_RUNTIME_ADMIN, AccessController,
42    AccessGroup, AccessResource, AccessRule, AccessView, AgentResourceAttributes,
43};
44use crate::blob_store::{BinaryBlobPayload, BinaryBlobStore, is_valid_blob_id_value};
45use crate::console_aggregator::{
46    ConsoleCursor, ConsoleFrame, ConsoleIdentityRecord, ConsoleLogError, ConsoleLogResult,
47    ConsoleLogStore, ConsoleReplayUnavailable, ConsoleSendError, ConsoleSendRequest,
48    ConsoleTimelineEvent, ConsoleTimelineMode, ConsoleTimelineQuery, ConsoleTimelineWindowPage,
49    ConsoleTimelineWindowQuery, ConsoleVisibility, ConsoleVisibilityPolicy,
50    HideImplicitDelegateMembersConsoleVisibilityPolicy, MobKitConsoleAggregator,
51};
52use crate::contact_directory::ContactDirectory;
53use crate::http_sse::{DEFAULT_KEEP_ALIVE_INTERVAL, KEEP_ALIVE_TEXT};
54use crate::mob_handle_runtime::{
55    MEMBER_STATE_ACTIVE, MEMBER_STATE_RETIRING, MobRuntime, member_status_state_string,
56};
57use crate::rpc::memory_methods::{
58    parse_agent_memory_forget_params, parse_agent_memory_manifest_params,
59    parse_agent_memory_recall_params, parse_agent_memory_remember_params,
60    parse_agent_memory_update_params,
61};
62use crate::rpc::{JSONRPC_VERSION, JsonRpcError, JsonRpcRequest, JsonRpcResponse};
63use crate::runtime::MobkitRuntimeHandle;
64use crate::runtime::{
65    ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember, ConsoleModelCapabilities,
66    ConsoleRestJsonRequest, DeliveryHistoryRequest, GatingDecideRequest, GatingDecision,
67    RuntimeDecisionState, extract_bearer_token_from_header,
68    handle_console_rest_json_route_with_snapshot_access_and_memory,
69    resolve_authorized_console_auth_from_token,
70};
71use crate::runtime::{MetadataScope, RuntimeMetadataTable, labels_to_json_value};
72use crate::types::{EventEnvelope, UnifiedEvent};
73use crate::unified_runtime::console_events::ConsoleEventStore;
74use crate::unified_runtime::mob_events::MobEventsStore;
75use crate::unified_runtime::{EventLogStore, EventQuery};
76
77#[derive(Clone)]
78pub struct ConsoleJsonState {
79    pub decisions: RuntimeDecisionState,
80    pub runtime: Option<MobRuntime>,
81    pub module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
82    pub contact_directory: Option<ContactDirectory>,
83    pub event_log: Option<std::sync::Arc<dyn EventLogStore>>,
84    /// Local gateway signing identity. Plumbed in so the console RPC
85    /// dispatch can answer `mobkit/peer_pubkey` and stamp non-inproc
86    /// `cross_mob/wire_local` descriptors with a real pubkey.
87    pub gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
88    pub(crate) identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
89    pub(crate) console_events: Option<ConsoleEventStore>,
90    pub(crate) console_aggregator: Option<MobKitConsoleAggregator>,
91    pub(crate) mob_events: Option<MobEventsStore>,
92    pub(crate) metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
93    pub(crate) visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
94    pub(crate) snapshot_read_model: ConsoleSnapshotReadModel,
95    /// Optional ABAC enforcement. `None` (or a disabled config) keeps every
96    /// console surface byte-for-byte compatible with the pre-access world.
97    pub(crate) access: Option<AccessController>,
98    /// Optional bundled-store handle for the console Memory panel's
99    /// read-only `mobkit/memory/panel/*` RPCs (§9.3). `None` (markdown
100    /// store, no memory configured) leaves those methods unadvertised.
101    pub(crate) memory_panel: Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>,
102}
103
104#[derive(Debug, Clone)]
105struct ConsoleHttpAuthContext {
106    principal: Option<String>,
107    /// Per-request access snapshot when an [`AccessController`] is wired.
108    access_view: Option<AccessView>,
109}
110
111#[derive(Clone, Default)]
112pub(crate) struct ConsoleSnapshotReadModel {
113    inner: Arc<tokio::sync::RwLock<ConsoleSnapshotReadModelState>>,
114    /// Mutex held by whichever task is currently running a refresh.
115    /// Background refreshes (from `refresh_soon`) skip when the lock
116    /// is contended; cold-cache request waiters acquire it via
117    /// `lock_owned().await`, which is the actual "the in-flight
118    /// refresh has finished" signal. See `prime_now`.
119    refresh_lock: Arc<tokio::sync::Mutex<()>>,
120    /// `true` once at least one refresh has populated `inner` with
121    /// real data. Snapshot reads gate on this so a cold cache never
122    /// returns an empty member list to the first request.
123    primed: Arc<std::sync::atomic::AtomicBool>,
124}
125
126#[derive(Clone, Default)]
127struct ConsoleSnapshotReadModelState {
128    running: Option<bool>,
129    session_id_by_identity: BTreeMap<String, String>,
130    session_owner_by_id: BTreeMap<String, String>,
131    /// Pre-projected primary-mob console members. The background refresh
132    /// populates this from `handle.list_all_members()` + projection; the
133    /// snapshot hot path just clones from here so it never touches
134    /// `MobHandle` async methods.
135    primary_members: Vec<ConsoleMember>,
136    /// Pre-projected delegate-mob member groups, one Vec per delegate mob,
137    /// each already carrying its host_identity / source_mob_id label
138    /// fixups. The snapshot hot path extends `members` with these instead
139    /// of walking delegate handles per-request.
140    delegate_member_groups: Vec<Vec<ConsoleMember>>,
141}
142
143impl ConsoleSnapshotReadModel {
144    /// Returns the current cached snapshot. On a cold cache (no
145    /// refresh has completed yet) the request thread drives the
146    /// first refresh inline — or, if a background refresh task
147    /// holds the lock, waits for it to finish before reading.
148    /// Either way, snapshot endpoints never see an empty member
149    /// list before the read model has been populated.
150    async fn snapshot(&self, runtime: &MobRuntime) -> ConsoleSnapshotReadModelState {
151        if !self.primed.load(std::sync::atomic::Ordering::Acquire) {
152            Box::pin(self.prime_now(runtime)).await;
153        }
154        self.inner.read().await.clone()
155    }
156
157    /// Cold-cache priming. Acquires `refresh_lock` via the awaiting
158    /// (FIFO) path:
159    ///
160    /// - If no refresh task currently holds the lock, we acquire
161    ///   it immediately and run the refresh inline. Subsequent
162    ///   waiters that come in while we're running will queue
163    ///   behind us in the same lock.
164    /// - If a refresh task (spawned by `refresh_soon`) holds the
165    ///   lock, our `lock_owned().await` parks until the task
166    ///   drops the guard. By construction, the task only drops
167    ///   the guard *after* writing `inner` and setting `primed`.
168    ///   So when we acquire the lock, the cache is already
169    ///   populated and the second `primed` check returns early
170    ///   without redoing the work.
171    ///
172    /// No `Notify` is involved, so there's no lost-wake race to
173    /// reason about: the lock release is the signal, and `tokio`'s
174    /// Mutex enforces FIFO acquisition fairness so a `try_lock`
175    /// caller can't barge past a queued `lock_owned` waiter.
176    async fn prime_now(&self, runtime: &MobRuntime) {
177        if self.primed.load(std::sync::atomic::Ordering::Acquire) {
178            return;
179        }
180        let _guard = self.refresh_lock.clone().lock_owned().await;
181        if self.primed.load(std::sync::atomic::Ordering::Acquire) {
182            return;
183        }
184        let refreshed = Box::pin(collect_console_snapshot_read_model(runtime)).await;
185        *self.inner.write().await = refreshed;
186        self.primed
187            .store(true, std::sync::atomic::Ordering::Release);
188        // _guard drops here, releasing the lock and waking the next
189        // queued cold-cache waiter (if any). They'll see `primed`
190        // true after acquiring and return early.
191    }
192
193    /// Fire-and-forget background refresh. If a refresh is already
194    /// in flight (lock contended) we skip — the in-flight one is
195    /// enough. The request hot path doesn't call this; it goes
196    /// through `prime_now` on cold cache so it always gets a
197    /// populated snapshot. `refresh_soon` exists to keep a hot
198    /// cache fresh over time without blocking response requests.
199    fn refresh_soon(&self, runtime: MobRuntime) {
200        let Ok(runtime_handle) = tokio::runtime::Handle::try_current() else {
201            return;
202        };
203        let Ok(guard) = self.refresh_lock.clone().try_lock_owned() else {
204            return;
205        };
206        let inner = Arc::clone(&self.inner);
207        let primed = Arc::clone(&self.primed);
208        runtime_handle.spawn(async move {
209            let _guard = guard;
210            let refreshed = Box::pin(collect_console_snapshot_read_model(&runtime)).await;
211            *inner.write().await = refreshed;
212            primed.store(true, std::sync::atomic::Ordering::Release);
213            // _guard drops; cold-cache waiters parked on
214            // `lock_owned().await` in `prime_now` wake here and
215            // observe `primed = true` after acquiring.
216        });
217    }
218}
219
220const CONSOLE_FRONTEND_INDEX_HTML: &str = include_str!("../console-dist/index.html");
221const CONSOLE_FRONTEND_APP_JS: &str = include_str!("../console-dist/console-app.js");
222const CONSOLE_FRONTEND_APP_CSS: &str = include_str!("../console-dist/console-app.css");
223const MAX_MULTIPART_IMAGE_BYTES: usize = 25 * 1024 * 1024;
224const MAX_MULTIPART_IMAGES: usize = 4;
225const MAX_MULTIPART_BODY_BYTES: usize =
226    (MAX_MULTIPART_IMAGE_BYTES * MAX_MULTIPART_IMAGES) + 1024 * 1024;
227
228pub fn console_json_router(decisions: RuntimeDecisionState) -> Router {
229    console_json_router_with_state(ConsoleJsonState {
230        decisions,
231        runtime: None,
232        module_runtime: None,
233        contact_directory: None,
234        event_log: None,
235        gateway_peer_keys: None,
236        identity_runtime: None,
237        console_events: None,
238        console_aggregator: None,
239        mob_events: None,
240        metadata_table: None,
241        visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
242        snapshot_read_model: ConsoleSnapshotReadModel::default(),
243        access: None,
244        memory_panel: None,
245    })
246}
247
248pub fn console_json_router_with_aggregator(
249    decisions: RuntimeDecisionState,
250    console_aggregator: MobKitConsoleAggregator,
251) -> Router {
252    console_json_router_with_aggregator_and_access(decisions, console_aggregator, None)
253}
254
255pub fn console_json_router_with_aggregator_and_access(
256    decisions: RuntimeDecisionState,
257    console_aggregator: MobKitConsoleAggregator,
258    access: Option<AccessController>,
259) -> Router {
260    console_json_router_with_state(ConsoleJsonState {
261        decisions,
262        runtime: None,
263        module_runtime: None,
264        contact_directory: None,
265        event_log: None,
266        gateway_peer_keys: None,
267        identity_runtime: None,
268        console_events: None,
269        console_aggregator: Some(console_aggregator),
270        mob_events: None,
271        metadata_table: None,
272        visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
273        snapshot_read_model: ConsoleSnapshotReadModel::default(),
274        access,
275        memory_panel: None,
276    })
277}
278
279pub fn console_json_router_with_runtime(
280    decisions: RuntimeDecisionState,
281    runtime: MobRuntime,
282    contact_directory: Option<ContactDirectory>,
283    event_log: Option<std::sync::Arc<dyn EventLogStore>>,
284) -> Router {
285    console_json_router_with_runtime_and_events(
286        decisions,
287        runtime,
288        None,
289        contact_directory,
290        event_log,
291        None,
292        None,
293        None,
294        None,
295        None,
296        None,
297    )
298}
299
300#[allow(clippy::too_many_arguments)]
301pub(crate) fn console_json_router_with_runtime_and_events(
302    decisions: RuntimeDecisionState,
303    runtime: MobRuntime,
304    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
305    contact_directory: Option<ContactDirectory>,
306    event_log: Option<std::sync::Arc<dyn EventLogStore>>,
307    gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
308    console_events: Option<ConsoleEventStore>,
309    console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
310    mob_events: Option<MobEventsStore>,
311    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
312    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
313) -> Router {
314    console_json_router_with_runtime_events_and_policy(
315        decisions,
316        runtime,
317        module_runtime,
318        contact_directory,
319        event_log,
320        gateway_peer_keys,
321        console_events,
322        console_log_store,
323        mob_events,
324        metadata_table,
325        identity_runtime,
326        Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
327        None,
328        None,
329    )
330}
331
332#[allow(clippy::too_many_arguments)]
333pub(crate) fn console_json_router_with_runtime_events_and_policy(
334    decisions: RuntimeDecisionState,
335    runtime: MobRuntime,
336    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
337    contact_directory: Option<ContactDirectory>,
338    event_log: Option<std::sync::Arc<dyn EventLogStore>>,
339    gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
340    console_events: Option<ConsoleEventStore>,
341    console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
342    mob_events: Option<MobEventsStore>,
343    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
344    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
345    visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
346    access: Option<AccessController>,
347    memory_panel: Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>,
348) -> Router {
349    let console_aggregator = console_events.clone().map(|events| {
350        if let Some(store) = console_log_store {
351            let aggregator = MobKitConsoleAggregator::new(store);
352            aggregator.register_runtime_handles_with_policy(
353                "default",
354                "",
355                runtime.clone(),
356                identity_runtime.clone(),
357                events,
358                visibility_policy.clone(),
359            );
360            aggregator
361        } else {
362            let aggregator = MobKitConsoleAggregator::in_memory();
363            aggregator.register_runtime_handles_with_policy(
364                "default",
365                "",
366                runtime.clone(),
367                identity_runtime.clone(),
368                events,
369                visibility_policy.clone(),
370            );
371            aggregator
372        }
373    });
374    let snapshot_read_model = ConsoleSnapshotReadModel::default();
375    snapshot_read_model.refresh_soon(runtime.clone());
376    console_json_router_with_state(ConsoleJsonState {
377        decisions,
378        runtime: Some(runtime),
379        module_runtime,
380        contact_directory,
381        event_log,
382        gateway_peer_keys,
383        identity_runtime,
384        console_events,
385        console_aggregator,
386        mob_events,
387        metadata_table,
388        visibility_policy,
389        snapshot_read_model,
390        access,
391        memory_panel,
392    })
393}
394
395pub fn console_frontend_router() -> Router {
396    Router::new()
397        .route("/", get(|| async { Redirect::temporary("/console") }))
398        .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
399        .route("/console", get(console_frontend_index_handler))
400        .route("/console/", get(console_frontend_index_handler))
401        .route(
402            "/console/assets/console-app.js",
403            get(console_frontend_app_js_handler),
404        )
405        .route(
406            "/console/assets/console-app.css",
407            get(console_frontend_app_css_handler),
408        )
409}
410
411fn console_json_router_with_state(state: ConsoleJsonState) -> Router {
412    let router = Router::new()
413        .route("/console/experience", get(console_json_handler))
414        .route("/console/modules", get(console_json_handler))
415        .route("/console/identities", get(console_identities_handler))
416        .route("/console/timeline", get(console_timeline_handler))
417        .route(
418            "/console/timeline/stream",
419            get(console_timeline_stream_handler),
420        )
421        .route(
422            "/console/identity/{identity}/stream",
423            get(console_identity_timeline_stream_handler),
424        )
425        .route("/console/send", post(console_send_handler))
426        .route("/console/rpc", post(console_rpc_handler))
427        .route(
428            "/console/rpc/multipart",
429            post(console_rpc_multipart_handler)
430                .layer(DefaultBodyLimit::max(MAX_MULTIPART_BODY_BYTES)),
431        )
432        .route("/blobs/{blob_id}", get(blob_get_handler));
433    router.with_state(state)
434}
435
436pub async fn console_json_handler(
437    State(state): State<ConsoleJsonState>,
438    headers: HeaderMap,
439    uri: Uri,
440) -> impl IntoResponse {
441    let mut path = uri
442        .path_and_query()
443        .map(|path_and_query| path_and_query.as_str().to_string())
444        .unwrap_or_else(|| uri.path().to_string());
445
446    // If the request carries a Bearer token and the URL doesn't already have
447    // an auth_token query param, inject it so the console-ingress auth
448    // resolver can validate it through the existing query-param path.
449    //
450    // JWT tokens use base64url characters (A-Za-z0-9_-.) plus optional '='
451    // padding. We percent-encode the bearer when injecting so opaque
452    // bearer tokens containing `&`, `=`, `+`, `%`, etc. (legal under
453    // RFC 6750 §2.1) survive the round trip — pre-fix, an `&` made
454    // injection skip and authentication fail. Substring detection of
455    // an existing `auth_token=` is now key-aware via form_urlencoded
456    // so `xauth_token=` doesn't masquerade as the real key.
457    let already_has_token = path
458        .split_once('?')
459        .map(|(_, q)| form_urlencoded::parse(q.as_bytes()).any(|(key, _)| key == "auth_token"))
460        .unwrap_or(false);
461    if !already_has_token
462        && let Some(bearer) = headers
463            .get(header::AUTHORIZATION)
464            .and_then(|v| v.to_str().ok())
465            .and_then(extract_bearer_token_from_header)
466    {
467        let encoded: String = form_urlencoded::byte_serialize(bearer.as_bytes()).collect();
468        let sep = if path.contains('?') { '&' } else { '?' };
469        path = format!("{path}{sep}auth_token={encoded}");
470    }
471
472    let config_module_ids: Vec<String> = state
473        .decisions
474        .modules
475        .iter()
476        .map(|m| m.id.clone())
477        .collect();
478    let live_snapshot = match &state.runtime {
479        Some(runtime) => {
480            state.snapshot_read_model.refresh_soon(runtime.clone());
481            Some(
482                Box::pin(build_live_snapshot(
483                    runtime,
484                    &config_module_ids,
485                    state.console_events.as_ref(),
486                    state.visibility_policy.as_ref(),
487                    &state.snapshot_read_model,
488                ))
489                .await,
490            )
491        }
492        None => match &state.console_aggregator {
493            Some(aggregator) => Box::pin(build_aggregator_live_snapshot(
494                aggregator,
495                &config_module_ids,
496            ))
497            .await
498            .ok(),
499            None => None,
500        },
501    }
502    .map(|mut snapshot| {
503        apply_console_visibility_policy(&mut snapshot, state.visibility_policy.as_ref());
504        snapshot
505    });
506
507    let response = handle_console_rest_json_route_with_snapshot_access_and_memory(
508        &state.decisions,
509        &ConsoleRestJsonRequest {
510            method: "GET".to_string(),
511            path,
512            auth: None,
513        },
514        live_snapshot.as_ref(),
515        state.access.as_ref(),
516        state.memory_panel.is_some(),
517    );
518    let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
519    (status, Json::<Value>(response.body))
520}
521
522pub async fn console_rpc_handler(
523    State(state): State<ConsoleJsonState>,
524    headers: HeaderMap,
525    uri: Uri,
526    Json(request): Json<Value>,
527) -> impl IntoResponse {
528    // Parse the request early so we can check the method for auth gating.
529    let parsed_request = match serde_json::from_value::<JsonRpcRequest>(request) {
530        Ok(req) => req,
531        Err(_) => {
532            return (
533                StatusCode::OK,
534                Json::<Value>(serde_json::json!({
535                    "jsonrpc": JSONRPC_VERSION,
536                    "id": Value::Null,
537                    "error": { "code": -32600, "message": "Invalid Request" }
538                })),
539            );
540        }
541    };
542
543    // Auth enforcement:
544    // - When require_app_auth is true: validate bearer token (OIDC + allowlist)
545    // - When require_app_auth is false: allow requests without a token.
546    // - When console.read_only is true: deny mutating methods even for
547    //   otherwise authorized callers.
548    let auth_context = match console_request_auth_context(&state, &headers, &uri) {
549        Some(context) => context,
550        None => {
551            return (
552                StatusCode::UNAUTHORIZED,
553                Json::<Value>(serde_json::json!({
554                    "jsonrpc": JSONRPC_VERSION,
555                    "id": parsed_request.id.unwrap_or(Value::Null),
556                    "error": {
557                        "code": -32600,
558                        "message": "unauthorized: console rpc requires a valid auth token",
559                    }
560                })),
561            );
562        }
563    };
564    // By this point the request is always authorized:
565    // - require_app_auth=true: an invalid token already returned 401 above.
566    // - require_app_auth=false: all methods are permitted unconditionally.
567    // Mutating methods may still be blocked by console.read_only.
568    let is_authenticated = true;
569    let read_only = state.decisions.console.read_only;
570    let Some(runtime) = &state.runtime else {
571        let response_value = Box::pin(handle_console_aggregator_rpc(
572            state.console_aggregator.clone(),
573            parsed_request,
574            is_authenticated,
575            read_only,
576            state.access.as_ref(),
577            auth_context.access_view.as_ref(),
578        ))
579        .await;
580        return (StatusCode::OK, Json::<Value>(response_value));
581    };
582
583    let response_value = Box::pin(handle_console_runtime_rpc_with_visibility(
584        runtime,
585        state.module_runtime.clone(),
586        state.contact_directory.as_ref(),
587        state.gateway_peer_keys.as_ref(),
588        state.console_events.clone(),
589        state.console_aggregator.clone(),
590        state.identity_runtime.clone(),
591        state.metadata_table.clone(),
592        state.mob_events.clone(),
593        state.visibility_policy.as_ref(),
594        parsed_request,
595        is_authenticated,
596        read_only,
597        auth_context.principal.as_deref(),
598        state.access.as_ref(),
599        auth_context.access_view.as_ref(),
600        state.memory_panel.as_ref(),
601    ))
602    .await;
603    (StatusCode::OK, Json::<Value>(response_value))
604}
605
606#[derive(Debug, serde::Deserialize)]
607struct ConsoleTimelineHttpQuery {
608    #[serde(default)]
609    identity: Option<String>,
610    #[serde(default)]
611    conversation_id: Option<String>,
612    #[serde(default)]
613    after: Option<String>,
614    #[serde(default)]
615    before: Option<String>,
616    #[serde(default)]
617    mode: Option<ConsoleTimelineMode>,
618    #[serde(default)]
619    limit: Option<usize>,
620}
621
622async fn console_identities_handler(
623    State(state): State<ConsoleJsonState>,
624    headers: HeaderMap,
625    uri: Uri,
626) -> impl IntoResponse {
627    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
628        return console_json_error(
629            StatusCode::UNAUTHORIZED,
630            "unauthorized",
631            "console identities require a valid auth token",
632        );
633    };
634    let Some(aggregator) = &state.console_aggregator else {
635        return console_json_error(
636            StatusCode::NOT_FOUND,
637            "unavailable",
638            "console aggregator unavailable",
639        );
640    };
641    let aggregator = aggregator.clone();
642    match Box::pin(aggregator.list_identities()).await {
643        Ok(mut identities) => {
644            retain_visible_identity_records(&mut identities, auth_context.access_view.as_ref());
645            (
646                StatusCode::OK,
647                Json::<Value>(json!({ "identities": identities })),
648            )
649                .into_response()
650        }
651        Err(err) => {
652            tracing::warn!(target: "mobkit::console", error = %err, "console identities request failed");
653            console_json_error(
654                StatusCode::INTERNAL_SERVER_ERROR,
655                "internal_error",
656                "console identities unavailable",
657            )
658        }
659    }
660}
661
662async fn console_timeline_handler(
663    State(state): State<ConsoleJsonState>,
664    headers: HeaderMap,
665    uri: Uri,
666    Query(query): Query<ConsoleTimelineHttpQuery>,
667) -> impl IntoResponse {
668    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
669        return console_json_error(
670            StatusCode::UNAUTHORIZED,
671            "unauthorized",
672            "console timeline requires a valid auth token",
673        );
674    };
675    let Some(aggregator) = &state.console_aggregator else {
676        return console_json_error(
677            StatusCode::NOT_FOUND,
678            "unavailable",
679            "console aggregator unavailable",
680        );
681    };
682    // Prime the attribute cache from the live roster so label/role rules
683    // resolve when filtering frames by `agent.view` — the timeline REST
684    // surface must not depend on a prior `/console/experience` call.
685    if let Some(runtime) = &state.runtime
686        && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
687    {
688        prime_access_cache_from_runtime(runtime, controller).await;
689    }
690    let timeline_query = timeline_query_from_http(query, None);
691    match Box::pin(aggregator.query_timeline_windowed(timeline_query)).await {
692        Ok(mut page) => {
693            if let Some(view) = auth_context.access_view.as_ref() {
694                page.frames
695                    .retain(|frame| view.can_view_agent(frame.identity.as_str()));
696            }
697            (
698                StatusCode::OK,
699                Json::<Value>(
700                    serde_json::to_value(page).unwrap_or_else(|_| json!({ "frames": [] })),
701                ),
702            )
703                .into_response()
704        }
705        Err(err) => {
706            console_json_error(StatusCode::CONFLICT, "replay_unavailable", &err.to_string())
707        }
708    }
709}
710
711async fn console_send_handler(
712    State(state): State<ConsoleJsonState>,
713    headers: HeaderMap,
714    uri: Uri,
715    Json(request): Json<ConsoleSendRequest>,
716) -> impl IntoResponse {
717    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
718        return console_json_error(
719            StatusCode::UNAUTHORIZED,
720            "unauthorized",
721            "console send requires a valid auth token",
722        );
723    };
724    if state.decisions.console.read_only {
725        return console_json_error(StatusCode::FORBIDDEN, "read_only", "console is read-only");
726    }
727    // Prime the attribute cache from the live roster BEFORE the `agent.send`
728    // decision: role/label-scoped rules resolve through the cache, and on a
729    // cold cache the decision degrades to a bare-identity resource — a deny
730    // rule scoped by `roles`/`match_labels` would not match, so a scripted
731    // caller hitting `/console/send` as its first request could reach a
732    // member the rule was meant to exclude (fail-closed requires the
733    // attributes to be present when the rule is evaluated).
734    if let Some(runtime) = &state.runtime
735        && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
736    {
737        prime_access_cache_from_runtime(runtime, controller).await;
738    }
739    if let Some(view) = auth_context.access_view.as_ref()
740        && view.enforced()
741        && !view.allows_agent(ACTION_AGENT_SEND, request.identity.as_str())
742    {
743        return console_json_error(
744            StatusCode::FORBIDDEN,
745            "access_denied",
746            "you are not allowed to send to this agent",
747        );
748    }
749    let Some(aggregator) = &state.console_aggregator else {
750        return console_json_error(
751            StatusCode::NOT_FOUND,
752            "unavailable",
753            "console aggregator unavailable",
754        );
755    };
756    if let Some(identity_runtime) = &state.identity_runtime {
757        return match Box::pin(console_send_with_identity_first_fallback(
758            aggregator,
759            identity_runtime.clone(),
760            state.runtime.as_ref(),
761            state.console_events.as_ref(),
762            request,
763        ))
764        .await
765        {
766            Ok(accepted) => (
767                StatusCode::OK,
768                Json::<Value>(
769                    serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
770                ),
771            )
772                .into_response(),
773            Err(err) => console_send_error_response(err),
774        };
775    }
776    match Box::pin(aggregator.send(request)).await {
777        Ok(accepted) => (
778            StatusCode::OK,
779            Json::<Value>(
780                serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
781            ),
782        )
783            .into_response(),
784        Err(err) => console_send_error_response(err),
785    }
786}
787
788async fn console_send_with_identity_first_fallback(
789    aggregator: &MobKitConsoleAggregator,
790    identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
791    runtime: Option<&MobRuntime>,
792    console_events: Option<&ConsoleEventStore>,
793    request: ConsoleSendRequest,
794) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
795    let member_send_request = request.clone();
796    match Box::pin(console_send_identity_first(
797        aggregator,
798        identity_runtime,
799        runtime,
800        console_events,
801        request,
802    ))
803    .await
804    {
805        Err(ConsoleSendError::UnknownIdentity(_)) => {
806            Box::pin(aggregator.send(member_send_request)).await
807        }
808        result => result,
809    }
810}
811
812async fn console_send_identity_first(
813    aggregator: &MobKitConsoleAggregator,
814    identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
815    runtime: Option<&MobRuntime>,
816    console_events: Option<&ConsoleEventStore>,
817    mut request: ConsoleSendRequest,
818) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
819    let requested_identity = request.identity.clone();
820    let parsed_identity = crate::identity_first::AgentIdentity::parse(request.identity.as_str())
821        .map_err(|err| ConsoleSendError::InvalidRequest(format!("invalid identity: {err}")))?;
822    let content: ContentInput = serde_json::from_value(request.content.clone())
823        .map_err(|err| ConsoleSendError::InvalidContent(err.to_string()))?;
824    if let ContentInput::Text(text) = &content
825        && text.trim().is_empty()
826    {
827        return Err(ConsoleSendError::InvalidContent(
828            "content must be non-empty".to_string(),
829        ));
830    }
831    if let ContentInput::Blocks(blocks) = &content
832        && blocks.is_empty()
833    {
834        return Err(ConsoleSendError::InvalidContent(
835            "content blocks must be non-empty".to_string(),
836        ));
837    }
838    let handling_mode = parse_identity_first_handling_mode(request.handling_mode.as_deref())?;
839
840    let (identity, status) = match identity_runtime.status(&parsed_identity).await {
841        Ok(status) => (parsed_identity, status),
842        Err(original_err) => {
843            let Some(canonical_identity) = Box::pin(resolve_console_send_identity_alias(
844                aggregator,
845                &requested_identity,
846            ))
847            .await
848            else {
849                return Err(identity_runtime_error_to_console_send_error(
850                    requested_identity.as_str(),
851                    original_err,
852                ));
853            };
854            let identity = crate::identity_first::AgentIdentity::parse(canonical_identity.as_str())
855                .map_err(|err| {
856                    ConsoleSendError::InvalidRequest(format!("invalid aliased identity: {err}"))
857                })?;
858            let status = identity_runtime.status(&identity).await.map_err(|_| {
859                identity_runtime_error_to_console_send_error(
860                    requested_identity.as_str(),
861                    original_err,
862                )
863            })?;
864            request.identity = canonical_identity;
865            (identity, status)
866        }
867    };
868    let session_id = status
869        .session_id
870        .as_ref()
871        .map(std::string::ToString::to_string);
872    let runtime_member_id = status
873        .agent_runtime_id
874        .as_ref()
875        .map(|id| id.as_str().to_string());
876    let accepted = Box::pin(
877        aggregator.reserve_identity_first_interaction(request.clone(), session_id.as_deref()),
878    )
879    .await?;
880
881    if let Some(events) = console_events {
882        events
883            .reserve_interaction_value(
884                identity.as_str(),
885                runtime_member_id.as_deref(),
886                &accepted.interaction_id,
887                &request.origin,
888                request.content.clone(),
889            )
890            .await
891            .map_err(ConsoleSendError::State)?;
892    }
893
894    if let (Some(runtime), Some(events), Some(runtime_member_id)) = (
895        runtime,
896        console_events.cloned(),
897        runtime_member_id.as_deref(),
898    ) {
899        start_identity_first_console_live_projection(
900            runtime,
901            events,
902            identity.as_str(),
903            runtime_member_id,
904        )
905        .await;
906    }
907
908    if handling_mode == meerkat_core::types::HandlingMode::Steer {
909        match identity_runtime
910            .send_with_mode(&identity, &content, handling_mode)
911            .await
912        {
913            Ok(_) => {
914                if let Err(err) = aggregator
915                    .mark_steer_interaction_delivered(
916                        &accepted.input_frame_id,
917                        &accepted.interaction_id,
918                    )
919                    .await
920                {
921                    tracing::warn!(
922                        identity = %identity,
923                        error = %err,
924                        "console identity-first steer was admitted but delivery status projection failed"
925                    );
926                }
927            }
928            Err(err) => {
929                let _ = aggregator
930                    .mark_interaction_delivery_failed(&accepted.input_frame_id)
931                    .await;
932                if let Some(events) = console_events {
933                    events
934                        .record_lifecycle(
935                            identity.as_str(),
936                            "interaction_failed",
937                            json!({
938                                "interaction_id": accepted.interaction_id,
939                                "origin": request.origin,
940                                "error": err.to_string(),
941                            }),
942                        )
943                        .await;
944                }
945                tracing::warn!(
946                    identity = %identity,
947                    error = %err,
948                    "console identity-first steer was accepted but delivery failed"
949                );
950                return Err(identity_runtime_error_to_console_send_error(
951                    identity.as_str(),
952                    err,
953                ));
954            }
955        }
956        return Ok(accepted);
957    }
958
959    let dispatch_aggregator = aggregator.clone();
960    let dispatch_events = console_events.cloned();
961    let dispatch_identity = identity.clone();
962    let dispatch_content = content.clone();
963    let dispatch_origin = request.origin.clone();
964    let dispatch_accepted = accepted.clone();
965    tokio::spawn(async move {
966        match identity_runtime
967            .send_with_mode(&dispatch_identity, &dispatch_content, handling_mode)
968            .await
969        {
970            Ok(_) => {
971                if let Err(err) = dispatch_aggregator
972                    .mark_interaction_delivered(&dispatch_accepted.input_frame_id)
973                    .await
974                {
975                    tracing::warn!(
976                        identity = %dispatch_identity,
977                        error = %err,
978                        "console identity-first send was accepted but delivery status projection failed"
979                    );
980                }
981            }
982            Err(err) => {
983                let _ = dispatch_aggregator
984                    .mark_interaction_delivery_failed(&dispatch_accepted.input_frame_id)
985                    .await;
986                if let Some(events) = dispatch_events {
987                    events
988                        .record_lifecycle(
989                            dispatch_identity.as_str(),
990                            "interaction_failed",
991                            json!({
992                                "interaction_id": dispatch_accepted.interaction_id,
993                                "origin": dispatch_origin,
994                                "error": err.to_string(),
995                            }),
996                        )
997                        .await;
998                }
999                tracing::warn!(
1000                    identity = %dispatch_identity,
1001                    error = %err,
1002                    "console identity-first send was accepted but delivery failed"
1003                );
1004            }
1005        }
1006    });
1007    Ok(accepted)
1008}
1009
1010async fn start_identity_first_console_live_projection(
1011    runtime: &MobRuntime,
1012    console_events: ConsoleEventStore,
1013    identity: &str,
1014    runtime_member_id: &str,
1015) {
1016    let identity = identity.to_string();
1017    let runtime_member_id = runtime_member_id.to_string();
1018    let mut stream = match runtime
1019        .handle()
1020        .subscribe_agent_events(&crate::member_comms_id::mob_member_id(
1021            runtime_member_id.as_str(),
1022        ))
1023        .await
1024    {
1025        Ok(stream) => stream,
1026        Err(err) => {
1027            tracing::warn!(
1028                identity = %identity,
1029                runtime_member_id = %runtime_member_id,
1030                error = %err,
1031                "console identity-first live projection could not subscribe to agent events"
1032            );
1033            return;
1034        }
1035    };
1036
1037    tokio::spawn(async move {
1038        while let Some(envelope) = stream.next().await {
1039            let event_type = agent_event_type(&envelope.payload).to_string();
1040            let unified = EventEnvelope {
1041                event_id: format!("evt-agent-{}", envelope.event_id),
1042                source: "agent".to_string(),
1043                timestamp_ms: envelope.timestamp_ms,
1044                event: UnifiedEvent::Agent {
1045                    agent_id: runtime_member_id.clone(),
1046                    event_type: event_type.clone(),
1047                    // Console wire shape, not the raw 0.7 event: keeps the
1048                    // derived `result` text (and `tool_call_id` mirror) that
1049                    // the embedded console's adapters and image-frame
1050                    // projection consume.
1051                    payload: Some(crate::mob_handle_runtime::console_agent_event_payload(
1052                        &envelope.payload,
1053                    )),
1054                },
1055            };
1056            console_events.project_unified_event(&unified).await;
1057            if matches!(event_type.as_str(), "run_completed" | "run_failed") {
1058                break;
1059            }
1060        }
1061    });
1062}
1063
1064fn parse_identity_first_handling_mode(
1065    value: Option<&str>,
1066) -> Result<meerkat_core::types::HandlingMode, ConsoleSendError> {
1067    match value.unwrap_or("queue") {
1068        "queue" => Ok(meerkat_core::types::HandlingMode::Queue),
1069        "steer" => Ok(meerkat_core::types::HandlingMode::Steer),
1070        other => Err(ConsoleSendError::InvalidHandlingMode(other.to_string())),
1071    }
1072}
1073
1074async fn resolve_console_send_identity_alias(
1075    aggregator: &MobKitConsoleAggregator,
1076    requested_identity: &str,
1077) -> Option<String> {
1078    let identities = Box::pin(aggregator.list_identities()).await.ok()?;
1079    identities
1080        .into_iter()
1081        .find(|record| {
1082            record.identity == requested_identity || record.runtime_member_id == requested_identity
1083        })
1084        .map(|record| record.identity)
1085}
1086
1087fn identity_runtime_error_to_console_send_error(
1088    identity: &str,
1089    err: crate::identity_first::IdentityRuntimeError,
1090) -> ConsoleSendError {
1091    match err {
1092        crate::identity_first::IdentityRuntimeError::UnknownIdentity(_) => {
1093            ConsoleSendError::UnknownIdentity(identity.to_string())
1094        }
1095        crate::identity_first::IdentityRuntimeError::NotAddressable(_) => {
1096            ConsoleSendError::NotAddressable(identity.to_string())
1097        }
1098        crate::identity_first::IdentityRuntimeError::InvalidState { .. } => {
1099            ConsoleSendError::Retired(identity.to_string())
1100        }
1101        other => ConsoleSendError::Dispatch(other.to_string()),
1102    }
1103}
1104
1105async fn console_timeline_stream_handler(
1106    State(state): State<ConsoleJsonState>,
1107    headers: HeaderMap,
1108    uri: Uri,
1109    Query(query): Query<ConsoleTimelineHttpQuery>,
1110) -> impl IntoResponse {
1111    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
1112        return console_json_error(
1113            StatusCode::UNAUTHORIZED,
1114            "unauthorized",
1115            "console timeline stream requires a valid auth token",
1116        );
1117    };
1118    let access_view = auth_context.access_view.filter(AccessView::enforced);
1119    // Prime the attribute cache from the live roster before any per-frame
1120    // `agent.view` filter so label/role rules resolve here exactly as on the
1121    // windowed timeline handler and the RPC/SSE seams — the stream must not
1122    // depend on a prior `/console/experience` call. Primed once at open; an
1123    // agent spawned mid-stream is filtered against the open-time roster, which
1124    // matches the windowed handler's contract.
1125    if access_view.is_some()
1126        && let Some(runtime) = &state.runtime
1127        && let Some(controller) = state
1128            .access
1129            .as_ref()
1130            .filter(|controller| controller.enabled())
1131    {
1132        prime_access_cache_from_runtime(runtime, controller).await;
1133    }
1134    let Some(aggregator) = &state.console_aggregator else {
1135        return console_json_error(
1136            StatusCode::NOT_FOUND,
1137            "unavailable",
1138            "console aggregator unavailable",
1139        );
1140    };
1141    let aggregator = aggregator.clone();
1142    let last_event_id = headers
1143        .get("last-event-id")
1144        .and_then(|value| value.to_str().ok())
1145        .map(str::trim)
1146        .filter(|value| !value.is_empty())
1147        .map(ToString::to_string);
1148    let timeline_query = timeline_query_from_http(query, last_event_id);
1149    let mut rx = aggregator.subscribe();
1150    let (snapshot_frames, snapshot_cursor) =
1151        match Box::pin(query_timeline_snapshot(&aggregator, timeline_query.clone())).await {
1152            Ok(snapshot) => snapshot,
1153            Err(_) => {
1154                let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
1155                let requested_cursor = timeline_query
1156                    .after
1157                    .as_ref()
1158                    .map(ToString::to_string)
1159                    .unwrap_or_default();
1160                return (
1161                    StatusCode::CONFLICT,
1162                    Json::<Value>(
1163                        serde_json::to_value(ConsoleReplayUnavailable {
1164                            error: "replay_unavailable".to_string(),
1165                            requested_cursor,
1166                            latest_cursor,
1167                        })
1168                        .unwrap_or_else(|_| json!({ "error": "replay_unavailable" })),
1169                    ),
1170                )
1171                    .into_response();
1172            }
1173        };
1174    let identity = timeline_query.identity.clone();
1175    let conversation_id = timeline_query.conversation_id.clone();
1176    let snapshot_after = timeline_query.after.clone();
1177    let stream = stream! {
1178        if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotStarted { after: snapshot_after }) {
1179            yield Ok::<Event, Infallible>(event);
1180        }
1181        let mut latest_cursor = snapshot_cursor;
1182        for frame in snapshot_frames {
1183            latest_cursor = Some(frame.cursor.clone());
1184            if access_view.as_ref().is_some_and(|view| !view.can_view_agent(frame.identity.as_str())) {
1185                continue;
1186            }
1187            if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::ConsoleFrame { frame }) {
1188                yield Ok::<Event, Infallible>(event);
1189            }
1190        }
1191        if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotComplete { cursor: latest_cursor.clone() }) {
1192            yield Ok::<Event, Infallible>(event);
1193        }
1194        loop {
1195            match rx.recv().await {
1196                Ok(event) if timeline_event_matches(&event, identity.as_deref(), conversation_id.as_deref()) => {
1197                    if !Box::pin(aggregator.timeline_event_visible(&event)).await {
1198                        continue;
1199                    }
1200                    if let Some(view) = access_view.as_ref()
1201                        && let Some(frame_identity) = timeline_event_identity(&event)
1202                        && !view.can_view_agent(frame_identity)
1203                    {
1204                        continue;
1205                    }
1206                    if let Some(event_cursor) = timeline_event_cursor(&event)
1207                        && let Some(current_cursor) = latest_cursor.as_ref()
1208                        && !cursor_is_after(event_cursor, current_cursor)
1209                    {
1210                        continue;
1211                    }
1212                    if let Some(sse) = sse_event_from_timeline_event(&event) {
1213                        if let Some(event_cursor) = timeline_event_cursor(&event) {
1214                            latest_cursor = Some(event_cursor.clone());
1215                        }
1216                        yield Ok::<Event, Infallible>(sse);
1217                    }
1218                }
1219                Ok(_) => {}
1220                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
1221                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1222            }
1223        }
1224    };
1225    Sse::new(stream)
1226        .keep_alive(
1227            KeepAlive::new()
1228                .interval(DEFAULT_KEEP_ALIVE_INTERVAL)
1229                .text(KEEP_ALIVE_TEXT),
1230        )
1231        .into_response()
1232}
1233
1234async fn console_identity_timeline_stream_handler(
1235    State(state): State<ConsoleJsonState>,
1236    headers: HeaderMap,
1237    uri: Uri,
1238    AxumPath(identity): AxumPath<String>,
1239    Query(mut query): Query<ConsoleTimelineHttpQuery>,
1240) -> impl IntoResponse {
1241    query.identity = Some(identity);
1242    Box::pin(console_timeline_stream_handler(
1243        State(state),
1244        headers,
1245        uri,
1246        Query(query),
1247    ))
1248    .await
1249    .into_response()
1250}
1251
1252fn timeline_query_from_http(
1253    query: ConsoleTimelineHttpQuery,
1254    fallback_after: Option<String>,
1255) -> ConsoleTimelineWindowQuery {
1256    let after = fallback_after.or(query.after).map(ConsoleCursor::from);
1257    let before = query.before.map(ConsoleCursor::from);
1258    ConsoleTimelineWindowQuery {
1259        identity: query
1260            .identity
1261            .map(|value| value.trim().to_string())
1262            .filter(|value| !value.is_empty()),
1263        conversation_id: query
1264            .conversation_id
1265            .map(|value| value.trim().to_string())
1266            .filter(|value| !value.is_empty()),
1267        after,
1268        before,
1269        mode: query.mode.unwrap_or_default(),
1270        limit: query.limit.unwrap_or(200),
1271    }
1272}
1273
1274async fn query_timeline_snapshot(
1275    aggregator: &MobKitConsoleAggregator,
1276    mut query: ConsoleTimelineWindowQuery,
1277) -> ConsoleLogResult<(Vec<ConsoleFrame>, Option<ConsoleCursor>)> {
1278    const DEFAULT_SNAPSHOT_LIMIT: usize = 200;
1279    query.limit = if query.limit == 0 {
1280        DEFAULT_SNAPSHOT_LIMIT
1281    } else {
1282        query.limit
1283    };
1284    if query.after.is_none() && query.mode == ConsoleTimelineMode::Since {
1285        query.mode = ConsoleTimelineMode::Recent;
1286    }
1287    let mode = query.mode;
1288    match mode {
1289        ConsoleTimelineMode::Recent => {
1290            let page = Box::pin(aggregator.query_timeline_windowed(query)).await?;
1291            Ok((page.frames, page.latest_cursor.or(page.next_cursor)))
1292        }
1293        ConsoleTimelineMode::Since => {
1294            if let (Some(after), Some(latest)) =
1295                (query.after.as_ref(), aggregator.latest_cursor().await?)
1296                && let (Some(after_seq), Some(latest_seq)) = (after.seq(), latest.seq())
1297                && after_seq > latest_seq
1298            {
1299                return Err(std::io::Error::other(
1300                    "timeline replay cursor is beyond the current store frontier",
1301                )
1302                .into());
1303            }
1304            let mut frames = Vec::new();
1305            let mut cursor = query.after.clone();
1306            let mut latest_cursor = None;
1307            loop {
1308                let page = Box::pin(aggregator.query_timeline_windowed(query.clone())).await?;
1309                latest_cursor = page.latest_cursor.clone().or(latest_cursor);
1310                if !page.frames.is_empty() {
1311                    cursor = page
1312                        .next_cursor
1313                        .clone()
1314                        .or_else(|| page.frames.last().map(|frame| frame.cursor.clone()));
1315                    frames.extend(page.frames);
1316                } else if page.next_cursor.is_some() {
1317                    cursor = page.next_cursor.clone();
1318                }
1319                if page.exhausted || page.next_cursor.is_none() {
1320                    return Ok((frames, cursor.or(latest_cursor)));
1321                }
1322                if page.next_cursor == query.after {
1323                    return Err(
1324                        std::io::Error::other("timeline replay made no cursor progress").into(),
1325                    );
1326                }
1327                query.after = page.next_cursor;
1328            }
1329        }
1330    }
1331}
1332
1333fn console_json_error(status: StatusCode, error: &str, message: &str) -> axum::response::Response {
1334    (
1335        status,
1336        Json::<Value>(json!({
1337            "error": error,
1338            "message": message,
1339        })),
1340    )
1341        .into_response()
1342}
1343
1344fn is_console_mutating_rpc_method(method: &str) -> bool {
1345    matches!(
1346        method,
1347        "mobkit/retire"
1348            | "mobkit/reset_all"
1349            | "mobkit/console/send"
1350            | "mobkit/blob/upload"
1351            | "mobkit/ensure_member"
1352            | "mobkit/retire_member"
1353            | "mobkit/respawn_member"
1354            | "mobkit/force_cancel_member"
1355            | "mobkit/cancel_flow"
1356            | "mobkit/collect_completed"
1357            | "mobkit/run_flow"
1358            | "mobkit/spawn_helper"
1359            | "mobkit/fork_helper"
1360            | "mobkit/attach_existing_session"
1361            | "mobkit/reconcile_edges"
1362            | "mobkit/cross_mob/wire_local"
1363            | "mobkit/cross_mob/unwire_local"
1364            | "mobkit/respawn"
1365            | "mobkit/reset"
1366            | "mobkit/delete_identity"
1367            | "mobkit/agent_memory/remember"
1368            | "mobkit/agent_memory/update"
1369            | "mobkit/agent_memory/forget"
1370            | "mobkit/gating/decide"
1371            | "mobkit/mob_labels/set"
1372            | "mobkit/mob_labels/delete"
1373            | "mobkit/run_labels/set"
1374            | "mobkit/run_labels/delete"
1375            | "mobkit/access/set"
1376            | "mobkit/access/enable"
1377            | "mobkit/access/rules/upsert"
1378            | "mobkit/access/rules/delete"
1379            | "mobkit/access/groups/set"
1380            | "mobkit/access/groups/delete"
1381    )
1382}
1383
1384fn console_read_only_rpc_error(response_id: Value) -> Value {
1385    response_value(
1386        response_id,
1387        None,
1388        Some(JsonRpcError {
1389            code: -32010,
1390            message: "console is read-only".to_string(),
1391            data: Some(json!({ "kind": "read_only" })),
1392        }),
1393    )
1394}
1395
1396fn console_send_error_response(err: ConsoleSendError) -> axum::response::Response {
1397    let (status, code) = match &err {
1398        ConsoleSendError::UnknownIdentity(_) => (StatusCode::NOT_FOUND, "unknown_identity"),
1399        ConsoleSendError::AmbiguousIdentity { .. } => {
1400            (StatusCode::CONFLICT, "ambiguous_live_identity_alias")
1401        }
1402        ConsoleSendError::NotAddressable(_) => (StatusCode::CONFLICT, "not_addressable"),
1403        ConsoleSendError::Retired(_) => (StatusCode::CONFLICT, "retired"),
1404        ConsoleSendError::InvalidContent(_)
1405        | ConsoleSendError::InvalidHandlingMode(_)
1406        | ConsoleSendError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
1407        ConsoleSendError::IdempotencyConflict(_) => (StatusCode::CONFLICT, "idempotency_conflict"),
1408        ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1409            (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
1410        }
1411    };
1412    console_json_error(status, code, &console_send_public_message(&err))
1413}
1414
1415fn console_send_rpc_code(err: &ConsoleSendError) -> i64 {
1416    match err {
1417        ConsoleSendError::UnknownIdentity(_) => -32001,
1418        ConsoleSendError::AmbiguousIdentity { .. } => -32602,
1419        ConsoleSendError::NotAddressable(_) => -32002,
1420        ConsoleSendError::InvalidContent(_)
1421        | ConsoleSendError::InvalidHandlingMode(_)
1422        | ConsoleSendError::InvalidRequest(_) => -32602,
1423        ConsoleSendError::IdempotencyConflict(_) => -32009,
1424        ConsoleSendError::Retired(_) => -32004,
1425        ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1426            -32000
1427        }
1428    }
1429}
1430
1431fn console_send_rpc_error(response_id: Value, err: ConsoleSendError) -> Value {
1432    response_value(response_id, None, Some(console_send_json_rpc_error(err)))
1433}
1434
1435fn console_send_json_rpc_error(err: ConsoleSendError) -> JsonRpcError {
1436    let code = console_send_rpc_code(&err);
1437    JsonRpcError {
1438        code,
1439        message: console_send_public_message(&err),
1440        data: None,
1441    }
1442}
1443
1444fn console_send_public_message(err: &ConsoleSendError) -> String {
1445    match err {
1446        ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1447            tracing::warn!(target: "mobkit::console", error = %err, "console send internal error");
1448            "console send failed".to_string()
1449        }
1450        _ => err.to_string(),
1451    }
1452}
1453
1454fn timeline_event_matches(
1455    event: &ConsoleTimelineEvent,
1456    identity: Option<&str>,
1457    conversation_id: Option<&str>,
1458) -> bool {
1459    let frame = match event {
1460        ConsoleTimelineEvent::ConsoleFrame { frame }
1461        | ConsoleTimelineEvent::FrameUpdated { frame } => frame,
1462        ConsoleTimelineEvent::SnapshotStarted { .. }
1463        | ConsoleTimelineEvent::SnapshotComplete { .. }
1464        | ConsoleTimelineEvent::ReplayUnavailable { .. } => return true,
1465    };
1466    if identity.is_some_and(|value| frame.identity != value) {
1467        return false;
1468    }
1469    if conversation_id.is_some_and(|value| frame.conversation_id.as_deref() != Some(value)) {
1470        return false;
1471    }
1472    true
1473}
1474
1475fn timeline_event_cursor(event: &ConsoleTimelineEvent) -> Option<&ConsoleCursor> {
1476    match event {
1477        ConsoleTimelineEvent::ConsoleFrame { frame }
1478        | ConsoleTimelineEvent::FrameUpdated { frame } => Some(&frame.cursor),
1479        ConsoleTimelineEvent::SnapshotStarted { .. }
1480        | ConsoleTimelineEvent::SnapshotComplete { .. }
1481        | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1482    }
1483}
1484
1485fn timeline_event_identity(event: &ConsoleTimelineEvent) -> Option<&str> {
1486    match event {
1487        ConsoleTimelineEvent::ConsoleFrame { frame }
1488        | ConsoleTimelineEvent::FrameUpdated { frame } => Some(frame.identity.as_str()),
1489        ConsoleTimelineEvent::SnapshotStarted { .. }
1490        | ConsoleTimelineEvent::SnapshotComplete { .. }
1491        | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1492    }
1493}
1494
1495// ---------------------------------------------------------------------------
1496// ABAC enforcement for console surfaces
1497// ---------------------------------------------------------------------------
1498
1499pub(crate) const ACCESS_DENIED_RPC_CODE: i64 = -32030;
1500
1501fn retain_visible_timeline_frames(page: &mut ConsoleTimelineWindowPage, view: Option<&AccessView>) {
1502    let Some(view) = view.filter(|view| view.enforced()) else {
1503        return;
1504    };
1505    page.frames
1506        .retain(|frame| view.can_view_agent(frame.identity.as_str()));
1507}
1508
1509/// Filter serialized member rows (`mobkit/list_members`, `find_members`)
1510/// down to the agents the caller may view. Field names cover both the
1511/// meerkat roster entry shape and the console projection shape.
1512fn retain_visible_member_rows(rows: &mut Vec<Value>, view: Option<&AccessView>) {
1513    let Some(view) = view.filter(|view| view.enforced()) else {
1514        return;
1515    };
1516    rows.retain(|row| {
1517        let labels: Option<std::collections::BTreeMap<String, String>> = row
1518            .get("labels")
1519            .and_then(|value| serde_json::from_value(value.clone()).ok());
1520        let agent_id = row
1521            .get("agent_identity")
1522            .or_else(|| row.get("member_id"))
1523            .or_else(|| row.get("agent_id"))
1524            .and_then(Value::as_str);
1525        let identity = labels
1526            .as_ref()
1527            .and_then(|labels| labels.get("agent_identity"))
1528            .map(String::as_str)
1529            .or_else(|| row.get("identity").and_then(Value::as_str))
1530            .or(agent_id);
1531        let role = row
1532            .get("role")
1533            .or_else(|| row.get("profile"))
1534            .and_then(Value::as_str);
1535        view.decide(
1536            ACTION_AGENT_VIEW,
1537            &AccessResource {
1538                identity,
1539                agent_id,
1540                role,
1541                labels: labels.as_ref(),
1542            },
1543        )
1544        .is_allow()
1545    });
1546}
1547
1548fn retain_visible_identity_records(
1549    identities: &mut Vec<ConsoleIdentityRecord>,
1550    view: Option<&AccessView>,
1551) {
1552    let Some(view) = view.filter(|view| view.enforced()) else {
1553        return;
1554    };
1555    identities.retain(|record| {
1556        view.decide(
1557            ACTION_AGENT_VIEW,
1558            &AccessResource {
1559                identity: Some(record.identity.as_str()),
1560                agent_id: Some(record.runtime_member_id.as_str()),
1561                role: None,
1562                labels: Some(&record.labels),
1563            },
1564        )
1565        .is_allow()
1566    });
1567}
1568
1569/// Maps a console RPC method to the access actions it requires, each with
1570/// the targeted agent (when the check is agent-scoped). Every listed action
1571/// must be allowed (logical AND). Methods not listed are either pure read
1572/// surfaces whose *results* are filtered per caller, or non-sensitive
1573/// metadata.
1574fn console_rpc_access_requirements(
1575    method: &str,
1576    params: &Value,
1577) -> Option<Vec<(&'static str, Option<String>)>> {
1578    let identity = normalized_console_rpc_string_param(params, "identity");
1579    let target = identity
1580        .clone()
1581        .or_else(|| normalized_console_rpc_string_param(params, "member_id"))
1582        .or_else(|| normalized_console_rpc_string_param(params, "agent_id"));
1583    let one = |action: &'static str, target: Option<String>| Some(vec![(action, target)]);
1584    match method {
1585        "mobkit/console/send" => one(ACTION_AGENT_SEND, identity),
1586        "mobkit/agent_memory/remember" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1587        // Update is a supersede — a write within the record's lineage.
1588        "mobkit/agent_memory/update" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1589        "mobkit/agent_memory/forget" => one(ACTION_AGENT_MEMORY_DELETE, identity),
1590        // §10.3 migration: memory reads moved from bare `agent.view` to the
1591        // per-scope read action, and the console keeps `agent.view` as a
1592        // prerequisite — both are required. Pre-migration configs are
1593        // covered by `normalize_access_config_for_memory_actions`.
1594        "mobkit/agent_memory/recall" | "mobkit/agent_memory/manifest" => Some(vec![
1595            (ACTION_AGENT_MEMORY_READ, identity.clone()),
1596            (ACTION_AGENT_VIEW, identity),
1597        ]),
1598        // Memory panel reads (§9.3). Identity-keyed listings gate up front
1599        // like recall; mob-scope listings need the mob read action;
1600        // unscoped listings carry no entry gate — every returned row is
1601        // filtered per caller scope in the handler
1602        // (`panel_record_visible`). The record-detail method is likewise
1603        // enforced post-load, where the record's scope is known.
1604        "mobkit/memory/panel/records" => match memory_panel_scope_param(params) {
1605            Some(scope) if scope == "mob" => one(ACTION_MOB_MEMORY_READ, None),
1606            Some(scope) if scope == "operator" => one(ACTION_OPERATOR_MEMORY_READ, None),
1607            _ if identity.is_some() => Some(vec![
1608                (ACTION_AGENT_MEMORY_READ, identity.clone()),
1609                (ACTION_AGENT_VIEW, identity),
1610            ]),
1611            _ => None,
1612        },
1613        // Dream history is realm-level steward activity spanning scopes; it
1614        // requires the unscoped read grant (a rule with no resource
1615        // selector), same as realm-scope record reads.
1616        "mobkit/memory/panel/dreams" => one(ACTION_AGENT_MEMORY_READ, None),
1617        "mobkit/memory/panel/quarantine" => one(ACTION_MEMORY_QUARANTINE_REVIEW, None),
1618        // `mob.memory.propose` gates future propose surfaces and
1619        // `mob.memory.commit` is reserved for a future direct-commit RPC —
1620        // steward promotions ride the existing gating flow (gating.decide),
1621        // so neither maps to a method yet.
1622        "mobkit/retire"
1623        | "mobkit/retire_member"
1624        | "mobkit/force_cancel_member"
1625        | "mobkit/delete_identity" => one(ACTION_AGENT_RETIRE, target),
1626        "mobkit/respawn" | "mobkit/respawn_member" => one(ACTION_AGENT_RESPAWN, target),
1627        "mobkit/reset" | "mobkit/reset_all" => one(ACTION_AGENT_RESET, target),
1628        "mobkit/ensure_member"
1629        | "mobkit/spawn_helper"
1630        | "mobkit/fork_helper"
1631        | "mobkit/attach_existing_session"
1632        | "mobkit/run_flow"
1633        | "mobkit/cancel_flow"
1634        | "mobkit/collect_completed" => one(ACTION_AGENT_SPAWN, target),
1635        // Flow state reads expose run records (including spawned member
1636        // identities), so they sit in the same tier as running flows.
1637        "mobkit/flow_status" | "mobkit/list_flows" | "mobkit/list_runs" => {
1638            one(ACTION_AGENT_SPAWN, None)
1639        }
1640        "mobkit/gating/decide" => one(ACTION_GATING_DECIDE, None),
1641        "mobkit/gating/pending" | "mobkit/gating/audit" => one(ACTION_GATING_VIEW, None),
1642        "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => one(ACTION_MOB_OBSERVE, None),
1643        "mobkit/reconcile_edges"
1644        | "mobkit/cross_mob/wire_local"
1645        | "mobkit/cross_mob/unwire_local"
1646        | "mobkit/mob_labels/set"
1647        | "mobkit/mob_labels/delete"
1648        | "mobkit/run_labels/set"
1649        | "mobkit/run_labels/delete"
1650        // Plumbing reads: routing tables, delivery records, cross-mob
1651        // contacts, and label tables enumerate agent identities without
1652        // passing through per-agent visibility filtering, so they require
1653        // the same grant as the mutations that shape them.
1654        | "mobkit/routing/routes/list"
1655        | "mobkit/delivery/history"
1656        | "mobkit/cross_mob/directory"
1657        | "mobkit/cross_mob/peer_info"
1658        | "mobkit/mob_labels/get"
1659        | "mobkit/run_labels/get" => one(ACTION_RUNTIME_ADMIN, None),
1660        "mobkit/get_member"
1661        | "mobkit/member_status"
1662        | "mobkit/identity/resolved_tools"
1663        | "mobkit/inspect_identity"
1664        | "mobkit/status_identity"
1665        | "mobkit/console/inspect_identity" => one(ACTION_AGENT_VIEW, target),
1666        _ => None,
1667    }
1668}
1669
1670fn memory_panel_scope_param(params: &Value) -> Option<String> {
1671    normalized_console_rpc_string_param(params, "scope").filter(|scope| !scope.is_empty())
1672}
1673
1674fn normalized_console_rpc_string_param(params: &Value, key: &str) -> Option<String> {
1675    params
1676        .get(key)
1677        .and_then(Value::as_str)
1678        .map(str::trim)
1679        .map(str::to_string)
1680}
1681
1682/// Returns the denial error when the caller's view forbids the method.
1683/// Every mapped requirement must pass; the first failing one is reported.
1684fn console_rpc_access_violation(
1685    view: Option<&AccessView>,
1686    method: &str,
1687    params: &Value,
1688) -> Option<JsonRpcError> {
1689    let view = view.filter(|view| view.enforced())?;
1690    let requirements = console_rpc_access_requirements(method, params)?;
1691    for (action, target) in requirements {
1692        let allowed = match target {
1693            Some(ref identity) => view.allows_agent(action, identity),
1694            None => view.allows(action),
1695        };
1696        if !allowed {
1697            return Some(JsonRpcError {
1698                code: ACCESS_DENIED_RPC_CODE,
1699                message: format!("access denied: {action}"),
1700                data: Some(json!({
1701                    "kind": "access_denied",
1702                    "action": action,
1703                    "resource": target,
1704                })),
1705            });
1706        }
1707    }
1708    None
1709}
1710
1711fn access_denied_rpc_error(response_id: Value, message: impl Into<String>) -> Value {
1712    response_value(
1713        response_id,
1714        None,
1715        Some(JsonRpcError {
1716            code: ACCESS_DENIED_RPC_CODE,
1717            message: message.into(),
1718            data: Some(json!({ "kind": "access_denied" })),
1719        }),
1720    )
1721}
1722
1723fn access_unavailable_rpc_error(response_id: Value) -> Value {
1724    response_value(
1725        response_id,
1726        None,
1727        Some(JsonRpcError {
1728            code: -32004,
1729            message: "access control is not configured on this runtime".to_string(),
1730            data: Some(json!({ "kind": "access_unavailable" })),
1731        }),
1732    )
1733}
1734
1735fn access_config_rpc_error(response_id: Value, err: crate::access::AccessConfigError) -> Value {
1736    response_value(
1737        response_id,
1738        None,
1739        Some(JsonRpcError {
1740            code: -32602,
1741            message: err.to_string(),
1742            data: Some(json!({ "kind": "invalid_access_config" })),
1743        }),
1744    )
1745}
1746
1747// ---------------------------------------------------------------------------
1748// Console Memory panel (§9.3): read-only `mobkit/memory/panel/*` RPCs
1749// ---------------------------------------------------------------------------
1750
1751const MEMORY_PANEL_DEFAULT_LIMIT: usize = 50;
1752const MEMORY_PANEL_MAX_LIMIT: usize = 200;
1753const MEMORY_PANEL_CHAIN_MAX: usize = 32;
1754const MEMORY_PANEL_INJECTIONS_MAX: usize = 50;
1755const MEMORY_PANEL_DREAMS_DEFAULT: usize = 20;
1756const MEMORY_PANEL_DREAMS_MAX: usize = 100;
1757
1758fn memory_panel_unavailable(response_id: Value) -> Value {
1759    response_value(
1760        response_id,
1761        None,
1762        Some(JsonRpcError {
1763            code: -32601,
1764            message: "memory panel is not configured".to_string(),
1765            data: None,
1766        }),
1767    )
1768}
1769
1770fn memory_panel_store_error(
1771    response_id: Value,
1772    err: crate::identity_first::agent_memory::AgentMemoryError,
1773) -> Value {
1774    response_value(
1775        response_id,
1776        None,
1777        Some(crate::rpc::agent_memory_rpc_error("panel", err)),
1778    )
1779}
1780
1781fn memory_panel_limit_param(params: &Value, default: usize, max: usize) -> usize {
1782    params
1783        .get("limit")
1784        .and_then(Value::as_u64)
1785        .map(|limit| (limit as usize).clamp(1, max))
1786        .unwrap_or(default)
1787}
1788
1789fn encode_memory_panel_cursor(cursor: &(u64, String)) -> String {
1790    format!("{}:{}", cursor.0, cursor.1)
1791}
1792
1793fn parse_memory_panel_cursor(raw: &str) -> Option<(u64, String)> {
1794    let (ms, id) = raw.split_once(':')?;
1795    let ms = ms.parse::<u64>().ok()?;
1796    (!id.is_empty()).then(|| (ms, id.to_string()))
1797}
1798
1799/// The read action guarding a record of this scope on the panel (§10.3).
1800/// Realm-scope reads ride an *unscoped* `agent.memory.read` grant (a rule
1801/// with no resource selector); operator scope (live since P4's provisional
1802/// keying) requires its own explicit `operator.memory.read` grant.
1803fn memory_panel_scope_action(scope: &crate::memory::records::MemoryScope) -> &'static str {
1804    match scope {
1805        crate::memory::records::MemoryScope::Identity { .. } => ACTION_AGENT_MEMORY_READ,
1806        crate::memory::records::MemoryScope::Mob { .. } => ACTION_MOB_MEMORY_READ,
1807        crate::memory::records::MemoryScope::Operator { .. } => ACTION_OPERATOR_MEMORY_READ,
1808        crate::memory::records::MemoryScope::Realm { .. } => ACTION_AGENT_MEMORY_READ,
1809    }
1810}
1811
1812/// Per-row visibility for panel reads. Identity-scope rows require the
1813/// per-scope read action AND `agent.view` on the identity (§10.3: the
1814/// console keeps view as a prerequisite); mob rows require
1815/// `mob.memory.read`; operator rows require the explicit
1816/// `operator.memory.read` grant (cross-mob personal facts — an unscoped
1817/// `agent.memory.read` deliberately does NOT cover them); realm rows
1818/// require the unscoped read grant. Quarantined rows are additionally
1819/// reviewer-only — their bodies are exactly the content the quarantine
1820/// gate exists for.
1821fn memory_panel_record_visible(
1822    view: Option<&AccessView>,
1823    record: &crate::memory::records::MemoryRecord,
1824) -> bool {
1825    let Some(view) = view.filter(|view| view.enforced()) else {
1826        return true;
1827    };
1828    let scope_allowed = match &record.scope {
1829        crate::memory::records::MemoryScope::Identity { identity, .. } => {
1830            view.allows_agent(ACTION_AGENT_MEMORY_READ, identity)
1831                && view.allows_agent(ACTION_AGENT_VIEW, identity)
1832        }
1833        crate::memory::records::MemoryScope::Mob { .. } => view.allows(ACTION_MOB_MEMORY_READ),
1834        crate::memory::records::MemoryScope::Operator { .. } => {
1835            view.allows(ACTION_OPERATOR_MEMORY_READ)
1836        }
1837        crate::memory::records::MemoryScope::Realm { .. } => view.allows(ACTION_AGENT_MEMORY_READ),
1838    };
1839    if !scope_allowed {
1840        return false;
1841    }
1842    if matches!(
1843        record.status,
1844        crate::memory::records::RecordStatus::Quarantined { .. }
1845    ) {
1846        return view.allows(ACTION_MEMORY_QUARANTINE_REVIEW);
1847    }
1848    true
1849}
1850
1851/// Per-row visibility for pending-promotion queue rows, mirroring
1852/// [`memory_panel_record_visible`] on the promotion's *target* scope:
1853/// promotions carry the scope key and steward rationale, so they leak the
1854/// same cross-scope metadata as record rows. `memory.quarantine.review` is
1855/// the queue's entry gate but is deliberately not sufficient per row.
1856/// Unknown scope kinds are denied.
1857fn memory_panel_promotion_visible(
1858    view: Option<&AccessView>,
1859    scope_kind: &str,
1860    scope_key: &str,
1861) -> bool {
1862    let Some(view) = view.filter(|view| view.enforced()) else {
1863        return true;
1864    };
1865    match scope_kind {
1866        "identity" => {
1867            view.allows_agent(ACTION_AGENT_MEMORY_READ, scope_key)
1868                && view.allows_agent(ACTION_AGENT_VIEW, scope_key)
1869        }
1870        "mob" => view.allows(ACTION_MOB_MEMORY_READ),
1871        "operator" => view.allows(ACTION_OPERATOR_MEMORY_READ),
1872        "realm" => view.allows(ACTION_AGENT_MEMORY_READ),
1873        _ => false,
1874    }
1875}
1876
1877/// Serialize a record for the panel. List rows are body-free (`body_bytes`
1878/// stands in); only the record-detail surface carries the body.
1879fn memory_panel_record_json(
1880    record: &crate::memory::records::MemoryRecord,
1881    include_body: bool,
1882) -> Value {
1883    let mut value = serde_json::to_value(record).unwrap_or(Value::Null);
1884    if !include_body && let Some(object) = value.as_object_mut() {
1885        object.remove("body");
1886        object.insert("body_bytes".to_string(), json!(record.body.len()));
1887    }
1888    value
1889}
1890
1891/// Resolve the realms a panel read spans: the explicit `realm` param, or
1892/// every realm with a store file.
1893async fn memory_panel_realms(
1894    store: &crate::memory::sqlite_store::SqliteAgentMemoryStore,
1895    params: &Value,
1896) -> Result<Vec<String>, crate::identity_first::agent_memory::AgentMemoryError> {
1897    match normalized_console_rpc_string_param(params, "realm").filter(|realm| !realm.is_empty()) {
1898        Some(realm) => Ok(vec![realm]),
1899        None => store.panel_realms().await,
1900    }
1901}
1902
1903async fn handle_memory_panel_records(
1904    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
1905    view: Option<&AccessView>,
1906    params: &Value,
1907    response_id: Value,
1908) -> Value {
1909    let Some(store) = store else {
1910        return memory_panel_unavailable(response_id);
1911    };
1912    let scope_kind = memory_panel_scope_param(params);
1913    if let Some(kind) = &scope_kind
1914        && !matches!(kind.as_str(), "identity" | "mob" | "operator" | "realm")
1915    {
1916        return invalid_params(
1917            response_id,
1918            format!("Invalid params: unknown scope '{kind}'"),
1919        );
1920    }
1921    let identity = normalized_console_rpc_string_param(params, "identity")
1922        .filter(|identity| !identity.is_empty());
1923    let scope_kind = scope_kind.or_else(|| identity.as_ref().map(|_| "identity".to_string()));
1924    let scope_key = normalized_console_rpc_string_param(params, "scope_key")
1925        .filter(|key| !key.is_empty())
1926        .or(identity);
1927    let status =
1928        normalized_console_rpc_string_param(params, "status").filter(|status| !status.is_empty());
1929    if let Some(status) = &status
1930        && !matches!(
1931            status.as_str(),
1932            "active" | "quarantined" | "superseded" | "tombstoned"
1933        )
1934    {
1935        return invalid_params(
1936            response_id,
1937            format!("Invalid params: unknown status '{status}'"),
1938        );
1939    }
1940    let limit =
1941        memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
1942    let cursor = normalized_console_rpc_string_param(params, "cursor")
1943        .filter(|cursor| !cursor.is_empty())
1944        .map(|raw| parse_memory_panel_cursor(&raw));
1945    let cursor = match cursor {
1946        Some(None) => {
1947            return invalid_params(response_id, "Invalid params: malformed cursor".to_string());
1948        }
1949        Some(Some(cursor)) => Some(cursor),
1950        None => None,
1951    };
1952    let realms = match memory_panel_realms(store, params).await {
1953        Ok(realms) => realms,
1954        Err(err) => return memory_panel_store_error(response_id, err),
1955    };
1956    // Keyset continuation is per-realm: honored only when the query names
1957    // one realm. Multi-realm listings merge one bounded page per realm.
1958    let single_realm = realms.len() == 1;
1959    let mut records = Vec::new();
1960    let mut next_cursor = None;
1961    for realm in &realms {
1962        let page = match store
1963            .records_page(
1964                realm,
1965                scope_kind.as_deref(),
1966                scope_key.as_deref(),
1967                status.as_deref(),
1968                limit,
1969                if single_realm { cursor.clone() } else { None },
1970            )
1971            .await
1972        {
1973            Ok(page) => page,
1974            Err(err) => return memory_panel_store_error(response_id, err),
1975        };
1976        if single_realm {
1977            next_cursor = page.next_cursor.as_ref().map(encode_memory_panel_cursor);
1978        }
1979        records.extend(page.records);
1980    }
1981    records.retain(|record| memory_panel_record_visible(view, record));
1982    records.sort_by(|a, b| {
1983        b.updated_at_ms
1984            .cmp(&a.updated_at_ms)
1985            .then_with(|| b.id.cmp(&a.id))
1986    });
1987    records.truncate(limit);
1988    let rows: Vec<Value> = records
1989        .iter()
1990        .map(|record| memory_panel_record_json(record, false))
1991        .collect();
1992    response_value(
1993        response_id,
1994        Some(json!({
1995            "records": rows,
1996            "next_cursor": next_cursor,
1997            "realms": realms,
1998        })),
1999        None,
2000    )
2001}
2002
2003async fn handle_memory_panel_record(
2004    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2005    view: Option<&AccessView>,
2006    params: &Value,
2007    response_id: Value,
2008) -> Value {
2009    let Some(store) = store else {
2010        return memory_panel_unavailable(response_id);
2011    };
2012    let Some(memory_id) = normalized_console_rpc_string_param(params, "memory_id")
2013        .filter(|memory_id| !memory_id.is_empty())
2014    else {
2015        return invalid_params(
2016            response_id,
2017            "Invalid params: memory_id is required".to_string(),
2018        );
2019    };
2020    let realms = match memory_panel_realms(store, params).await {
2021        Ok(realms) => realms,
2022        Err(err) => return memory_panel_store_error(response_id, err),
2023    };
2024    let mut found = None;
2025    for realm in &realms {
2026        match store.record_by_id(realm, &memory_id).await {
2027            Ok(Some(record)) => {
2028                found = Some((realm.clone(), record));
2029                break;
2030            }
2031            Ok(None) => {}
2032            Err(err) => return memory_panel_store_error(response_id, err),
2033        }
2034    }
2035    let Some((realm, record)) = found else {
2036        return response_value(
2037            response_id,
2038            None,
2039            Some(JsonRpcError {
2040                code: -32001,
2041                message: format!("unknown memory record '{memory_id}'"),
2042                data: None,
2043            }),
2044        );
2045    };
2046    // Scope is only known post-load, so the entry gate lives here rather
2047    // than in the requirements table.
2048    if !memory_panel_record_visible(view, &record) {
2049        let action = if matches!(
2050            record.status,
2051            crate::memory::records::RecordStatus::Quarantined { .. }
2052        ) && view
2053            .is_some_and(|view| view.enforced() && !view.allows(ACTION_MEMORY_QUARANTINE_REVIEW))
2054        {
2055            ACTION_MEMORY_QUARANTINE_REVIEW
2056        } else {
2057            memory_panel_scope_action(&record.scope)
2058        };
2059        return response_value(
2060            response_id,
2061            None,
2062            Some(JsonRpcError {
2063                code: ACCESS_DENIED_RPC_CODE,
2064                message: format!("access denied: {action}"),
2065                data: Some(json!({
2066                    "kind": "access_denied",
2067                    "action": action,
2068                    "resource": record.id,
2069                })),
2070            }),
2071        );
2072    }
2073    let chain = match store
2074        .supersede_chain(&realm, &memory_id, MEMORY_PANEL_CHAIN_MAX)
2075        .await
2076    {
2077        Ok(chain) => chain,
2078        Err(err) => return memory_panel_store_error(response_id, err),
2079    };
2080    let chain_rows: Vec<Value> = chain
2081        .iter()
2082        .filter(|link| memory_panel_record_visible(view, link))
2083        .map(|link| memory_panel_record_json(link, false))
2084        .collect();
2085    let injections = match store
2086        .injection_log_for_record(&realm, &memory_id, MEMORY_PANEL_INJECTIONS_MAX)
2087        .await
2088    {
2089        Ok(entries) => entries,
2090        Err(err) => return memory_panel_store_error(response_id, err),
2091    };
2092    response_value(
2093        response_id,
2094        Some(json!({
2095            "realm": realm,
2096            "record": memory_panel_record_json(&record, true),
2097            "chain": chain_rows,
2098            "injections": injections,
2099        })),
2100        None,
2101    )
2102}
2103
2104async fn handle_memory_panel_quarantine(
2105    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2106    view: Option<&AccessView>,
2107    params: &Value,
2108    response_id: Value,
2109) -> Value {
2110    let Some(store) = store else {
2111        return memory_panel_unavailable(response_id);
2112    };
2113    let limit =
2114        memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2115    let realms = match memory_panel_realms(store, params).await {
2116        Ok(realms) => realms,
2117        Err(err) => return memory_panel_store_error(response_id, err),
2118    };
2119    let mut records = Vec::new();
2120    let mut pending = Vec::new();
2121    for realm in &realms {
2122        match store.quarantined_records(realm, limit).await {
2123            Ok(rows) => records.extend(rows),
2124            Err(err) => return memory_panel_store_error(response_id, err),
2125        }
2126        match store.pending_promotions(realm).await {
2127            Ok(rows) => pending.extend(
2128                rows.into_iter()
2129                    .filter(|promotion| {
2130                        memory_panel_promotion_visible(
2131                            view,
2132                            &promotion.scope_kind,
2133                            &promotion.scope_key,
2134                        )
2135                    })
2136                    .map(|promotion| {
2137                        // stage_token is a commit capability — never surfaced.
2138                        json!({
2139                            "realm": realm,
2140                            "pending_id": promotion.pending_id,
2141                            "record_id": promotion.record_id,
2142                            "scope_kind": promotion.scope_kind,
2143                            "scope_key": promotion.scope_key,
2144                            "rationale": promotion.rationale,
2145                            "status": promotion.status,
2146                            "created_at_ms": promotion.created_at_ms,
2147                        })
2148                    }),
2149            ),
2150            Err(err) => return memory_panel_store_error(response_id, err),
2151        }
2152    }
2153    // The reviewer entry gate is necessary but not sufficient: each queue row
2154    // still needs the caller's per-scope read grant, and hidden rows must not
2155    // consume the page budget.
2156    records.retain(|record| memory_panel_record_visible(view, record));
2157    records.sort_by_key(|record| std::cmp::Reverse(record.created_at_ms));
2158    records.truncate(limit);
2159    // Queue rows stay body-free even for reviewers: verdicts are decided on
2160    // the record detail surface, which renders the body with provenance.
2161    let rows: Vec<Value> = records
2162        .iter()
2163        .map(|record| memory_panel_record_json(record, false))
2164        .collect();
2165    response_value(
2166        response_id,
2167        Some(json!({
2168            "records": rows,
2169            "pending_promotions": pending,
2170            "realms": realms,
2171        })),
2172        None,
2173    )
2174}
2175
2176async fn handle_memory_panel_dreams(
2177    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2178    params: &Value,
2179    response_id: Value,
2180) -> Value {
2181    let Some(store) = store else {
2182        return memory_panel_unavailable(response_id);
2183    };
2184    let limit =
2185        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2186    let realms = match memory_panel_realms(store, params).await {
2187        Ok(realms) => realms,
2188        Err(err) => return memory_panel_store_error(response_id, err),
2189    };
2190    let mut runs = Vec::new();
2191    for realm in &realms {
2192        match store.dream_history(realm, limit).await {
2193            Ok(history) => runs.extend(history.into_iter().map(|run| (realm.clone(), run))),
2194            Err(err) => return memory_panel_store_error(response_id, err),
2195        }
2196    }
2197    runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.last_op_at_ms));
2198    runs.truncate(limit);
2199    let rows: Vec<Value> = runs
2200        .iter()
2201        .map(|(realm, run)| {
2202            json!({
2203                "realm": realm,
2204                "run_id": run.run_id,
2205                "first_op_at_ms": run.first_op_at_ms,
2206                "last_op_at_ms": run.last_op_at_ms,
2207                "ops": run.ops,
2208                "op_kinds": run.op_kinds,
2209                "quarantined_ops": run.quarantined_ops,
2210                "memory_ids": run.memory_ids,
2211                "rationales": run.rationales,
2212            })
2213        })
2214        .collect();
2215    response_value(
2216        response_id,
2217        Some(json!({ "runs": rows, "realms": realms })),
2218        None,
2219    )
2220}
2221
2222fn access_status_result(access: Option<&AccessController>, view: Option<&AccessView>) -> Value {
2223    match (access, view) {
2224        (Some(controller), Some(view)) => {
2225            let (_, revision) = controller.snapshot();
2226            json!({
2227                "available": true,
2228                "enabled": view.enforced(),
2229                "revision": revision,
2230                "subject": view.subject(),
2231                "groups": view.groups().iter().collect::<Vec<_>>(),
2232                "is_admin": view.is_admin(),
2233                "can_administer": view.can_administer(),
2234                "actions": ACCESS_ACTIONS,
2235            })
2236        }
2237        _ => json!({
2238            "available": false,
2239            "enabled": false,
2240            "actions": ACCESS_ACTIONS,
2241        }),
2242    }
2243}
2244
2245/// Handles every `mobkit/access/*` method. Returns `None` when the method
2246/// belongs to another namespace.
2247fn handle_access_admin_rpc(
2248    access: Option<&AccessController>,
2249    view: Option<&AccessView>,
2250    request: &JsonRpcRequest,
2251) -> Option<Value> {
2252    if !request.method.starts_with("mobkit/access/") {
2253        return None;
2254    }
2255    let response_id = request.id.clone().unwrap_or(Value::Null);
2256    let controller = match request.method.as_str() {
2257        "mobkit/access/status" => {
2258            return Some(response_value(
2259                response_id,
2260                Some(access_status_result(access, view)),
2261                None,
2262            ));
2263        }
2264        _ => {
2265            let Some(controller) = access else {
2266                return Some(access_unavailable_rpc_error(response_id));
2267            };
2268            if !view.is_some_and(AccessView::can_administer) {
2269                return Some(access_denied_rpc_error(
2270                    response_id,
2271                    "access denied: access.admin",
2272                ));
2273            }
2274            controller
2275        }
2276    };
2277    let result = match request.method.as_str() {
2278        "mobkit/access/get" => {
2279            let (config, revision) = controller.snapshot();
2280            Ok(json!({ "config": &*config, "revision": revision }))
2281        }
2282        "mobkit/access/set" => {
2283            match serde_json::from_value(request.params.get("config").cloned().unwrap_or_default())
2284            {
2285                Ok(config) => controller
2286                    .replace_config(config)
2287                    .map(|revision| json!({ "revision": revision })),
2288                Err(err) => {
2289                    return Some(invalid_params(
2290                        response_id,
2291                        format!("invalid access config: {err}"),
2292                    ));
2293                }
2294            }
2295        }
2296        "mobkit/access/rules/upsert" => {
2297            match serde_json::from_value::<AccessRule>(
2298                request.params.get("rule").cloned().unwrap_or_default(),
2299            ) {
2300                Ok(rule) => controller
2301                    .upsert_rule(rule)
2302                    .map(|revision| json!({ "revision": revision })),
2303                Err(err) => {
2304                    return Some(invalid_params(
2305                        response_id,
2306                        format!("invalid access rule: {err}"),
2307                    ));
2308                }
2309            }
2310        }
2311        "mobkit/access/rules/delete" => {
2312            let Some(rule_id) = request.params.get("id").and_then(Value::as_str) else {
2313                return Some(invalid_params(response_id, "id required"));
2314            };
2315            controller
2316                .delete_rule(rule_id)
2317                .map(|revision| json!({ "revision": revision }))
2318        }
2319        "mobkit/access/groups/set" => {
2320            let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2321                return Some(invalid_params(response_id, "name required"));
2322            };
2323            match serde_json::from_value::<AccessGroup>(
2324                request.params.get("group").cloned().unwrap_or_default(),
2325            ) {
2326                Ok(group) => controller
2327                    .set_group(name, group)
2328                    .map(|revision| json!({ "revision": revision })),
2329                Err(err) => {
2330                    return Some(invalid_params(
2331                        response_id,
2332                        format!("invalid access group: {err}"),
2333                    ));
2334                }
2335            }
2336        }
2337        "mobkit/access/groups/delete" => {
2338            let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2339                return Some(invalid_params(response_id, "name required"));
2340            };
2341            controller
2342                .delete_group(name)
2343                .map(|revision| json!({ "revision": revision }))
2344        }
2345        "mobkit/access/enable" => {
2346            let Some(enabled) = request.params.get("enabled").and_then(Value::as_bool) else {
2347                return Some(invalid_params(response_id, "enabled (bool) required"));
2348            };
2349            controller
2350                .set_enabled(enabled)
2351                .map(|revision| json!({ "revision": revision }))
2352        }
2353        "mobkit/access/preview" => {
2354            let subject = request.params.get("subject").and_then(Value::as_str);
2355            let Some(action) = request.params.get("action").and_then(Value::as_str) else {
2356                return Some(invalid_params(response_id, "action required"));
2357            };
2358            let identity = request.params.get("identity").and_then(Value::as_str);
2359            let preview_view = controller.view_for_subject(subject);
2360            let decision = match identity {
2361                Some(identity) => preview_view.decide_agent(action, identity),
2362                None => preview_view.decide(action, &AccessResource::none()),
2363            };
2364            Ok(json!({
2365                "subject": subject,
2366                "action": action,
2367                "identity": identity,
2368                "allowed": decision.is_allow(),
2369                "reason": decision.reason(),
2370                "groups": preview_view.groups().iter().collect::<Vec<_>>(),
2371                "is_admin": preview_view.is_admin(),
2372            }))
2373        }
2374        _ => {
2375            return Some(response_value(
2376                response_id,
2377                None,
2378                Some(JsonRpcError {
2379                    code: -32601,
2380                    message: "Method not found".to_string(),
2381                    data: None,
2382                }),
2383            ));
2384        }
2385    };
2386    Some(match result {
2387        Ok(value) => response_value(response_id, Some(value), None),
2388        Err(err) => access_config_rpc_error(response_id, err),
2389    })
2390}
2391
2392fn cursor_is_after(candidate: &ConsoleCursor, current: &ConsoleCursor) -> bool {
2393    match (candidate.seq(), current.seq()) {
2394        (Some(candidate), Some(current)) => candidate > current,
2395        _ => candidate > current,
2396    }
2397}
2398
2399fn sse_event_from_timeline_event(event: &ConsoleTimelineEvent) -> Option<Event> {
2400    let (event_name, id) = match event {
2401        ConsoleTimelineEvent::SnapshotStarted { .. } => ("snapshot_started", None),
2402        ConsoleTimelineEvent::ConsoleFrame { frame } => (
2403            if frame.kind == "frame_updated" {
2404                "frame_updated"
2405            } else {
2406                "console_frame"
2407            },
2408            Some(frame.cursor.to_string()),
2409        ),
2410        ConsoleTimelineEvent::FrameUpdated { frame } => {
2411            ("frame_updated", Some(frame.cursor.to_string()))
2412        }
2413        ConsoleTimelineEvent::SnapshotComplete { cursor } => (
2414            "snapshot_complete",
2415            cursor.as_ref().map(ToString::to_string),
2416        ),
2417        ConsoleTimelineEvent::ReplayUnavailable { .. } => ("replay_unavailable", None),
2418    };
2419    let data = match serde_json::to_string(event) {
2420        Ok(value) => value,
2421        Err(_) => return None,
2422    };
2423    let mut sse = Event::default().event(event_name).data(data);
2424    if let Some(id) = id {
2425        sse = sse.id(id);
2426    }
2427    Some(sse)
2428}
2429
2430pub async fn console_rpc_multipart_handler(
2431    State(state): State<ConsoleJsonState>,
2432    headers: HeaderMap,
2433    uri: Uri,
2434    mut multipart: Multipart,
2435) -> impl IntoResponse {
2436    let auth_context = match console_request_auth_context(&state, &headers, &uri) {
2437        Some(context) => context,
2438        None => {
2439            return (
2440                StatusCode::UNAUTHORIZED,
2441                Json::<Value>(serde_json::json!({
2442                    "jsonrpc": JSONRPC_VERSION,
2443                    "id": Value::Null,
2444                    "error": {
2445                        "code": -32600,
2446                        "message": "unauthorized: console rpc requires a valid auth token",
2447                    }
2448                })),
2449            );
2450        }
2451    };
2452
2453    let mut payload: Option<String> = None;
2454    let mut files: std::collections::BTreeMap<String, MultipartImageUpload> =
2455        std::collections::BTreeMap::new();
2456
2457    while let Some(mut field) = match multipart.next_field().await {
2458        Ok(field) => field,
2459        Err(err) => {
2460            return (
2461                StatusCode::BAD_REQUEST,
2462                Json::<Value>(json_rpc_error_value(
2463                    Value::Null,
2464                    -32602,
2465                    format!("invalid multipart body: {err}"),
2466                )),
2467            );
2468        }
2469    } {
2470        let name = field.name().unwrap_or("").to_string();
2471        if name == "payload" {
2472            if payload.is_some() {
2473                return (
2474                    StatusCode::BAD_REQUEST,
2475                    Json::<Value>(json_rpc_error_value(
2476                        Value::Null,
2477                        -32602,
2478                        "duplicate payload part",
2479                    )),
2480                );
2481            }
2482            payload = match field.text().await {
2483                Ok(text) => Some(text),
2484                Err(err) => {
2485                    return (
2486                        StatusCode::BAD_REQUEST,
2487                        Json::<Value>(json_rpc_error_value(
2488                            Value::Null,
2489                            -32602,
2490                            format!("invalid payload part: {err}"),
2491                        )),
2492                    );
2493                }
2494            };
2495            continue;
2496        }
2497
2498        let Some(upload_id) = name.strip_prefix("file:").filter(|id| !id.is_empty()) else {
2499            return (
2500                StatusCode::BAD_REQUEST,
2501                Json::<Value>(json_rpc_error_value(
2502                    Value::Null,
2503                    -32602,
2504                    format!("unexpected multipart field: {name}"),
2505                )),
2506            );
2507        };
2508        if files.len() >= MAX_MULTIPART_IMAGES {
2509            return (
2510                StatusCode::BAD_REQUEST,
2511                Json::<Value>(json_rpc_error_value(
2512                    Value::Null,
2513                    -32602,
2514                    format!("too many image attachments; max {MAX_MULTIPART_IMAGES}"),
2515                )),
2516            );
2517        }
2518        if files.contains_key(upload_id) {
2519            return (
2520                StatusCode::BAD_REQUEST,
2521                Json::<Value>(json_rpc_error_value(
2522                    Value::Null,
2523                    -32602,
2524                    format!("duplicate file part for upload_id {upload_id}"),
2525                )),
2526            );
2527        }
2528        let media_type = field
2529            .content_type()
2530            .map(str::to_string)
2531            .unwrap_or_else(|| "application/octet-stream".to_string());
2532        if !is_allowed_image_media_type(&media_type) {
2533            return (
2534                StatusCode::BAD_REQUEST,
2535                Json::<Value>(json_rpc_error_value(
2536                    Value::Null,
2537                    -32602,
2538                    format!("unsupported image media type: {media_type}"),
2539                )),
2540            );
2541        }
2542        let mut bytes = bytes::BytesMut::new();
2543        loop {
2544            let chunk = match field.chunk().await {
2545                Ok(chunk) => chunk,
2546                Err(err) => {
2547                    return (
2548                        StatusCode::BAD_REQUEST,
2549                        Json::<Value>(json_rpc_error_value(
2550                            Value::Null,
2551                            -32602,
2552                            format!("invalid file part {upload_id}: {err}"),
2553                        )),
2554                    );
2555                }
2556            };
2557            let Some(chunk) = chunk else {
2558                break;
2559            };
2560            if bytes.len() + chunk.len() > MAX_MULTIPART_IMAGE_BYTES {
2561                return (
2562                    StatusCode::BAD_REQUEST,
2563                    Json::<Value>(json_rpc_error_value(
2564                        Value::Null,
2565                        -32602,
2566                        format!("image attachment {upload_id} exceeds 25 MiB"),
2567                    )),
2568                );
2569            }
2570            bytes.extend_from_slice(&chunk);
2571        }
2572        files.insert(
2573            upload_id.to_string(),
2574            MultipartImageUpload {
2575                media_type,
2576                bytes: bytes.freeze(),
2577            },
2578        );
2579    }
2580
2581    let payload = match payload {
2582        Some(payload) => payload,
2583        None => {
2584            return (
2585                StatusCode::BAD_REQUEST,
2586                Json::<Value>(json_rpc_error_value(
2587                    Value::Null,
2588                    -32602,
2589                    "payload part required",
2590                )),
2591            );
2592        }
2593    };
2594    let mut parsed_request = match serde_json::from_str::<JsonRpcRequest>(&payload) {
2595        Ok(req) => req,
2596        Err(err) => {
2597            return (
2598                StatusCode::OK,
2599                Json::<Value>(json_rpc_error_value(
2600                    Value::Null,
2601                    -32600,
2602                    format!("Invalid Request: {err}"),
2603                )),
2604            );
2605        }
2606    };
2607    let response_id = parsed_request.id.clone().unwrap_or(Value::Null);
2608    if state.decisions.console.read_only && is_console_mutating_rpc_method(&parsed_request.method) {
2609        return (
2610            StatusCode::OK,
2611            Json::<Value>(console_read_only_rpc_error(response_id)),
2612        );
2613    }
2614    match parsed_request.method.as_str() {
2615        "mobkit/console/send" => {
2616            let Some(aggregator) = &state.console_aggregator else {
2617                return (
2618                    StatusCode::OK,
2619                    Json::<Value>(invalid_params(
2620                        response_id,
2621                        "mobkit/console/send multipart requires a console aggregator",
2622                    )),
2623                );
2624            };
2625            let Some(identity) = parsed_request
2626                .params
2627                .get("identity")
2628                .and_then(Value::as_str)
2629            else {
2630                return (
2631                    StatusCode::OK,
2632                    Json::<Value>(invalid_params(response_id, "identity required")),
2633                );
2634            };
2635            // Gate BEFORE externalizing/persisting uploads, mirroring the JSON
2636            // /console/send gate-first ordering. Without this, a caller denied
2637            // `agent.send` on this identity could still write attacker-supplied
2638            // image bytes into the target identity's blob store (a pre-auth
2639            // side effect / storage-amplification vector) — the send is denied
2640            // later, but the blob write already happened. Prime the attribute
2641            // cache first so role/label-scoped deny rules resolve fail-closed.
2642            if let Some(runtime) = &state.runtime
2643                && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
2644            {
2645                prime_access_cache_from_runtime(runtime, controller).await;
2646            }
2647            if let Some(violation) = console_rpc_access_violation(
2648                auth_context.access_view.as_ref(),
2649                "mobkit/console/send",
2650                &parsed_request.params,
2651            ) {
2652                return (
2653                    StatusCode::OK,
2654                    Json::<Value>(response_value(response_id, None, Some(violation))),
2655                );
2656            }
2657            let binary_blob_store =
2658                match Box::pin(aggregator.binary_blob_store_for_identity(identity)).await {
2659                    Ok(Some(store)) => store,
2660                    Ok(None) => {
2661                        return (
2662                            StatusCode::OK,
2663                            Json::<Value>(invalid_params(
2664                                response_id,
2665                                "binary blob store unavailable for identity",
2666                            )),
2667                        );
2668                    }
2669                    Err(err) => {
2670                        return (
2671                            StatusCode::OK,
2672                            Json::<Value>(console_send_rpc_error(response_id, err)),
2673                        );
2674                    }
2675                };
2676            if let Err(message) = externalize_image_upload_placeholders(
2677                &mut parsed_request.params,
2678                files,
2679                binary_blob_store,
2680            )
2681            .await
2682            {
2683                return (
2684                    StatusCode::OK,
2685                    Json::<Value>(invalid_params(response_id, message)),
2686                );
2687            }
2688        }
2689        "mobkit/blob/upload" => {
2690            let Some(runtime) = &state.runtime else {
2691                return (
2692                    StatusCode::NOT_FOUND,
2693                    Json::<Value>(json_rpc_error_value(
2694                        response_id,
2695                        -32600,
2696                        "mobkit/blob/upload multipart requires a unified runtime",
2697                    )),
2698                );
2699            };
2700            let Some(binary_blob_store) = runtime.binary_blob_store() else {
2701                return (
2702                    StatusCode::INTERNAL_SERVER_ERROR,
2703                    Json::<Value>(json_rpc_error_value(
2704                        response_id,
2705                        -32000,
2706                        "binary blob store unavailable",
2707                    )),
2708                );
2709            };
2710            let result = match externalize_single_image_upload(
2711                &parsed_request.params,
2712                files,
2713                binary_blob_store,
2714            )
2715            .await
2716            {
2717                Ok(result) => result,
2718                Err(message) => {
2719                    return (
2720                        StatusCode::OK,
2721                        Json::<Value>(invalid_params(response_id, message)),
2722                    );
2723                }
2724            };
2725            return (
2726                StatusCode::OK,
2727                Json::<Value>(response_value(response_id, Some(result), None)),
2728            );
2729        }
2730        _ => {
2731            return (
2732                StatusCode::OK,
2733                Json::<Value>(invalid_params(
2734                    response_id,
2735                    "multipart RPC supports mobkit/console/send and mobkit/blob/upload only",
2736                )),
2737            );
2738        }
2739    }
2740    let response_value =
2741        if parsed_request.method == "mobkit/console/send" && state.runtime.is_none() {
2742            Box::pin(handle_console_aggregator_rpc(
2743                state.console_aggregator.clone(),
2744                parsed_request,
2745                true,
2746                state.decisions.console.read_only,
2747                state.access.as_ref(),
2748                auth_context.access_view.as_ref(),
2749            ))
2750            .await
2751        } else {
2752            let Some(runtime) = &state.runtime else {
2753                return (
2754                    StatusCode::NOT_FOUND,
2755                    Json::<Value>(json_rpc_error_value(
2756                        response_id,
2757                        -32600,
2758                        "console rpc multipart requires a unified runtime",
2759                    )),
2760                );
2761            };
2762            Box::pin(handle_console_runtime_rpc_with_visibility(
2763                runtime,
2764                state.module_runtime.clone(),
2765                state.contact_directory.as_ref(),
2766                state.gateway_peer_keys.as_ref(),
2767                state.console_events.clone(),
2768                state.console_aggregator.clone(),
2769                state.identity_runtime.clone(),
2770                state.metadata_table.clone(),
2771                state.mob_events.clone(),
2772                state.visibility_policy.as_ref(),
2773                parsed_request,
2774                true,
2775                state.decisions.console.read_only,
2776                auth_context.principal.as_deref(),
2777                state.access.as_ref(),
2778                auth_context.access_view.as_ref(),
2779                state.memory_panel.as_ref(),
2780            ))
2781            .await
2782        };
2783    (StatusCode::OK, Json::<Value>(response_value))
2784}
2785
2786pub async fn blob_get_handler(
2787    State(state): State<ConsoleJsonState>,
2788    headers: HeaderMap,
2789    uri: Uri,
2790    AxumPath(blob_id): AxumPath<String>,
2791) -> impl IntoResponse {
2792    if !console_request_authorized(&state, &headers, &uri) {
2793        return (
2794            StatusCode::UNAUTHORIZED,
2795            Json::<Value>(serde_json::json!({ "error": "unauthorized" })),
2796        )
2797            .into_response();
2798    }
2799    if !is_valid_blob_id_value(&blob_id) {
2800        return (
2801            StatusCode::BAD_REQUEST,
2802            Json::<Value>(serde_json::json!({ "error": "invalid_blob_id" })),
2803        )
2804            .into_response();
2805    }
2806    let blob_id = meerkat_core::BlobId::from(blob_id.as_str());
2807    let mut stores: Vec<std::sync::Arc<dyn BinaryBlobStore>> = Vec::new();
2808    if let Some(runtime) = &state.runtime
2809        && let Some(store) = runtime.binary_blob_store()
2810    {
2811        stores.push(store);
2812    }
2813    if let Some(aggregator) = &state.console_aggregator {
2814        stores.extend(aggregator.binary_blob_stores());
2815    }
2816    if stores.is_empty() {
2817        return (
2818            StatusCode::NOT_FOUND,
2819            Json::<Value>(serde_json::json!({ "error": "blob_store_unavailable" })),
2820        )
2821            .into_response();
2822    }
2823    for store in stores {
2824        match store.get_bytes(&blob_id).await {
2825            Ok(payload) => return blob_payload_response(payload),
2826            Err(meerkat_core::BlobStoreError::NotFound(_)) => continue,
2827            Err(err) => {
2828                return (
2829                    StatusCode::INTERNAL_SERVER_ERROR,
2830                    Json::<Value>(serde_json::json!({ "error": err.to_string() })),
2831                )
2832                    .into_response();
2833            }
2834        }
2835    }
2836    (
2837        StatusCode::NOT_FOUND,
2838        Json::<Value>(serde_json::json!({ "error": "blob_not_found" })),
2839    )
2840        .into_response()
2841}
2842
2843fn blob_payload_response(payload: BinaryBlobPayload) -> axum::response::Response {
2844    let mut response_headers = HeaderMap::new();
2845    let content_type = HeaderValue::from_str(&payload.media_type)
2846        .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
2847    response_headers.insert(header::CONTENT_TYPE, content_type);
2848    if let Ok(content_length) = HeaderValue::from_str(&payload.size.to_string()) {
2849        response_headers.insert(header::CONTENT_LENGTH, content_length);
2850    }
2851    response_headers.insert(
2852        header::CACHE_CONTROL,
2853        HeaderValue::from_static("private, max-age=31536000, immutable"),
2854    );
2855    (StatusCode::OK, response_headers, payload.data).into_response()
2856}
2857
2858fn console_request_authorized(state: &ConsoleJsonState, headers: &HeaderMap, uri: &Uri) -> bool {
2859    console_request_auth_context(state, headers, uri).is_some()
2860}
2861
2862fn console_request_auth_context(
2863    state: &ConsoleJsonState,
2864    headers: &HeaderMap,
2865    uri: &Uri,
2866) -> Option<ConsoleHttpAuthContext> {
2867    if !state.decisions.console.require_app_auth {
2868        // Open console: identify callers that volunteered a valid token so
2869        // per-user ABAC grants still apply; everyone else is anonymous.
2870        let principal = state.access.as_ref().and_then(|_| {
2871            let token = console_request_token(headers, uri)?;
2872            resolve_authorized_console_auth_from_token(&state.decisions, &token)
2873                .map(|auth| auth.email)
2874        });
2875        return Some(ConsoleHttpAuthContext {
2876            access_view: state
2877                .access
2878                .as_ref()
2879                .map(|controller| controller.view_for_subject(principal.as_deref())),
2880            principal,
2881        });
2882    }
2883    let token = console_request_token(headers, uri)?;
2884    let auth = resolve_authorized_console_auth_from_token(&state.decisions, &token)?;
2885    Some(ConsoleHttpAuthContext {
2886        access_view: state
2887            .access
2888            .as_ref()
2889            .map(|controller| controller.view_for_subject(Some(auth.email.as_str()))),
2890        principal: Some(auth.email),
2891    })
2892}
2893
2894fn console_request_token(headers: &HeaderMap, uri: &Uri) -> Option<String> {
2895    let bearer_token = headers
2896        .get(header::AUTHORIZATION)
2897        .and_then(|v| v.to_str().ok())
2898        .and_then(extract_bearer_token_from_header)
2899        .map(String::from);
2900    // Parse with form_urlencoded so percent-encoded tokens decode and
2901    // `xauth_token=` substring shadowing does NOT match the real key.
2902    let query_token = uri.query().and_then(|q| {
2903        form_urlencoded::parse(q.as_bytes())
2904            .find(|(key, _)| key == "auth_token")
2905            .map(|(_, value)| value.into_owned())
2906    });
2907    bearer_token.or(query_token)
2908}
2909
2910#[derive(Debug)]
2911struct MultipartImageUpload {
2912    media_type: String,
2913    bytes: bytes::Bytes,
2914}
2915
2916fn json_rpc_error_value(id: Value, code: i64, message: impl Into<String>) -> Value {
2917    serde_json::json!({
2918        "jsonrpc": JSONRPC_VERSION,
2919        "id": id,
2920        "error": {
2921            "code": code,
2922            "message": message.into(),
2923        }
2924    })
2925}
2926
2927fn is_allowed_image_media_type(media_type: &str) -> bool {
2928    matches!(
2929        media_type,
2930        "image/png" | "image/jpeg" | "image/webp" | "image/gif"
2931    )
2932}
2933
2934fn image_upload_part_name<'a>(
2935    object: &'a serde_json::Map<String, Value>,
2936    context: &str,
2937) -> Result<&'a str, String> {
2938    object
2939        .get("upload_id")
2940        .or_else(|| object.get("part_name"))
2941        .and_then(Value::as_str)
2942        .map(str::trim)
2943        .filter(|value| !value.is_empty())
2944        .ok_or_else(|| format!("{context}.upload_id or {context}.part_name is required"))
2945}
2946
2947async fn externalize_image_upload_placeholders(
2948    params: &mut Value,
2949    files: std::collections::BTreeMap<String, MultipartImageUpload>,
2950    blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
2951) -> Result<(), String> {
2952    let Some(content) = params.get_mut("content") else {
2953        return Err("multipart payload params.content is required".to_string());
2954    };
2955    let mut placeholders = std::collections::BTreeMap::<String, String>::new();
2956    collect_image_upload_placeholders(content, &mut placeholders)?;
2957    if placeholders.is_empty() {
2958        return Err(
2959            "multipart payload must contain at least one image_upload placeholder".to_string(),
2960        );
2961    }
2962    if placeholders.len() > MAX_MULTIPART_IMAGES {
2963        return Err(format!(
2964            "too many image_upload placeholders; max {MAX_MULTIPART_IMAGES}"
2965        ));
2966    }
2967    for upload_id in files.keys() {
2968        if !placeholders.contains_key(upload_id) {
2969            return Err(format!(
2970                "file part has no matching image_upload placeholder: {upload_id}"
2971            ));
2972        }
2973    }
2974    for upload_id in placeholders.keys() {
2975        if !files.contains_key(upload_id) {
2976            return Err(format!(
2977                "image_upload placeholder missing file part: {upload_id}"
2978            ));
2979        }
2980    }
2981
2982    let mut refs = std::collections::BTreeMap::<String, Value>::new();
2983    for (upload_id, file) in files {
2984        let declared_media_type = placeholders
2985            .get(&upload_id)
2986            .cloned()
2987            .unwrap_or_else(|| file.media_type.clone());
2988        if !is_allowed_image_media_type(&declared_media_type) {
2989            return Err(format!(
2990                "unsupported image media type in placeholder {upload_id}: {declared_media_type}"
2991            ));
2992        }
2993        if declared_media_type != file.media_type {
2994            return Err(format!(
2995                "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
2996                file.media_type
2997            ));
2998        }
2999        let blob_ref = blob_store
3000            .put_bytes(&file.media_type, file.bytes)
3001            .await
3002            .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3003        refs.insert(
3004            upload_id,
3005            serde_json::json!({
3006                "type": "image",
3007                "media_type": blob_ref.media_type,
3008                "source": "blob",
3009                "blob_id": blob_ref.blob_id,
3010            }),
3011        );
3012    }
3013    replace_image_upload_placeholders(content, &refs)?;
3014    if let Some(object) = params.as_object_mut() {
3015        object.remove("message");
3016    }
3017    Ok(())
3018}
3019
3020async fn externalize_single_image_upload(
3021    params: &Value,
3022    files: std::collections::BTreeMap<String, MultipartImageUpload>,
3023    blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3024) -> Result<Value, String> {
3025    let upload = params.get("upload").unwrap_or(params);
3026    if upload
3027        .get("type")
3028        .and_then(Value::as_str)
3029        .is_some_and(|kind| kind != "image_upload")
3030    {
3031        return Err("upload.type must be image_upload".to_string());
3032    }
3033    let upload_object = upload
3034        .as_object()
3035        .ok_or_else(|| "upload must be an object".to_string())?;
3036    let upload_id = image_upload_part_name(upload_object, "upload")?;
3037    let Some(file) = files.get(upload_id) else {
3038        return Err(format!(
3039            "image_upload placeholder missing file part: {upload_id}"
3040        ));
3041    };
3042    if files.len() != 1 {
3043        return Err("mobkit/blob/upload accepts exactly one file part".to_string());
3044    }
3045    let declared_media_type = upload
3046        .get("media_type")
3047        .and_then(Value::as_str)
3048        .unwrap_or(file.media_type.as_str());
3049    if !is_allowed_image_media_type(declared_media_type) {
3050        return Err(format!(
3051            "unsupported image media type in upload {upload_id}: {declared_media_type}"
3052        ));
3053    }
3054    if declared_media_type != file.media_type {
3055        return Err(format!(
3056            "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3057            file.media_type
3058        ));
3059    }
3060    let size = file.bytes.len() as u64;
3061    let blob_ref = blob_store
3062        .put_bytes(&file.media_type, file.bytes.clone())
3063        .await
3064        .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3065    Ok(json!({
3066        "blob_id": blob_ref.blob_id,
3067        "media_type": blob_ref.media_type,
3068        "size": size,
3069    }))
3070}
3071
3072fn collect_image_upload_placeholders(
3073    value: &Value,
3074    placeholders: &mut std::collections::BTreeMap<String, String>,
3075) -> Result<(), String> {
3076    match value {
3077        Value::Array(items) => {
3078            for item in items {
3079                collect_image_upload_placeholders(item, placeholders)?;
3080            }
3081        }
3082        Value::Object(object) => {
3083            if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3084                let upload_id = image_upload_part_name(object, "image_upload")?;
3085                let media_type = object
3086                    .get("media_type")
3087                    .and_then(Value::as_str)
3088                    .map(str::trim)
3089                    .filter(|value| !value.is_empty())
3090                    .ok_or_else(|| format!("image_upload {upload_id} requires media_type"))?;
3091                if placeholders
3092                    .insert(upload_id.to_string(), media_type.to_string())
3093                    .is_some()
3094                {
3095                    return Err(format!("duplicate image_upload placeholder: {upload_id}"));
3096                }
3097            } else {
3098                for child in object.values() {
3099                    collect_image_upload_placeholders(child, placeholders)?;
3100                }
3101            }
3102        }
3103        _ => {}
3104    }
3105    Ok(())
3106}
3107
3108fn replace_image_upload_placeholders(
3109    value: &mut Value,
3110    refs: &std::collections::BTreeMap<String, Value>,
3111) -> Result<(), String> {
3112    match value {
3113        Value::Array(items) => {
3114            for item in items {
3115                replace_image_upload_placeholders(item, refs)?;
3116            }
3117        }
3118        Value::Object(object) => {
3119            if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3120                let upload_id = image_upload_part_name(object, "image_upload")?;
3121                let replacement = refs
3122                    .get(upload_id)
3123                    .ok_or_else(|| format!("missing blob replacement for {upload_id}"))?;
3124                *value = replacement.clone();
3125            } else {
3126                for child in object.values_mut() {
3127                    replace_image_upload_placeholders(child, refs)?;
3128                }
3129            }
3130        }
3131        _ => {}
3132    }
3133    Ok(())
3134}
3135
3136fn response_value(id: Value, result: Option<Value>, error: Option<JsonRpcError>) -> Value {
3137    serde_json::to_value(JsonRpcResponse {
3138        jsonrpc: JSONRPC_VERSION.to_string(),
3139        id,
3140        result,
3141        error,
3142    })
3143    .unwrap_or_else(|_| {
3144        serde_json::json!({
3145            "jsonrpc": JSONRPC_VERSION,
3146            "id": Value::Null,
3147            "error": {
3148                "code": -32603,
3149                "message": "serialization failed",
3150            }
3151        })
3152    })
3153}
3154
3155fn invalid_params(id: Value, message: impl Into<String>) -> Value {
3156    response_value(
3157        id,
3158        None,
3159        Some(JsonRpcError {
3160            code: -32602,
3161            message: message.into(),
3162            data: None,
3163        }),
3164    )
3165}
3166
3167fn gating_decision_failed_error(id: Value, err: impl std::fmt::Display) -> Value {
3168    tracing::warn!(
3169        target: "mobkit::console",
3170        error = %err,
3171        "console gating decision failed"
3172    );
3173    invalid_params(id, "gating decision failed")
3174}
3175
3176fn runtime_binding_from_wire(
3177    binding: WireRuntimeBinding,
3178) -> Result<meerkat_mob::RuntimeBinding, String> {
3179    match binding {
3180        WireRuntimeBinding::Session => Ok(meerkat_mob::RuntimeBinding::Session),
3181        WireRuntimeBinding::External {
3182            address,
3183            bootstrap_token,
3184            identity,
3185        } => {
3186            let resolved = identity.resolve().map_err(|err| err.to_string())?;
3187            Ok(meerkat_mob::RuntimeBinding::External {
3188                peer_id: resolved.peer_id.to_string(),
3189                address,
3190                bootstrap_token,
3191                pubkey: resolved.pubkey,
3192            })
3193        }
3194    }
3195}
3196
3197fn parse_optional_runtime_mode(params: &Value) -> Result<Option<MobRuntimeMode>, String> {
3198    match params.get("runtime_mode") {
3199        None | Some(Value::Null) => Ok(None),
3200        Some(value) => serde_json::from_value::<MobRuntimeMode>(value.clone())
3201            .map(Some)
3202            .map_err(|err| {
3203                format!("runtime_mode must be \"autonomous_host\" or \"turn_driven\": {err}")
3204            }),
3205    }
3206}
3207
3208fn parse_optional_backend(params: &Value) -> Result<Option<MobBackendKind>, String> {
3209    match params.get("backend") {
3210        None | Some(Value::Null) => Ok(None),
3211        Some(value) => serde_json::from_value::<MobBackendKind>(value.clone())
3212            .map(Some)
3213            .map_err(|err| format!("backend must be \"session\" or \"external\": {err}")),
3214    }
3215}
3216
3217fn parse_optional_runtime_binding(
3218    params: &Value,
3219) -> Result<Option<meerkat_mob::RuntimeBinding>, String> {
3220    match params.get("binding") {
3221        None | Some(Value::Null) => Ok(None),
3222        Some(value) => {
3223            let wire = serde_json::from_value::<WireRuntimeBinding>(value.clone())
3224                .map_err(|err| format!("binding: {err}"))?;
3225            runtime_binding_from_wire(wire).map(Some)
3226        }
3227    }
3228}
3229
3230async fn member_entry_to_console_json(
3231    runtime: &MobRuntime,
3232    entry: &meerkat_mob::runtime::MobMemberListEntry,
3233) -> Value {
3234    let mut value = member_entry_to_json(entry);
3235    if let Some(object) = value.as_object_mut() {
3236        object.insert(
3237            "model_capabilities".to_string(),
3238            serde_json::to_value(model_capabilities_for_member_entry(
3239                runtime.handle().definition(),
3240                entry,
3241            ))
3242            .unwrap_or(Value::Null),
3243        );
3244    }
3245    value
3246}
3247
3248fn internal_error(id: Value, message: impl Into<String>) -> Value {
3249    let message = message.into();
3250    tracing::warn!(
3251        target: "mobkit::console",
3252        error = %message,
3253        "console JSON-RPC internal error"
3254    );
3255    response_value(
3256        id,
3257        None,
3258        Some(JsonRpcError {
3259            code: -32000,
3260            message: "internal error".to_string(),
3261            data: Some(json!({ "error": "internal_error" })),
3262        }),
3263    )
3264}
3265
3266/// Render a stale-cursor failure as a JSON-RPC envelope with code
3267/// `-32010`, a typed error body the SDKs can parse into the
3268/// `MobEventsStaleError` exception, and a `data` field carrying both
3269/// cursors so callers can rewind to the current frontier.
3270fn stale_event_cursor_response(id: Value, after_cursor: u64, latest_cursor: u64) -> Value {
3271    response_value(
3272        id,
3273        None,
3274        Some(JsonRpcError {
3275            code: crate::rpc::MOB_EVENTS_STALE_CURSOR_CODE,
3276            message: format!(
3277                "stale mob event cursor: requested {after_cursor}, latest {latest_cursor}"
3278            ),
3279            data: Some(serde_json::json!({
3280                "error": "event_query_stale",
3281                "after_cursor": after_cursor,
3282                "latest_cursor": latest_cursor,
3283            })),
3284        }),
3285    )
3286}
3287
3288fn console_timeline_replay_unavailable_response(
3289    id: Value,
3290    err: ConsoleLogError,
3291    requested_cursor: Option<&ConsoleCursor>,
3292    latest_cursor: Option<ConsoleCursor>,
3293) -> Value {
3294    tracing::warn!(
3295        target: "mobkit::console",
3296        error = %err,
3297        "console timeline replay unavailable"
3298    );
3299    response_value(
3300        id,
3301        None,
3302        Some(JsonRpcError {
3303            code: crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE,
3304            message: "timeline replay unavailable".to_string(),
3305            data: Some(json!({
3306                "error": "replay_unavailable",
3307                "stream": "timeline",
3308                "requested_cursor": requested_cursor.map(ToString::to_string),
3309                "latest_cursor": latest_cursor.map(|cursor| cursor.to_string()),
3310            })),
3311        }),
3312    )
3313}
3314
3315fn parse_console_helper_options(
3316    options_val: Option<&Value>,
3317) -> Result<meerkat_mob::HelperOptions, String> {
3318    crate::rpc::mob_methods::parse_helper_options(options_val)
3319}
3320
3321fn member_is_addressable(member: &meerkat_mob::runtime::MobMemberListEntry) -> bool {
3322    member
3323        .labels
3324        .get("addressable")
3325        .map(|value: &String| !value.eq_ignore_ascii_case("false"))
3326        .unwrap_or(true)
3327}
3328
3329fn member_addressability(member: &meerkat_mob::runtime::MobMemberListEntry) -> &'static str {
3330    if member_is_addressable(member) {
3331        "addressable"
3332    } else {
3333        "internal_only"
3334    }
3335}
3336
3337fn console_identity_status_json_for_identity(
3338    identity: &str,
3339    member: &meerkat_mob::runtime::MobMemberListEntry,
3340    session_id: Option<String>,
3341    response_phase: Option<String>,
3342) -> Value {
3343    json!({
3344        "identity": identity,
3345        "state": member_status_state_string(member.status),
3346        "role": member.role.to_string(),
3347        "addressability": member_addressability(member),
3348        "display_name": member.labels.get("display_name"),
3349        "labels": member.labels,
3350        "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3351        "session_id": session_id,
3352        "generation": Value::Null,
3353        "checkpoint_version": Value::Null,
3354        "continuity_health": Value::Null,
3355        "lease_healthy": Value::Null,
3356        "lease": Value::Null,
3357        "response_phase": response_phase,
3358    })
3359}
3360
3361fn console_identity_inspect_json_for_identity(
3362    identity: &str,
3363    member: &meerkat_mob::runtime::MobMemberListEntry,
3364    session_id: Option<String>,
3365    response_phase: Option<String>,
3366) -> Value {
3367    let peers: Vec<String> = member.wired_to.iter().map(ToString::to_string).collect();
3368    json!({
3369        "identity": identity,
3370        "state": member_status_state_string(member.status),
3371        "role": member.role.to_string(),
3372        "addressability": member_addressability(member),
3373        "display_name": member.labels.get("display_name"),
3374        "labels": member.labels,
3375        "continuity_health": Value::Null,
3376        "lease_healthy": Value::Null,
3377        "lease": Value::Null,
3378        "continuity": {
3379            "generation": Value::Null,
3380            "checkpoint_version": Value::Null,
3381            "session_id": session_id,
3382            "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3383        },
3384        "topology_peers": peers,
3385        "output_preview": Value::Null,
3386        "response_phase": response_phase,
3387    })
3388}
3389
3390/// Resolve a mob member by identity plus its current bridge session id.
3391///
3392/// Returns `None` if no member with the given identity exists.
3393async fn lookup_member_with_session(
3394    handle: &MobHandle,
3395    identity: &AgentIdentity,
3396) -> Option<(meerkat_mob::runtime::MobMemberListEntry, Option<String>)> {
3397    let entries = handle.list_members_including_retiring().await;
3398    let entry = entries
3399        .into_iter()
3400        .find(|e| &e.agent_identity == identity)?;
3401    let session_id = handle
3402        .resolve_bridge_session_id_observation(identity)
3403        .await
3404        .map(|s| s.to_string());
3405    Some((entry, session_id))
3406}
3407
3408#[derive(Debug, Clone)]
3409struct ConsoleRuntimeIdentityAlias {
3410    identity: String,
3411    runtime_member_id: String,
3412    member: meerkat_mob::runtime::MobMemberListEntry,
3413    session_id: Option<String>,
3414}
3415
3416fn durable_identity_for_member(member: &meerkat_mob::runtime::MobMemberListEntry) -> String {
3417    member
3418        .labels
3419        .get("agent_identity")
3420        .filter(|value| !value.trim().is_empty())
3421        .cloned()
3422        // Fallback surfaces the public alias, not the comms-safe roster id.
3423        .unwrap_or_else(|| {
3424            crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned()
3425        })
3426}
3427
3428async fn lookup_member_alias_with_session(
3429    handle: &MobHandle,
3430    visibility_policy: &dyn ConsoleVisibilityPolicy,
3431    requested_identity: &str,
3432) -> Result<Option<ConsoleRuntimeIdentityAlias>, JsonRpcError> {
3433    let all_matches = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
3434    let mut visible_matches = Vec::new();
3435    for alias in &all_matches {
3436        if runtime_alias_visible_to_console(handle, visibility_policy, alias) {
3437            visible_matches.push(alias.clone());
3438        }
3439    }
3440    let member = if visible_matches.len() > 1 {
3441        return Err(ambiguous_live_identity_alias_error(
3442            requested_identity,
3443            &visible_matches
3444                .iter()
3445                .map(|alias| alias.runtime_member_id.clone())
3446                .collect::<Vec<_>>(),
3447        ));
3448    } else if let Some(alias) = visible_matches.into_iter().next() {
3449        Some(alias)
3450    } else {
3451        all_matches.into_iter().next()
3452    };
3453    Ok(member)
3454}
3455
3456async fn lookup_visible_member_alias_candidates_with_session(
3457    handle: &MobHandle,
3458    visibility_policy: &dyn ConsoleVisibilityPolicy,
3459    requested_identity: &str,
3460) -> Vec<ConsoleRuntimeIdentityAlias> {
3461    let mut visible = Vec::new();
3462    for alias in lookup_member_alias_candidates_with_session(handle, requested_identity).await {
3463        if runtime_alias_visible_to_console(handle, visibility_policy, &alias) {
3464            visible.push(alias);
3465        }
3466    }
3467    visible
3468}
3469
3470async fn lookup_member_alias_candidates_with_session(
3471    handle: &MobHandle,
3472    requested_identity: &str,
3473) -> Vec<ConsoleRuntimeIdentityAlias> {
3474    // Requested identities arrive in the public alias space; roster ids are
3475    // their comms-safe encodings (meerkat 0.7 MemberCommsName).
3476    let requested_member_id = crate::member_comms_id::mob_member_id(requested_identity);
3477    let entries = handle.list_members_including_retiring().await;
3478    let exact_matches = entries
3479        .iter()
3480        .filter(|entry| entry.agent_identity == requested_member_id)
3481        .cloned()
3482        .collect::<Vec<_>>();
3483    let label_matches = entries
3484        .iter()
3485        .filter(|entry| {
3486            entry
3487                .labels
3488                .get("agent_identity")
3489                .is_some_and(|identity| identity == requested_identity)
3490        })
3491        .cloned()
3492        .collect::<Vec<_>>();
3493    let mut matches = exact_matches;
3494    matches.extend(label_matches);
3495    let mut seen_member_ids = BTreeSet::new();
3496    matches.retain(|entry| seen_member_ids.insert(entry.agent_identity.to_string()));
3497    let mut aliases = Vec::with_capacity(matches.len());
3498    for member in matches {
3499        let runtime_member_id =
3500            crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3501        let identity = durable_identity_for_member(&member);
3502        let session_id = handle
3503            .resolve_bridge_session_id_observation(&member.agent_identity)
3504            .await
3505            .map(|s| s.to_string());
3506        aliases.push(ConsoleRuntimeIdentityAlias {
3507            identity,
3508            runtime_member_id,
3509            member,
3510            session_id,
3511        });
3512    }
3513    aliases
3514}
3515
3516async fn reject_ambiguous_projected_live_identity(
3517    handle: &MobHandle,
3518    visibility_policy: &dyn ConsoleVisibilityPolicy,
3519    alias: &ConsoleRuntimeIdentityAlias,
3520) -> Result<(), JsonRpcError> {
3521    let candidates = lookup_visible_member_alias_candidates_with_session(
3522        handle,
3523        visibility_policy,
3524        &alias.identity,
3525    )
3526    .await;
3527    if candidates.len() > 1 {
3528        return Err(ambiguous_live_identity_alias_error(
3529            &alias.identity,
3530            &candidates
3531                .iter()
3532                .map(|candidate| candidate.runtime_member_id.clone())
3533                .collect::<Vec<_>>(),
3534        ));
3535    }
3536    Ok(())
3537}
3538
3539fn ambiguous_live_identity_alias_error(
3540    requested_identity: &str,
3541    candidates: &[String],
3542) -> JsonRpcError {
3543    JsonRpcError {
3544        code: -32602,
3545        message: format!(
3546            "ambiguous live identity alias {requested_identity}: candidates [{}]",
3547            candidates.join(", ")
3548        ),
3549        data: Some(json!({
3550            "kind": "ambiguous_live_identity_alias",
3551            "identity": requested_identity,
3552            "candidates": candidates,
3553        })),
3554    }
3555}
3556
3557async fn lookup_member_runtime_alias_with_session(
3558    handle: &MobHandle,
3559    runtime_member_id: &str,
3560) -> Option<ConsoleRuntimeIdentityAlias> {
3561    let requested_member_id = crate::member_comms_id::mob_member_id(runtime_member_id);
3562    let entries = handle.list_members_including_retiring().await;
3563    let member = entries
3564        .into_iter()
3565        .find(|entry| entry.agent_identity == requested_member_id)?;
3566    let runtime_member_id =
3567        crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3568    let identity = durable_identity_for_member(&member);
3569    let session_id = handle
3570        .resolve_bridge_session_id_observation(&member.agent_identity)
3571        .await
3572        .map(|s| s.to_string());
3573    Some(ConsoleRuntimeIdentityAlias {
3574        identity,
3575        runtime_member_id,
3576        member,
3577        session_id,
3578    })
3579}
3580
3581async fn identity_runtime_alias(
3582    identity_runtime: &crate::identity_first::IdentityRuntime,
3583    requested_identity: &str,
3584) -> Result<Option<(crate::identity_first::AgentIdentity, bool)>, String> {
3585    if requested_identity.starts_with("rt:") {
3586        for status in identity_runtime.statuses().await {
3587            if status
3588                .agent_runtime_id
3589                .as_ref()
3590                .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
3591            {
3592                return Ok(Some((status.identity, false)));
3593            }
3594        }
3595        return Ok(None);
3596    }
3597    if let Ok(identity) = crate::identity_first::AgentIdentity::parse(requested_identity) {
3598        match identity_runtime.status(&identity).await {
3599            Ok(_) => return Ok(Some((identity, true))),
3600            Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
3601            Err(err) => return Err(err.to_string()),
3602        }
3603    }
3604
3605    for status in identity_runtime.statuses().await {
3606        if status
3607            .agent_runtime_id
3608            .as_ref()
3609            .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
3610        {
3611            return Ok(Some((status.identity, false)));
3612        }
3613    }
3614    Ok(None)
3615}
3616
3617async fn resolve_console_identity_control_target(
3618    handle: &MobHandle,
3619    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
3620    visibility_policy: &dyn ConsoleVisibilityPolicy,
3621    requested_identity: &str,
3622) -> Result<
3623    Option<(
3624        crate::identity_first::AgentIdentity,
3625        bool,
3626        Option<ConsoleRuntimeIdentityAlias>,
3627    )>,
3628    JsonRpcError,
3629> {
3630    if let Some(identity_runtime) = identity_runtime {
3631        match identity_runtime_alias(identity_runtime, requested_identity).await {
3632            Ok(Some((identity, exact))) => {
3633                let live = if exact {
3634                    let registered_live = match identity_runtime.status(&identity).await {
3635                        Ok(status) => match status.agent_runtime_id.as_ref() {
3636                            Some(runtime_id) => {
3637                                lookup_member_runtime_alias_with_session(
3638                                    handle,
3639                                    runtime_id.as_str(),
3640                                )
3641                                .await
3642                            }
3643                            None => None,
3644                        },
3645                        Err(_) => None,
3646                    };
3647                    if let Some(alias) = registered_live.as_ref()
3648                        && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
3649                    {
3650                        return Err(identity_hidden_by_policy_error(requested_identity));
3651                    }
3652                    if let Some(registered) = registered_live {
3653                        return Ok(Some((identity, exact, Some(registered))));
3654                    }
3655                    let requested_live_candidates =
3656                        lookup_visible_member_alias_candidates_with_session(
3657                            handle,
3658                            visibility_policy,
3659                            requested_identity,
3660                        )
3661                        .await;
3662                    let requested_live = if requested_live_candidates.len() > 1 {
3663                        return Err(ambiguous_live_identity_alias_error(
3664                            requested_identity,
3665                            &requested_live_candidates
3666                                .iter()
3667                                .map(|alias| alias.runtime_member_id.clone())
3668                                .collect::<Vec<_>>(),
3669                        ));
3670                    } else {
3671                        requested_live_candidates.into_iter().next()
3672                    };
3673                    match (registered_live, requested_live) {
3674                        (Some(registered), Some(requested))
3675                            if registered.runtime_member_id == requested.runtime_member_id =>
3676                        {
3677                            Some(registered)
3678                        }
3679                        (Some(registered), None) => Some(registered),
3680                        (Some(_registered), Some(requested)) => Some(requested),
3681                        (None, requested) => requested,
3682                    }
3683                } else {
3684                    let registered_live =
3685                        lookup_member_runtime_alias_with_session(handle, requested_identity).await;
3686                    if let Some(alias) = registered_live.as_ref()
3687                        && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
3688                    {
3689                        return Err(identity_hidden_by_policy_error(requested_identity));
3690                    }
3691                    if let Some(registered) = registered_live {
3692                        return Ok(Some((identity, exact, Some(registered))));
3693                    }
3694                    let durable_live_candidates =
3695                        lookup_visible_member_alias_candidates_with_session(
3696                            handle,
3697                            visibility_policy,
3698                            identity.as_str(),
3699                        )
3700                        .await;
3701                    let durable_live = if durable_live_candidates.len() > 1 {
3702                        return Err(ambiguous_live_identity_alias_error(
3703                            identity.as_str(),
3704                            &durable_live_candidates
3705                                .iter()
3706                                .map(|alias| alias.runtime_member_id.clone())
3707                                .collect::<Vec<_>>(),
3708                        ));
3709                    } else {
3710                        durable_live_candidates.into_iter().next()
3711                    };
3712                    match (registered_live, durable_live) {
3713                        (Some(registered), Some(durable))
3714                            if registered.runtime_member_id == durable.runtime_member_id =>
3715                        {
3716                            Some(registered)
3717                        }
3718                        (Some(registered), None) => Some(registered),
3719                        (Some(_registered), Some(durable)) => Some(durable),
3720                        (None, durable) => durable,
3721                    }
3722                };
3723                return Ok(Some((identity, exact, live)));
3724            }
3725            Ok(None) => {}
3726            Err(err) => {
3727                return Err(JsonRpcError {
3728                    code: -32000,
3729                    message: err,
3730                    data: None,
3731                });
3732            }
3733        }
3734    }
3735
3736    let live_alias =
3737        lookup_member_alias_with_session(handle, visibility_policy, requested_identity).await?;
3738    let Some(alias) = live_alias else {
3739        return Ok(None);
3740    };
3741    if let Some(identity_runtime) = identity_runtime
3742        && let Some(bound_status) = identity_runtime
3743            .statuses()
3744            .await
3745            .into_iter()
3746            .find(|status| {
3747                status
3748                    .agent_runtime_id
3749                    .as_ref()
3750                    .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
3751            })
3752        && bound_status.identity.as_str() != alias.identity
3753    {
3754        return Err(JsonRpcError {
3755            code: -32000,
3756            message: format!(
3757                "stale live identity alias: live console alias {} resolves to {}, but identity runtime binding belongs to {}",
3758                alias.identity,
3759                alias.runtime_member_id,
3760                bound_status.identity.as_str(),
3761            ),
3762            data: Some(json!({
3763                "kind": "stale_live_identity_alias",
3764                "identity": alias.identity,
3765                "runtime_member_id": alias.runtime_member_id,
3766                "registered_identity": bound_status.identity.as_str(),
3767            })),
3768        });
3769    }
3770    let identity = crate::identity_first::AgentIdentity::parse(&alias.identity).map_err(|err| {
3771        JsonRpcError {
3772            code: -32602,
3773            message: format!("invalid identity: {err}"),
3774            data: None,
3775        }
3776    })?;
3777    let durable_live_candidates = lookup_visible_member_alias_candidates_with_session(
3778        handle,
3779        visibility_policy,
3780        identity.as_str(),
3781    )
3782    .await;
3783    if durable_live_candidates.len() > 1 {
3784        return Err(ambiguous_live_identity_alias_error(
3785            identity.as_str(),
3786            &durable_live_candidates
3787                .iter()
3788                .map(|alias| alias.runtime_member_id.clone())
3789                .collect::<Vec<_>>(),
3790        ));
3791    }
3792    Ok(Some((identity, false, Some(alias))))
3793}
3794
3795fn live_alias_matches_status_runtime(
3796    alias: Option<&ConsoleRuntimeIdentityAlias>,
3797    status: &crate::identity_first::IdentityStatus,
3798) -> bool {
3799    let Some(alias) = alias else {
3800        return true;
3801    };
3802    let session_matches = match (
3803        status.session_id.as_ref().map(ToString::to_string),
3804        alias.session_id.as_deref(),
3805    ) {
3806        (Some(status_session), Some(live_session)) => status_session == live_session,
3807        _ => true,
3808    };
3809    status
3810        .agent_runtime_id
3811        .as_ref()
3812        .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
3813        && alias.identity == status.identity.as_str()
3814        && session_matches
3815}
3816
3817async fn stale_live_alias_json_rpc_error(
3818    operation: &str,
3819    identity_runtime: &crate::identity_first::IdentityRuntime,
3820    identity: &crate::identity_first::AgentIdentity,
3821    live_alias: Option<&ConsoleRuntimeIdentityAlias>,
3822) -> Option<JsonRpcError> {
3823    let live_alias = live_alias?;
3824    let Ok(status) = identity_runtime.status(identity).await else {
3825        return None;
3826    };
3827    if live_alias_matches_status_runtime(Some(live_alias), &status) {
3828        return None;
3829    }
3830    let registered_runtime_member_id = status
3831        .agent_runtime_id
3832        .as_ref()
3833        .map(crate::identity_first::AgentRuntimeId::as_str);
3834    Some(JsonRpcError {
3835        code: -32000,
3836        message: format!(
3837            "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
3838            identity.as_str(),
3839            registered_runtime_member_id.unwrap_or("<none>"),
3840            live_alias.runtime_member_id
3841        ),
3842        data: Some(json!({
3843            "kind": "stale_identity_runtime_binding",
3844            "identity": identity.as_str(),
3845            "registered_runtime_member_id": registered_runtime_member_id,
3846            "live_runtime_member_id": live_alias.runtime_member_id,
3847            "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
3848            "live_session_id": live_alias.session_id,
3849        })),
3850    })
3851}
3852
3853fn identity_from_runtime_alias(alias: &str) -> Option<crate::identity_first::AgentIdentity> {
3854    let rest = alias.strip_prefix("rt:")?;
3855    let (identity, generation) = rest.rsplit_once(':')?;
3856    if identity.is_empty()
3857        || generation.is_empty()
3858        || !generation.chars().all(|ch| ch.is_ascii_digit())
3859    {
3860        return None;
3861    }
3862    crate::identity_first::AgentIdentity::parse(identity).ok()
3863}
3864
3865async fn stale_runtime_alias_json_rpc_error(
3866    operation: &str,
3867    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
3868    alias: &str,
3869) -> Option<JsonRpcError> {
3870    let identity_runtime = identity_runtime?;
3871    let identity = identity_from_runtime_alias(alias)?;
3872    let Ok(status) = identity_runtime.status(&identity).await else {
3873        return None;
3874    };
3875    let registered_runtime_member_id = status
3876        .agent_runtime_id
3877        .as_ref()
3878        .map(crate::identity_first::AgentRuntimeId::as_str);
3879    if registered_runtime_member_id == Some(alias) {
3880        return None;
3881    }
3882    Some(JsonRpcError {
3883        code: -32000,
3884        message: format!(
3885            "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
3886            identity.as_str(),
3887            registered_runtime_member_id.unwrap_or("<none>"),
3888            alias
3889        ),
3890        data: Some(json!({
3891            "kind": "stale_identity_runtime_binding",
3892            "identity": identity.as_str(),
3893            "registered_runtime_member_id": registered_runtime_member_id,
3894            "live_runtime_member_id": alias,
3895            "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
3896            "live_session_id": Value::Null,
3897        })),
3898    })
3899}
3900
3901fn reset_requires_session_bridge_json_rpc_error() -> JsonRpcError {
3902    JsonRpcError {
3903        code: -32602,
3904        message: "reset requires an identity runtime with a session bridge".to_string(),
3905        data: Some(json!({
3906            "kind": "identity_reset_requires_session_bridge",
3907        })),
3908    }
3909}
3910
3911fn console_identity_status_json_from_record(
3912    record: &crate::console_aggregator::ConsoleIdentityRecord,
3913    response_phase: Option<String>,
3914) -> Value {
3915    json!({
3916        "identity": record.identity,
3917        "state": record.health,
3918        "role": record.labels.get("role"),
3919        "addressability": if record.addressable { "addressable" } else { "internal_only" },
3920        "display_name": record.display_name,
3921        "labels": record.labels,
3922        "agent_runtime_id": record.runtime_member_id,
3923        "session_id": record.session_id,
3924        "generation": Value::Null,
3925        "checkpoint_version": Value::Null,
3926        "continuity_health": Value::Null,
3927        "lease_healthy": Value::Null,
3928        "lease": Value::Null,
3929        "response_phase": response_phase,
3930    })
3931}
3932
3933fn console_addressability_json(
3934    addressability: crate::identity_first::AgentAddressability,
3935) -> &'static str {
3936    match addressability {
3937        crate::identity_first::AgentAddressability::Addressable => "addressable",
3938        crate::identity_first::AgentAddressability::InternalOnly => "internal_only",
3939    }
3940}
3941
3942fn console_identity_record_from_identity_status(
3943    status: &crate::identity_first::IdentityStatus,
3944) -> ConsoleIdentityRecord {
3945    let mut labels = status.labels.clone();
3946    if let Some(profile) = status.profile.as_ref() {
3947        labels
3948            .entry("role".to_string())
3949            .or_insert_with(|| profile.as_str().to_string());
3950    }
3951    let runtime_member_id = status
3952        .agent_runtime_id
3953        .as_ref()
3954        .map(crate::identity_first::AgentRuntimeId::as_str)
3955        .unwrap_or_else(|| status.identity.as_str())
3956        .to_string();
3957    let addressable = status.addressability
3958        == crate::identity_first::AgentAddressability::Addressable
3959        && matches!(
3960            status.state,
3961            crate::identity_first::IdentityLifecycleState::Active
3962                | crate::identity_first::IdentityLifecycleState::Dormant
3963                | crate::identity_first::IdentityLifecycleState::Uninitialized
3964        );
3965    let visibility = match status.state {
3966        crate::identity_first::IdentityLifecycleState::Retiring => {
3967            ConsoleVisibility::RetiredReadable
3968        }
3969        crate::identity_first::IdentityLifecycleState::Broken
3970        | crate::identity_first::IdentityLifecycleState::Suspended => {
3971            ConsoleVisibility::Unreachable
3972        }
3973        _ if addressable => ConsoleVisibility::Addressable,
3974        _ => ConsoleVisibility::Hidden,
3975    };
3976    let health = match status.state {
3977        crate::identity_first::IdentityLifecycleState::Active => "ready",
3978        crate::identity_first::IdentityLifecycleState::Dormant => "dormant",
3979        crate::identity_first::IdentityLifecycleState::Uninitialized => "uninitialized",
3980        crate::identity_first::IdentityLifecycleState::Broken => "broken",
3981        crate::identity_first::IdentityLifecycleState::Suspended => "suspended",
3982        crate::identity_first::IdentityLifecycleState::Retiring => "retired",
3983    }
3984    .to_string();
3985    ConsoleIdentityRecord {
3986        identity: status.identity.as_str().to_string(),
3987        display_name: status
3988            .display_name
3989            .as_ref()
3990            .map(crate::identity_first::DisplayName::as_str)
3991            .unwrap_or_else(|| status.identity.as_str())
3992            .to_string(),
3993        runtime_key: "identity-first".to_string(),
3994        runtime_member_id,
3995        session_id: status.session_id.as_ref().map(ToString::to_string),
3996        visibility,
3997        addressable,
3998        health,
3999        topology_peers: Vec::new(),
4000        labels,
4001    }
4002}
4003
4004fn identity_hidden_by_policy_response(response_id: Value, identity: &str) -> Value {
4005    response_value(
4006        response_id,
4007        None,
4008        Some(identity_hidden_by_policy_error(identity)),
4009    )
4010}
4011
4012fn identity_hidden_by_policy_error(identity: &str) -> JsonRpcError {
4013    JsonRpcError {
4014        code: -32001,
4015        message: format!("unknown identity: {identity}"),
4016        data: Some(json!({
4017            "kind": "identity_hidden_by_policy",
4018            "identity": identity,
4019        })),
4020    }
4021}
4022
4023fn identity_status_visible_to_console(
4024    visibility_policy: &dyn ConsoleVisibilityPolicy,
4025    status: &crate::identity_first::IdentityStatus,
4026) -> bool {
4027    visibility_policy.identity_visible(&console_identity_record_from_identity_status(status))
4028}
4029
4030fn console_member_from_runtime_alias(
4031    handle: &MobHandle,
4032    alias: &ConsoleRuntimeIdentityAlias,
4033) -> ConsoleMember {
4034    ConsoleMember {
4035        agent_identity: alias.runtime_member_id.clone(),
4036        role: alias.member.role.to_string(),
4037        state: member_status_state_string(alias.member.status),
4038        model_capabilities: model_capabilities_for_member_entry(handle.definition(), &alias.member),
4039        runtime_mode: Some(alias.member.runtime_mode.to_string()),
4040        session_id: alias.session_id.clone(),
4041        wired_to: alias
4042            .member
4043            .wired_to
4044            .iter()
4045            .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
4046            .collect(),
4047        labels: alias.member.labels.clone(),
4048    }
4049}
4050
4051fn console_identity_record_from_runtime_alias(
4052    alias: &ConsoleRuntimeIdentityAlias,
4053) -> ConsoleIdentityRecord {
4054    let addressable = alias
4055        .member
4056        .labels
4057        .get("addressable")
4058        .map(|value| !value.eq_ignore_ascii_case("false"))
4059        .unwrap_or(true)
4060        && alias.member.status == meerkat_mob::MobMemberStatus::Active;
4061    let visibility = match alias.member.status {
4062        meerkat_mob::MobMemberStatus::Retiring | meerkat_mob::MobMemberStatus::Completed => {
4063            ConsoleVisibility::RetiredReadable
4064        }
4065        // Broken/unknown members have no live runtime binding; mirror the
4066        // identity-first projection, which marks them unreachable.
4067        meerkat_mob::MobMemberStatus::Broken | meerkat_mob::MobMemberStatus::Unknown => {
4068            ConsoleVisibility::Unreachable
4069        }
4070        _ if addressable => ConsoleVisibility::Addressable,
4071        _ => ConsoleVisibility::Hidden,
4072    };
4073    ConsoleIdentityRecord {
4074        identity: alias.identity.clone(),
4075        display_name: alias
4076            .member
4077            .labels
4078            .get("display_name")
4079            .cloned()
4080            .unwrap_or_else(|| alias.identity.clone()),
4081        runtime_key: "runtime".to_string(),
4082        runtime_member_id: alias.runtime_member_id.clone(),
4083        session_id: alias.session_id.clone(),
4084        visibility,
4085        addressable,
4086        health: match alias.member.status {
4087            meerkat_mob::MobMemberStatus::Active => "ready".to_string(),
4088            meerkat_mob::MobMemberStatus::Retiring => "retired".to_string(),
4089            // New machine statuses surface verbatim (broken/completed/unknown),
4090            // matching the identity-first health vocabulary.
4091            other => format!("{other:?}").to_ascii_lowercase(),
4092        },
4093        topology_peers: alias
4094            .member
4095            .wired_to
4096            .iter()
4097            .map(ToString::to_string)
4098            .collect(),
4099        labels: alias.member.labels.clone(),
4100    }
4101}
4102
4103fn runtime_alias_visible_to_console(
4104    handle: &MobHandle,
4105    visibility_policy: &dyn ConsoleVisibilityPolicy,
4106    alias: &ConsoleRuntimeIdentityAlias,
4107) -> bool {
4108    let member = console_member_from_runtime_alias(handle, alias);
4109    if !visibility_policy.member_visible(&member) {
4110        return false;
4111    }
4112    visibility_policy.identity_visible(&console_identity_record_from_runtime_alias(alias))
4113}
4114
4115fn console_identity_status_json_from_identity_status(
4116    status: &crate::identity_first::IdentityStatus,
4117    response_phase: Option<String>,
4118) -> Value {
4119    json!({
4120        "identity": status.identity.as_str(),
4121        "state": status.state.wire_str(),
4122        "role": status.profile.as_ref().map(ProfileName::as_str),
4123        "addressability": console_addressability_json(status.addressability),
4124        "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4125        "labels": status.labels,
4126        "agent_runtime_id": status.agent_runtime_id.as_ref().map(crate::identity_first::AgentRuntimeId::as_str),
4127        "session_id": status.session_id.as_ref().map(ToString::to_string),
4128        "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4129        "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4130        "continuity_health": status.continuity_health,
4131        "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4132        "lease": status.lease.as_ref().map(|lease| json!({
4133            "fencing_token": lease.fencing_token.get(),
4134            "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4135            "healthy": lease.healthy,
4136        })),
4137        "response_phase": response_phase,
4138    })
4139}
4140
4141fn console_identity_inspect_json_from_identity_status(
4142    status: &crate::identity_first::IdentityStatus,
4143    live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4144    response_phase: Option<String>,
4145) -> Value {
4146    let topology_peers = live_alias
4147        .map(|alias| {
4148            alias
4149                .member
4150                .wired_to
4151                .iter()
4152                .map(ToString::to_string)
4153                .map(Value::String)
4154                .collect::<Vec<_>>()
4155        })
4156        .unwrap_or_default();
4157    let session_id = status
4158        .session_id
4159        .as_ref()
4160        .map(ToString::to_string)
4161        .or_else(|| live_alias.and_then(|alias| alias.session_id.clone()));
4162    let agent_runtime_id = status
4163        .agent_runtime_id
4164        .as_ref()
4165        .map(crate::identity_first::AgentRuntimeId::as_str)
4166        .map(ToString::to_string)
4167        .or_else(|| live_alias.map(|alias| alias.runtime_member_id.clone()));
4168    json!({
4169        "identity": status.identity.as_str(),
4170        "state": status.state.wire_str(),
4171        "role": status.profile.as_ref().map(ProfileName::as_str),
4172        "addressability": console_addressability_json(status.addressability),
4173        "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4174        "labels": status.labels,
4175        "continuity_health": status.continuity_health,
4176        "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4177        "lease": status.lease.as_ref().map(|lease| json!({
4178            "fencing_token": lease.fencing_token.get(),
4179            "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4180            "healthy": lease.healthy,
4181        })),
4182        "continuity": {
4183            "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4184            "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4185            "session_id": session_id,
4186            "agent_runtime_id": agent_runtime_id,
4187        },
4188        "topology_peers": topology_peers,
4189        "output_preview": Value::Null,
4190        "response_phase": response_phase,
4191    })
4192}
4193
4194fn console_identity_inspect_json_from_record(
4195    inspection: &crate::console_aggregator::ConsoleIdentityInspection,
4196    response_phase: Option<String>,
4197) -> Value {
4198    let record = &inspection.identity;
4199    json!({
4200        "identity": record.identity,
4201        "state": record.health,
4202        "role": record.labels.get("role"),
4203        "addressability": if record.addressable { "addressable" } else { "internal_only" },
4204        "display_name": record.display_name,
4205        "labels": record.labels,
4206        "continuity_health": Value::Null,
4207        "lease_healthy": Value::Null,
4208        "lease": Value::Null,
4209        "continuity": {
4210            "generation": Value::Null,
4211            "checkpoint_version": Value::Null,
4212            "session_id": record.session_id,
4213            "agent_runtime_id": record.runtime_member_id,
4214        },
4215        "topology_peers": inspection.peers,
4216        "output_preview": Value::Null,
4217        "response_phase": response_phase,
4218    })
4219}
4220
4221fn lifecycle_archive_cleanup_completed(error: &str) -> bool {
4222    is_recoverable_lifecycle_cleanup_error(error)
4223}
4224
4225async fn respawn_console_member(
4226    handle: &MobHandle,
4227    runtime_member_id: &AgentIdentity,
4228) -> Result<Option<Value>, String> {
4229    // Best-effort repair material: a faulted lookup degrades to None (the
4230    // respawn itself surfaces real faults).
4231    let entry_before_respawn = handle.get_member(runtime_member_id).await.ok().flatten();
4232    match handle.respawn(runtime_member_id.clone(), None).await {
4233        Ok(_receipt) => Ok(None),
4234        Err(err) => {
4235            if let Some(failed_peer_ids) = topology_restore_failed_peer_ids(&err) {
4236                tracing::warn!(
4237                    member_id = %runtime_member_id,
4238                    failed_peer_count = failed_peer_ids.len(),
4239                    failed_peer_ids = ?failed_peer_ids,
4240                    "console member respawn restored member with isolated peer edges; continuing degraded respawn"
4241                );
4242                return Ok(Some(topology_restore_warning_json(&failed_peer_ids)));
4243            }
4244
4245            if lifecycle_archive_cleanup_completed(&err.to_string()) {
4246                // A faulted lookup must not read as "absent" (that would mint
4247                // a spurious replacement member); surface it instead.
4248                if handle
4249                    .get_member(runtime_member_id)
4250                    .await
4251                    .map_err(|lookup_err| lookup_err.to_string())?
4252                    .is_none()
4253                    && let Some(entry) = entry_before_respawn
4254                {
4255                    let mut spec =
4256                        SpawnMemberSpec::new(entry.role.clone(), runtime_member_id.clone());
4257                    if !entry.labels.is_empty() {
4258                        spec = spec.with_labels(entry.labels.clone());
4259                    }
4260                    handle
4261                        .ensure_member(spec)
4262                        .await
4263                        .map_err(|ensure_err| ensure_err.to_string())?;
4264                }
4265                return Ok(None);
4266            }
4267
4268            Err(err.to_string())
4269        }
4270    }
4271}
4272
4273/// Live-member fallback shared by `mobkit/respawn` and `mobkit/reset` when no
4274/// identity-first record backs the target: respawn the runtime member (fresh
4275/// session, same configuration) and report the projected member status.
4276async fn respawn_live_console_member_response(
4277    handle: &MobHandle,
4278    console_events: Option<&ConsoleEventStore>,
4279    alias: &ConsoleRuntimeIdentityAlias,
4280    lifecycle_kind: &str,
4281    action: &str,
4282    response_id: Value,
4283) -> Value {
4284    let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
4285    match Box::pin(respawn_console_member(handle, &mid)).await {
4286        Ok(topology_restore_warning) => {
4287            if let Some(store) = console_events {
4288                store
4289                    .record_lifecycle(
4290                        &alias.identity,
4291                        lifecycle_kind,
4292                        json!({ "topology_restore_warning": topology_restore_warning.clone() }),
4293                    )
4294                    .await;
4295            }
4296            let mut body = match lookup_member_with_session(handle, &mid).await {
4297                Some((entry, session_id)) => console_identity_status_json_for_identity(
4298                    &alias.identity,
4299                    &entry,
4300                    session_id,
4301                    None,
4302                ),
4303                None => json!({ "identity": alias.identity }),
4304            };
4305            if let Some(warning) = topology_restore_warning {
4306                body["topology_restore_warning"] = warning;
4307            }
4308            response_value(response_id, Some(body), None)
4309        }
4310        Err(err) => internal_error(response_id, format!("{action} failed: {err}")),
4311    }
4312}
4313
4314async fn retire_console_member(
4315    handle: &MobHandle,
4316    runtime_member_id: &AgentIdentity,
4317) -> Result<(), String> {
4318    match handle.retire(runtime_member_id.clone()).await {
4319        Ok(()) => Ok(()),
4320        Err(err) if lifecycle_archive_cleanup_completed(&err.to_string()) => Ok(()),
4321        Err(err) => Err(err.to_string()),
4322    }
4323}
4324
4325#[cfg(test)]
4326fn member_id_matches_durable_identity(member_id: &str, durable_identity: &str) -> bool {
4327    member_id == durable_identity
4328}
4329
4330async fn retire_stale_console_members_for_identity(
4331    handle: &MobHandle,
4332    visibility_policy: &dyn ConsoleVisibilityPolicy,
4333    durable_identity: &str,
4334    keep_runtime_member_id: Option<&str>,
4335) -> Result<(), String> {
4336    let stale_members = lookup_member_alias_candidates_with_session(handle, durable_identity)
4337        .await
4338        .into_iter()
4339        .filter(|alias| {
4340            runtime_alias_visible_to_console(handle, visibility_policy, alias)
4341                && keep_runtime_member_id
4342                    .map(|keep| alias.runtime_member_id != keep)
4343                    .unwrap_or(true)
4344        })
4345        .map(|alias| crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str()))
4346        .collect::<Vec<_>>();
4347    for member_id in stale_members {
4348        retire_console_member(handle, &member_id).await?;
4349    }
4350    Ok(())
4351}
4352
4353fn console_identity_error_response(
4354    response_id: Value,
4355    operation: &str,
4356    err: crate::identity_first::IdentityRuntimeError,
4357) -> Value {
4358    match err {
4359        crate::identity_first::IdentityRuntimeError::UnknownIdentity(identity) => {
4360            invalid_params(response_id, format!("identity not found: {identity}"))
4361        }
4362        other => internal_error(response_id, format!("{operation} failed: {other}")),
4363    }
4364}
4365
4366#[allow(clippy::too_many_arguments)]
4367async fn handle_console_aggregator_rpc(
4368    console_aggregator: Option<MobKitConsoleAggregator>,
4369    request: JsonRpcRequest,
4370    is_authenticated: bool,
4371    read_only: bool,
4372    access: Option<&AccessController>,
4373    access_view: Option<&AccessView>,
4374) -> Value {
4375    let response_id = request.id.clone().unwrap_or(Value::Null);
4376    let can_mutate = is_authenticated && !read_only;
4377    if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
4378        return console_read_only_rpc_error(response_id);
4379    }
4380    if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
4381        return response;
4382    }
4383    if let Some(error) =
4384        console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
4385    {
4386        return response_value(response_id, None, Some(error));
4387    }
4388    match request.method.as_str() {
4389        "mobkit/capabilities" => {
4390            let mut methods = vec![
4391                "mobkit/capabilities",
4392                "mobkit/console/list_identities",
4393                "mobkit/console/inspect_identity",
4394                "mobkit/console/query_timeline",
4395            ];
4396            if can_mutate {
4397                methods.extend_from_slice(&["mobkit/retire", "mobkit/console/send"]);
4398            }
4399            if access.is_some() {
4400                methods.push("mobkit/access/status");
4401                if access_view.is_some_and(AccessView::can_administer) {
4402                    methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
4403                    if can_mutate {
4404                        methods.extend_from_slice(&[
4405                            "mobkit/access/set",
4406                            "mobkit/access/enable",
4407                            "mobkit/access/rules/upsert",
4408                            "mobkit/access/rules/delete",
4409                            "mobkit/access/groups/set",
4410                            "mobkit/access/groups/delete",
4411                        ]);
4412                    }
4413                }
4414            }
4415            response_value(
4416                response_id,
4417                Some(json!({
4418                    "methods": methods,
4419                    "authenticated": is_authenticated,
4420                    "read_only": read_only,
4421                    "runtime_capabilities": {
4422                        "can_send_messages": can_mutate,
4423                        "can_retire_members": can_mutate,
4424                        "can_spawn_members": false,
4425                    },
4426                    "features": {
4427                        "console_aggregator": console_aggregator.is_some(),
4428                        "multi_runtime_console": console_aggregator.is_some(),
4429                    }
4430                })),
4431                None,
4432            )
4433        }
4434        "mobkit/console/list_identities" => {
4435            let Some(aggregator) = &console_aggregator else {
4436                return console_aggregator_unavailable(response_id);
4437            };
4438            match Box::pin(aggregator.list_identities()).await {
4439                Ok(mut identities) => {
4440                    retain_visible_identity_records(&mut identities, access_view);
4441                    response_value(response_id, Some(json!({ "identities": identities })), None)
4442                }
4443                Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
4444            }
4445        }
4446        "mobkit/console/inspect_identity" => {
4447            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4448                return invalid_params(response_id, "identity required");
4449            };
4450            let Some(aggregator) = &console_aggregator else {
4451                return console_aggregator_unavailable(response_id);
4452            };
4453            match Box::pin(aggregator.inspect_identity(identity)).await {
4454                Ok(Some(inspection)) => response_value(
4455                    response_id,
4456                    Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
4457                    None,
4458                ),
4459                Ok(None) => response_value(
4460                    response_id,
4461                    None,
4462                    Some(JsonRpcError {
4463                        code: -32001,
4464                        message: format!("unknown identity: {identity}"),
4465                        data: None,
4466                    }),
4467                ),
4468                Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
4469            }
4470        }
4471        "mobkit/console/query_timeline" => {
4472            let query: ConsoleTimelineWindowQuery =
4473                match serde_json::from_value(request.params.clone()) {
4474                    Ok(query) => query,
4475                    Err(err) => {
4476                        return invalid_params(response_id, format!("invalid query params: {err}"));
4477                    }
4478                };
4479            let Some(aggregator) = &console_aggregator else {
4480                return console_aggregator_unavailable(response_id);
4481            };
4482            match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
4483                Ok(mut page) => {
4484                    retain_visible_timeline_frames(&mut page, access_view);
4485                    response_value(
4486                        response_id,
4487                        Some(serde_json::to_value(page).unwrap_or(Value::Null)),
4488                        None,
4489                    )
4490                }
4491                Err(err) => {
4492                    let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
4493                    console_timeline_replay_unavailable_response(
4494                        response_id,
4495                        err,
4496                        query.after.as_ref(),
4497                        latest_cursor,
4498                    )
4499                }
4500            }
4501        }
4502        "mobkit/console/send" => {
4503            let send_request: ConsoleSendRequest =
4504                match serde_json::from_value(request.params.clone()) {
4505                    Ok(request) => request,
4506                    Err(err) => {
4507                        return invalid_params(response_id, format!("invalid send params: {err}"));
4508                    }
4509                };
4510            let Some(aggregator) = &console_aggregator else {
4511                return console_aggregator_unavailable(response_id);
4512            };
4513            match Box::pin(aggregator.send(send_request)).await {
4514                Ok(accepted) => response_value(
4515                    response_id,
4516                    Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
4517                    None,
4518                ),
4519                Err(err) => {
4520                    response_value(response_id, None, Some(console_send_json_rpc_error(err)))
4521                }
4522            }
4523        }
4524        "mobkit/retire" => {
4525            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4526                return invalid_params(response_id, "identity required");
4527            };
4528            let Some(aggregator) = &console_aggregator else {
4529                return console_aggregator_unavailable(response_id);
4530            };
4531            match Box::pin(aggregator.retire_identity(identity)).await {
4532                Ok(true) => {
4533                    response_value(response_id, Some(json!({ "identity": identity })), None)
4534                }
4535                Ok(false) => response_value(
4536                    response_id,
4537                    None,
4538                    Some(JsonRpcError {
4539                        code: -32001,
4540                        message: format!("unknown identity: {identity}"),
4541                        data: None,
4542                    }),
4543                ),
4544                Err(err) => internal_error(response_id, format!("retire failed: {err}")),
4545            }
4546        }
4547        "mobkit/reset_all" => {
4548            let Some(_aggregator) = &console_aggregator else {
4549                return console_aggregator_unavailable(response_id);
4550            };
4551            response_value(
4552                response_id,
4553                None,
4554                Some(JsonRpcError {
4555                    code: -32002,
4556                    message: "reset_all is not supported on the aggregator-only RPC surface"
4557                        .to_string(),
4558                    data: Some(json!({
4559                        "kind": "unsupported_reset_all_surface",
4560                        "reason": "aggregator reset_all cannot preserve baseline identity semantics",
4561                    })),
4562                }),
4563            )
4564        }
4565        _ => response_value(
4566            response_id,
4567            None,
4568            Some(JsonRpcError {
4569                code: -32601,
4570                message: "Method not found".to_string(),
4571                data: None,
4572            }),
4573        ),
4574    }
4575}
4576
4577fn console_aggregator_unavailable(response_id: Value) -> Value {
4578    response_value(
4579        response_id,
4580        None,
4581        Some(JsonRpcError {
4582            code: -32004,
4583            message: "console aggregator unavailable".to_string(),
4584            data: None,
4585        }),
4586    )
4587}
4588
4589fn resolve_gating_approver_id(
4590    params: &Value,
4591    authenticated_principal: Option<&str>,
4592) -> Result<String, &'static str> {
4593    if let Some(principal) = authenticated_principal.and_then(|value| {
4594        let trimmed = value.trim();
4595        (!trimmed.is_empty()).then_some(trimmed)
4596    }) {
4597        return Ok(principal.to_string());
4598    }
4599    let Some(approver_id) = params.get("approver_id").and_then(Value::as_str) else {
4600        return Err("approver_id required");
4601    };
4602    let trimmed = approver_id.trim();
4603    if trimmed.is_empty() {
4604        return Err("approver_id required");
4605    }
4606    Ok(trimmed.to_string())
4607}
4608
4609#[allow(clippy::large_futures, clippy::too_many_arguments)]
4610#[cfg(test)]
4611async fn handle_console_runtime_rpc(
4612    runtime: &MobRuntime,
4613    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
4614    contact_directory: Option<&ContactDirectory>,
4615    gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
4616    console_events: Option<ConsoleEventStore>,
4617    console_aggregator: Option<MobKitConsoleAggregator>,
4618    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
4619    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
4620    mob_events: Option<MobEventsStore>,
4621    request: JsonRpcRequest,
4622    is_authenticated: bool,
4623) -> Value {
4624    handle_console_runtime_rpc_with_visibility(
4625        runtime,
4626        module_runtime,
4627        contact_directory,
4628        gateway_peer_keys,
4629        console_events,
4630        console_aggregator,
4631        identity_runtime,
4632        metadata_table,
4633        mob_events,
4634        &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
4635        request,
4636        is_authenticated,
4637        false,
4638        None,
4639        None,
4640        None,
4641        None,
4642    )
4643    .await
4644}
4645
4646#[allow(clippy::too_many_arguments)]
4647async fn handle_console_runtime_rpc_with_visibility(
4648    runtime: &MobRuntime,
4649    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
4650    contact_directory: Option<&ContactDirectory>,
4651    gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
4652    console_events: Option<ConsoleEventStore>,
4653    console_aggregator: Option<MobKitConsoleAggregator>,
4654    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
4655    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
4656    mob_events: Option<MobEventsStore>,
4657    visibility_policy: &dyn ConsoleVisibilityPolicy,
4658    request: JsonRpcRequest,
4659    is_authenticated: bool,
4660    read_only: bool,
4661    authenticated_principal: Option<&str>,
4662    access: Option<&AccessController>,
4663    access_view: Option<&AccessView>,
4664    memory_panel: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
4665) -> Value {
4666    let response_id = request.id.clone().unwrap_or(Value::Null);
4667    let can_mutate = is_authenticated && !read_only;
4668    if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
4669        return console_read_only_rpc_error(response_id);
4670    }
4671    // Refresh the attribute cache from the live roster before any per-agent
4672    // access decision (the gate below and per-row result filtering) so
4673    // label/role rules resolve here exactly as they do on
4674    // `/console/experience`. RPC callers must not depend on having polled the
4675    // experience endpoint first.
4676    if let Some(controller) = access.filter(|controller| controller.enabled()) {
4677        prime_access_cache_from_runtime(runtime, controller).await;
4678    }
4679    if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
4680        return response;
4681    }
4682    if let Some(error) =
4683        console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
4684    {
4685        return response_value(response_id, None, Some(error));
4686    }
4687
4688    match request.method.as_str() {
4689        "mobkit/capabilities" => {
4690            let mut methods = vec![
4691                "mobkit/status",
4692                "mobkit/capabilities",
4693                "mobkit/list_members",
4694                "mobkit/get_member",
4695                "mobkit/find_members",
4696                "mobkit/member_status",
4697                "mobkit/identity/resolved_tools",
4698                "mobkit/blob/get",
4699                "mobkit/wait_ready",
4700                "mobkit/flow_status",
4701                "mobkit/list_flows",
4702                "mobkit/list_runs",
4703                "mobkit/console/list_identities",
4704                "mobkit/console/inspect_identity",
4705                "mobkit/console/query_timeline",
4706                "mobkit/mob_events/query",
4707                "mobkit/mob_events/subscribe",
4708                "mobkit/cross_mob/peer_info",
4709                "mobkit/cross_mob/directory",
4710                "mobkit/peer_pubkey",
4711            ];
4712            if identity_runtime.is_some() {
4713                methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
4714                if let Some(identity_runtime) = &identity_runtime
4715                    && identity_runtime.agent_memory_supports_recall().await
4716                {
4717                    methods.push("mobkit/agent_memory/recall");
4718                    if can_mutate && identity_runtime.agent_memory_supports_remember().await {
4719                        methods.push("mobkit/agent_memory/remember");
4720                    }
4721                    if can_mutate && identity_runtime.agent_memory_supports_forget().await {
4722                        methods.push("mobkit/agent_memory/forget");
4723                    }
4724                    if can_mutate && identity_runtime.agent_memory_supports_update().await {
4725                        methods.push("mobkit/agent_memory/update");
4726                    }
4727                    if identity_runtime.agent_memory_supports_manifest().await {
4728                        methods.push("mobkit/agent_memory/manifest");
4729                    }
4730                }
4731                if can_mutate {
4732                    methods.push("mobkit/delete_identity");
4733                }
4734            } else if console_aggregator.is_some() {
4735                methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
4736            }
4737            if module_runtime.is_some() {
4738                methods.extend_from_slice(&[
4739                    "mobkit/routing/routes/list",
4740                    "mobkit/delivery/history",
4741                    "mobkit/gating/pending",
4742                    "mobkit/gating/audit",
4743                ]);
4744                if can_mutate {
4745                    methods.push("mobkit/gating/decide");
4746                }
4747            }
4748            if can_mutate {
4749                methods.extend_from_slice(&[
4750                    "mobkit/retire",
4751                    // Respawn/reset dispatch on every console runtime: with an
4752                    // identity-first runtime they refresh the durable record,
4753                    // and without one they fall back to a live-member respawn.
4754                    // Advertising them only under the identity runtime left
4755                    // the console buttons dead on plain runtimes even though
4756                    // the dispatcher accepted the calls.
4757                    "mobkit/respawn",
4758                    "mobkit/reset",
4759                    "mobkit/reset_all",
4760                    "mobkit/console/send",
4761                    "mobkit/blob/upload",
4762                    "mobkit/ensure_member",
4763                    "mobkit/retire_member",
4764                    "mobkit/respawn_member",
4765                    "mobkit/force_cancel_member",
4766                    "mobkit/cancel_flow",
4767                    "mobkit/collect_completed",
4768                    "mobkit/run_flow",
4769                    "mobkit/spawn_helper",
4770                    "mobkit/fork_helper",
4771                    "mobkit/attach_existing_session",
4772                    "mobkit/reconcile_edges",
4773                    "mobkit/cross_mob/wire_local",
4774                    "mobkit/cross_mob/unwire_local",
4775                ]);
4776            }
4777            if metadata_table.is_some() {
4778                methods.extend_from_slice(&["mobkit/mob_labels/get", "mobkit/run_labels/get"]);
4779                if can_mutate {
4780                    methods.extend_from_slice(&[
4781                        "mobkit/mob_labels/set",
4782                        "mobkit/mob_labels/delete",
4783                        "mobkit/run_labels/set",
4784                        "mobkit/run_labels/delete",
4785                    ]);
4786                }
4787            }
4788            if memory_panel.is_some() {
4789                // Provider-dependent supports pattern: the panel methods
4790                // exist only when the bundled sqlite store is wired. The
4791                // grant-intersection probe below additionally strips the
4792                // resource-less ones (dreams/quarantine) the caller could
4793                // never use.
4794                methods.extend_from_slice(&[
4795                    "mobkit/memory/panel/records",
4796                    "mobkit/memory/panel/record",
4797                    "mobkit/memory/panel/dreams",
4798                    "mobkit/memory/panel/quarantine",
4799                ]);
4800            }
4801            if access.is_some() {
4802                methods.push("mobkit/access/status");
4803                if access_view.is_some_and(AccessView::can_administer) {
4804                    methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
4805                    if can_mutate {
4806                        methods.extend_from_slice(&[
4807                            "mobkit/access/set",
4808                            "mobkit/access/enable",
4809                            "mobkit/access/rules/upsert",
4810                            "mobkit/access/rules/delete",
4811                            "mobkit/access/groups/set",
4812                            "mobkit/access/groups/delete",
4813                        ]);
4814                    }
4815                }
4816            }
4817            // Intersect the advertised methods with the caller's grants, the
4818            // same way `/console/experience` does, so a non-admin doesn't get
4819            // a method list its panels act on only to hit `-32030`. A probe
4820            // identity reveals which mapped requirements are resource-less
4821            // (target `None` regardless of params): those are filtered by the
4822            // action grant; agent-scoped methods stay advertised and are
4823            // enforced per call.
4824            if let Some(view) = access_view.filter(|view| view.enforced()) {
4825                let probe = serde_json::json!({ "identity": "\u{0}cap-probe" });
4826                methods.retain(
4827                    |method| match console_rpc_access_requirements(method, &probe) {
4828                        Some(requirements) => requirements
4829                            .iter()
4830                            .all(|(action, target)| target.is_some() || view.allows(action)),
4831                        None => true,
4832                    },
4833                );
4834            }
4835            // Coarse capability flags, intersected with the caller's grants so
4836            // they agree with the per-agent affordances in `/console/experience`.
4837            let cap = |action: &str| -> bool {
4838                can_mutate
4839                    && access_view
4840                        .filter(|view| view.enforced())
4841                        .is_none_or(|view| view.may_perform_anywhere(action))
4842            };
4843            response_value(
4844                response_id,
4845                Some(serde_json::json!({
4846                    "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
4847                    "methods": methods,
4848                    "read_only": read_only,
4849                    // The console routes to MobRuntime directly and has no
4850                    // access to the module runtime, so loaded_modules is always [].
4851                    "loaded_modules": serde_json::json!([]),
4852                    "runtime_capabilities": {
4853                        "can_send_messages": cap(ACTION_AGENT_SEND),
4854                        "can_retire_members": cap(ACTION_AGENT_RETIRE),
4855                        "can_spawn_members": cap(ACTION_AGENT_SPAWN),
4856                    }
4857                })),
4858                None,
4859            )
4860        }
4861        "mobkit/agent_memory/remember" => {
4862            let Some(identity_runtime) = &identity_runtime else {
4863                return response_value(
4864                    response_id,
4865                    None,
4866                    Some(JsonRpcError {
4867                        code: -32601,
4868                        message: "agent memory is not configured".to_string(),
4869                        data: None,
4870                    }),
4871                );
4872            };
4873            match parse_agent_memory_remember_params(&request.params) {
4874                Ok(remember_request) => match identity_runtime
4875                    .remember_agent_memory(
4876                        &remember_request.realm,
4877                        &remember_request.identity,
4878                        remember_request.memory,
4879                    )
4880                    .await
4881                {
4882                    Ok(record) => response_value(
4883                        response_id,
4884                        Some(serde_json::to_value(record).unwrap_or(Value::Null)),
4885                        None,
4886                    ),
4887                    Err(err) => response_value(
4888                        response_id,
4889                        None,
4890                        Some(crate::rpc::agent_memory_rpc_error("write", err)),
4891                    ),
4892                },
4893                Err(err) => {
4894                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
4895                }
4896            }
4897        }
4898        "mobkit/agent_memory/forget" => {
4899            let Some(identity_runtime) = &identity_runtime else {
4900                return response_value(
4901                    response_id,
4902                    None,
4903                    Some(JsonRpcError {
4904                        code: -32601,
4905                        message: "agent memory is not configured".to_string(),
4906                        data: None,
4907                    }),
4908                );
4909            };
4910            match parse_agent_memory_forget_params(&request.params) {
4911                Ok(forget_request) => match identity_runtime
4912                    .forget_agent_memory(
4913                        &forget_request.realm,
4914                        &forget_request.identity,
4915                        &forget_request.memory_id,
4916                    )
4917                    .await
4918                {
4919                    Ok(result) => response_value(
4920                        response_id,
4921                        Some(serde_json::to_value(result).unwrap_or(Value::Null)),
4922                        None,
4923                    ),
4924                    Err(err) => response_value(
4925                        response_id,
4926                        None,
4927                        Some(crate::rpc::agent_memory_rpc_error("forget", err)),
4928                    ),
4929                },
4930                Err(err) => {
4931                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
4932                }
4933            }
4934        }
4935        "mobkit/agent_memory/recall" => {
4936            let Some(identity_runtime) = &identity_runtime else {
4937                return response_value(
4938                    response_id,
4939                    None,
4940                    Some(JsonRpcError {
4941                        code: -32601,
4942                        message: "agent memory is not configured".to_string(),
4943                        data: None,
4944                    }),
4945                );
4946            };
4947            match parse_agent_memory_recall_params(&request.params) {
4948                Ok(recall_request) => match identity_runtime
4949                    .recall_agent_memory(recall_request.request)
4950                    .await
4951                {
4952                    Ok(records) => {
4953                        response_value(response_id, Some(json!({ "records": records })), None)
4954                    }
4955                    Err(err) => response_value(
4956                        response_id,
4957                        None,
4958                        Some(crate::rpc::agent_memory_rpc_error("recall", err)),
4959                    ),
4960                },
4961                Err(err) => {
4962                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
4963                }
4964            }
4965        }
4966        "mobkit/agent_memory/update" => {
4967            let Some(identity_runtime) = &identity_runtime else {
4968                return response_value(
4969                    response_id,
4970                    None,
4971                    Some(JsonRpcError {
4972                        code: -32601,
4973                        message: "agent memory is not configured".to_string(),
4974                        data: None,
4975                    }),
4976                );
4977            };
4978            match parse_agent_memory_update_params(&request.params) {
4979                Ok(update_request) => match identity_runtime
4980                    .update_agent_memory(
4981                        &update_request.realm,
4982                        &update_request.identity,
4983                        &update_request.memory_id,
4984                        update_request.memory,
4985                    )
4986                    .await
4987                {
4988                    Ok(new_id) => response_value(
4989                        response_id,
4990                        Some(json!({
4991                            "memory_id": new_id,
4992                            "supersedes": update_request.memory_id,
4993                        })),
4994                        None,
4995                    ),
4996                    Err(err) => response_value(
4997                        response_id,
4998                        None,
4999                        Some(crate::rpc::agent_memory_rpc_error("update", err)),
5000                    ),
5001                },
5002                Err(err) => {
5003                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5004                }
5005            }
5006        }
5007        "mobkit/agent_memory/manifest" => {
5008            let Some(identity_runtime) = &identity_runtime else {
5009                return response_value(
5010                    response_id,
5011                    None,
5012                    Some(JsonRpcError {
5013                        code: -32601,
5014                        message: "agent memory is not configured".to_string(),
5015                        data: None,
5016                    }),
5017                );
5018            };
5019            match parse_agent_memory_manifest_params(&request.params) {
5020                Ok(manifest_request) => match identity_runtime
5021                    .manifest_agent_memory(
5022                        &manifest_request.realm,
5023                        &manifest_request.identity,
5024                        manifest_request.tier,
5025                    )
5026                    .await
5027                {
5028                    Ok(records) => {
5029                        response_value(response_id, Some(json!({ "records": records })), None)
5030                    }
5031                    Err(err) => response_value(
5032                        response_id,
5033                        None,
5034                        Some(crate::rpc::agent_memory_rpc_error("manifest", err)),
5035                    ),
5036                },
5037                Err(err) => {
5038                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5039                }
5040            }
5041        }
5042        // §9.3 Memory panel reads. Read-only mode allows all of these (they
5043        // are reads); the ACL mapping lives in
5044        // `console_rpc_access_requirements` plus per-row scope filtering in
5045        // the handlers.
5046        "mobkit/memory/panel/records" => {
5047            handle_memory_panel_records(memory_panel, access_view, &request.params, response_id)
5048                .await
5049        }
5050        "mobkit/memory/panel/record" => {
5051            handle_memory_panel_record(memory_panel, access_view, &request.params, response_id)
5052                .await
5053        }
5054        "mobkit/memory/panel/quarantine" => {
5055            handle_memory_panel_quarantine(memory_panel, access_view, &request.params, response_id)
5056                .await
5057        }
5058        "mobkit/memory/panel/dreams" => {
5059            handle_memory_panel_dreams(memory_panel, &request.params, response_id).await
5060        }
5061        "mobkit/status" => {
5062            let mob_state = runtime.handle().status_observation_snapshot();
5063            response_value(
5064                response_id,
5065                Some(serde_json::json!({
5066                    "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5067                    "running": matches!(mob_state, MobState::Creating | MobState::Running),
5068                    // Console routes to MobRuntime directly — no module runtime available.
5069                    // Return [] to keep StatusResult.loaded_modules schema-consistent.
5070                    "loaded_modules": serde_json::json!([]),
5071                })),
5072                None,
5073            )
5074        }
5075        "mobkit/console/list_identities" => {
5076            let Some(aggregator) = &console_aggregator else {
5077                return response_value(
5078                    response_id,
5079                    None,
5080                    Some(JsonRpcError {
5081                        code: -32004,
5082                        message: "console aggregator unavailable".to_string(),
5083                        data: None,
5084                    }),
5085                );
5086            };
5087            match Box::pin(aggregator.list_identities()).await {
5088                Ok(mut identities) => {
5089                    retain_visible_identity_records(&mut identities, access_view);
5090                    response_value(response_id, Some(json!({ "identities": identities })), None)
5091                }
5092                Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
5093            }
5094        }
5095        "mobkit/console/inspect_identity" => {
5096            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5097                return invalid_params(response_id, "identity required");
5098            };
5099            let Some(aggregator) = &console_aggregator else {
5100                return response_value(
5101                    response_id,
5102                    None,
5103                    Some(JsonRpcError {
5104                        code: -32004,
5105                        message: "console aggregator unavailable".to_string(),
5106                        data: None,
5107                    }),
5108                );
5109            };
5110            match Box::pin(aggregator.inspect_identity(identity)).await {
5111                Ok(Some(inspection)) => response_value(
5112                    response_id,
5113                    Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
5114                    None,
5115                ),
5116                Ok(None) => response_value(
5117                    response_id,
5118                    None,
5119                    Some(JsonRpcError {
5120                        code: -32001,
5121                        message: format!("unknown identity: {identity}"),
5122                        data: None,
5123                    }),
5124                ),
5125                Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
5126            }
5127        }
5128        "mobkit/console/query_timeline" => {
5129            let query: ConsoleTimelineWindowQuery =
5130                match serde_json::from_value(request.params.clone()) {
5131                    Ok(query) => query,
5132                    Err(err) => {
5133                        return invalid_params(response_id, format!("invalid query params: {err}"));
5134                    }
5135                };
5136            let Some(aggregator) = &console_aggregator else {
5137                return response_value(
5138                    response_id,
5139                    None,
5140                    Some(JsonRpcError {
5141                        code: -32004,
5142                        message: "console aggregator unavailable".to_string(),
5143                        data: None,
5144                    }),
5145                );
5146            };
5147            match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
5148                Ok(mut page) => {
5149                    retain_visible_timeline_frames(&mut page, access_view);
5150                    response_value(
5151                        response_id,
5152                        Some(serde_json::to_value(page).unwrap_or(Value::Null)),
5153                        None,
5154                    )
5155                }
5156                Err(err) => {
5157                    let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
5158                    console_timeline_replay_unavailable_response(
5159                        response_id,
5160                        err,
5161                        query.after.as_ref(),
5162                        latest_cursor,
5163                    )
5164                }
5165            }
5166        }
5167        "mobkit/console/send" => {
5168            let send_request: ConsoleSendRequest =
5169                match serde_json::from_value(request.params.clone()) {
5170                    Ok(request) => request,
5171                    Err(err) => {
5172                        return invalid_params(response_id, format!("invalid send params: {err}"));
5173                    }
5174                };
5175            let Some(aggregator) = &console_aggregator else {
5176                return response_value(
5177                    response_id,
5178                    None,
5179                    Some(JsonRpcError {
5180                        code: -32004,
5181                        message: "console aggregator unavailable".to_string(),
5182                        data: None,
5183                    }),
5184                );
5185            };
5186            if let Some(identity_runtime) = &identity_runtime {
5187                return match Box::pin(console_send_with_identity_first_fallback(
5188                    aggregator,
5189                    identity_runtime.clone(),
5190                    Some(runtime),
5191                    console_events.as_ref(),
5192                    send_request,
5193                ))
5194                .await
5195                {
5196                    Ok(accepted) => response_value(
5197                        response_id,
5198                        Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5199                        None,
5200                    ),
5201                    Err(err) => {
5202                        response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5203                    }
5204                };
5205            }
5206            match Box::pin(aggregator.send(send_request)).await {
5207                Ok(accepted) => response_value(
5208                    response_id,
5209                    Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5210                    None,
5211                ),
5212                Err(err) => {
5213                    response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5214                }
5215            }
5216        }
5217        "mobkit/blob/get" => {
5218            let Some(blob_id) = request
5219                .params
5220                .get("blob_id")
5221                .or_else(|| request.params.get("id"))
5222                .and_then(Value::as_str)
5223            else {
5224                return invalid_params(response_id, "blob_id required");
5225            };
5226            if !is_valid_blob_id_value(blob_id) {
5227                return invalid_params(response_id, "invalid blob_id");
5228            }
5229            let Some(store) = runtime.binary_blob_store() else {
5230                return internal_error(response_id, "binary blob store unavailable");
5231            };
5232            match store.get_bytes(&meerkat_core::BlobId::from(blob_id)).await {
5233                Ok(payload) => response_value(
5234                    response_id,
5235                    Some(serde_json::json!({
5236                        "blob_id": payload.blob_id,
5237                        "media_type": payload.media_type,
5238                        "size": payload.size,
5239                        "data": base64::engine::general_purpose::STANDARD.encode(payload.data.as_ref()),
5240                    })),
5241                    None,
5242                ),
5243                Err(meerkat_core::BlobStoreError::NotFound(_)) => response_value(
5244                    response_id,
5245                    None,
5246                    Some(JsonRpcError {
5247                        code: -32001,
5248                        message: format!("blob not found: {blob_id}"),
5249                        data: Some(json!({ "kind": "not_found", "blob_id": blob_id })),
5250                    }),
5251                ),
5252                Err(err) => internal_error(response_id, format!("blob get failed: {err}")),
5253            }
5254        }
5255        "mobkit/list_members" => {
5256            let handle = runtime.handle();
5257            let entries = handle.list_members_including_retiring().await;
5258            let mut members = Vec::with_capacity(entries.len());
5259            for entry in &entries {
5260                let alias =
5261                    crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
5262                if stale_runtime_alias_json_rpc_error(
5263                    "list_members",
5264                    identity_runtime.as_ref(),
5265                    alias.as_ref(),
5266                )
5267                .await
5268                .is_some()
5269                {
5270                    continue;
5271                }
5272                members.push(member_entry_to_console_json(runtime, entry).await);
5273            }
5274            retain_visible_member_rows(&mut members, access_view);
5275            response_value(response_id, Some(Value::Array(members)), None)
5276        }
5277        "mobkit/get_member" => {
5278            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
5279                return invalid_params(response_id, "member_id required");
5280            };
5281            if let Some(error) = stale_runtime_alias_json_rpc_error(
5282                "get_member",
5283                identity_runtime.as_ref(),
5284                member_id,
5285            )
5286            .await
5287            {
5288                return response_value(response_id, None, Some(error));
5289            }
5290            let handle = runtime.handle();
5291            let identity = crate::member_comms_id::mob_member_id(member_id);
5292            let entries = handle.list_members_including_retiring().await;
5293            match entries.into_iter().find(|e| e.agent_identity == identity) {
5294                Some(entry) => response_value(
5295                    response_id,
5296                    Some(member_entry_to_console_json(runtime, &entry).await),
5297                    None,
5298                ),
5299                None => invalid_params(response_id, format!("member not found: {member_id}")),
5300            }
5301        }
5302        "mobkit/find_members" => {
5303            let Some(label_key) = request.params.get("label_key").and_then(Value::as_str) else {
5304                return invalid_params(response_id, "label_key required");
5305            };
5306            let Some(label_value) = request.params.get("label_value").and_then(Value::as_str)
5307            else {
5308                return invalid_params(response_id, "label_value required");
5309            };
5310            let handle = runtime.handle();
5311            let filter = MemberFilter {
5312                labels: std::collections::BTreeMap::from([(
5313                    label_key.to_string(),
5314                    label_value.to_string(),
5315                )]),
5316                role: None,
5317                status: None,
5318            };
5319            let entries = match handle.list_members_matching(filter).await {
5320                Ok(entries) => entries,
5321                Err(err) => {
5322                    return invalid_params(response_id, format!("member lookup failed: {err}"));
5323                }
5324            };
5325            let mut matches = Vec::with_capacity(entries.len());
5326            for entry in &entries {
5327                let alias =
5328                    crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
5329                if stale_runtime_alias_json_rpc_error(
5330                    "find_members",
5331                    identity_runtime.as_ref(),
5332                    alias.as_ref(),
5333                )
5334                .await
5335                .is_some()
5336                {
5337                    continue;
5338                }
5339                matches.push(member_entry_to_console_json(runtime, entry).await);
5340            }
5341            retain_visible_member_rows(&mut matches, access_view);
5342            response_value(response_id, Some(Value::Array(matches)), None)
5343        }
5344        "mobkit/status_identity" => {
5345            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5346                return invalid_params(response_id, "identity required");
5347            };
5348            let handle = runtime.handle();
5349            if let Some(identity_runtime) = &identity_runtime {
5350                let (parsed_identity, _requested_exact_identity, live_alias) =
5351                    match resolve_console_identity_control_target(
5352                        &handle,
5353                        Some(identity_runtime),
5354                        visibility_policy,
5355                        identity,
5356                    )
5357                    .await
5358                    {
5359                        Ok(Some(target)) => target,
5360                        Ok(None) => {
5361                            return invalid_params(
5362                                response_id,
5363                                format!("identity not found: {identity}"),
5364                            );
5365                        }
5366                        Err(err) => return response_value(response_id, None, Some(err)),
5367                    };
5368                match identity_runtime.status(&parsed_identity).await {
5369                    Ok(status) => {
5370                        if !identity_status_visible_to_console(visibility_policy, &status) {
5371                            return identity_hidden_by_policy_response(response_id, identity);
5372                        }
5373                        let phase = if let Some(store) = &console_events {
5374                            store
5375                                .response_phase_for_identity(status.identity.as_str())
5376                                .await
5377                        } else {
5378                            None
5379                        };
5380                        if let Some(error) = stale_live_alias_json_rpc_error(
5381                            "status_identity",
5382                            identity_runtime,
5383                            &parsed_identity,
5384                            live_alias.as_ref(),
5385                        )
5386                        .await
5387                        {
5388                            return response_value(response_id, None, Some(error));
5389                        }
5390                        return response_value(
5391                            response_id,
5392                            Some(console_identity_status_json_from_identity_status(
5393                                &status, phase,
5394                            )),
5395                            None,
5396                        );
5397                    }
5398                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
5399                    Err(err) => {
5400                        return console_identity_error_response(
5401                            response_id,
5402                            "status_identity",
5403                            err,
5404                        );
5405                    }
5406                }
5407            }
5408            if let Some(aggregator) = &console_aggregator {
5409                return match Box::pin(aggregator.inspect_identity(identity)).await {
5410                    Ok(Some(inspection)) => {
5411                        let phase = if let Some(store) = &console_events {
5412                            store
5413                                .response_phase_for_identity(&inspection.identity.identity)
5414                                .await
5415                        } else {
5416                            None
5417                        };
5418                        response_value(
5419                            response_id,
5420                            Some(console_identity_status_json_from_record(
5421                                &inspection.identity,
5422                                phase,
5423                            )),
5424                            None,
5425                        )
5426                    }
5427                    Ok(None) => response_value(
5428                        response_id,
5429                        None,
5430                        Some(JsonRpcError {
5431                            code: -32001,
5432                            message: format!("unknown identity: {identity}"),
5433                            data: None,
5434                        }),
5435                    ),
5436                    Err(err) => {
5437                        internal_error(response_id, format!("status_identity failed: {err}"))
5438                    }
5439                };
5440            }
5441            let live_alias = match lookup_member_alias_with_session(
5442                &handle,
5443                visibility_policy,
5444                identity,
5445            )
5446            .await
5447            {
5448                Ok(alias) => alias,
5449                Err(err) => return response_value(response_id, None, Some(err)),
5450            };
5451            let Some(alias) = live_alias else {
5452                return invalid_params(response_id, format!("identity not found: {identity}"));
5453            };
5454            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
5455                return identity_hidden_by_policy_response(response_id, identity);
5456            }
5457            if let Err(err) =
5458                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
5459            {
5460                return response_value(response_id, None, Some(err));
5461            }
5462            let phase = if let Some(store) = &console_events {
5463                store.response_phase_for_identity(&alias.identity).await
5464            } else {
5465                None
5466            };
5467            response_value(
5468                response_id,
5469                Some(console_identity_status_json_for_identity(
5470                    &alias.identity,
5471                    &alias.member,
5472                    alias.session_id,
5473                    phase,
5474                )),
5475                None,
5476            )
5477        }
5478        "mobkit/inspect_identity" => {
5479            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5480                return invalid_params(response_id, "identity required");
5481            };
5482            let handle = runtime.handle();
5483            if let Some(identity_runtime) = &identity_runtime {
5484                let (parsed_identity, _requested_exact_identity, live_alias) =
5485                    match resolve_console_identity_control_target(
5486                        &handle,
5487                        Some(identity_runtime),
5488                        visibility_policy,
5489                        identity,
5490                    )
5491                    .await
5492                    {
5493                        Ok(Some(target)) => target,
5494                        Ok(None) => {
5495                            return invalid_params(
5496                                response_id,
5497                                format!("identity not found: {identity}"),
5498                            );
5499                        }
5500                        Err(err) => return response_value(response_id, None, Some(err)),
5501                    };
5502                match identity_runtime.status(&parsed_identity).await {
5503                    Ok(status) => {
5504                        if !identity_status_visible_to_console(visibility_policy, &status) {
5505                            return identity_hidden_by_policy_response(response_id, identity);
5506                        }
5507                        let phase = if let Some(store) = &console_events {
5508                            store
5509                                .response_phase_for_identity(status.identity.as_str())
5510                                .await
5511                        } else {
5512                            None
5513                        };
5514                        if let Some(error) = stale_live_alias_json_rpc_error(
5515                            "inspect_identity",
5516                            identity_runtime,
5517                            &parsed_identity,
5518                            live_alias.as_ref(),
5519                        )
5520                        .await
5521                        {
5522                            return response_value(response_id, None, Some(error));
5523                        }
5524                        return response_value(
5525                            response_id,
5526                            Some(console_identity_inspect_json_from_identity_status(
5527                                &status,
5528                                live_alias.as_ref(),
5529                                phase,
5530                            )),
5531                            None,
5532                        );
5533                    }
5534                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
5535                    Err(err) => {
5536                        return console_identity_error_response(
5537                            response_id,
5538                            "inspect_identity",
5539                            err,
5540                        );
5541                    }
5542                }
5543            }
5544            if let Some(aggregator) = &console_aggregator {
5545                return match Box::pin(aggregator.inspect_identity(identity)).await {
5546                    Ok(Some(inspection)) => {
5547                        let phase = if let Some(store) = &console_events {
5548                            store
5549                                .response_phase_for_identity(&inspection.identity.identity)
5550                                .await
5551                        } else {
5552                            None
5553                        };
5554                        response_value(
5555                            response_id,
5556                            Some(console_identity_inspect_json_from_record(
5557                                &inspection,
5558                                phase,
5559                            )),
5560                            None,
5561                        )
5562                    }
5563                    Ok(None) => response_value(
5564                        response_id,
5565                        None,
5566                        Some(JsonRpcError {
5567                            code: -32001,
5568                            message: format!("unknown identity: {identity}"),
5569                            data: None,
5570                        }),
5571                    ),
5572                    Err(err) => {
5573                        internal_error(response_id, format!("inspect_identity failed: {err}"))
5574                    }
5575                };
5576            }
5577            let live_alias = match lookup_member_alias_with_session(
5578                &handle,
5579                visibility_policy,
5580                identity,
5581            )
5582            .await
5583            {
5584                Ok(alias) => alias,
5585                Err(err) => return response_value(response_id, None, Some(err)),
5586            };
5587            let Some(alias) = live_alias else {
5588                return invalid_params(response_id, format!("identity not found: {identity}"));
5589            };
5590            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
5591                return identity_hidden_by_policy_response(response_id, identity);
5592            }
5593            if let Err(err) =
5594                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
5595            {
5596                return response_value(response_id, None, Some(err));
5597            }
5598            let phase = if let Some(store) = &console_events {
5599                store.response_phase_for_identity(&alias.identity).await
5600            } else {
5601                None
5602            };
5603            response_value(
5604                response_id,
5605                Some(console_identity_inspect_json_for_identity(
5606                    &alias.identity,
5607                    &alias.member,
5608                    alias.session_id,
5609                    phase,
5610                )),
5611                None,
5612            )
5613        }
5614        "mobkit/retire" => {
5615            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5616                return invalid_params(response_id, "identity required");
5617            };
5618            let handle = runtime.handle();
5619            if let Some(identity_runtime) = &identity_runtime {
5620                let (parsed_identity, _requested_exact_identity, live_alias) =
5621                    match resolve_console_identity_control_target(
5622                        &handle,
5623                        Some(identity_runtime),
5624                        visibility_policy,
5625                        identity,
5626                    )
5627                    .await
5628                    {
5629                        Ok(Some(target)) => target,
5630                        Ok(None) => {
5631                            return invalid_params(
5632                                response_id,
5633                                format!("identity not found: {identity}"),
5634                            );
5635                        }
5636                        Err(err) => return response_value(response_id, None, Some(err)),
5637                    };
5638                let registered_status = match identity_runtime.status(&parsed_identity).await {
5639                    Ok(status) => Some(status),
5640                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
5641                    Err(err) => {
5642                        return console_identity_error_response(response_id, "retire", err);
5643                    }
5644                };
5645                if let Some(status) = registered_status.as_ref() {
5646                    if !identity_status_visible_to_console(visibility_policy, status) {
5647                        return identity_hidden_by_policy_response(response_id, identity);
5648                    }
5649                    if let Some(error) = stale_live_alias_json_rpc_error(
5650                        "retire",
5651                        identity_runtime,
5652                        &parsed_identity,
5653                        live_alias.as_ref(),
5654                    )
5655                    .await
5656                    {
5657                        return response_value(response_id, None, Some(error));
5658                    }
5659                }
5660                match identity_runtime.retire(&parsed_identity).await {
5661                    Ok(token) => {
5662                        let keep_runtime_member_id = registered_status
5663                            .as_ref()
5664                            .and_then(|status| status.agent_runtime_id.as_ref())
5665                            .filter(|_| identity_runtime.has_session_bridge())
5666                            .map(crate::identity_first::AgentRuntimeId::as_str);
5667                        let cleanup_warning = if registered_status.is_some()
5668                            && let Err(err) = retire_stale_console_members_for_identity(
5669                                &handle,
5670                                visibility_policy,
5671                                parsed_identity.as_str(),
5672                                keep_runtime_member_id,
5673                            )
5674                            .await
5675                        {
5676                            Some(json!({
5677                                "kind": "stale_member_cleanup_failed_after_identity_retire",
5678                                "identity": parsed_identity.as_str(),
5679                                "message": err,
5680                            }))
5681                        } else {
5682                            None
5683                        };
5684                        if let Some(store) = &console_events {
5685                            store
5686                                .record_lifecycle(
5687                                    parsed_identity.as_str(),
5688                                    "identity_retired",
5689                                    json!({
5690                                        "fencing_token": token.get(),
5691                                        "cleanup_warning": cleanup_warning.clone(),
5692                                    }),
5693                                )
5694                                .await;
5695                        }
5696                        return response_value(
5697                            response_id,
5698                            Some(json!({
5699                                "identity": parsed_identity.as_str(),
5700                                "fencing_token": token.get(),
5701                                "cleanup_warning": cleanup_warning,
5702                            })),
5703                            None,
5704                        );
5705                    }
5706                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
5707                        if let Some(alias) = live_alias.as_ref() {
5708                            if !runtime_alias_visible_to_console(&handle, visibility_policy, alias)
5709                            {
5710                                return identity_hidden_by_policy_response(response_id, identity);
5711                            }
5712                            let mid = crate::member_comms_id::mob_member_id(
5713                                alias.runtime_member_id.as_str(),
5714                            );
5715                            return match retire_console_member(&handle, &mid).await {
5716                                Ok(()) => {
5717                                    if let Some(store) = &console_events {
5718                                        store
5719                                            .record_lifecycle(
5720                                                &alias.identity,
5721                                                "identity_retired",
5722                                                json!({}),
5723                                            )
5724                                            .await;
5725                                    }
5726                                    response_value(
5727                                        response_id,
5728                                        Some(json!({ "identity": alias.identity })),
5729                                        None,
5730                                    )
5731                                }
5732                                Err(err) => {
5733                                    internal_error(response_id, format!("retire failed: {err}"))
5734                                }
5735                            };
5736                        }
5737                    }
5738                    Err(err) => return console_identity_error_response(response_id, "retire", err),
5739                }
5740            }
5741            if let Some(aggregator) = &console_aggregator {
5742                let canonical_identity = match Box::pin(aggregator.inspect_identity(identity)).await
5743                {
5744                    Ok(Some(inspection)) => inspection.identity.identity,
5745                    Ok(None) => identity.to_string(),
5746                    Err(_) => identity.to_string(),
5747                };
5748                return match Box::pin(aggregator.retire_identity(identity)).await {
5749                    Ok(true) => {
5750                        if let Some(store) = &console_events {
5751                            store
5752                                .record_lifecycle(
5753                                    &canonical_identity,
5754                                    "identity_retired",
5755                                    json!({}),
5756                                )
5757                                .await;
5758                        }
5759                        response_value(
5760                            response_id,
5761                            Some(json!({ "identity": canonical_identity })),
5762                            None,
5763                        )
5764                    }
5765                    Ok(false) => response_value(
5766                        response_id,
5767                        None,
5768                        Some(JsonRpcError {
5769                            code: -32001,
5770                            message: format!("unknown identity: {identity}"),
5771                            data: None,
5772                        }),
5773                    ),
5774                    Err(err) => internal_error(response_id, format!("retire failed: {err}")),
5775                };
5776            }
5777            let live_alias = match lookup_member_alias_with_session(
5778                &handle,
5779                visibility_policy,
5780                identity,
5781            )
5782            .await
5783            {
5784                Ok(alias) => alias,
5785                Err(err) => return response_value(response_id, None, Some(err)),
5786            };
5787            let Some(alias) = live_alias else {
5788                return invalid_params(response_id, format!("identity not found: {identity}"));
5789            };
5790            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
5791                return identity_hidden_by_policy_response(response_id, identity);
5792            }
5793            if let Err(err) =
5794                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
5795            {
5796                return response_value(response_id, None, Some(err));
5797            }
5798            let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
5799            match retire_console_member(&handle, &mid).await {
5800                Ok(()) => {
5801                    if let Some(store) = &console_events {
5802                        store
5803                            .record_lifecycle(&alias.identity, "identity_retired", json!({}))
5804                            .await;
5805                    }
5806                    response_value(
5807                        response_id,
5808                        Some(json!({ "identity": alias.identity })),
5809                        None,
5810                    )
5811                }
5812                Err(err) => internal_error(response_id, format!("retire failed: {err}")),
5813            }
5814        }
5815        "mobkit/respawn" => {
5816            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5817                return invalid_params(response_id, "identity required");
5818            };
5819            let handle = runtime.handle();
5820            if let Some(identity_runtime) = &identity_runtime {
5821                let (parsed_identity, _requested_exact_identity, live_alias) =
5822                    match resolve_console_identity_control_target(
5823                        &handle,
5824                        Some(identity_runtime),
5825                        visibility_policy,
5826                        identity,
5827                    )
5828                    .await
5829                    {
5830                        Ok(Some(target)) => target,
5831                        Ok(None) => {
5832                            return invalid_params(
5833                                response_id,
5834                                format!("identity not found: {identity}"),
5835                            );
5836                        }
5837                        Err(err) => return response_value(response_id, None, Some(err)),
5838                    };
5839                let registered_status = match identity_runtime.status(&parsed_identity).await {
5840                    Ok(status) => Some(status),
5841                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
5842                    Err(err) => {
5843                        return console_identity_error_response(response_id, "respawn", err);
5844                    }
5845                };
5846                if let Some(status) = registered_status.as_ref() {
5847                    if !identity_status_visible_to_console(visibility_policy, status) {
5848                        return identity_hidden_by_policy_response(response_id, identity);
5849                    }
5850                    if let Some(error) = stale_live_alias_json_rpc_error(
5851                        "respawn",
5852                        identity_runtime,
5853                        &parsed_identity,
5854                        live_alias.as_ref(),
5855                    )
5856                    .await
5857                    {
5858                        return response_value(response_id, None, Some(error));
5859                    }
5860                }
5861                match identity_runtime.respawn(&parsed_identity).await {
5862                    Ok(mut record) => {
5863                        let live_respawn_warning = match Box::pin(respawn_console_member(
5864                            &handle,
5865                            &crate::member_comms_id::mob_member_id(
5866                                record.agent_runtime_id.as_str(),
5867                            ),
5868                        ))
5869                        .await
5870                        {
5871                            Ok(topology_restore_warning) => {
5872                                let live_session_id = handle
5873                                    .resolve_bridge_session_id_observation(
5874                                        &crate::member_comms_id::mob_member_id(
5875                                            record.agent_runtime_id.as_str(),
5876                                        ),
5877                                    )
5878                                    .await;
5879                                if let Some(live_session_id) = live_session_id {
5880                                    match identity_runtime
5881                                        .rebind_session_after_live_respawn(
5882                                            &parsed_identity,
5883                                            live_session_id.clone(),
5884                                        )
5885                                        .await
5886                                    {
5887                                        Ok(updated_record) => {
5888                                            record = updated_record;
5889                                            topology_restore_warning
5890                                        }
5891                                        Err(err) => Some(json!({
5892                                            "kind": "identity_rebind_failed_after_member_respawn",
5893                                            "identity": record.identity.as_str(),
5894                                            "agent_runtime_id": record.agent_runtime_id.as_str(),
5895                                            "live_session_id": live_session_id.to_string(),
5896                                            "message": err.to_string(),
5897                                        })),
5898                                    }
5899                                } else {
5900                                    topology_restore_warning
5901                                }
5902                            }
5903                            Err(err) => Some(json!({
5904                                "kind": "member_respawn_failed_after_identity_refresh",
5905                                "identity": record.identity.as_str(),
5906                                "agent_runtime_id": record.agent_runtime_id.as_str(),
5907                                "message": err,
5908                            })),
5909                        };
5910                        let cleanup_warning = if registered_status.is_some()
5911                            && let Err(err) = retire_stale_console_members_for_identity(
5912                                &handle,
5913                                visibility_policy,
5914                                parsed_identity.as_str(),
5915                                Some(record.agent_runtime_id.as_str()),
5916                            )
5917                            .await
5918                        {
5919                            Some(json!({
5920                                "kind": "stale_member_cleanup_failed_after_identity_respawn",
5921                                "identity": parsed_identity.as_str(),
5922                                "agent_runtime_id": record.agent_runtime_id.as_str(),
5923                                "message": err,
5924                            }))
5925                        } else {
5926                            None
5927                        };
5928                        if let Some(store) = &console_events {
5929                            store
5930                                .record_lifecycle(
5931                                    parsed_identity.as_str(),
5932                                    "identity_respawned",
5933                                    json!({
5934                                        "generation": record.generation.get(),
5935                                        "checkpoint_version": record.checkpoint_version.get(),
5936                                        "live_respawn_warning": live_respawn_warning.clone(),
5937                                        "cleanup_warning": cleanup_warning.clone(),
5938                                    }),
5939                                )
5940                                .await;
5941                        }
5942                        return response_value(
5943                            response_id,
5944                            Some(json!({
5945                                "identity": record.identity.as_str(),
5946                                "agent_runtime_id": record.agent_runtime_id.as_str(),
5947                                "session_id": record.session_id.to_string(),
5948                                "generation": record.generation.get(),
5949                                "checkpoint_version": record.checkpoint_version.get(),
5950                                "live_respawn_warning": live_respawn_warning,
5951                                "cleanup_warning": cleanup_warning,
5952                            })),
5953                            None,
5954                        );
5955                    }
5956                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
5957                        if live_alias.is_none() {
5958                            return invalid_params(
5959                                response_id,
5960                                format!("identity not found: {identity}"),
5961                            );
5962                        }
5963                    }
5964                    Err(err) => {
5965                        return console_identity_error_response(response_id, "respawn", err);
5966                    }
5967                }
5968            }
5969            let live_alias = match lookup_member_alias_with_session(
5970                &handle,
5971                visibility_policy,
5972                identity,
5973            )
5974            .await
5975            {
5976                Ok(alias) => alias,
5977                Err(err) => return response_value(response_id, None, Some(err)),
5978            };
5979            let Some(alias) = live_alias else {
5980                return invalid_params(response_id, format!("identity not found: {identity}"));
5981            };
5982            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
5983                return identity_hidden_by_policy_response(response_id, identity);
5984            }
5985            if let Err(err) =
5986                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
5987            {
5988                return response_value(response_id, None, Some(err));
5989            }
5990            Box::pin(respawn_live_console_member_response(
5991                &handle,
5992                console_events.as_ref(),
5993                &alias,
5994                "identity_respawned",
5995                "respawn",
5996                response_id,
5997            ))
5998            .await
5999        }
6000        "mobkit/reset" => {
6001            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6002                return invalid_params(response_id, "identity required");
6003            };
6004            let handle = runtime.handle();
6005            let Some(identity_runtime) = &identity_runtime else {
6006                // No identity-first runtime: degrade to the same live-member
6007                // respawn fallback the identity path applies to live-only
6008                // members (fresh session, same configuration). This keeps the
6009                // console Reset button working on plain runtimes, matching
6010                // the advertised `mobkit/reset` capability.
6011                let live_alias =
6012                    match lookup_member_alias_with_session(&handle, visibility_policy, identity)
6013                        .await
6014                    {
6015                        Ok(alias) => alias,
6016                        Err(err) => return response_value(response_id, None, Some(err)),
6017                    };
6018                let Some(alias) = live_alias else {
6019                    return invalid_params(response_id, format!("identity not found: {identity}"));
6020                };
6021                if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6022                    return identity_hidden_by_policy_response(response_id, identity);
6023                }
6024                if let Err(err) =
6025                    reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias)
6026                        .await
6027                {
6028                    return response_value(response_id, None, Some(err));
6029                }
6030                return Box::pin(respawn_live_console_member_response(
6031                    &handle,
6032                    console_events.as_ref(),
6033                    &alias,
6034                    "identity_reset",
6035                    "reset",
6036                    response_id,
6037                ))
6038                .await;
6039            };
6040            let (parsed_identity, _requested_exact_identity, live_alias) =
6041                match resolve_console_identity_control_target(
6042                    &handle,
6043                    Some(identity_runtime),
6044                    visibility_policy,
6045                    identity,
6046                )
6047                .await
6048                {
6049                    Ok(Some(target)) => target,
6050                    Ok(None) => {
6051                        return invalid_params(
6052                            response_id,
6053                            format!("identity not found: {identity}"),
6054                        );
6055                    }
6056                    Err(err) => return response_value(response_id, None, Some(err)),
6057                };
6058            match identity_runtime.status(&parsed_identity).await {
6059                Ok(status) => {
6060                    if !identity_status_visible_to_console(visibility_policy, &status) {
6061                        return identity_hidden_by_policy_response(response_id, identity);
6062                    }
6063                    if let Some(error) = stale_live_alias_json_rpc_error(
6064                        "reset",
6065                        identity_runtime,
6066                        &parsed_identity,
6067                        live_alias.as_ref(),
6068                    )
6069                    .await
6070                    {
6071                        return response_value(response_id, None, Some(error));
6072                    }
6073                    if !identity_runtime.has_session_bridge() {
6074                        return response_value(
6075                            response_id,
6076                            None,
6077                            Some(reset_requires_session_bridge_json_rpc_error()),
6078                        );
6079                    }
6080                }
6081                Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6082                    if let Some(alias) = live_alias.as_ref() {
6083                        if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6084                            return identity_hidden_by_policy_response(response_id, identity);
6085                        }
6086                        return Box::pin(respawn_live_console_member_response(
6087                            &handle,
6088                            console_events.as_ref(),
6089                            alias,
6090                            "identity_reset",
6091                            "reset",
6092                            response_id,
6093                        ))
6094                        .await;
6095                    }
6096                    return invalid_params(response_id, format!("identity not found: {identity}"));
6097                }
6098                Err(err) => return console_identity_error_response(response_id, "reset", err),
6099            }
6100            match identity_runtime.reset(&parsed_identity).await {
6101                Ok(record) => {
6102                    let cleanup_warning = Some(json!({
6103                        "kind": "stale_member_cleanup_skipped_after_identity_reset",
6104                        "identity": parsed_identity.as_str(),
6105                        "agent_runtime_id": record.agent_runtime_id.as_str(),
6106                        "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
6107                    }));
6108                    if let Some(store) = &console_events {
6109                        store
6110                            .record_lifecycle(
6111                                parsed_identity.as_str(),
6112                                "identity_reset",
6113                                json!({
6114                                    "generation": record.generation.get(),
6115                                    "checkpoint_version": record.checkpoint_version.get(),
6116                                    "cleanup_warning": cleanup_warning.clone(),
6117                                }),
6118                            )
6119                            .await;
6120                    }
6121                    response_value(
6122                        response_id,
6123                        Some(json!({
6124                            "identity": record.identity.as_str(),
6125                            "agent_runtime_id": record.agent_runtime_id.as_str(),
6126                            "session_id": record.session_id.to_string(),
6127                            "generation": record.generation.get(),
6128                            "checkpoint_version": record.checkpoint_version.get(),
6129                            "cleanup_warning": cleanup_warning,
6130                        })),
6131                        None,
6132                    )
6133                }
6134                Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6135                    invalid_params(response_id, format!("identity not found: {identity}"))
6136                }
6137                Err(err) => console_identity_error_response(response_id, "reset", err),
6138            }
6139        }
6140        "mobkit/delete_identity" => {
6141            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6142                return invalid_params(response_id, "identity required");
6143            };
6144            let handle = runtime.handle();
6145            let Some(identity_runtime) = &identity_runtime else {
6146                return invalid_params(
6147                    response_id,
6148                    "identity-first runtime required for delete_identity",
6149                );
6150            };
6151            let (parsed_identity, _requested_exact_identity, live_alias) =
6152                match resolve_console_identity_control_target(
6153                    &handle,
6154                    Some(identity_runtime),
6155                    visibility_policy,
6156                    identity,
6157                )
6158                .await
6159                {
6160                    Ok(Some(target)) => target,
6161                    Ok(None) => {
6162                        return invalid_params(
6163                            response_id,
6164                            format!("identity not found: {identity}"),
6165                        );
6166                    }
6167                    Err(err) => return response_value(response_id, None, Some(err)),
6168                };
6169            let registered_status = match identity_runtime.status(&parsed_identity).await {
6170                Ok(status) => status,
6171                Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6172                    if let Some(alias) = live_alias.as_ref() {
6173                        if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6174                            return identity_hidden_by_policy_response(response_id, identity);
6175                        }
6176                        return response_value(
6177                            response_id,
6178                            None,
6179                            Some(JsonRpcError {
6180                                code: -32602,
6181                                message: format!(
6182                                    "delete_identity requires durable identity: {} is live-only",
6183                                    parsed_identity.as_str()
6184                                ),
6185                                data: Some(json!({
6186                                    "kind": "live_only_identity_delete_unsupported",
6187                                    "identity": parsed_identity.as_str(),
6188                                })),
6189                            }),
6190                        );
6191                    }
6192                    return invalid_params(response_id, format!("identity not found: {identity}"));
6193                }
6194                Err(err) => {
6195                    return console_identity_error_response(response_id, "delete_identity", err);
6196                }
6197            };
6198            if !identity_status_visible_to_console(visibility_policy, &registered_status) {
6199                return identity_hidden_by_policy_response(response_id, identity);
6200            }
6201            if let Some(error) = stale_live_alias_json_rpc_error(
6202                "delete_identity",
6203                identity_runtime,
6204                &parsed_identity,
6205                live_alias.as_ref(),
6206            )
6207            .await
6208            {
6209                return response_value(response_id, None, Some(error));
6210            }
6211            match identity_runtime.delete_identity(&parsed_identity).await {
6212                Ok(()) => {
6213                    let keep_runtime_member_id = registered_status
6214                        .agent_runtime_id
6215                        .as_ref()
6216                        .filter(|_| identity_runtime.has_session_bridge())
6217                        .map(crate::identity_first::AgentRuntimeId::as_str);
6218                    let cleanup_warning = if let Err(err) =
6219                        retire_stale_console_members_for_identity(
6220                            &handle,
6221                            visibility_policy,
6222                            parsed_identity.as_str(),
6223                            keep_runtime_member_id,
6224                        )
6225                        .await
6226                    {
6227                        Some(json!({
6228                            "kind": "stale_member_cleanup_failed_after_identity_delete",
6229                            "identity": parsed_identity.as_str(),
6230                            "message": err,
6231                        }))
6232                    } else {
6233                        None
6234                    };
6235                    if let Some(store) = &console_events {
6236                        store
6237                            .record_lifecycle(
6238                                parsed_identity.as_str(),
6239                                "identity_deleted",
6240                                json!({
6241                                    "cleanup_warning": cleanup_warning.clone(),
6242                                }),
6243                            )
6244                            .await;
6245                    }
6246                    response_value(
6247                        response_id,
6248                        Some(json!({
6249                            "identity": parsed_identity.as_str(),
6250                            "cleanup_warning": cleanup_warning,
6251                        })),
6252                        None,
6253                    )
6254                }
6255                Err(err) => console_identity_error_response(response_id, "delete_identity", err),
6256            }
6257        }
6258        "mobkit/reset_all" => {
6259            match Box::pin(reset_all_live_console_agents(
6260                runtime,
6261                console_events.as_ref(),
6262                console_aggregator.as_ref(),
6263                identity_runtime.as_ref(),
6264                visibility_policy,
6265            ))
6266            .await
6267            {
6268                Ok(body) => {
6269                    if body
6270                        .get("failed")
6271                        .and_then(Value::as_array)
6272                        .is_some_and(|failed| !failed.is_empty())
6273                    {
6274                        response_value(
6275                            response_id,
6276                            None,
6277                            Some(JsonRpcError {
6278                                code: -32000,
6279                                message: "reset_all failed for one or more identities".to_string(),
6280                                data: Some(body),
6281                            }),
6282                        )
6283                    } else {
6284                        response_value(response_id, Some(body), None)
6285                    }
6286                }
6287                Err(err) => internal_error(response_id, format!("reset_all failed: {err}")),
6288            }
6289        }
6290        "mobkit/routing/routes/list" => {
6291            let Some(module_runtime) = &module_runtime else {
6292                return response_value(
6293                    response_id,
6294                    None,
6295                    Some(JsonRpcError {
6296                        code: -32601,
6297                        message: "Method not found".to_string(),
6298                        data: None,
6299                    }),
6300                );
6301            };
6302            let routes = module_runtime.lock().await.list_runtime_routes();
6303            response_value(response_id, Some(json!({ "routes": routes })), None)
6304        }
6305        "mobkit/delivery/history" => {
6306            let Some(module_runtime) = &module_runtime else {
6307                return response_value(
6308                    response_id,
6309                    None,
6310                    Some(JsonRpcError {
6311                        code: -32601,
6312                        message: "Method not found".to_string(),
6313                        data: None,
6314                    }),
6315                );
6316            };
6317            let limit = request
6318                .params
6319                .get("limit")
6320                .and_then(Value::as_u64)
6321                .unwrap_or(50) as usize;
6322            let history = module_runtime
6323                .lock()
6324                .await
6325                .delivery_history(DeliveryHistoryRequest {
6326                    recipient: None,
6327                    sink: None,
6328                    limit,
6329                });
6330            response_value(
6331                response_id,
6332                Some(serde_json::to_value(history).unwrap_or(Value::Null)),
6333                None,
6334            )
6335        }
6336        "mobkit/gating/pending" => {
6337            let Some(module_runtime) = &module_runtime else {
6338                return response_value(
6339                    response_id,
6340                    None,
6341                    Some(JsonRpcError {
6342                        code: -32601,
6343                        message: "Method not found".to_string(),
6344                        data: None,
6345                    }),
6346                );
6347            };
6348            let pending = module_runtime.lock().await.list_gating_pending();
6349            response_value(response_id, Some(json!({ "pending": pending })), None)
6350        }
6351        "mobkit/gating/audit" => {
6352            let Some(module_runtime) = &module_runtime else {
6353                return response_value(
6354                    response_id,
6355                    None,
6356                    Some(JsonRpcError {
6357                        code: -32601,
6358                        message: "Method not found".to_string(),
6359                        data: None,
6360                    }),
6361                );
6362            };
6363            let limit = request
6364                .params
6365                .get("limit")
6366                .and_then(Value::as_u64)
6367                .unwrap_or(50) as usize;
6368            let entries = module_runtime.lock().await.gating_audit_entries(limit);
6369            response_value(response_id, Some(json!({ "entries": entries })), None)
6370        }
6371        "mobkit/gating/decide" => {
6372            let Some(module_runtime) = &module_runtime else {
6373                return response_value(
6374                    response_id,
6375                    None,
6376                    Some(JsonRpcError {
6377                        code: -32601,
6378                        message: "Method not found".to_string(),
6379                        data: None,
6380                    }),
6381                );
6382            };
6383            let Some(pending_id) = request.params.get("pending_id").and_then(Value::as_str) else {
6384                return invalid_params(response_id, "pending_id required");
6385            };
6386            let approver_id =
6387                match resolve_gating_approver_id(&request.params, authenticated_principal) {
6388                    Ok(approver_id) => approver_id,
6389                    Err(message) => return invalid_params(response_id, message),
6390                };
6391            let Some(raw_decision) = request.params.get("decision").and_then(Value::as_str) else {
6392                return invalid_params(response_id, "decision required");
6393            };
6394            let decision = match raw_decision {
6395                "approve" => GatingDecision::Approve,
6396                "reject" | "deny" => GatingDecision::Reject,
6397                "escalate" => GatingDecision::Escalate,
6398                _ => {
6399                    return invalid_params(
6400                        response_id,
6401                        format!("unsupported decision: {raw_decision}"),
6402                    );
6403                }
6404            };
6405            let reason = request
6406                .params
6407                .get("reason")
6408                .and_then(Value::as_str)
6409                .map(ToString::to_string);
6410            match module_runtime
6411                .lock()
6412                .await
6413                .decide_gating_action(GatingDecideRequest {
6414                    pending_id: pending_id.to_string(),
6415                    approver_id,
6416                    decision,
6417                    reason,
6418                }) {
6419                Ok(result) => response_value(
6420                    response_id,
6421                    Some(serde_json::to_value(result).unwrap_or(Value::Null)),
6422                    None,
6423                ),
6424                Err(err) => gating_decision_failed_error(response_id, err),
6425            }
6426        }
6427        "mobkit/ensure_member" => {
6428            let Some(role) = request.params.get("role").and_then(Value::as_str) else {
6429                return invalid_params(response_id, "role required");
6430            };
6431            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
6432            else {
6433                return invalid_params(response_id, "agent_identity required");
6434            };
6435            let labels = match request.params.get("labels") {
6436                None | Some(Value::Null) => std::collections::BTreeMap::new(),
6437                Some(value) => match serde_json::from_value(value.clone()) {
6438                    Ok(map) => map,
6439                    Err(err) => {
6440                        return invalid_params(response_id, format!("invalid labels: {err}"));
6441                    }
6442                },
6443            };
6444            let context = request.params.get("context").cloned();
6445            let resume_session_id = match request.params.get("resume_session_id") {
6446                None => None,
6447                Some(Value::Null) => None,
6448                Some(v) => match v.as_str() {
6449                    Some(s) => match meerkat_core::types::SessionId::parse(s) {
6450                        Ok(sid) => Some(sid),
6451                        Err(_) => {
6452                            return invalid_params(
6453                                response_id,
6454                                format!("invalid resume_session_id: {s:?}"),
6455                            );
6456                        }
6457                    },
6458                    None => {
6459                        return invalid_params(
6460                            response_id,
6461                            "resume_session_id must be a string".to_string(),
6462                        );
6463                    }
6464                },
6465            };
6466            let additional_instructions = match request.params.get("additional_instructions") {
6467                None | Some(Value::Null) => None,
6468                Some(Value::Array(arr)) => {
6469                    let mut strs = Vec::with_capacity(arr.len());
6470                    for (i, entry) in arr.iter().enumerate() {
6471                        match entry.as_str() {
6472                            Some(s) => strs.push(s.to_string()),
6473                            None => {
6474                                return invalid_params(
6475                                    response_id,
6476                                    format!("additional_instructions[{i}] must be a string"),
6477                                );
6478                            }
6479                        }
6480                    }
6481                    if strs.is_empty() { None } else { Some(strs) }
6482                }
6483                Some(_) => {
6484                    return invalid_params(
6485                        response_id,
6486                        "additional_instructions must be an array of strings",
6487                    );
6488                }
6489            };
6490            let runtime_mode = match parse_optional_runtime_mode(&request.params) {
6491                Ok(value) => value,
6492                Err(message) => return invalid_params(response_id, message),
6493            };
6494            let backend = match parse_optional_backend(&request.params) {
6495                Ok(value) => value,
6496                Err(message) => return invalid_params(response_id, message),
6497            };
6498            let binding = match parse_optional_runtime_binding(&request.params) {
6499                Ok(value) => value,
6500                Err(message) => return invalid_params(response_id, message),
6501            };
6502            let mut spec = SpawnMemberSpec::new(
6503                ProfileName::from(role),
6504                crate::member_comms_id::mob_member_id(agent_identity),
6505            );
6506            if let Some(runtime_mode) = runtime_mode {
6507                spec = spec.with_runtime_mode(runtime_mode);
6508            }
6509            if let Some(backend) = backend {
6510                spec = spec.with_backend(backend);
6511            }
6512            if let Some(binding) = binding {
6513                spec.binding = Some(binding);
6514            }
6515            if !labels.is_empty() {
6516                spec = spec.with_labels(labels);
6517            }
6518            if let Some(ctx) = context {
6519                spec = spec.with_context(ctx);
6520            }
6521            if let Some(sid) = resume_session_id {
6522                spec = spec.with_resume_bridge_session_id(sid);
6523            }
6524            if let Some(instructions) = additional_instructions {
6525                spec = spec.with_additional_instructions(instructions);
6526            }
6527            let handle = runtime.handle();
6528            let mid = spec.identity.clone();
6529            match handle.ensure_member(spec).await {
6530                Ok(_outcome) => {
6531                    let body = match lookup_member_with_session(&handle, &mid).await {
6532                        Some((entry, _sid)) => member_entry_to_json(&entry),
6533                        None => Value::Null,
6534                    };
6535                    response_value(response_id, Some(body), None)
6536                }
6537                Err(err) => internal_error(response_id, format!("ensure_member failed: {err}")),
6538            }
6539        }
6540        "mobkit/retire_member" => {
6541            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6542                return invalid_params(response_id, "member_id required");
6543            };
6544            if let Some(error) = stale_runtime_alias_json_rpc_error(
6545                "retire_member",
6546                identity_runtime.as_ref(),
6547                member_id,
6548            )
6549            .await
6550            {
6551                return response_value(response_id, None, Some(error));
6552            }
6553            if let Some(aggregator) = &console_aggregator {
6554                return match Box::pin(aggregator.retire_identity(member_id)).await {
6555                    Ok(true) => response_value(
6556                        response_id,
6557                        Some(serde_json::json!({ "accepted": true })),
6558                        None,
6559                    ),
6560                    Ok(false) => response_value(
6561                        response_id,
6562                        None,
6563                        Some(JsonRpcError {
6564                            code: -32001,
6565                            message: format!("unknown identity: {member_id}"),
6566                            data: None,
6567                        }),
6568                    ),
6569                    Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
6570                };
6571            }
6572            match runtime
6573                .handle()
6574                .retire(crate::member_comms_id::mob_member_id(member_id))
6575                .await
6576            {
6577                Ok(()) => response_value(
6578                    response_id,
6579                    Some(serde_json::json!({ "accepted": true })),
6580                    None,
6581                ),
6582                Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
6583            }
6584        }
6585        "mobkit/respawn_member" => {
6586            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6587                return invalid_params(response_id, "member_id required");
6588            };
6589            if let Some(error) = stale_runtime_alias_json_rpc_error(
6590                "respawn_member",
6591                identity_runtime.as_ref(),
6592                member_id,
6593            )
6594            .await
6595            {
6596                return response_value(response_id, None, Some(error));
6597            }
6598            match runtime
6599                .handle()
6600                .respawn(crate::member_comms_id::mob_member_id(member_id), None)
6601                .await
6602            {
6603                Ok(_receipt) => response_value(
6604                    response_id,
6605                    Some(serde_json::json!({ "accepted": true })),
6606                    None,
6607                ),
6608                Err(err) => {
6609                    if let Some(failed_peer_ids) = topology_restore_failed_peer_ids(&err) {
6610                        tracing::warn!(
6611                            member_id = %member_id,
6612                            failed_peer_count = failed_peer_ids.len(),
6613                            failed_peer_ids = ?failed_peer_ids,
6614                            "console member respawn restored member with isolated peer edges; accepting degraded respawn"
6615                        );
6616                        response_value(
6617                            response_id,
6618                            Some(serde_json::json!({
6619                                "accepted": true,
6620                                "topology_restore_warning": topology_restore_warning_json(&failed_peer_ids),
6621                            })),
6622                            None,
6623                        )
6624                    } else {
6625                        internal_error(response_id, format!("respawn_member failed: {err}"))
6626                    }
6627                }
6628            }
6629        }
6630        "mobkit/reconcile_edges" => response_value(
6631            response_id,
6632            Some(serde_json::json!({
6633                "status": "noop",
6634                "reason": "console runtime routes directly to MobRuntime",
6635            })),
6636            None,
6637        ),
6638        "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => {
6639            let query: EventQuery = if request.params.is_null() {
6640                EventQuery::default()
6641            } else {
6642                match serde_json::from_value(request.params.clone()) {
6643                    Ok(q) => q,
6644                    Err(err) => {
6645                        return invalid_params(response_id, format!("invalid query params: {err}"));
6646                    }
6647                }
6648            };
6649            let Some(store) = mob_events.as_ref() else {
6650                return response_value(
6651                    response_id,
6652                    Some(serde_json::json!({
6653                        "events": [],
6654                        "next_after_seq": Value::Null,
6655                    })),
6656                    None,
6657                );
6658            };
6659            let events_view = runtime.handle().events();
6660            // Capture latest_cursor at handshake so the SSE continuation
6661            // URL still covers the empty-snapshot case without losing
6662            // events between the JSON-RPC response and the SSE connect.
6663            let latest_at_handshake = events_view.latest_cursor().await.unwrap_or(0);
6664            let result = crate::unified_runtime::mob_events::query_ledger_with_filter(
6665                &events_view,
6666                store,
6667                &query,
6668            )
6669            .await;
6670            match result {
6671                Ok(mut events) => {
6672                    // `mob.observe` gates the surface; agent-attributed
6673                    // ledger entries are still filtered by `agent.view` so a
6674                    // mob.observe grant cannot reveal the lifecycle of an
6675                    // agent the caller is denied. Mob-level entries (no
6676                    // attribution) pass on the mob.observe grant alone.
6677                    if let Some(view) = access_view.filter(|view| view.enforced()) {
6678                        events.retain(|event| {
6679                            event
6680                                .agent_identity
6681                                .as_deref()
6682                                .is_none_or(|identity| view.can_view_agent(identity))
6683                        });
6684                    }
6685                    let last_cursor = events.last().map(|event| event.cursor);
6686                    let body = if request.method == "mobkit/mob_events/subscribe" {
6687                        let subscribe_url = crate::unified_runtime::mob_events::build_subscribe_url(
6688                            &query,
6689                            last_cursor,
6690                            latest_at_handshake,
6691                        );
6692                        serde_json::json!({
6693                            "stream": "mob_events",
6694                            "events": events,
6695                            "next_after_seq": last_cursor,
6696                            "subscribe_url": subscribe_url,
6697                            "keep_alive": {
6698                                "interval_ms": 15_000_u64,
6699                                "event": "keep_alive",
6700                            },
6701                        })
6702                    } else {
6703                        serde_json::json!({
6704                            "events": events,
6705                            "next_after_seq": last_cursor,
6706                        })
6707                    };
6708                    response_value(response_id, Some(body), None)
6709                }
6710                Err(crate::unified_runtime::mob_events::MobEventsQueryError::Stale {
6711                    after_cursor,
6712                    latest_cursor,
6713                }) => stale_event_cursor_response(response_id, after_cursor, latest_cursor),
6714                Err(err) => internal_error(response_id, format!("mob_events query failed: {err}")),
6715            }
6716        }
6717        // 0.5 API methods
6718        "mobkit/member_status" => {
6719            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6720                return invalid_params(response_id, "member_id required");
6721            };
6722            if let Some(error) = stale_runtime_alias_json_rpc_error(
6723                "member_status",
6724                identity_runtime.as_ref(),
6725                member_id,
6726            )
6727            .await
6728            {
6729                return response_value(response_id, None, Some(error));
6730            }
6731            match runtime
6732                .handle()
6733                .member_status(&crate::member_comms_id::mob_member_id(member_id))
6734                .await
6735            {
6736                Ok(snapshot) => response_value(
6737                    response_id,
6738                    Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
6739                    None,
6740                ),
6741                Err(err) => internal_error(response_id, format!("member_status failed: {err}")),
6742            }
6743        }
6744        "mobkit/identity/resolved_tools" => {
6745            let Some(identity) = request
6746                .params
6747                .get("identity")
6748                .or_else(|| request.params.get("member_id"))
6749                .and_then(Value::as_str)
6750            else {
6751                return invalid_params(response_id, "identity required");
6752            };
6753            if let Some(error) = stale_runtime_alias_json_rpc_error(
6754                "identity_resolved_tools",
6755                identity_runtime.as_ref(),
6756                identity,
6757            )
6758            .await
6759            {
6760                return response_value(response_id, None, Some(error));
6761            }
6762            if let Some(identity_runtime) = identity_runtime.as_ref()
6763                && let Ok(parsed) = crate::identity_first::AgentIdentity::parse(identity)
6764                && let Ok(status) = identity_runtime.status(&parsed).await
6765                && let Some(session_id) = status.session_id
6766            {
6767                match resolved_tools_for_session(runtime.session_service(), identity, session_id)
6768                    .await
6769                {
6770                    Ok(snapshot) => {
6771                        return response_value(
6772                            response_id,
6773                            Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
6774                            None,
6775                        );
6776                    }
6777                    Err(err) => {
6778                        return internal_error(
6779                            response_id,
6780                            format!("resolved_tools failed: {err}"),
6781                        );
6782                    }
6783                }
6784            }
6785            match resolved_tools_for_member(&runtime.handle(), runtime.session_service(), identity)
6786                .await
6787            {
6788                Ok(snapshot) => response_value(
6789                    response_id,
6790                    Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
6791                    None,
6792                ),
6793                Err(err) => internal_error(response_id, format!("resolved_tools failed: {err}")),
6794            }
6795        }
6796        "mobkit/force_cancel_member" => {
6797            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6798                return invalid_params(response_id, "member_id required");
6799            };
6800            if let Some(error) = stale_runtime_alias_json_rpc_error(
6801                "force_cancel_member",
6802                identity_runtime.as_ref(),
6803                member_id,
6804            )
6805            .await
6806            {
6807                return response_value(response_id, None, Some(error));
6808            }
6809            match runtime
6810                .handle()
6811                .force_cancel_member(crate::member_comms_id::mob_member_id(member_id))
6812                .await
6813            {
6814                Ok(()) => response_value(
6815                    response_id,
6816                    Some(serde_json::json!({ "accepted": true })),
6817                    None,
6818                ),
6819                Err(err) => {
6820                    internal_error(response_id, format!("force_cancel_member failed: {err}"))
6821                }
6822            }
6823        }
6824        "mobkit/wait_ready" => {
6825            // Omit `timeout_ms` => mobkit's generous default ceiling (the SDK
6826            // contract is "wait until ready"), not meerkat-mob 0.7.9's lowered
6827            // 60s internal default that `None` would otherwise inherit.
6828            let timeout = Some(
6829                request
6830                    .params
6831                    .get("timeout_ms")
6832                    .and_then(Value::as_u64)
6833                    .map(std::time::Duration::from_millis)
6834                    .unwrap_or(crate::unified_runtime::mob_ops::DEFAULT_WAIT_READY_TIMEOUT),
6835            );
6836            match runtime.handle().wait_for_ready(timeout).await {
6837                Ok(ready) => {
6838                    let entries: Vec<Value> = ready
6839                        .into_iter()
6840                        // Per-agent visibility: a caller only sees readiness
6841                        // for agents they may `agent.view`. The cache was
6842                        // primed from the roster at dispatch.
6843                        .filter_map(|(identity, snapshot)| {
6844                            let identity = identity.to_string();
6845                            let visible = access_view
6846                                .filter(|view| view.enforced())
6847                                .is_none_or(|view| view.can_view_agent(&identity));
6848                            visible.then(|| {
6849                                serde_json::json!({
6850                                    "agent_identity": identity,
6851                                    "snapshot": serde_json::to_value(&snapshot)
6852                                        .unwrap_or(Value::Null),
6853                                })
6854                            })
6855                        })
6856                        .collect();
6857                    response_value(
6858                        response_id,
6859                        Some(serde_json::json!({
6860                            "ready": entries,
6861                            "timeout": false,
6862                        })),
6863                        None,
6864                    )
6865                }
6866                Err(err) => {
6867                    if crate::unified_runtime::mob_ops::is_ready_wait_timeout(&err) {
6868                        response_value(
6869                            response_id,
6870                            Some(serde_json::json!({
6871                                "ready": Vec::<Value>::new(),
6872                                "timeout": true,
6873                            })),
6874                            None,
6875                        )
6876                    } else {
6877                        internal_error(response_id, format!("wait_for_ready failed: {err}"))
6878                    }
6879                }
6880            }
6881        }
6882        "mobkit/collect_completed" => {
6883            let completed = runtime.handle().collect_completed().await;
6884            let entries: Vec<Value> = completed
6885                .into_iter()
6886                // Per-agent visibility: even an `agent.spawn`-gated caller only
6887                // collects completions for agents they may `agent.view`.
6888                .filter_map(|(mid, snapshot)| {
6889                    let mid = mid.to_string();
6890                    let visible = access_view
6891                        .filter(|view| view.enforced())
6892                        .is_none_or(|view| view.can_view_agent(&mid));
6893                    visible.then(|| {
6894                        serde_json::json!({
6895                            "member_id": mid,
6896                            "snapshot": serde_json::to_value(&snapshot).unwrap_or(Value::Null),
6897                        })
6898                    })
6899                })
6900                .collect();
6901            response_value(
6902                response_id,
6903                Some(serde_json::json!({ "completed": entries })),
6904                None,
6905            )
6906        }
6907        "mobkit/cancel_flow" => {
6908            let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
6909                return invalid_params(response_id, "run_id required");
6910            };
6911            let run_id: meerkat_mob::RunId = match run_id.parse() {
6912                Ok(id) => id,
6913                Err(_) => return invalid_params(response_id, "invalid run_id format"),
6914            };
6915            match runtime.handle().cancel_flow(run_id).await {
6916                Ok(()) => response_value(
6917                    response_id,
6918                    Some(serde_json::json!({ "accepted": true })),
6919                    None,
6920                ),
6921                Err(err) => internal_error(response_id, format!("cancel_flow failed: {err}")),
6922            }
6923        }
6924        "mobkit/flow_status" => {
6925            let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
6926                return invalid_params(response_id, "run_id required");
6927            };
6928            let run_id: meerkat_mob::RunId = match run_id.parse() {
6929                Ok(id) => id,
6930                Err(_) => return invalid_params(response_id, "invalid run_id format"),
6931            };
6932            match runtime.handle().flow_status(run_id).await {
6933                Ok(Some(mob_run)) => response_value(
6934                    response_id,
6935                    Some(serde_json::to_value(&mob_run).unwrap_or(Value::Null)),
6936                    None,
6937                ),
6938                Ok(None) => response_value(response_id, Some(Value::Null), None),
6939                Err(err) => internal_error(response_id, format!("flow_status failed: {err}")),
6940            }
6941        }
6942        "mobkit/list_flows" => {
6943            let flows: Vec<String> = runtime
6944                .handle()
6945                .list_flows()
6946                .into_iter()
6947                .map(|id| id.to_string())
6948                .collect();
6949            response_value(
6950                response_id,
6951                Some(serde_json::json!({ "flows": flows })),
6952                None,
6953            )
6954        }
6955        "mobkit/list_runs" => {
6956            let flow_id = request
6957                .params
6958                .get("flow_id")
6959                .and_then(Value::as_str)
6960                .filter(|value| !value.is_empty())
6961                .map(meerkat_mob::FlowId::from);
6962            match runtime.handle().list_runs(flow_id.as_ref()).await {
6963                Ok(runs) => response_value(
6964                    response_id,
6965                    Some(serde_json::json!({
6966                        "runs": serde_json::to_value(&runs).unwrap_or(Value::Null),
6967                    })),
6968                    None,
6969                ),
6970                Err(err) => internal_error(response_id, format!("list_runs failed: {err}")),
6971            }
6972        }
6973        "mobkit/run_flow" => {
6974            let Some(flow_id_str) = request.params.get("flow_id").and_then(Value::as_str) else {
6975                return invalid_params(response_id, "flow_id required");
6976            };
6977            if flow_id_str.is_empty() {
6978                return invalid_params(response_id, "flow_id required");
6979            }
6980            let flow_id = meerkat_mob::FlowId::from(flow_id_str);
6981            let flow_params = request.params.get("params").cloned().unwrap_or(Value::Null);
6982            if let Some(identity_runtime) = &identity_runtime
6983                && let Err(err) = identity_runtime.materialize_all_required().await
6984            {
6985                return internal_error(
6986                    response_id,
6987                    format!("identity-first flow materialization failed: {err}"),
6988                );
6989            }
6990            match Box::pin(runtime.handle().run_flow(flow_id, flow_params)).await {
6991                Ok(run_id) => response_value(
6992                    response_id,
6993                    Some(serde_json::json!({ "run_id": run_id.to_string() })),
6994                    None,
6995                ),
6996                Err(err) => invalid_params(response_id, format!("run_flow failed: {err}")),
6997            }
6998        }
6999        "mobkit/spawn_helper" => {
7000            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7001            else {
7002                return invalid_params(response_id, "agent_identity required");
7003            };
7004            let Some(task) = request.params.get("task").and_then(Value::as_str) else {
7005                return invalid_params(response_id, "task required");
7006            };
7007            let options = match parse_console_helper_options(request.params.get("options")) {
7008                Ok(opts) => opts,
7009                Err(msg) => return invalid_params(response_id, msg),
7010            };
7011            let handle = runtime.handle();
7012            match Box::pin(handle.spawn_helper(
7013                crate::member_comms_id::mob_member_id(agent_identity),
7014                task,
7015                options,
7016            ))
7017            .await
7018            {
7019                Ok(result) => {
7020                    // Meerkat 0.6 retires the helper before `spawn_helper`
7021                    // returns, so a post-hoc `resolve_bridge_session_id`
7022                    // call would come back `None`. We drop `session_id`
7023                    // from the response rather than emit a misleading null.
7024                    response_value(
7025                        response_id,
7026                        Some(serde_json::json!({
7027                            "output": result.output,
7028                            "tokens_used": result.tokens_used,
7029                        })),
7030                        None,
7031                    )
7032                }
7033                Err(err) => internal_error(response_id, format!("spawn_helper failed: {err}")),
7034            }
7035        }
7036        "mobkit/fork_helper" => {
7037            let Some(source) = request
7038                .params
7039                .get("source_member_id")
7040                .and_then(Value::as_str)
7041            else {
7042                return invalid_params(response_id, "source_member_id required");
7043            };
7044            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7045            else {
7046                return invalid_params(response_id, "agent_identity required");
7047            };
7048            let Some(task) = request.params.get("task").and_then(Value::as_str) else {
7049                return invalid_params(response_id, "task required");
7050            };
7051            let fork_context = match request.params.get("fork_context") {
7052                Some(v) if !v.is_null() => {
7053                    match serde_json::from_value::<meerkat_mob::launch::ForkContext>(v.clone()) {
7054                        Ok(ctx) => ctx,
7055                        Err(err) => {
7056                            return invalid_params(
7057                                response_id,
7058                                format!("invalid fork_context: {err}"),
7059                            );
7060                        }
7061                    }
7062                }
7063                _ => meerkat_mob::launch::ForkContext::default(),
7064            };
7065            let options = match parse_console_helper_options(request.params.get("options")) {
7066                Ok(opts) => opts,
7067                Err(msg) => return invalid_params(response_id, msg),
7068            };
7069            let handle = runtime.handle();
7070            match Box::pin(handle.fork_helper(
7071                &crate::member_comms_id::mob_member_id(source),
7072                crate::member_comms_id::mob_member_id(agent_identity),
7073                task,
7074                fork_context,
7075                options,
7076            ))
7077            .await
7078            {
7079                Ok(result) => {
7080                    // See `spawn_helper`: meerkat 0.6 retires the forked
7081                    // helper before returning, so session_id is omitted
7082                    // rather than silently null.
7083                    response_value(
7084                        response_id,
7085                        Some(serde_json::json!({
7086                            "output": result.output,
7087                            "tokens_used": result.tokens_used,
7088                        })),
7089                        None,
7090                    )
7091                }
7092                Err(err) => internal_error(response_id, format!("fork_helper failed: {err}")),
7093            }
7094        }
7095        "mobkit/attach_existing_session" => {
7096            let Some(role) = request.params.get("role").and_then(Value::as_str) else {
7097                return invalid_params(response_id, "role required");
7098            };
7099            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7100            else {
7101                return invalid_params(response_id, "agent_identity required");
7102            };
7103            let Some(session_id_str) = request.params.get("session_id").and_then(Value::as_str)
7104            else {
7105                return invalid_params(response_id, "session_id required");
7106            };
7107            let bridge_session_id = match meerkat_core::types::SessionId::parse(session_id_str) {
7108                Ok(s) => s,
7109                Err(_) => return invalid_params(response_id, "invalid session_id format"),
7110            };
7111            let mid = crate::member_comms_id::mob_member_id(agent_identity);
7112            let spec = SpawnMemberSpec::new(ProfileName::from(role), mid.clone())
7113                .with_launch_mode(MemberLaunchMode::Resume { bridge_session_id });
7114            let handle = runtime.handle();
7115            match Box::pin(handle.spawn_spec(spec)).await {
7116                Ok(_) => match handle.member_status(&mid).await {
7117                    Ok(snapshot) => response_value(
7118                        response_id,
7119                        Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7120                        None,
7121                    ),
7122                    Err(err) => internal_error(
7123                        response_id,
7124                        format!("attach_existing_session status lookup failed: {err}"),
7125                    ),
7126                },
7127                Err(err) => internal_error(
7128                    response_id,
7129                    format!("attach_existing_session failed: {err}"),
7130                ),
7131            }
7132        }
7133        "mobkit/cross_mob/wire_local" => {
7134            handle_console_wire_local(runtime, &request.params, response_id, true).await
7135        }
7136        "mobkit/cross_mob/unwire_local" => {
7137            handle_console_wire_local(runtime, &request.params, response_id, false).await
7138        }
7139        "mobkit/peer_pubkey" => match gateway_peer_keys {
7140            Some(keys) => response_value(
7141                response_id,
7142                Some(serde_json::json!({ "pubkey_b64": keys.pubkey_b64() })),
7143                None,
7144            ),
7145            None => response_value(
7146                response_id,
7147                None,
7148                Some(JsonRpcError {
7149                    code: -32004,
7150                    message: "gateway has no signing keypair configured".to_string(),
7151                    data: None,
7152                }),
7153            ),
7154        },
7155        "mobkit/cross_mob/peer_info" => {
7156            let member_id = request.params.get("member_id").and_then(Value::as_str);
7157            match member_id {
7158                Some(mid) if !mid.is_empty() => {
7159                    let handle = runtime.handle();
7160                    let mob_id = handle.mob_id().to_string();
7161                    let meerkat_id = crate::member_comms_id::mob_member_id(mid);
7162                    match handle.get_member(&meerkat_id).await {
7163                        Err(err) => response_value(
7164                            response_id,
7165                            None,
7166                            Some(JsonRpcError {
7167                                code: -32000,
7168                                message: format!("member lookup failed: {err}"),
7169                                data: None,
7170                            }),
7171                        ),
7172                        Ok(Some(entry)) => match entry.peer_id() {
7173                            Some(peer_id) => {
7174                                let comms_name = format!("{}/{}/{}", mob_id, entry.role, mid);
7175                                let address = format!("inproc://{comms_name}");
7176                                response_value(
7177                                    response_id,
7178                                    Some(serde_json::json!({
7179                                        "member_id": mid,
7180                                        "mob_id": mob_id,
7181                                        "comms_name": comms_name,
7182                                        "peer_id": peer_id,
7183                                        "address": address,
7184                                    })),
7185                                    None,
7186                                )
7187                            }
7188                            None => response_value(
7189                                response_id,
7190                                None,
7191                                Some(JsonRpcError {
7192                                    code: -32000,
7193                                    message: format!("member {mid:?} has no comms runtime"),
7194                                    data: None,
7195                                }),
7196                            ),
7197                        },
7198                        Ok(None) => response_value(
7199                            response_id,
7200                            None,
7201                            Some(JsonRpcError {
7202                                code: -32000,
7203                                message: format!("member {mid:?} not found"),
7204                                data: None,
7205                            }),
7206                        ),
7207                    }
7208                }
7209                _ => invalid_params(response_id, "member_id required".to_string()),
7210            }
7211        }
7212        "mobkit/cross_mob/directory" => {
7213            let entries: Vec<Value> = contact_directory
7214                .map(|dir| {
7215                    dir.list()
7216                        .into_iter()
7217                        .filter_map(|e| serde_json::to_value(e).ok())
7218                        .collect()
7219                })
7220                .unwrap_or_default();
7221            response_value(
7222                response_id,
7223                Some(serde_json::json!({ "mobs": entries })),
7224                None,
7225            )
7226        }
7227        method
7228            if matches!(
7229                method,
7230                "mobkit/mob_labels/set"
7231                    | "mobkit/mob_labels/get"
7232                    | "mobkit/mob_labels/delete"
7233                    | "mobkit/run_labels/set"
7234                    | "mobkit/run_labels/get"
7235                    | "mobkit/run_labels/delete",
7236            ) =>
7237        {
7238            dispatch_label_method(
7239                method,
7240                metadata_table.as_deref(),
7241                runtime.handle().mob_id().as_str(),
7242                response_id,
7243                &request.params,
7244            )
7245            .await
7246        }
7247        _ => response_value(
7248            response_id,
7249            None,
7250            Some(JsonRpcError {
7251                code: -32601,
7252                message: "Method not found".to_string(),
7253                data: None,
7254            }),
7255        ),
7256    }
7257}
7258
7259/// Dispatch the six `mobkit/{mob,run}_labels/*` RPCs against a metadata table.
7260///
7261/// Single entrypoint shared by every label method; the dispatch arms in
7262/// `handle_console_runtime_rpc` simply delegate based on the matched method
7263/// name. Mirrors the unified-runtime handlers in `rpc::mob_methods` — both
7264/// transports project the same outcomes to the same wire shape.
7265async fn dispatch_label_method(
7266    method: &str,
7267    metadata_table: Option<&RuntimeMetadataTable>,
7268    mob_id: &str,
7269    response_id: Value,
7270    params: &Value,
7271) -> Value {
7272    let Some(table) = metadata_table else {
7273        return invalid_params(
7274            response_id,
7275            "metadata table not configured for this runtime",
7276        );
7277    };
7278
7279    let scope = match method {
7280        "mobkit/mob_labels/set" | "mobkit/mob_labels/get" | "mobkit/mob_labels/delete" => {
7281            MetadataScope::Mob(mob_id.to_string())
7282        }
7283        _ => match crate::runtime::parse_run_id_param(params) {
7284            Ok(run_id) => MetadataScope::Run(mob_id.to_string(), run_id.to_string()),
7285            Err(message) => return invalid_params(response_id, message),
7286        },
7287    };
7288
7289    let outcome = match method {
7290        "mobkit/mob_labels/set" | "mobkit/run_labels/set" => {
7291            crate::runtime::dispatch_labels_set(table, scope, params).await
7292        }
7293        "mobkit/mob_labels/get" | "mobkit/run_labels/get" => {
7294            crate::runtime::dispatch_labels_get(table, scope).await
7295        }
7296        "mobkit/mob_labels/delete" | "mobkit/run_labels/delete" => {
7297            crate::runtime::dispatch_labels_delete(table, scope).await
7298        }
7299        _ => unreachable!("dispatch_label_method called with non-label method: {method}"),
7300    };
7301
7302    match outcome {
7303        crate::runtime::LabelRpcResult::Accepted => response_value(
7304            response_id,
7305            Some(serde_json::json!({"accepted": true})),
7306            None,
7307        ),
7308        crate::runtime::LabelRpcResult::Labels(labels) => response_value(
7309            response_id,
7310            Some(serde_json::json!({"labels": labels_to_json_value(&labels)})),
7311            None,
7312        ),
7313        crate::runtime::LabelRpcResult::InvalidParams(message) => {
7314            invalid_params(response_id, message)
7315        }
7316    }
7317}
7318
7319/// Shared body for `mobkit/cross_mob/wire_local` and `unwire_local` over
7320/// the console transport. `wire = true` calls `MobHandle::wire`, `false`
7321/// calls `MobHandle::unwire`. Both share param parsing and response shape.
7322///
7323/// Non-inproc transports (`tcp://`, `uds://`) require a non-zero pubkey;
7324/// the caller may supply it via `remote_pubkey_b64` or rely on TOFU
7325/// flows configured at the contact-directory layer (which this handler
7326/// does not consult — it only sees the explicit params).
7327async fn handle_console_wire_local(
7328    runtime: &MobRuntime,
7329    params: &Value,
7330    response_id: Value,
7331    wire: bool,
7332) -> Value {
7333    let local = params.get("local_member_id").and_then(Value::as_str);
7334    let comms_name = params.get("remote_comms_name").and_then(Value::as_str);
7335    let peer_id = params.get("remote_peer_id").and_then(Value::as_str);
7336    let addr = params.get("remote_address").and_then(Value::as_str);
7337
7338    let remote_pubkey = match params.get("remote_pubkey_b64") {
7339        None => None,
7340        Some(v) if v.is_null() => None,
7341        Some(v) => match v.as_str() {
7342            Some(s) if !s.is_empty() => match crate::auth::peer_keys::decode_pubkey_b64(s) {
7343                Ok(bytes) => Some(bytes),
7344                Err(err) => {
7345                    return invalid_params(response_id, format!("remote_pubkey_b64: {err}"));
7346                }
7347            },
7348            _ => None,
7349        },
7350    };
7351
7352    let (local_id, cname, pid, address) = match (local, comms_name, peer_id, addr) {
7353        (Some(l), Some(c), Some(p), Some(a))
7354            if !l.is_empty() && !c.is_empty() && !p.is_empty() && !a.is_empty() =>
7355        {
7356            (l, c, p, a)
7357        }
7358        _ => {
7359            return invalid_params(
7360                response_id,
7361                "local_member_id, remote_comms_name, remote_peer_id, and remote_address required",
7362            );
7363        }
7364    };
7365
7366    let is_inproc = address.starts_with("inproc://");
7367    let spec_result = match (is_inproc, remote_pubkey) {
7368        (true, None) => TrustedPeerDescriptor::test_only_unsigned(cname, pid, address),
7369        (true, Some(bytes)) => {
7370            TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
7371        }
7372        (false, None) => {
7373            return invalid_params(
7374                response_id,
7375                "remote_pubkey_b64 is required for non-inproc transports",
7376            );
7377        }
7378        (false, Some(bytes)) => {
7379            if bytes == [0u8; 32] {
7380                return invalid_params(
7381                    response_id,
7382                    "remote_pubkey_b64 must be non-zero for non-inproc transports",
7383                );
7384            }
7385            TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
7386        }
7387    };
7388
7389    let spec = match spec_result {
7390        Ok(spec) => spec,
7391        Err(err) => {
7392            return invalid_params(response_id, format!("invalid peer spec: {err}"));
7393        }
7394    };
7395
7396    let result = if wire {
7397        runtime
7398            .handle()
7399            .wire(
7400                crate::member_comms_id::mob_member_id(local_id),
7401                PeerTarget::External(spec),
7402            )
7403            .await
7404    } else {
7405        runtime
7406            .handle()
7407            .unwire(
7408                crate::member_comms_id::mob_member_id(local_id),
7409                PeerTarget::External(spec),
7410            )
7411            .await
7412    };
7413
7414    let action = if wire { "wire_local" } else { "unwire_local" };
7415    match result {
7416        Ok(()) => response_value(
7417            response_id,
7418            Some(serde_json::json!({
7419                "accepted": true,
7420                "local_member_id": local_id,
7421                "remote_comms_name": cname,
7422            })),
7423            None,
7424        ),
7425        Err(err) => internal_error(response_id, format!("cross_mob/{action} failed: {err}")),
7426    }
7427}
7428
7429async fn build_live_snapshot(
7430    runtime: &MobRuntime,
7431    config_module_ids: &[String],
7432    console_events: Option<&ConsoleEventStore>,
7433    visibility_policy: &dyn ConsoleVisibilityPolicy,
7434    read_model: &ConsoleSnapshotReadModel,
7435) -> ConsoleLiveSnapshot {
7436    let read_model_state = Box::pin(read_model.snapshot(runtime)).await;
7437    let running = read_model_state.running.unwrap_or(true);
7438    // Hot path: clone the pre-projected members from the cached read
7439    // model. NO `handle.*` async calls happen here — the background
7440    // refresh task is the only thing that walks the mob roster, so
7441    // snapshot requests never contend with spawn/retire activity.
7442    // First request on a cold cache pays one synchronous refresh via
7443    // `snapshot(runtime).await` above; subsequent requests just clone.
7444    let mut members = read_model_state.primary_members.clone();
7445    if visibility_policy.include_implicit_delegate_members() {
7446        for group in &read_model_state.delegate_member_groups {
7447            members.extend(group.iter().cloned());
7448        }
7449    }
7450    dedupe_console_members_by_identity(&mut members);
7451    members.retain(|member| {
7452        visibility_policy.member_visible(member)
7453            && visibility_policy
7454                .identity_visible(&console_identity_record_from_console_member(member))
7455    });
7456
7457    // Use configured module IDs when available because topology and health
7458    // surfaces describe loaded modules, not live mob members.
7459    // Fall back to member IDs only for pure mob runtimes with no module config.
7460    let loaded_modules = if config_module_ids.is_empty() {
7461        let mut mods: Vec<String> = members
7462            .iter()
7463            .filter(|member| member.state != MEMBER_STATE_RETIRING)
7464            .map(|member| member.agent_identity.clone())
7465            .collect();
7466        mods.sort();
7467        mods
7468    } else {
7469        let mut mods = config_module_ids.to_vec();
7470        mods.sort();
7471        mods
7472    };
7473
7474    let agents = members
7475        .iter()
7476        .map(|member| async move {
7477            let console_identity = console_member_console_identity(member);
7478            let label = member
7479                .labels
7480                .get("display_name")
7481                .cloned()
7482                .unwrap_or_else(|| member.agent_identity.clone());
7483            let watched = member
7484                .labels
7485                .get("console_watched")
7486                .map(|value: &String| value == "true");
7487            let alert_level = member
7488                .labels
7489                .get("console_alert_level")
7490                .filter(|value: &&String| matches!(value.as_str(), "elevated" | "critical"))
7491                .cloned();
7492            let degraded = member
7493                .labels
7494                .get("console_degraded")
7495                .map(|value: &String| value == "true");
7496            let degraded_reason = member.labels.get("console_degraded_reason").cloned();
7497            let response_phase = match console_events {
7498                Some(store) => store.response_phase_for_identity(console_identity).await,
7499                None => None,
7500            };
7501            ConsoleAgentLiveSnapshot {
7502                agent_id: member.agent_identity.clone(),
7503                member_id: member.agent_identity.clone(),
7504                label,
7505                kind: "meerkat".to_string(),
7506                identity: Some(console_identity.to_string()),
7507                role: Some(member.role.clone()),
7508                state: Some(member.state.clone()),
7509                session_id: member.session_id.clone(),
7510                model_capabilities: member.model_capabilities.clone(),
7511                response_phase,
7512                watched,
7513                alert_level,
7514                degraded,
7515                degraded_reason,
7516            }
7517        })
7518        .collect::<Vec<_>>();
7519    let mut agents = join_all(agents).await;
7520    agents.sort_by(|left, right| left.label.cmp(&right.label));
7521    ConsoleLiveSnapshot::new(
7522        Some(runtime.handle().mob_id().to_string()),
7523        running,
7524        loaded_modules,
7525        agents,
7526        members,
7527        true,
7528    )
7529}
7530
7531async fn collect_console_snapshot_read_model(
7532    runtime: &MobRuntime,
7533) -> ConsoleSnapshotReadModelState {
7534    let handle = runtime.handle();
7535    let mut state = ConsoleSnapshotReadModelState {
7536        running: Some(matches!(
7537            handle.status_observation_snapshot(),
7538            MobState::Creating | MobState::Running
7539        )),
7540        ..ConsoleSnapshotReadModelState::default()
7541    };
7542    collect_console_session_index_for_handle(&handle, &mut state).await;
7543    let registered_labels = runtime.console_identity_labels().await;
7544
7545    // Snapshot + project the primary mob into the cache. Done here
7546    // under the background refresh lock so per-request
7547    // `build_live_snapshot` calls never need to enter MobHandle async
7548    // methods. The session-id index in `state` was populated above by
7549    // `collect_console_session_index_for_handle`.
7550    let (primary_members, _primary_owner_index) =
7551        project_console_members_from_handle(&handle, None, None, &state, &registered_labels).await;
7552    state.primary_members = primary_members;
7553
7554    let Some(mcp_state) = runtime.agent_mob_mcp_state() else {
7555        return state;
7556    };
7557    let primary_mob_id = handle.mob_id().to_string();
7558    let mut processed = BTreeSet::from([primary_mob_id]);
7559    let mut delegate_groups: Vec<Vec<ConsoleMember>> = Vec::new();
7560    loop {
7561        let mut progressed = false;
7562        for (mob_id, delegate_handle) in Box::pin(mcp_state.mob_handles_snapshot())
7563            .await
7564            .unwrap_or_default()
7565        {
7566            if processed.contains(mob_id.as_str()) {
7567                continue;
7568            }
7569            // Meerkat 0.7: the owner bridge binding is machine-owned state,
7570            // no longer a definition-level index.
7571            let Some(owner_authority) = delegate_handle.owner_bridge_session_lifecycle_authority()
7572            else {
7573                processed.insert(mob_id.to_string());
7574                continue;
7575            };
7576            let owner_session_id = owner_authority.bridge_session_id.to_string();
7577            let Some(host_identity) = state
7578                .session_owner_by_id
7579                .get(owner_session_id.as_str())
7580                .cloned()
7581            else {
7582                continue;
7583            };
7584            collect_console_session_index_for_handle(&delegate_handle, &mut state).await;
7585            let (delegate_members, _delegate_owner_index) = project_console_members_from_handle(
7586                &delegate_handle,
7587                Some(&host_identity),
7588                Some(mob_id.as_str()),
7589                &state,
7590                &registered_labels,
7591            )
7592            .await;
7593            delegate_groups.push(delegate_members);
7594            processed.insert(mob_id.to_string());
7595            progressed = true;
7596        }
7597        if !progressed {
7598            break;
7599        }
7600    }
7601    state.delegate_member_groups = delegate_groups;
7602    state
7603}
7604
7605async fn collect_console_session_index_for_handle(
7606    handle: &MobHandle,
7607    state: &mut ConsoleSnapshotReadModelState,
7608) {
7609    for entry in handle.list_members_observation_snapshot().await {
7610        // Session read-model keys live in the public alias space (decoded
7611        // from the comms-safe roster id, meerkat 0.7).
7612        let identity =
7613            crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
7614        let Some(session_id) = handle
7615            .resolve_bridge_session_id_observation(&entry.agent_identity)
7616            .await
7617            .map(|session_id| session_id.to_string())
7618        else {
7619            state.session_id_by_identity.remove(&identity);
7620            continue;
7621        };
7622        state
7623            .session_owner_by_id
7624            .insert(session_id.clone(), identity.clone());
7625        state.session_id_by_identity.insert(identity, session_id);
7626    }
7627}
7628
7629fn apply_console_visibility_policy(
7630    snapshot: &mut ConsoleLiveSnapshot,
7631    visibility_policy: &dyn ConsoleVisibilityPolicy,
7632) {
7633    let mut hidden = BTreeSet::new();
7634    snapshot.members.retain(|member| {
7635        let visible = visibility_policy.member_visible(member)
7636            && visibility_policy
7637                .identity_visible(&console_identity_record_from_console_member(member));
7638        if !visible {
7639            hidden.insert(member.agent_identity.clone());
7640        }
7641        visible
7642    });
7643    snapshot
7644        .agents
7645        .retain(|agent| !hidden.contains(&agent.agent_id));
7646    snapshot
7647        .loaded_modules
7648        .retain(|module_id| !hidden.contains(module_id));
7649}
7650
7651async fn reset_all_live_console_agents(
7652    runtime: &MobRuntime,
7653    console_events: Option<&ConsoleEventStore>,
7654    console_aggregator: Option<&MobKitConsoleAggregator>,
7655    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
7656    visibility_policy: &dyn ConsoleVisibilityPolicy,
7657) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
7658    let read_model = ConsoleSnapshotReadModel::default();
7659    *read_model.inner.write().await = Box::pin(collect_console_snapshot_read_model(runtime)).await;
7660    // Mark the freshly-built model primed so `build_live_snapshot` doesn't
7661    // try to re-prime; this is a one-shot read for the reset path.
7662    read_model
7663        .primed
7664        .store(true, std::sync::atomic::Ordering::Release);
7665    let snapshot = Box::pin(build_live_snapshot(
7666        runtime,
7667        &[],
7668        console_events,
7669        visibility_policy,
7670        &read_model,
7671    ))
7672    .await;
7673    let raw_snapshot = Box::pin(build_live_snapshot(
7674        runtime,
7675        &[],
7676        console_events,
7677        &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
7678        &read_model,
7679    ))
7680    .await;
7681    let identity_runtime_statuses = if let Some(identity_runtime) = identity_runtime {
7682        identity_runtime.statuses().await
7683    } else {
7684        Vec::new()
7685    };
7686    let identity_by_runtime_member_id = identity_runtime_statuses
7687        .iter()
7688        .filter_map(|status| {
7689            status
7690                .agent_runtime_id
7691                .as_ref()
7692                .map(|runtime_id| (runtime_id.as_str().to_string(), status.identity.to_string()))
7693        })
7694        .collect::<BTreeMap<_, _>>();
7695    let mut durable_identity_runtime_identities = identity_runtime_statuses
7696        .iter()
7697        .filter(|status| identity_status_visible_to_console(visibility_policy, status))
7698        .map(|status| status.identity.to_string())
7699        .collect::<BTreeSet<_>>();
7700    let mut main_identities = BTreeSet::new();
7701    let mut runtime_member_id_by_identity = BTreeMap::new();
7702    let mut runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7703    let mut session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
7704        BTreeMap::new();
7705    let mut live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
7706        BTreeMap::new();
7707    let mut visible_runtime_member_ids = BTreeSet::new();
7708    let mut duplicate_live_identities = BTreeSet::new();
7709    let mut delegate_members = BTreeSet::new();
7710    for member in snapshot.members {
7711        if member.state == MEMBER_STATE_RETIRING {
7712            continue;
7713        }
7714        if let Some(source_mob_id) = member.labels.get("source_mob_id").cloned() {
7715            delegate_members.insert((source_mob_id, member.agent_identity));
7716        } else {
7717            let identity = member
7718                .labels
7719                .get("agent_identity")
7720                .filter(|value| !value.trim().is_empty())
7721                .cloned()
7722                .or_else(|| {
7723                    identity_by_runtime_member_id
7724                        .get(&member.agent_identity)
7725                        .cloned()
7726                })
7727                .unwrap_or_else(|| member.agent_identity.clone());
7728            if let Some(existing) = runtime_member_id_by_identity.get(&identity)
7729                && existing != &member.agent_identity
7730            {
7731                duplicate_live_identities.insert(identity.clone());
7732            }
7733            runtime_member_ids_by_identity
7734                .entry(identity.clone())
7735                .or_default()
7736                .insert(member.agent_identity.clone());
7737            session_id_by_identity_runtime_member.insert(
7738                (identity.clone(), member.agent_identity.clone()),
7739                member.session_id.clone(),
7740            );
7741            live_alias_by_runtime_member_id.insert(
7742                member.agent_identity.clone(),
7743                (identity.clone(), member.session_id.clone()),
7744            );
7745            visible_runtime_member_ids.insert(member.agent_identity.clone());
7746            runtime_member_id_by_identity
7747                .entry(identity.clone())
7748                .or_insert(member.agent_identity);
7749            main_identities.insert(identity);
7750        }
7751    }
7752    let mut raw_runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> =
7753        BTreeMap::new();
7754    let mut raw_session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
7755        BTreeMap::new();
7756    let mut raw_live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
7757        BTreeMap::new();
7758    for member in raw_snapshot.members {
7759        if member.state == MEMBER_STATE_RETIRING || member.labels.contains_key("source_mob_id") {
7760            continue;
7761        }
7762        let identity = member
7763            .labels
7764            .get("agent_identity")
7765            .filter(|value| !value.trim().is_empty())
7766            .cloned()
7767            .or_else(|| {
7768                identity_by_runtime_member_id
7769                    .get(&member.agent_identity)
7770                    .cloned()
7771            })
7772            .unwrap_or_else(|| member.agent_identity.clone());
7773        raw_runtime_member_ids_by_identity
7774            .entry(identity.clone())
7775            .or_default()
7776            .insert(member.agent_identity.clone());
7777        raw_session_id_by_identity_runtime_member.insert(
7778            (identity.clone(), member.agent_identity.clone()),
7779            member.session_id.clone(),
7780        );
7781        raw_live_alias_by_runtime_member_id
7782            .insert(member.agent_identity, (identity, member.session_id));
7783    }
7784    durable_identity_runtime_identities.retain(|identity| {
7785        identity_runtime_statuses
7786            .iter()
7787            .find(|status| status.identity.as_str() == identity)
7788            .and_then(|status| status.agent_runtime_id.as_ref())
7789            .is_none_or(|runtime_id| {
7790                let runtime_id = runtime_id.as_str();
7791                !raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
7792                    || visible_runtime_member_ids.contains(runtime_id)
7793            })
7794    });
7795    let current_main_identities = main_identities.clone();
7796    let baseline_specs = runtime.baseline_member_specs().await;
7797    let baseline_identities = baseline_specs
7798        .iter()
7799        .filter(|spec| baseline_spec_visible_to_console(visibility_policy, spec))
7800        .map(|spec| spec.identity.to_string())
7801        .collect::<BTreeSet<_>>();
7802    main_identities.extend(baseline_identities.iter().cloned());
7803    main_identities.extend(durable_identity_runtime_identities.iter().cloned());
7804
7805    let mut retired_delegates = Vec::new();
7806    let mut reset_main = Vec::new();
7807    let mut retired_delegate_details = Vec::new();
7808    let mut reset_details = Vec::new();
7809    let mut failures = Vec::new();
7810    let mut warnings = Vec::new();
7811
7812    for identity in &main_identities {
7813        let parsed_identity = crate::identity_first::AgentIdentity::parse(identity).ok();
7814        let registered_status = if let (Some(identity_runtime), Some(parsed_identity)) =
7815            (identity_runtime, parsed_identity.as_ref())
7816        {
7817            identity_runtime.status(parsed_identity).await.ok()
7818        } else {
7819            None
7820        };
7821        let baseline_identity_runtime_registered = registered_status.is_some();
7822        if baseline_identities.contains(identity)
7823            && !current_main_identities.contains(identity)
7824            && !baseline_identity_runtime_registered
7825        {
7826            continue;
7827        }
7828        let registered_runtime_id = registered_status
7829            .as_ref()
7830            .and_then(|status| status.agent_runtime_id.as_ref())
7831            .map(crate::identity_first::AgentRuntimeId::as_str);
7832        let registered_visible = registered_runtime_id
7833            .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
7834        let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
7835            raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
7836                && !visible_runtime_member_ids.contains(runtime_id)
7837        });
7838        if registered_hidden {
7839            continue;
7840        }
7841        if duplicate_live_identities.contains(identity) && !registered_visible {
7842            failures.push(json!({
7843                "identity": identity,
7844                "error": "ambiguous live identity alias",
7845            }));
7846            continue;
7847        }
7848        if let Some(status) = registered_status.as_ref() {
7849            if let Some(registered_runtime_id) = registered_runtime_id
7850                && let Some((live_identity, _live_session_id)) =
7851                    raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
7852                && live_identity != identity
7853            {
7854                failures.push(json!({
7855                    "identity": identity,
7856                    "error": format!(
7857                        "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
7858                    ),
7859                    "kind": "stale_live_identity_alias",
7860                }));
7861                continue;
7862            }
7863            if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(identity) {
7864                if !registered_runtime_id
7865                    .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
7866                {
7867                    failures.push(json!({
7868                        "identity": identity,
7869                        "error": format!(
7870                            "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
7871                            registered_runtime_id.unwrap_or("<none>"),
7872                            live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
7873                        ),
7874                        "kind": "stale_live_identity_alias",
7875                    }));
7876                    continue;
7877                }
7878                if let Some(registered_runtime_id) = registered_runtime_id
7879                    && let Some(registered_session_id) =
7880                        status.session_id.as_ref().map(ToString::to_string)
7881                    && let Some(Some(live_session_id)) = raw_session_id_by_identity_runtime_member
7882                        .get(&(identity.clone(), registered_runtime_id.to_string()))
7883                    && live_session_id != &registered_session_id
7884                {
7885                    failures.push(json!({
7886                        "identity": identity,
7887                        "error": format!(
7888                            "stale live identity alias: identity runtime binding points at {registered_runtime_id} session {registered_session_id}, but live console alias resolves to session {live_session_id}"
7889                        ),
7890                        "kind": "stale_live_identity_alias",
7891                    }));
7892                    continue;
7893                }
7894            }
7895            if baseline_identities.contains(identity)
7896                && !identity_runtime
7897                    .is_some_and(|identity_runtime| identity_runtime.has_session_bridge())
7898            {
7899                failures.push(json!({
7900                    "identity": identity,
7901                    "error": "reset requires an identity runtime with a session bridge",
7902                    "kind": "identity_reset_requires_session_bridge",
7903                }));
7904            }
7905            continue;
7906        }
7907
7908        let runtime_member_id = runtime_member_id_by_identity
7909            .get(identity)
7910            .map(String::as_str)
7911            .unwrap_or(identity.as_str());
7912        if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
7913            && bound_identity != identity
7914        {
7915            failures.push(json!({
7916                "identity": identity,
7917                "error": format!(
7918                    "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
7919                ),
7920                "kind": "stale_live_identity_alias",
7921            }));
7922        }
7923    }
7924
7925    if !failures.is_empty() {
7926        return Ok(json!({
7927            "reset": reset_main,
7928            "retired_delegates": retired_delegates,
7929            "reset_details": reset_details,
7930            "retired_delegate_details": retired_delegate_details,
7931            "warnings": warnings,
7932            "failed": failures,
7933            "startup_history": Value::Null,
7934        }));
7935    }
7936
7937    if let Some(state) = runtime.agent_mob_mcp_state() {
7938        for (mob_id, identity) in delegate_members {
7939            match Box::pin(state.handle_for(&MobId::from(mob_id.as_str()))).await {
7940                Ok(handle) => {
7941                    match retire_console_member(
7942                        &handle,
7943                        &crate::member_comms_id::mob_member_id(identity.as_str()),
7944                    )
7945                    .await
7946                    {
7947                        Ok(()) => {
7948                            let detail = json!({
7949                                "identity": identity,
7950                                "mob_id": mob_id,
7951                            });
7952                            retired_delegates.push(detail.clone());
7953                            retired_delegate_details.push(detail);
7954                        }
7955                        Err(err) => failures.push(json!({
7956                            "identity": identity,
7957                            "mob_id": mob_id,
7958                            "error": err,
7959                        })),
7960                    }
7961                }
7962                Err(err) => failures.push(json!({
7963                    "identity": identity,
7964                    "mob_id": mob_id,
7965                    "error": err.to_string(),
7966                })),
7967            }
7968        }
7969    } else if let Some(aggregator) = console_aggregator {
7970        let identities = delegate_members
7971            .into_iter()
7972            .map(|(_, identity)| identity)
7973            .collect::<BTreeSet<_>>();
7974        for identity in identities {
7975            match Box::pin(aggregator.retire_identity(&identity)).await {
7976                Ok(true) => {
7977                    let detail = json!({ "identity": identity });
7978                    retired_delegates.push(detail.clone());
7979                    retired_delegate_details.push(detail);
7980                }
7981                Ok(false) => failures.push(json!({
7982                    "identity": identity,
7983                    "error": "unknown identity",
7984                })),
7985                Err(err) => failures.push(json!({
7986                    "identity": identity,
7987                    "error": err.to_string(),
7988                })),
7989            }
7990        }
7991    }
7992
7993    let handle = runtime.handle();
7994    for spec in baseline_specs {
7995        let identity = spec.identity.to_string();
7996        if !baseline_spec_visible_to_console(visibility_policy, &spec) {
7997            continue;
7998        }
7999        if current_main_identities.contains(&identity) {
8000            continue;
8001        }
8002        if let Some(identity_runtime) = identity_runtime
8003            && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8004            && identity_runtime.status(&parsed_identity).await.is_ok()
8005        {
8006            continue;
8007        }
8008        match handle.ensure_member(spec).await {
8009            Ok(_outcome) => {
8010                if let Some(store) = console_events {
8011                    store
8012                        .record_lifecycle(
8013                            &identity,
8014                            "identity_reset",
8015                            json!({ "scope": "reset_all", "restored": true }),
8016                        )
8017                        .await;
8018                }
8019                reset_main.push(identity.clone());
8020                reset_details.push(json!({ "identity": identity }));
8021            }
8022            Err(err) => failures.push(json!({
8023                "identity": identity,
8024                "error": err.to_string(),
8025            })),
8026        }
8027    }
8028    for identity in main_identities {
8029        let baseline_identity_runtime_registered = if let Some(identity_runtime) = identity_runtime
8030            && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8031        {
8032            identity_runtime.status(&parsed_identity).await.is_ok()
8033        } else {
8034            false
8035        };
8036        if baseline_identities.contains(&identity)
8037            && !current_main_identities.contains(&identity)
8038            && !baseline_identity_runtime_registered
8039        {
8040            continue;
8041        }
8042        let registered_status = if let Some(identity_runtime) = identity_runtime
8043            && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8044        {
8045            identity_runtime.status(&parsed_identity).await.ok()
8046        } else {
8047            None
8048        };
8049        let registered_runtime_id = registered_status
8050            .as_ref()
8051            .and_then(|status| status.agent_runtime_id.as_ref())
8052            .map(crate::identity_first::AgentRuntimeId::as_str);
8053        let registered_visible = registered_runtime_id
8054            .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
8055        let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
8056            raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
8057                && !visible_runtime_member_ids.contains(runtime_id)
8058        });
8059        if registered_hidden {
8060            continue;
8061        }
8062        if duplicate_live_identities.contains(&identity) && !registered_visible {
8063            failures.push(json!({
8064                "identity": identity,
8065                "error": "ambiguous live identity alias",
8066            }));
8067            continue;
8068        }
8069        if baseline_identities.contains(&identity) {
8070            if let Some(identity_runtime) = identity_runtime
8071                && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8072                && let Ok(status) = identity_runtime.status(&parsed_identity).await
8073            {
8074                let registered_runtime_id = status
8075                    .agent_runtime_id
8076                    .as_ref()
8077                    .map(crate::identity_first::AgentRuntimeId::as_str);
8078                if let Some(registered_runtime_id) = registered_runtime_id
8079                    && let Some((live_identity, _live_session_id)) =
8080                        raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
8081                    && live_identity != &identity
8082                {
8083                    failures.push(json!({
8084                        "identity": identity,
8085                        "error": format!(
8086                            "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
8087                        ),
8088                        "kind": "stale_live_identity_alias",
8089                    }));
8090                    continue;
8091                }
8092                if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
8093                    if !registered_runtime_id
8094                        .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
8095                    {
8096                        failures.push(json!({
8097                            "identity": identity,
8098                            "error": format!(
8099                                "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
8100                                registered_runtime_id.unwrap_or("<none>"),
8101                                live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
8102                            ),
8103                            "kind": "stale_live_identity_alias",
8104                        }));
8105                        continue;
8106                    }
8107                    if let Some(registered_runtime_id) = registered_runtime_id
8108                        && let Some(registered_session_id) =
8109                            status.session_id.as_ref().map(ToString::to_string)
8110                        && let Some(Some(live_session_id)) =
8111                            raw_session_id_by_identity_runtime_member
8112                                .get(&(identity.clone(), registered_runtime_id.to_string()))
8113                        && live_session_id != &registered_session_id
8114                    {
8115                        failures.push(json!({
8116                            "identity": identity,
8117                            "error": format!(
8118                                "stale live identity alias: identity runtime binding points at {registered_runtime_id} session {registered_session_id}, but live console alias resolves to session {live_session_id}"
8119                            ),
8120                            "kind": "stale_live_identity_alias",
8121                        }));
8122                        continue;
8123                    }
8124                }
8125                if !identity_runtime.has_session_bridge() {
8126                    failures.push(json!({
8127                        "identity": identity,
8128                        "error": "reset requires an identity runtime with a session bridge",
8129                        "kind": "identity_reset_requires_session_bridge",
8130                    }));
8131                    continue;
8132                }
8133                match identity_runtime.reset(&parsed_identity).await {
8134                    Ok(record) => {
8135                        let cleanup_warning = json!({
8136                            "identity": identity,
8137                            "kind": "stale_member_cleanup_skipped_after_identity_reset",
8138                            "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
8139                        });
8140                        warnings.push(cleanup_warning);
8141                        reset_details.push(json!({
8142                            "identity": identity,
8143                            "cleanup_warning": warnings.last().cloned(),
8144                        }));
8145                        reset_main.push(identity);
8146                        if let Some(store) = console_events {
8147                            store
8148                                .record_lifecycle(
8149                                    parsed_identity.as_str(),
8150                                    "identity_reset",
8151                                    json!({
8152                                        "scope": "reset_all",
8153                                        "generation": record.generation.get(),
8154                                        "checkpoint_version": record.checkpoint_version.get(),
8155                                        "cleanup_warning": warnings.last().cloned(),
8156                                    }),
8157                                )
8158                                .await;
8159                        }
8160                    }
8161                    Err(err) => failures.push(json!({
8162                        "identity": identity,
8163                        "error": err.to_string(),
8164                    })),
8165                }
8166                continue;
8167            }
8168            let runtime_member_id = runtime_member_id_by_identity
8169                .get(&identity)
8170                .map(String::as_str)
8171                .unwrap_or(identity.as_str());
8172            if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
8173                && bound_identity != &identity
8174            {
8175                failures.push(json!({
8176                    "identity": identity,
8177                    "error": format!(
8178                        "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
8179                    ),
8180                    "kind": "stale_live_identity_alias",
8181                }));
8182                continue;
8183            }
8184            match Box::pin(respawn_console_member(
8185                &handle,
8186                &crate::member_comms_id::mob_member_id(runtime_member_id),
8187            ))
8188            .await
8189            {
8190                Ok(topology_restore_warning) => {
8191                    if let Some(store) = console_events {
8192                        store
8193                            .record_lifecycle(
8194                                &identity,
8195                                "identity_reset",
8196                                json!({
8197                                    "scope": "reset_all",
8198                                    "topology_restore_warning": topology_restore_warning.clone(),
8199                                }),
8200                            )
8201                            .await;
8202                    }
8203                    reset_main.push(identity.clone());
8204                    let mut detail = json!({ "identity": identity });
8205                    if let Some(warning) = topology_restore_warning {
8206                        detail["topology_restore_warning"] = warning;
8207                    }
8208                    reset_details.push(detail);
8209                }
8210                Err(err) => failures.push(json!({
8211                    "identity": identity,
8212                    "error": err,
8213                })),
8214            }
8215        } else {
8216            if let Some(identity_runtime) = identity_runtime
8217                && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8218                && let Ok(registered_status) = identity_runtime.status(&parsed_identity).await
8219            {
8220                let registered_runtime_id = registered_status
8221                    .agent_runtime_id
8222                    .as_ref()
8223                    .map(crate::identity_first::AgentRuntimeId::as_str);
8224                let registered_visible = registered_runtime_id
8225                    .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
8226                if duplicate_live_identities.contains(&identity) && !registered_visible {
8227                    failures.push(json!({
8228                        "identity": identity,
8229                        "error": "ambiguous live identity alias",
8230                    }));
8231                    continue;
8232                }
8233                if let Some(registered_runtime_id) = registered_runtime_id
8234                    && let Some((live_identity, _live_session_id)) =
8235                        raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
8236                    && live_identity != &identity
8237                {
8238                    failures.push(json!({
8239                        "identity": identity,
8240                        "error": format!(
8241                            "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
8242                        ),
8243                        "kind": "stale_live_identity_alias",
8244                    }));
8245                    continue;
8246                }
8247                if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
8248                    if !registered_runtime_id
8249                        .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
8250                    {
8251                        failures.push(json!({
8252                            "identity": identity,
8253                            "error": format!(
8254                                "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
8255                                registered_runtime_id.unwrap_or("<none>"),
8256                                live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
8257                            ),
8258                            "kind": "stale_live_identity_alias",
8259                        }));
8260                        continue;
8261                    }
8262                    if let Some(registered_runtime_id) = registered_runtime_id
8263                        && let Some(registered_session_id) = registered_status
8264                            .session_id
8265                            .as_ref()
8266                            .map(ToString::to_string)
8267                        && let Some(Some(live_session_id)) =
8268                            raw_session_id_by_identity_runtime_member
8269                                .get(&(identity.clone(), registered_runtime_id.to_string()))
8270                        && live_session_id != &registered_session_id
8271                    {
8272                        failures.push(json!({
8273                            "identity": identity,
8274                            "error": format!(
8275                                "stale live identity alias: identity runtime binding points at {registered_runtime_id} session {registered_session_id}, but live console alias resolves to session {live_session_id}"
8276                            ),
8277                            "kind": "stale_live_identity_alias",
8278                        }));
8279                        continue;
8280                    }
8281                }
8282                match identity_runtime.retire(&parsed_identity).await {
8283                    Ok(token) => {
8284                        let keep_runtime_member_id = registered_status
8285                            .agent_runtime_id
8286                            .as_ref()
8287                            .filter(|_| identity_runtime.has_session_bridge())
8288                            .map(crate::identity_first::AgentRuntimeId::as_str);
8289                        match retire_stale_console_members_for_identity(
8290                            &handle,
8291                            visibility_policy,
8292                            parsed_identity.as_str(),
8293                            keep_runtime_member_id,
8294                        )
8295                        .await
8296                        {
8297                            Ok(()) => {
8298                                retired_delegate_details.push(json!({ "identity": identity }));
8299                                retired_delegates.push(json!({ "identity": identity }));
8300                                if let Some(store) = console_events {
8301                                    store
8302                                        .record_lifecycle(
8303                                            parsed_identity.as_str(),
8304                                            "identity_retired",
8305                                            json!({
8306                                                "scope": "reset_all",
8307                                                "dynamic": true,
8308                                                "fencing_token": token.get(),
8309                                            }),
8310                                        )
8311                                        .await;
8312                                }
8313                            }
8314                            Err(err) => {
8315                                warnings.push(json!({
8316                                    "identity": identity,
8317                                    "kind": "stale_member_cleanup_failed_after_identity_retire",
8318                                    "message": err,
8319                                }));
8320                                retired_delegate_details.push(json!({
8321                                    "identity": identity,
8322                                    "cleanup_warning": warnings.last().cloned(),
8323                                }));
8324                                retired_delegates.push(json!({ "identity": identity }));
8325                                if let Some(store) = console_events {
8326                                    store
8327                                        .record_lifecycle(
8328                                            parsed_identity.as_str(),
8329                                            "identity_retired",
8330                                            json!({
8331                                                "scope": "reset_all",
8332                                                "dynamic": true,
8333                                                "fencing_token": token.get(),
8334                                                "cleanup_warning": warnings.last().cloned(),
8335                                            }),
8336                                        )
8337                                        .await;
8338                                }
8339                            }
8340                        }
8341                    }
8342                    Err(err) => failures.push(json!({
8343                        "identity": identity,
8344                        "error": err.to_string(),
8345                    })),
8346                }
8347                continue;
8348            }
8349            let runtime_member_id = runtime_member_id_by_identity
8350                .get(&identity)
8351                .map(String::as_str)
8352                .unwrap_or(identity.as_str());
8353            if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
8354                && bound_identity != &identity
8355            {
8356                failures.push(json!({
8357                    "identity": identity,
8358                    "error": format!(
8359                        "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
8360                    ),
8361                    "kind": "stale_live_identity_alias",
8362                }));
8363                continue;
8364            }
8365            match retire_console_member(
8366                &handle,
8367                &crate::member_comms_id::mob_member_id(runtime_member_id),
8368            )
8369            .await
8370            {
8371                Ok(()) => {
8372                    if let Some(store) = console_events {
8373                        store
8374                            .record_lifecycle(
8375                                &identity,
8376                                "identity_retired",
8377                                json!({ "scope": "reset_all", "dynamic": true }),
8378                            )
8379                            .await;
8380                    }
8381                    retired_delegates.push(json!({ "identity": identity }));
8382                    retired_delegate_details.push(json!({ "identity": identity }));
8383                }
8384                Err(err) => failures.push(json!({
8385                    "identity": identity,
8386                    "error": err,
8387                })),
8388            }
8389        }
8390    }
8391
8392    let startup_history = if failures.is_empty() {
8393        if let Some(aggregator) = console_aggregator {
8394            Box::pin(wait_for_reset_startup_history(
8395                aggregator,
8396                reset_main.iter().cloned().collect::<BTreeSet<_>>(),
8397                Duration::from_secs(10),
8398            ))
8399            .await
8400            .unwrap_or_else(|err| json!({ "error": err.to_string() }))
8401        } else {
8402            Value::Null
8403        }
8404    } else {
8405        Value::Null
8406    };
8407
8408    Ok(json!({
8409        "reset": reset_main,
8410        "retired_delegates": retired_delegates,
8411        "reset_details": reset_details,
8412        "retired_delegate_details": retired_delegate_details,
8413        "warnings": warnings,
8414        "failed": failures,
8415        "startup_history": startup_history,
8416    }))
8417}
8418
8419async fn wait_for_reset_startup_history(
8420    aggregator: &MobKitConsoleAggregator,
8421    identities: BTreeSet<String>,
8422    timeout: Duration,
8423) -> ConsoleLogResult<Value> {
8424    if identities.is_empty() {
8425        return Ok(json!({
8426            "timeout": false,
8427            "ready": Vec::<String>::new(),
8428            "pending": Vec::<String>::new(),
8429        }));
8430    }
8431
8432    let deadline = Instant::now() + timeout;
8433    let mut pending = identities;
8434    let mut ready = BTreeSet::new();
8435    while !pending.is_empty() {
8436        for identity in pending.clone() {
8437            let page = Box::pin(aggregator.query_timeline(ConsoleTimelineQuery {
8438                identity: Some(identity.clone()),
8439                limit: 1000,
8440                ..ConsoleTimelineQuery::default()
8441            }))
8442            .await?;
8443            let startup_completed = page.frames.iter().any(|frame| {
8444                matches!(
8445                    frame.kind.as_str(),
8446                    "interaction_complete" | "turn_completed"
8447                )
8448            });
8449            if startup_completed {
8450                pending.remove(&identity);
8451                ready.insert(identity);
8452            }
8453        }
8454
8455        if pending.is_empty() {
8456            break;
8457        }
8458        if Instant::now() >= deadline {
8459            return Ok(json!({
8460                "timeout": true,
8461                "ready": ready.into_iter().collect::<Vec<_>>(),
8462                "pending": pending.into_iter().collect::<Vec<_>>(),
8463            }));
8464        }
8465        tokio::time::sleep(Duration::from_millis(250)).await;
8466    }
8467
8468    Ok(json!({
8469        "timeout": false,
8470        "ready": ready.into_iter().collect::<Vec<_>>(),
8471        "pending": Vec::<String>::new(),
8472    }))
8473}
8474
8475fn dedupe_console_members_by_identity(members: &mut Vec<ConsoleMember>) {
8476    let mut seen_member_ids = BTreeSet::new();
8477    members.retain(|member| seen_member_ids.insert(member.agent_identity.clone()));
8478}
8479
8480fn console_member_console_identity(member: &ConsoleMember) -> &str {
8481    member
8482        .labels
8483        .get("agent_identity")
8484        .filter(|value| !value.trim().is_empty())
8485        .map_or(member.agent_identity.as_str(), String::as_str)
8486}
8487
8488fn console_identity_record_from_console_member(member: &ConsoleMember) -> ConsoleIdentityRecord {
8489    let identity = console_member_console_identity(member).to_string();
8490    let addressable = member
8491        .labels
8492        .get("addressable")
8493        .map(|value| !value.eq_ignore_ascii_case("false"))
8494        .unwrap_or(true)
8495        && member.state == MEMBER_STATE_ACTIVE;
8496    let visibility = if member.state == MEMBER_STATE_RETIRING {
8497        ConsoleVisibility::RetiredReadable
8498    } else if addressable {
8499        ConsoleVisibility::Addressable
8500    } else {
8501        ConsoleVisibility::Hidden
8502    };
8503    ConsoleIdentityRecord {
8504        identity: identity.clone(),
8505        display_name: member
8506            .labels
8507            .get("display_name")
8508            .cloned()
8509            .unwrap_or(identity),
8510        runtime_key: "runtime".to_string(),
8511        runtime_member_id: member.agent_identity.clone(),
8512        session_id: member.session_id.clone(),
8513        visibility,
8514        addressable,
8515        health: member.state.clone(),
8516        topology_peers: member.wired_to.clone(),
8517        labels: member.labels.clone(),
8518    }
8519}
8520
8521fn baseline_spec_visible_to_console(
8522    visibility_policy: &dyn ConsoleVisibilityPolicy,
8523    spec: &SpawnMemberSpec,
8524) -> bool {
8525    let mut labels = spec.labels.clone().unwrap_or_default();
8526    labels
8527        .entry("role".to_string())
8528        .or_insert_with(|| spec.role_name.to_string());
8529    let record = ConsoleIdentityRecord {
8530        identity: spec.identity.to_string(),
8531        display_name: spec.identity.to_string(),
8532        runtime_key: "baseline".to_string(),
8533        runtime_member_id: spec.identity.to_string(),
8534        session_id: None,
8535        visibility: ConsoleVisibility::Addressable,
8536        addressable: true,
8537        health: "baseline".to_string(),
8538        topology_peers: Vec::new(),
8539        labels,
8540    };
8541    let member = ConsoleMember {
8542        agent_identity: spec.identity.to_string(),
8543        role: spec.role_name.to_string(),
8544        state: MEMBER_STATE_ACTIVE.to_string(),
8545        model_capabilities: ConsoleModelCapabilities::default(),
8546        runtime_mode: spec
8547            .runtime_mode
8548            .as_ref()
8549            .map(std::string::ToString::to_string),
8550        session_id: None,
8551        wired_to: Vec::new(),
8552        labels: record.labels.clone(),
8553    };
8554    visibility_policy.member_visible(&member) && visibility_policy.identity_visible(&record)
8555}
8556
8557/// Prime the access-control attribute cache from the live primary-mob roster
8558/// so label/role rules resolve on surfaces that carry only an identity string
8559/// (SSE event streams, timeline frames, `mob_events`). Without this, those
8560/// surfaces evaluate role/label rules with attributes unknown and can fail
8561/// open for "broad allow + label-keyed deny" policies — the cache must not be
8562/// gated on a prior `/console/experience` call. Additive (it does not evict),
8563/// so it never clears delegate-member attributes primed by the experience
8564/// projection; the experience path owns wholesale rebuild + eviction.
8565///
8566/// Console metadata registered by agent-tool spawns (`spawned_by` lineage,
8567/// spawn labels) fills label gaps the roster does not carry, and identities
8568/// known only to the spawn registry (e.g. members of agent-created sub-mobs)
8569/// are primed registry-only so lineage rules resolve for them too.
8570pub(crate) async fn prime_access_cache_from_runtime(
8571    runtime: &MobRuntime,
8572    access: &AccessController,
8573) {
8574    if !access.enabled() {
8575        return;
8576    }
8577    let registered = runtime.console_identity_labels().await;
8578    prime_access_cache_from_handle_with_registry(&runtime.handle(), access, &registered).await;
8579}
8580
8581pub(crate) async fn prime_access_cache_from_handle(handle: &MobHandle, access: &AccessController) {
8582    if !access.enabled() {
8583        return;
8584    }
8585    prime_access_cache_from_handle_with_registry(handle, access, &BTreeMap::new()).await;
8586}
8587
8588async fn prime_access_cache_from_handle_with_registry(
8589    handle: &MobHandle,
8590    access: &AccessController,
8591    registered: &BTreeMap<String, BTreeMap<String, String>>,
8592) {
8593    let mut registry_only: BTreeMap<&String, &BTreeMap<String, String>> =
8594        registered.iter().collect();
8595    for entry in handle.list_all_members().await {
8596        // Roster ids are comms-safe encodings (meerkat 0.7); the console/ABAC
8597        // attribute space uses the public alias.
8598        let member_identity =
8599            crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
8600        // Console identity may be overridden by a label, mirroring
8601        // `console_member_console_identity`.
8602        let console_identity = entry
8603            .labels
8604            .get("agent_identity")
8605            .map(|value| value.trim())
8606            .filter(|value| !value.is_empty())
8607            .map_or_else(|| member_identity.clone(), ToString::to_string);
8608        let mut labels = entry.labels.clone();
8609        // Roster labels are caller-controlled; only the spawn registry may
8610        // assert lineage, or a spoofed `spawned_by` could mint inherited
8611        // visibility (or hide a member behind a denied parent).
8612        crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
8613        if let Some(spawn_labels) = registry_only.remove(&console_identity) {
8614            crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
8615        }
8616        access.record_agent_attributes(AgentResourceAttributes {
8617            identity: console_identity,
8618            agent_id: Some(member_identity),
8619            role: Some(entry.role.to_string()),
8620            labels,
8621        });
8622    }
8623    for (identity, spawn_labels) in registry_only {
8624        access.record_agent_attributes(AgentResourceAttributes {
8625            identity: identity.clone(),
8626            agent_id: None,
8627            role: spawn_labels.get("role").cloned(),
8628            labels: spawn_labels.clone(),
8629        });
8630    }
8631}
8632
8633async fn project_console_members_from_handle(
8634    handle: &MobHandle,
8635    host_identity: Option<&str>,
8636    source_mob_id: Option<&str>,
8637    read_model: &ConsoleSnapshotReadModelState,
8638    registered_labels: &BTreeMap<String, BTreeMap<String, String>>,
8639) -> (Vec<ConsoleMember>, BTreeMap<String, String>) {
8640    // Meerkat 0.7: lifecycle status is machine-projected onto
8641    // `MobMemberListEntry`; the structural `list_all_members` roster no longer
8642    // carries it, so the console projection reads the operational list.
8643    let entries = handle.list_members_including_retiring().await;
8644    let mut members = Vec::with_capacity(entries.len());
8645    let mut session_owner_by_id = BTreeMap::new();
8646    for entry in &entries {
8647        // Roster ids are comms-safe encodings (meerkat 0.7 MemberCommsName);
8648        // console members surface the public alias.
8649        let identity =
8650            crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
8651        let session_id = read_model.session_id_by_identity.get(&identity).cloned();
8652        if let Some(session_id) = session_id.as_ref() {
8653            session_owner_by_id.insert(session_id.clone(), identity.clone());
8654        }
8655        let model_capabilities =
8656            model_capabilities_for_role(handle.definition(), entry.role.as_str());
8657        let mut labels = entry.labels.clone();
8658        // Spawn-time console metadata (group/display labels, spawned_by
8659        // lineage) fills gaps the roster does not carry; roster labels win —
8660        // except lineage, which only the spawn registry may assert (this
8661        // projection feeds the experience-path ABAC attribute rebuild).
8662        crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
8663        let console_identity_key = entry
8664            .labels
8665            .get("agent_identity")
8666            .map(|value| value.trim())
8667            .filter(|value| !value.is_empty())
8668            .map_or(identity.as_str(), |value| value);
8669        if let Some(spawn_labels) = registered_labels.get(console_identity_key) {
8670            crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
8671        }
8672        if let Some(host_identity) = host_identity {
8673            labels
8674                .entry("delegate_host_identity".to_string())
8675                .or_insert_with(|| host_identity.to_string());
8676            labels
8677                .entry("group".to_string())
8678                .or_insert_with(|| "Coordinators".to_string());
8679        }
8680        if let Some(source_mob_id) = source_mob_id {
8681            labels
8682                .entry("source_mob_id".to_string())
8683                .or_insert_with(|| source_mob_id.to_string());
8684        }
8685        let mut wired_to: Vec<String> = entry
8686            .wired_to
8687            .iter()
8688            .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
8689            .collect();
8690        if let Some(host_identity) = host_identity
8691            && !wired_to.iter().any(|peer| peer == host_identity)
8692        {
8693            wired_to.push(host_identity.to_string());
8694        }
8695        members.push(ConsoleMember {
8696            agent_identity: identity,
8697            role: entry.role.to_string(),
8698            state: member_status_state_string(entry.status),
8699            model_capabilities,
8700            runtime_mode: Some(entry.runtime_mode.to_string()),
8701            session_id,
8702            wired_to,
8703            labels,
8704        });
8705    }
8706    (members, session_owner_by_id)
8707}
8708
8709async fn build_aggregator_live_snapshot(
8710    aggregator: &MobKitConsoleAggregator,
8711    config_module_ids: &[String],
8712) -> Result<ConsoleLiveSnapshot, Box<dyn std::error::Error + Send + Sync>> {
8713    let identities = Box::pin(aggregator.list_identities()).await?;
8714    let mut members = Vec::with_capacity(identities.len());
8715    for identity in &identities {
8716        let mut labels = identity.labels.clone();
8717        labels
8718            .entry("display_name".to_string())
8719            .or_insert_with(|| identity.display_name.clone());
8720        labels
8721            .entry("addressable".to_string())
8722            .or_insert_with(|| identity.addressable.to_string());
8723        // Keep /console/experience on the cached identity read model. Live
8724        // peer inspection walks the actor-backed mob/member path, and a stuck
8725        // turn must not make the console shell fail to load.
8726        let wired_to = identity.topology_peers.clone();
8727        members.push(ConsoleMember {
8728            agent_identity: identity.identity.clone(),
8729            role: labels
8730                .get("role")
8731                .cloned()
8732                .unwrap_or_else(|| "identity".to_string()),
8733            state: identity.health.clone(),
8734            model_capabilities: ConsoleModelCapabilities::default(),
8735            runtime_mode: Some("console_aggregator".to_string()),
8736            session_id: identity.session_id.clone(),
8737            wired_to,
8738            labels,
8739        });
8740    }
8741    members.sort_by(|left, right| left.agent_identity.cmp(&right.agent_identity));
8742    let agents = members
8743        .iter()
8744        .map(|member| ConsoleAgentLiveSnapshot {
8745            agent_id: member.agent_identity.clone(),
8746            member_id: member.agent_identity.clone(),
8747            label: member
8748                .labels
8749                .get("display_name")
8750                .cloned()
8751                .unwrap_or_else(|| member.agent_identity.clone()),
8752            kind: "meerkat".to_string(),
8753            identity: Some(member.agent_identity.clone()),
8754            role: Some(member.role.clone()),
8755            state: Some(member.state.clone()),
8756            session_id: member.session_id.clone(),
8757            model_capabilities: member.model_capabilities.clone(),
8758            response_phase: None,
8759            watched: None,
8760            alert_level: None,
8761            degraded: None,
8762            degraded_reason: None,
8763        })
8764        .collect::<Vec<_>>();
8765    let loaded_modules = if config_module_ids.is_empty() {
8766        members
8767            .iter()
8768            .map(|member| member.agent_identity.clone())
8769            .collect()
8770    } else {
8771        config_module_ids.to_vec()
8772    };
8773    Ok(ConsoleLiveSnapshot::new(
8774        Some("console-aggregator".to_string()),
8775        true,
8776        loaded_modules,
8777        agents,
8778        members,
8779        true,
8780    ))
8781}
8782
8783pub async fn console_frontend_index_handler() -> impl IntoResponse {
8784    (
8785        [
8786            (header::CONTENT_TYPE, "text/html; charset=utf-8"),
8787            (header::CACHE_CONTROL, "no-store"),
8788        ],
8789        CONSOLE_FRONTEND_INDEX_HTML,
8790    )
8791}
8792
8793pub async fn console_frontend_app_js_handler() -> impl IntoResponse {
8794    (
8795        [
8796            (
8797                header::CONTENT_TYPE,
8798                "application/javascript; charset=utf-8",
8799            ),
8800            (header::CACHE_CONTROL, "no-store"),
8801        ],
8802        CONSOLE_FRONTEND_APP_JS,
8803    )
8804}
8805
8806pub async fn console_frontend_app_css_handler() -> impl IntoResponse {
8807    (
8808        [
8809            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
8810            (header::CACHE_CONTROL, "no-store"),
8811        ],
8812        CONSOLE_FRONTEND_APP_CSS,
8813    )
8814}
8815
8816#[cfg(test)]
8817#[allow(clippy::expect_used, clippy::large_futures)]
8818mod tests {
8819    use super::ConsoleTimelineHttpQuery;
8820    use super::{
8821        ConsoleSnapshotReadModel, ConsoleSnapshotReadModelState, MAX_MULTIPART_BODY_BYTES,
8822        MAX_MULTIPART_IMAGE_BYTES, MultipartImageUpload, apply_console_visibility_policy,
8823        build_aggregator_live_snapshot, collect_console_snapshot_read_model,
8824        console_send_identity_first, console_send_with_identity_first_fallback,
8825        console_timeline_replay_unavailable_response, cursor_is_after,
8826        dedupe_console_members_by_identity, externalize_image_upload_placeholders,
8827        externalize_single_image_upload, handle_console_aggregator_rpc, handle_console_runtime_rpc,
8828        handle_console_runtime_rpc_with_visibility, member_id_matches_durable_identity,
8829        project_console_members_from_handle, query_timeline_snapshot, timeline_query_from_http,
8830    };
8831    use crate::access::{
8832        ACTION_AGENT_MEMORY_DELETE, ACTION_AGENT_MEMORY_READ, ACTION_AGENT_MEMORY_WRITE,
8833        ACTION_AGENT_SEND, ACTION_AGENT_VIEW, AccessController,
8834    };
8835    use crate::blob_store::{BinaryBlobStore, ObjectStoreBlobStore};
8836    use crate::console_aggregator::{
8837        AllowAllConsoleVisibilityPolicy, ConsoleIdentityRecord,
8838        HideImplicitDelegateMembersConsoleVisibilityPolicy,
8839    };
8840    use crate::console_aggregator::{
8841        ConsoleCursor, ConsoleFrameSource, ConsoleFrameSourceKind, ConsoleFrameStatus,
8842        ConsoleTimelineQuery, ConsoleTimelineWindowQuery, ConsoleVisibilityPolicy,
8843        MobKitConsoleAggregator, NewConsoleFrame,
8844    };
8845    use crate::identity_first::contracts::{ContinuityStore, LeaseProvider};
8846    use crate::identity_first::{
8847        AgentAddressability, AgentBuildDraft, AgentIdentity, AgentMemoryConfig,
8848        AgentMemoryRuntimeInjector, AgentRuntimeId, BridgeError, CheckpointVersion,
8849        ContinuityGeneration, ContinuityRecord, DurabilityPolicy, DurableAgentSpec, FencingToken,
8850        IdentityLifecycleState, IdentityRuntime, IdentityRuntimeConfig, LeaseAcquireResult,
8851        LeaseGrant, LocalContinuityStore, LocalLeaseProvider, ManagedPeerEdge,
8852        MarkdownAgentMemoryStore, ResumeSessionOutcome, SessionBridge, SessionSnapshot,
8853    };
8854    use crate::mob_handle_runtime::{MobRuntime, model_capabilities_for_role};
8855    use crate::rpc::{JSONRPC_VERSION, JsonRpcRequest};
8856    use crate::runtime::{ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember};
8857    use crate::unified_runtime::ConsoleEventStore;
8858    use crate::{MobBootstrapOptions, MobBootstrapSpec};
8859    use bytes::Bytes;
8860    use meerkat::{AgentFactory, Config, build_ephemeral_service};
8861    use meerkat_client::TestClient;
8862    use meerkat_core::types::HandlingMode;
8863    use meerkat_mob::ProfileName;
8864    use meerkat_mob::{MobDefinition, MobStorage, SpawnMemberSpec};
8865    use serde_json::{Value, json};
8866    use std::collections::BTreeMap;
8867    use std::sync::atomic::{AtomicUsize, Ordering};
8868    use std::sync::{Arc, Mutex};
8869    use std::time::Duration;
8870
8871    struct BlockingIdentityBridge {
8872        deliver_calls: Arc<AtomicUsize>,
8873    }
8874
8875    struct RecordingIdentityBridge {
8876        session_id: meerkat_core::types::SessionId,
8877        handling_modes: Arc<Mutex<Vec<HandlingMode>>>,
8878    }
8879
8880    #[async_trait::async_trait]
8881    impl SessionBridge for BlockingIdentityBridge {
8882        async fn create_session(
8883            &self,
8884            _identity: &AgentIdentity,
8885            _runtime_id: &AgentRuntimeId,
8886            _spec: &DurableAgentSpec,
8887            _draft: &AgentBuildDraft,
8888            session_id: &meerkat_core::types::SessionId,
8889        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8890            Ok(session_id.clone())
8891        }
8892
8893        async fn resume_session(
8894            &self,
8895            _identity: &AgentIdentity,
8896            _runtime_id: &AgentRuntimeId,
8897            _spec: &DurableAgentSpec,
8898            _draft: &AgentBuildDraft,
8899            session_id: &meerkat_core::types::SessionId,
8900            _snapshot: &SessionSnapshot,
8901        ) -> Result<ResumeSessionOutcome, BridgeError> {
8902            Ok(ResumeSessionOutcome::Resumed {
8903                session_id: session_id.clone(),
8904            })
8905        }
8906
8907        async fn deliver(
8908            &self,
8909            _runtime_id: &AgentRuntimeId,
8910            _content: &meerkat_core::ContentInput,
8911        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8912            self.deliver_calls.fetch_add(1, Ordering::SeqCst);
8913            std::future::pending().await
8914        }
8915
8916        async fn checkpoint_session(
8917            &self,
8918            _runtime_id: &AgentRuntimeId,
8919            _session_id: &meerkat_core::types::SessionId,
8920        ) -> Result<SessionSnapshot, BridgeError> {
8921            Err(BridgeError::Mob("checkpoint not used in test".to_string()))
8922        }
8923
8924        async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
8925            Ok(())
8926        }
8927    }
8928
8929    #[async_trait::async_trait]
8930    impl SessionBridge for RecordingIdentityBridge {
8931        async fn create_session(
8932            &self,
8933            _identity: &AgentIdentity,
8934            _runtime_id: &AgentRuntimeId,
8935            _spec: &DurableAgentSpec,
8936            _draft: &AgentBuildDraft,
8937            session_id: &meerkat_core::types::SessionId,
8938        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8939            Ok(session_id.clone())
8940        }
8941
8942        async fn resume_session(
8943            &self,
8944            _identity: &AgentIdentity,
8945            _runtime_id: &AgentRuntimeId,
8946            _spec: &DurableAgentSpec,
8947            _draft: &AgentBuildDraft,
8948            session_id: &meerkat_core::types::SessionId,
8949            _snapshot: &SessionSnapshot,
8950        ) -> Result<ResumeSessionOutcome, BridgeError> {
8951            Ok(ResumeSessionOutcome::Resumed {
8952                session_id: session_id.clone(),
8953            })
8954        }
8955
8956        async fn deliver(
8957            &self,
8958            runtime_id: &AgentRuntimeId,
8959            content: &meerkat_core::ContentInput,
8960        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8961            self.deliver_with_mode(runtime_id, content, HandlingMode::Queue)
8962                .await
8963        }
8964
8965        async fn deliver_with_mode(
8966            &self,
8967            _runtime_id: &AgentRuntimeId,
8968            _content: &meerkat_core::ContentInput,
8969            handling_mode: HandlingMode,
8970        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8971            self.handling_modes
8972                .lock()
8973                .map_err(|_| BridgeError::Mob("handling modes mutex poisoned".to_string()))?
8974                .push(handling_mode);
8975            Ok(self.session_id.clone())
8976        }
8977
8978        async fn checkpoint_session(
8979            &self,
8980            _runtime_id: &AgentRuntimeId,
8981            _session_id: &meerkat_core::types::SessionId,
8982        ) -> Result<SessionSnapshot, BridgeError> {
8983            Err(BridgeError::Mob("checkpoint not used in test".to_string()))
8984        }
8985
8986        async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
8987            Ok(())
8988        }
8989    }
8990
8991    async fn build_empty_console_test_runtime(
8992        mob_id: &str,
8993    ) -> Result<(tempfile::TempDir, MobRuntime), Box<dyn std::error::Error + Send + Sync>> {
8994        let temp_dir = tempfile::tempdir()?;
8995        let session_path = temp_dir.path().join("sessions");
8996        std::fs::create_dir_all(&session_path)?;
8997        let factory = AgentFactory::new(&session_path).comms(true);
8998        let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
8999        let definition = MobDefinition::from_toml(&format!(
9000            r#"
9001[mob]
9002id = "{mob_id}"
9003
9004[profiles.worker]
9005model = "gpt-5.5"
9006external_addressable = true
9007
9008[profiles.worker.tools]
9009comms = true
9010"#
9011        ))?;
9012        let runtime = MobRuntime::bootstrap(
9013            MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
9014                .with_options(MobBootstrapOptions {
9015                    allow_ephemeral_sessions: true,
9016                    notify_orchestrator_on_resume: true,
9017                    default_llm_client: Some(Arc::new(TestClient::default())),
9018                }),
9019        )
9020        .await?;
9021        Ok((temp_dir, runtime))
9022    }
9023
9024    fn rpc_request(method: &str) -> JsonRpcRequest {
9025        JsonRpcRequest {
9026            jsonrpc: JSONRPC_VERSION.to_string(),
9027            id: Some(json!(1)),
9028            method: method.to_string(),
9029            params: json!({}),
9030        }
9031    }
9032
9033    fn rpc_request_with_params(method: &str, params: Value) -> JsonRpcRequest {
9034        JsonRpcRequest {
9035            jsonrpc: JSONRPC_VERSION.to_string(),
9036            id: Some(json!(1)),
9037            method: method.to_string(),
9038            params,
9039        }
9040    }
9041
9042    #[tokio::test]
9043    async fn read_only_aggregator_capabilities_omit_mutating_methods() {
9044        let response = handle_console_aggregator_rpc(
9045            None,
9046            rpc_request("mobkit/capabilities"),
9047            true,
9048            true,
9049            None,
9050            None,
9051        )
9052        .await;
9053
9054        let methods = response["result"]["methods"]
9055            .as_array()
9056            .expect("capabilities methods");
9057        assert!(
9058            methods.iter().all(|method| method != "mobkit/console/send"),
9059            "read-only capabilities must omit send: {methods:#?}"
9060        );
9061        assert_eq!(response["result"]["read_only"], json!(true));
9062        assert_eq!(
9063            response["result"]["runtime_capabilities"]["can_send_messages"],
9064            json!(false)
9065        );
9066    }
9067
9068    #[tokio::test]
9069    async fn read_only_aggregator_denies_direct_mutating_rpc() {
9070        let response = handle_console_aggregator_rpc(
9071            None,
9072            rpc_request_with_params(
9073                "mobkit/console/send",
9074                json!({
9075                    "identity": "worker",
9076                    "content": "hello",
9077                    "origin": "test",
9078                    "idempotency_key": "read-only-send",
9079                }),
9080            ),
9081            true,
9082            true,
9083            None,
9084            None,
9085        )
9086        .await;
9087
9088        assert_eq!(response["result"], Value::Null);
9089        assert_eq!(response["error"]["code"], json!(-32010));
9090        assert_eq!(response["error"]["data"]["kind"], json!("read_only"));
9091    }
9092
9093    #[test]
9094    fn read_only_mutating_methods_include_state_draining_collect_completed() {
9095        assert!(super::is_console_mutating_rpc_method(
9096            "mobkit/collect_completed"
9097        ));
9098    }
9099
9100    #[test]
9101    fn authenticated_gating_approver_comes_from_console_principal() {
9102        let forged_params = json!({ "approver_id": "forged-browser-value" });
9103
9104        assert_eq!(
9105            super::resolve_gating_approver_id(&forged_params, Some("admin@example.com"),),
9106            Ok("admin@example.com".to_string()),
9107        );
9108    }
9109
9110    #[test]
9111    fn unauthenticated_local_gating_approver_requires_request_param() {
9112        assert_eq!(
9113            super::resolve_gating_approver_id(&json!({ "approver_id": " local-operator " }), None,),
9114            Ok("local-operator".to_string()),
9115        );
9116        assert_eq!(
9117            super::resolve_gating_approver_id(&json!({}), None),
9118            Err("approver_id required"),
9119        );
9120    }
9121
9122    #[test]
9123    fn internal_error_does_not_disclose_backend_details() {
9124        let response = super::internal_error(json!(7), "secret backend DSN");
9125
9126        assert_eq!(response["error"]["code"], json!(-32000));
9127        assert_eq!(response["error"]["message"], json!("internal error"));
9128        assert_eq!(response["error"]["data"]["error"], json!("internal_error"));
9129        assert!(!response.to_string().contains("secret backend DSN"));
9130    }
9131
9132    #[test]
9133    fn console_send_public_message_hides_dispatch_details() {
9134        let message = super::console_send_public_message(
9135            &crate::console_aggregator::ConsoleSendError::Dispatch(
9136                "secret backend DSN".to_string(),
9137            ),
9138        );
9139
9140        assert_eq!(message, "console send failed");
9141        assert!(!message.contains("secret backend DSN"));
9142    }
9143
9144    #[test]
9145    fn console_send_json_rpc_error_hides_backend_details() {
9146        let response = super::console_send_rpc_error(
9147            json!(7),
9148            crate::console_aggregator::ConsoleSendError::Dispatch("secret backend DSN".to_string()),
9149        );
9150
9151        assert_eq!(response["error"]["code"], json!(-32000));
9152        assert_eq!(response["error"]["message"], json!("console send failed"));
9153        assert!(!response.to_string().contains("secret backend DSN"));
9154    }
9155
9156    #[test]
9157    fn console_timeline_replay_error_hides_backend_details() {
9158        let response = super::console_timeline_replay_unavailable_response(
9159            json!(7),
9160            Box::new(std::io::Error::other("secret backend DSN")),
9161            None,
9162            None,
9163        );
9164
9165        assert_eq!(
9166            response["error"]["code"],
9167            json!(crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE)
9168        );
9169        assert_eq!(
9170            response["error"]["message"],
9171            json!("timeline replay unavailable")
9172        );
9173        assert!(!response.to_string().contains("secret backend DSN"));
9174    }
9175
9176    #[test]
9177    fn gating_decision_error_hides_backend_details() {
9178        let response = super::gating_decision_failed_error(json!(7), "secret backend DSN");
9179
9180        assert_eq!(response["error"]["code"], json!(-32602));
9181        assert_eq!(
9182            response["error"]["message"],
9183            json!("gating decision failed")
9184        );
9185        assert!(!response.to_string().contains("secret backend DSN"));
9186    }
9187
9188    #[tokio::test]
9189    async fn console_runtime_identity_controls_resolve_durable_member_aliases()
9190    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9191        let (_temp_dir, runtime) =
9192            build_empty_console_test_runtime("console-identity-control-alias").await?;
9193        let mut labels = BTreeMap::new();
9194        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
9195        labels.insert("display_name".to_string(), "Review Agent".to_string());
9196        runtime
9197            .handle()
9198            .spawn_spec(
9199                SpawnMemberSpec::from_wire(
9200                    "worker".to_string(),
9201                    // meerkat 0.7: roster ids are comms-safe encodings of the
9202                    // public alias (MemberCommsName rejects ":").
9203                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
9204                    Some("You are Review Agent.".into()),
9205                    None,
9206                    None,
9207                )
9208                .with_labels(labels),
9209            )
9210            .await?;
9211
9212        let durable_status = Box::pin(handle_console_runtime_rpc(
9213            &runtime,
9214            None,
9215            None,
9216            None,
9217            None,
9218            None,
9219            None,
9220            None,
9221            None,
9222            rpc_request_with_params(
9223                "mobkit/status_identity",
9224                json!({ "identity": "review:singleton" }),
9225            ),
9226            true,
9227        ))
9228        .await;
9229        assert_eq!(durable_status["error"], Value::Null);
9230        assert_eq!(
9231            durable_status["result"]["identity"],
9232            json!("review:singleton")
9233        );
9234        assert_eq!(
9235            durable_status["result"]["agent_runtime_id"],
9236            json!("rt:review:singleton:0")
9237        );
9238
9239        let runtime_id_status = Box::pin(handle_console_runtime_rpc(
9240            &runtime,
9241            None,
9242            None,
9243            None,
9244            None,
9245            None,
9246            None,
9247            None,
9248            None,
9249            rpc_request_with_params(
9250                "mobkit/status_identity",
9251                json!({ "identity": "rt:review:singleton:0" }),
9252            ),
9253            true,
9254        ))
9255        .await;
9256        assert_eq!(runtime_id_status["error"], Value::Null);
9257        assert_eq!(
9258            runtime_id_status["result"]["identity"],
9259            json!("review:singleton")
9260        );
9261
9262        let runtime_id_inspect = Box::pin(handle_console_runtime_rpc(
9263            &runtime,
9264            None,
9265            None,
9266            None,
9267            None,
9268            None,
9269            None,
9270            None,
9271            None,
9272            rpc_request_with_params(
9273                "mobkit/inspect_identity",
9274                json!({ "identity": "rt:review:singleton:0" }),
9275            ),
9276            true,
9277        ))
9278        .await;
9279        assert_eq!(runtime_id_inspect["error"], Value::Null);
9280        assert_eq!(
9281            runtime_id_inspect["result"]["identity"],
9282            json!("review:singleton")
9283        );
9284
9285        let respawn = Box::pin(handle_console_runtime_rpc(
9286            &runtime,
9287            None,
9288            None,
9289            None,
9290            Some(ConsoleEventStore::new()),
9291            None,
9292            None,
9293            None,
9294            None,
9295            rpc_request_with_params("mobkit/respawn", json!({ "identity": "review:singleton" })),
9296            true,
9297        ))
9298        .await;
9299        assert_eq!(respawn["error"], Value::Null);
9300        assert_eq!(respawn["result"]["identity"], json!("review:singleton"));
9301        assert_eq!(
9302            respawn["result"]["agent_runtime_id"],
9303            json!("rt:review:singleton:0")
9304        );
9305
9306        // Without an identity-first runtime, reset degrades to the live
9307        // member respawn fallback instead of erroring; the console Reset
9308        // button stays functional on plain runtimes.
9309        let reset_without_identity_runtime = Box::pin(handle_console_runtime_rpc(
9310            &runtime,
9311            None,
9312            None,
9313            None,
9314            Some(ConsoleEventStore::new()),
9315            None,
9316            None,
9317            None,
9318            None,
9319            rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
9320            true,
9321        ))
9322        .await;
9323        assert_eq!(
9324            reset_without_identity_runtime["error"],
9325            Value::Null,
9326            "{reset_without_identity_runtime:#?}"
9327        );
9328        assert_eq!(
9329            reset_without_identity_runtime["result"]["identity"],
9330            json!("review:singleton")
9331        );
9332        assert_eq!(
9333            reset_without_identity_runtime["result"]["agent_runtime_id"],
9334            json!("rt:review:singleton:0")
9335        );
9336
9337        let reset_unknown_identity = Box::pin(handle_console_runtime_rpc(
9338            &runtime,
9339            None,
9340            None,
9341            None,
9342            Some(ConsoleEventStore::new()),
9343            None,
9344            None,
9345            None,
9346            None,
9347            rpc_request_with_params("mobkit/reset", json!({ "identity": "missing:member" })),
9348            true,
9349        ))
9350        .await;
9351        assert_ne!(reset_unknown_identity["error"], Value::Null);
9352        assert!(
9353            reset_unknown_identity["error"]["message"]
9354                .as_str()
9355                .unwrap_or_default()
9356                .contains("identity not found")
9357        );
9358
9359        let _ = runtime.handle().stop().await;
9360        Ok(())
9361    }
9362
9363    #[tokio::test]
9364    async fn console_runtime_identity_controls_reject_ambiguous_live_label_aliases()
9365    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9366        let (_temp_dir, runtime) =
9367            build_empty_console_test_runtime("console-identity-ambiguous-live-alias").await?;
9368        for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
9369            let mut labels = BTreeMap::new();
9370            labels.insert("agent_identity".to_string(), "review:singleton".to_string());
9371            runtime
9372                .handle()
9373                .spawn_spec(
9374                    SpawnMemberSpec::from_wire(
9375                        "worker".to_string(),
9376                        // meerkat 0.7: roster ids are comms-safe encodings of
9377                        // the public alias (MemberCommsName rejects ":").
9378                        crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
9379                        Some("You are a duplicate Review Agent.".into()),
9380                        None,
9381                        None,
9382                    )
9383                    .with_labels(labels),
9384                )
9385                .await?;
9386        }
9387
9388        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
9389            for method in [
9390                "mobkit/status_identity",
9391                "mobkit/inspect_identity",
9392                "mobkit/retire",
9393                "mobkit/respawn",
9394                "mobkit/reset",
9395            ] {
9396                let response = Box::pin(handle_console_runtime_rpc(
9397                    &runtime,
9398                    None,
9399                    None,
9400                    None,
9401                    Some(ConsoleEventStore::new()),
9402                    None,
9403                    None,
9404                    None,
9405                    None,
9406                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
9407                    true,
9408                ))
9409                .await;
9410                assert_ne!(
9411                    response["error"],
9412                    Value::Null,
9413                    "{method} must reject ambiguous live alias for {requested_identity}"
9414                );
9415                assert_eq!(
9416                    response["error"]["data"]["kind"],
9417                    json!("ambiguous_live_identity_alias"),
9418                    "unexpected response for {method}/{requested_identity}: {response:#?}"
9419                );
9420            }
9421        }
9422
9423        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9424            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9425            lease_provider: Arc::new(LocalLeaseProvider::new()),
9426            runtime_instance_id: "console-identity-ambiguous-live-alias".to_string(),
9427            has_runtime_store: true,
9428            durability_policy: DurabilityPolicy::SyncWriteThrough,
9429            bridge: None,
9430            default_timeout: None,
9431        }));
9432        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
9433            for method in ["mobkit/reset", "mobkit/delete_identity"] {
9434                let response = Box::pin(handle_console_runtime_rpc(
9435                    &runtime,
9436                    None,
9437                    None,
9438                    None,
9439                    Some(ConsoleEventStore::new()),
9440                    None,
9441                    Some(identity_runtime.clone()),
9442                    None,
9443                    None,
9444                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
9445                    true,
9446                ))
9447                .await;
9448                assert_ne!(
9449                    response["error"],
9450                    Value::Null,
9451                    "{method} must reject ambiguous live alias for {requested_identity}"
9452                );
9453                assert_eq!(
9454                    response["error"]["data"]["kind"],
9455                    json!("ambiguous_live_identity_alias"),
9456                    "unexpected response for {method}/{requested_identity}: {response:#?}"
9457                );
9458            }
9459        }
9460
9461        let _ = runtime.handle().stop().await;
9462        Ok(())
9463    }
9464
9465    #[tokio::test]
9466    async fn console_runtime_durable_identity_prefers_registered_live_over_duplicate_labels()
9467    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9468        let (_temp_dir, runtime) =
9469            build_empty_console_test_runtime("console-durable-wins-duplicate-live-labels").await?;
9470        for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
9471            let mut labels = BTreeMap::new();
9472            labels.insert("agent_identity".to_string(), "review:singleton".to_string());
9473            runtime
9474                .handle()
9475                .spawn_spec(
9476                    SpawnMemberSpec::from_wire(
9477                        "worker".to_string(),
9478                        // meerkat 0.7: roster ids are comms-safe encodings of
9479                        // the public alias (MemberCommsName rejects ":").
9480                        crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
9481                        Some("You are a Review Agent candidate.".into()),
9482                        None,
9483                        None,
9484                    )
9485                    .with_labels(labels),
9486                )
9487                .await?;
9488        }
9489
9490        let store = Arc::new(LocalContinuityStore::in_memory()?);
9491        let lease_provider = Arc::new(LocalLeaseProvider::new());
9492        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9493            continuity_store: store.clone(),
9494            lease_provider: lease_provider.clone(),
9495            runtime_instance_id: "test-runtime".to_string(),
9496            has_runtime_store: true,
9497            durability_policy: DurabilityPolicy::SyncWriteThrough,
9498            bridge: None,
9499            default_timeout: None,
9500        }));
9501        let identity = AgentIdentity::parse("review:singleton")?;
9502        let registered_session_id = runtime
9503            .handle()
9504            .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
9505                "rt:review:singleton:0",
9506            ))
9507            .await
9508            .unwrap_or_else(meerkat_core::types::SessionId::new);
9509        let record = ContinuityRecord {
9510            identity: identity.clone(),
9511            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
9512            session_id: registered_session_id,
9513            generation: ContinuityGeneration::new(0),
9514            checkpoint_version: CheckpointVersion::new(0),
9515        };
9516        let grants = lease_provider
9517            .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
9518            .await?;
9519        let grant = match grants.get(&identity).cloned() {
9520            Some(LeaseAcquireResult::Acquired(grant)) => grant,
9521            other => return Err(format!("expected acquired lease, got {other:?}").into()),
9522        };
9523        store
9524            .upsert_continuity_record(&record, grant.fencing_token)
9525            .await?;
9526        identity_runtime
9527            .register(
9528                DurableAgentSpec {
9529                    identity: identity.clone(),
9530                    profile: ProfileName::from("worker"),
9531                    addressability: AgentAddressability::Addressable,
9532                    display_name: None,
9533                    labels: BTreeMap::new(),
9534                    context: None,
9535                    additional_instructions: Vec::new(),
9536                    initial_message: None,
9537                    runtime_mode_override: None,
9538                    backend: None,
9539                    binding: None,
9540                },
9541                IdentityLifecycleState::Active,
9542                Some(record),
9543                Some(grant),
9544            )
9545            .await;
9546
9547        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
9548            for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
9549                let response = Box::pin(handle_console_runtime_rpc(
9550                    &runtime,
9551                    None,
9552                    None,
9553                    None,
9554                    Some(ConsoleEventStore::new()),
9555                    None,
9556                    Some(identity_runtime.clone()),
9557                    None,
9558                    None,
9559                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
9560                    true,
9561                ))
9562                .await;
9563                assert_eq!(
9564                    response["error"],
9565                    Value::Null,
9566                    "{method} must use durable registered live binding despite duplicate labels for {requested_identity}: {response:#?}"
9567                );
9568            }
9569        }
9570        let reset_all_response = Box::pin(handle_console_runtime_rpc(
9571            &runtime,
9572            None,
9573            None,
9574            None,
9575            Some(ConsoleEventStore::new()),
9576            None,
9577            Some(identity_runtime.clone()),
9578            None,
9579            None,
9580            rpc_request("mobkit/reset_all"),
9581            true,
9582        ))
9583        .await;
9584        assert_eq!(
9585            reset_all_response["error"],
9586            Value::Null,
9587            "reset_all must also prefer the durable registered live binding despite duplicate labels: {reset_all_response:#?}"
9588        );
9589        assert!(
9590            reset_all_response["result"]["failed"]
9591                .as_array()
9592                .is_some_and(Vec::is_empty),
9593            "reset_all should not report duplicate-label failure for durable registered binding: {reset_all_response:#?}"
9594        );
9595
9596        let _ = runtime.handle().stop().await;
9597        Ok(())
9598    }
9599
9600    #[tokio::test]
9601    async fn console_runtime_identity_controls_reject_wrong_projected_live_only_alias()
9602    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9603        let (_temp_dir, runtime) =
9604            build_empty_console_test_runtime("console-identity-wrong-projected-live-only").await?;
9605
9606        let mut labels = BTreeMap::new();
9607        labels.insert("agent_identity".to_string(), "other:singleton".to_string());
9608        runtime
9609            .handle()
9610            .spawn_spec(
9611                SpawnMemberSpec::from_wire(
9612                    "worker".to_string(),
9613                    // meerkat 0.7: roster ids are comms-safe encodings of the
9614                    // public alias (MemberCommsName rejects ":").
9615                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
9616                    Some("You are a wrong-projected Review Agent.".into()),
9617                    None,
9618                    None,
9619                )
9620                .with_labels(labels),
9621            )
9622            .await?;
9623
9624        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9625            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9626            lease_provider: Arc::new(LocalLeaseProvider::new()),
9627            runtime_instance_id: "console-identity-wrong-projected-live-only".to_string(),
9628            has_runtime_store: true,
9629            durability_policy: DurabilityPolicy::SyncWriteThrough,
9630            bridge: None,
9631            default_timeout: None,
9632        }));
9633        let identity = AgentIdentity::parse("review:singleton")?;
9634        let record = ContinuityRecord {
9635            identity: identity.clone(),
9636            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
9637            session_id: meerkat_core::types::SessionId::new(),
9638            generation: ContinuityGeneration::new(0),
9639            checkpoint_version: CheckpointVersion::new(0),
9640        };
9641        identity_runtime
9642            .register(
9643                DurableAgentSpec {
9644                    identity: identity.clone(),
9645                    profile: ProfileName::from("worker"),
9646                    addressability: AgentAddressability::Addressable,
9647                    display_name: None,
9648                    labels: BTreeMap::new(),
9649                    context: None,
9650                    additional_instructions: Vec::new(),
9651                    initial_message: None,
9652                    runtime_mode_override: None,
9653                    backend: None,
9654                    binding: None,
9655                },
9656                IdentityLifecycleState::Active,
9657                Some(record),
9658                Some(LeaseGrant {
9659                    identity,
9660                    fencing_token: FencingToken::new(1),
9661                    ttl: Duration::from_mins(1),
9662                }),
9663            )
9664            .await;
9665
9666        for method in [
9667            "mobkit/status_identity",
9668            "mobkit/inspect_identity",
9669            "mobkit/retire",
9670        ] {
9671            let response = Box::pin(handle_console_runtime_rpc(
9672                &runtime,
9673                None,
9674                None,
9675                None,
9676                Some(ConsoleEventStore::new()),
9677                None,
9678                Some(identity_runtime.clone()),
9679                None,
9680                None,
9681                rpc_request_with_params(method, json!({ "identity": "other:singleton" })),
9682                true,
9683            ))
9684            .await;
9685            assert_ne!(
9686                response["error"],
9687                Value::Null,
9688                "{method} must reject wrong-projected live-only alias"
9689            );
9690            assert_eq!(
9691                response["error"]["data"]["kind"],
9692                json!("stale_live_identity_alias"),
9693                "unexpected response for {method}: {response:#?}"
9694            );
9695        }
9696        assert!(
9697            runtime
9698                .handle()
9699                .get_member(&crate::member_comms_id::mob_member_id(
9700                    "rt:review:singleton:0"
9701                ))
9702                .await
9703                .ok()
9704                .flatten()
9705                .is_some(),
9706            "wrong-projected durable runtime member must not be retired through projected alias"
9707        );
9708
9709        let _ = runtime.handle().stop().await;
9710        Ok(())
9711    }
9712
9713    #[derive(Debug)]
9714    struct HideIdentityPolicy(&'static str);
9715
9716    impl ConsoleVisibilityPolicy for HideIdentityPolicy {
9717        fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
9718            record.identity != self.0
9719        }
9720    }
9721
9722    #[derive(Debug)]
9723    struct HideMemberPolicy(&'static str);
9724
9725    impl ConsoleVisibilityPolicy for HideMemberPolicy {
9726        fn member_visible(&self, member: &ConsoleMember) -> bool {
9727            member.agent_identity != self.0
9728                && member
9729                    .labels
9730                    .get("agent_identity")
9731                    .is_none_or(|identity| identity != self.0)
9732        }
9733
9734        fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
9735            record.runtime_member_id != self.0
9736        }
9737    }
9738
9739    #[derive(Debug)]
9740    struct HideOnlyMemberPolicy(&'static str);
9741
9742    impl ConsoleVisibilityPolicy for HideOnlyMemberPolicy {
9743        fn member_visible(&self, member: &ConsoleMember) -> bool {
9744            member.agent_identity != self.0
9745        }
9746    }
9747
9748    #[tokio::test]
9749    async fn console_runtime_identity_controls_respect_visibility_policy()
9750    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9751        let (_temp_dir, runtime) =
9752            build_empty_console_test_runtime("console-identity-hidden-controls").await?;
9753        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9754            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9755            lease_provider: Arc::new(LocalLeaseProvider::new()),
9756            runtime_instance_id: "console-identity-hidden-controls".to_string(),
9757            has_runtime_store: true,
9758            durability_policy: DurabilityPolicy::SyncWriteThrough,
9759            bridge: None,
9760            default_timeout: None,
9761        }));
9762        let identity = AgentIdentity::parse("review:singleton")?;
9763        let record = ContinuityRecord {
9764            identity: identity.clone(),
9765            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
9766            session_id: meerkat_core::types::SessionId::new(),
9767            generation: ContinuityGeneration::new(0),
9768            checkpoint_version: CheckpointVersion::new(0),
9769        };
9770        identity_runtime
9771            .register(
9772                DurableAgentSpec {
9773                    identity: identity.clone(),
9774                    profile: ProfileName::from("worker"),
9775                    addressability: AgentAddressability::Addressable,
9776                    display_name: None,
9777                    labels: BTreeMap::new(),
9778                    context: None,
9779                    additional_instructions: Vec::new(),
9780                    initial_message: None,
9781                    runtime_mode_override: None,
9782                    backend: None,
9783                    binding: None,
9784                },
9785                IdentityLifecycleState::Active,
9786                Some(record),
9787                Some(LeaseGrant {
9788                    identity: identity.clone(),
9789                    fencing_token: FencingToken::new(7),
9790                    ttl: Duration::from_mins(1),
9791                }),
9792            )
9793            .await;
9794
9795        for method in [
9796            "mobkit/status_identity",
9797            "mobkit/inspect_identity",
9798            "mobkit/retire",
9799            "mobkit/respawn",
9800            "mobkit/reset",
9801            "mobkit/delete_identity",
9802        ] {
9803            let response = Box::pin(handle_console_runtime_rpc_with_visibility(
9804                &runtime,
9805                None,
9806                None,
9807                None,
9808                Some(ConsoleEventStore::new()),
9809                None,
9810                Some(identity_runtime.clone()),
9811                None,
9812                None,
9813                &HideIdentityPolicy("review:singleton"),
9814                rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
9815                true,
9816                false,
9817                None,
9818                None,
9819                None,
9820                None,
9821            ))
9822            .await;
9823            assert_ne!(
9824                response["error"],
9825                Value::Null,
9826                "{method} must reject hidden durable identity"
9827            );
9828            assert_eq!(
9829                response["error"]["data"]["kind"],
9830                json!("identity_hidden_by_policy"),
9831                "unexpected hidden response for {method}: {response:#?}"
9832            );
9833        }
9834        identity_runtime
9835            .status(&AgentIdentity::parse("review:singleton")?)
9836            .await
9837            .expect("hidden control RPCs must not mutate the durable identity");
9838
9839        let _ = runtime.handle().stop().await;
9840        Ok(())
9841    }
9842
9843    #[tokio::test]
9844    async fn console_runtime_durable_identity_controls_reject_hidden_bound_member()
9845    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9846        let (_temp_dir, runtime) =
9847            build_empty_console_test_runtime("console-durable-hidden-bound-member").await?;
9848        runtime
9849            .handle()
9850            .spawn_spec(SpawnMemberSpec::from_wire(
9851                "worker".to_string(),
9852                // meerkat 0.7: roster ids are comms-safe encodings of the
9853                // public alias (MemberCommsName rejects ":").
9854                crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
9855                Some("You are the live Review Agent.".into()),
9856                None,
9857                None,
9858            ))
9859            .await?;
9860
9861        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9862            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9863            lease_provider: Arc::new(LocalLeaseProvider::new()),
9864            runtime_instance_id: "console-durable-hidden-bound-member".to_string(),
9865            has_runtime_store: true,
9866            durability_policy: DurabilityPolicy::SyncWriteThrough,
9867            bridge: None,
9868            default_timeout: None,
9869        }));
9870        let identity = AgentIdentity::parse("review:singleton")?;
9871        let record = ContinuityRecord {
9872            identity: identity.clone(),
9873            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
9874            session_id: meerkat_core::types::SessionId::new(),
9875            generation: ContinuityGeneration::new(0),
9876            checkpoint_version: CheckpointVersion::new(0),
9877        };
9878        identity_runtime
9879            .register(
9880                DurableAgentSpec {
9881                    identity: identity.clone(),
9882                    profile: ProfileName::from("worker"),
9883                    addressability: AgentAddressability::Addressable,
9884                    display_name: None,
9885                    labels: BTreeMap::new(),
9886                    context: None,
9887                    additional_instructions: Vec::new(),
9888                    initial_message: None,
9889                    runtime_mode_override: None,
9890                    backend: None,
9891                    binding: None,
9892                },
9893                IdentityLifecycleState::Active,
9894                Some(record),
9895                Some(LeaseGrant {
9896                    identity: identity.clone(),
9897                    fencing_token: FencingToken::new(7),
9898                    ttl: Duration::from_mins(1),
9899                }),
9900            )
9901            .await;
9902
9903        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
9904            for method in [
9905                "mobkit/status_identity",
9906                "mobkit/inspect_identity",
9907                "mobkit/retire",
9908                "mobkit/respawn",
9909                "mobkit/reset",
9910                "mobkit/delete_identity",
9911            ] {
9912                let response = Box::pin(handle_console_runtime_rpc_with_visibility(
9913                    &runtime,
9914                    None,
9915                    None,
9916                    None,
9917                    Some(ConsoleEventStore::new()),
9918                    None,
9919                    Some(identity_runtime.clone()),
9920                    None,
9921                    None,
9922                    &HideOnlyMemberPolicy("rt:review:singleton:0"),
9923                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
9924                    true,
9925                    false,
9926                    None,
9927                    None,
9928                    None,
9929                    None,
9930                ))
9931                .await;
9932                assert_eq!(
9933                    response["error"]["data"]["kind"],
9934                    json!("identity_hidden_by_policy"),
9935                    "durable {method} must reject hidden bound live member for {requested_identity}: {response:#?}"
9936                );
9937            }
9938        }
9939
9940        let _ = runtime.handle().stop().await;
9941        Ok(())
9942    }
9943
9944    #[tokio::test]
9945    async fn console_runtime_live_only_identity_controls_respect_visibility_policy()
9946    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9947        let (_temp_dir, runtime) =
9948            build_empty_console_test_runtime("console-live-only-hidden-controls").await?;
9949
9950        let mut labels = BTreeMap::new();
9951        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
9952        runtime
9953            .handle()
9954            .spawn_spec(
9955                SpawnMemberSpec::from_wire(
9956                    "worker".to_string(),
9957                    // meerkat 0.7: roster ids are comms-safe encodings of the
9958                    // public alias (MemberCommsName rejects ":").
9959                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
9960                    Some("You are the live Review Agent.".into()),
9961                    None,
9962                    None,
9963                )
9964                .with_labels(labels),
9965            )
9966            .await?;
9967
9968        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9969            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9970            lease_provider: Arc::new(LocalLeaseProvider::new()),
9971            runtime_instance_id: "console-live-only-hidden-controls".to_string(),
9972            has_runtime_store: true,
9973            durability_policy: DurabilityPolicy::SyncWriteThrough,
9974            bridge: None,
9975            default_timeout: None,
9976        }));
9977
9978        for method in [
9979            "mobkit/status_identity",
9980            "mobkit/inspect_identity",
9981            "mobkit/retire",
9982            "mobkit/respawn",
9983            "mobkit/reset",
9984            "mobkit/delete_identity",
9985        ] {
9986            let response = Box::pin(handle_console_runtime_rpc_with_visibility(
9987                &runtime,
9988                None,
9989                None,
9990                None,
9991                Some(ConsoleEventStore::new()),
9992                None,
9993                Some(identity_runtime.clone()),
9994                None,
9995                None,
9996                &HideMemberPolicy("rt:review:singleton:0"),
9997                rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
9998                true,
9999                false,
10000                None,
10001                None,
10002                None,
10003                None,
10004            ))
10005            .await;
10006            assert_ne!(
10007                response["error"],
10008                Value::Null,
10009                "{method} must reject hidden live-only identity"
10010            );
10011            assert_eq!(
10012                response["error"]["data"]["kind"],
10013                json!("identity_hidden_by_policy"),
10014                "unexpected hidden live-only response for {method}: {response:#?}"
10015            );
10016        }
10017        assert!(
10018            runtime
10019                .handle()
10020                .get_member(&crate::member_comms_id::mob_member_id(
10021                    "rt:review:singleton:0"
10022                ))
10023                .await
10024                .ok()
10025                .flatten()
10026                .is_some(),
10027            "hidden live-only controls must not mutate the live member"
10028        );
10029
10030        let _ = runtime.handle().stop().await;
10031        Ok(())
10032    }
10033
10034    #[tokio::test]
10035    async fn console_runtime_reset_live_only_alias_without_session_bridge_uses_live_fallback()
10036    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10037        let (_temp_dir, runtime) =
10038            build_empty_console_test_runtime("console-reset-live-only-no-bridge").await?;
10039
10040        let mut labels = BTreeMap::new();
10041        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10042        runtime
10043            .handle()
10044            .spawn_spec(
10045                SpawnMemberSpec::from_wire(
10046                    "worker".to_string(),
10047                    // meerkat 0.7: roster ids are comms-safe encodings of the
10048                    // public alias (MemberCommsName rejects ":").
10049                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10050                    Some("You are the live Review Agent.".into()),
10051                    None,
10052                    None,
10053                )
10054                .with_labels(labels),
10055            )
10056            .await?;
10057
10058        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10059            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10060            lease_provider: Arc::new(LocalLeaseProvider::new()),
10061            runtime_instance_id: "console-reset-live-only-no-bridge".to_string(),
10062            has_runtime_store: true,
10063            durability_policy: DurabilityPolicy::SyncWriteThrough,
10064            bridge: None,
10065            default_timeout: None,
10066        }));
10067
10068        let response = Box::pin(handle_console_runtime_rpc(
10069            &runtime,
10070            None,
10071            None,
10072            None,
10073            Some(ConsoleEventStore::new()),
10074            None,
10075            Some(identity_runtime),
10076            None,
10077            None,
10078            rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
10079            true,
10080        ))
10081        .await;
10082        assert_eq!(
10083            response["error"],
10084            Value::Null,
10085            "live-only reset should use live fallback instead of requiring session bridge: {response:#?}"
10086        );
10087        assert_eq!(response["result"]["identity"], json!("review:singleton"));
10088
10089        let _ = runtime.handle().stop().await;
10090        Ok(())
10091    }
10092
10093    #[tokio::test]
10094    async fn reset_all_rejects_registered_runtime_projected_under_wrong_identity()
10095    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10096        let (_temp_dir, runtime) =
10097            build_empty_console_test_runtime("console-reset-all-stale-projection").await?;
10098
10099        let mut labels = BTreeMap::new();
10100        labels.insert("agent_identity".to_string(), "other:singleton".to_string());
10101        runtime
10102            .handle()
10103            .spawn_spec(
10104                SpawnMemberSpec::from_wire(
10105                    "worker".to_string(),
10106                    // meerkat 0.7: roster ids are comms-safe encodings of the
10107                    // public alias (MemberCommsName rejects ":").
10108                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10109                    Some("You are a mislabeled Review Agent.".into()),
10110                    None,
10111                    None,
10112                )
10113                .with_labels(labels),
10114            )
10115            .await?;
10116
10117        let store = Arc::new(LocalContinuityStore::in_memory()?);
10118        let lease_provider = Arc::new(LocalLeaseProvider::new());
10119        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10120            continuity_store: store.clone(),
10121            lease_provider: lease_provider.clone(),
10122            runtime_instance_id: "test-runtime".to_string(),
10123            has_runtime_store: true,
10124            durability_policy: DurabilityPolicy::SyncWriteThrough,
10125            bridge: None,
10126            default_timeout: None,
10127        }));
10128        let identity = AgentIdentity::parse("review:singleton")?;
10129        let record = ContinuityRecord {
10130            identity: identity.clone(),
10131            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10132            session_id: meerkat_core::types::SessionId::new(),
10133            generation: ContinuityGeneration::new(0),
10134            checkpoint_version: CheckpointVersion::new(0),
10135        };
10136        let grants = lease_provider
10137            .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
10138            .await?;
10139        let grant = match grants.get(&identity).cloned() {
10140            Some(LeaseAcquireResult::Acquired(grant)) => grant,
10141            other => return Err(format!("expected acquired lease, got {other:?}").into()),
10142        };
10143        store
10144            .upsert_continuity_record(&record, grant.fencing_token)
10145            .await?;
10146        identity_runtime
10147            .register(
10148                DurableAgentSpec {
10149                    identity: identity.clone(),
10150                    profile: ProfileName::from("worker"),
10151                    addressability: AgentAddressability::Addressable,
10152                    display_name: None,
10153                    labels: BTreeMap::new(),
10154                    context: None,
10155                    additional_instructions: Vec::new(),
10156                    initial_message: None,
10157                    runtime_mode_override: None,
10158                    backend: None,
10159                    binding: None,
10160                },
10161                IdentityLifecycleState::Active,
10162                Some(record),
10163                Some(grant),
10164            )
10165            .await;
10166
10167        let response = Box::pin(handle_console_runtime_rpc(
10168            &runtime,
10169            None,
10170            None,
10171            None,
10172            Some(ConsoleEventStore::new()),
10173            None,
10174            Some(identity_runtime),
10175            None,
10176            None,
10177            rpc_request("mobkit/reset_all"),
10178            true,
10179        ))
10180        .await;
10181        assert_ne!(response["error"], Value::Null);
10182        let failed = response["error"]["data"]["failed"]
10183            .as_array()
10184            .expect("reset_all should report failed identities");
10185        let stale_failure = failed
10186            .iter()
10187            .find(|failure| failure["identity"] == json!("review:singleton"))
10188            .expect("review identity should fail stale alias validation");
10189        assert_eq!(
10190            stale_failure["kind"],
10191            json!("stale_live_identity_alias"),
10192            "unexpected reset_all response: {response:#?}"
10193        );
10194        assert!(
10195            stale_failure["error"]
10196                .as_str()
10197                .unwrap_or_default()
10198                .contains("projects identity other:singleton"),
10199            "unexpected stale failure: {stale_failure:#?}"
10200        );
10201        let retired = response["error"]["data"]["retired_delegates"]
10202            .as_array()
10203            .expect("reset_all should return retired delegates");
10204        assert!(
10205            !retired
10206                .iter()
10207                .any(|entry| entry["identity"] == json!("other:singleton")),
10208            "wrong-projected live alias must not be destructively retired before stale validation; response: {response:#?}"
10209        );
10210        assert!(
10211            runtime
10212                .handle()
10213                .get_member(&crate::member_comms_id::mob_member_id(
10214                    "rt:review:singleton:0"
10215                ))
10216                .await
10217                .ok()
10218                .flatten()
10219                .is_some(),
10220            "wrong-projected durable runtime member must remain present after reset_all rejection"
10221        );
10222
10223        let _ = runtime.handle().stop().await;
10224        Ok(())
10225    }
10226
10227    #[tokio::test]
10228    async fn reset_all_respects_console_visibility_policy_for_live_members()
10229    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10230        let (_temp_dir, runtime) =
10231            build_empty_console_test_runtime("console-reset-all-hidden-live").await?;
10232
10233        runtime
10234            .handle()
10235            .spawn_spec(
10236                SpawnMemberSpec::from_wire(
10237                    "worker".to_string(),
10238                    // meerkat 0.7: roster ids are comms-safe encodings of the
10239                    // public alias (MemberCommsName rejects ":").
10240                    crate::member_comms_id::mob_member_id_str("hidden:singleton").into_owned(),
10241                    Some("You are hidden from console lifecycle controls.".into()),
10242                    None,
10243                    None,
10244                )
10245                .with_labels(BTreeMap::from([(
10246                    "agent_identity".to_string(),
10247                    "hidden:singleton".to_string(),
10248                )])),
10249            )
10250            .await?;
10251
10252        let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10253            &runtime,
10254            None,
10255            None,
10256            None,
10257            Some(ConsoleEventStore::new()),
10258            None,
10259            None,
10260            None,
10261            None,
10262            &HideMemberPolicy("hidden:singleton"),
10263            rpc_request("mobkit/reset_all"),
10264            true,
10265            false,
10266            None,
10267            None,
10268            None,
10269            None,
10270        ))
10271        .await;
10272        assert_eq!(
10273            response["error"],
10274            Value::Null,
10275            "hidden live member should be outside reset_all target set: {response:#?}"
10276        );
10277        assert!(
10278            runtime
10279                .handle()
10280                .get_member(&crate::member_comms_id::mob_member_id("hidden:singleton"))
10281                .await
10282                .ok()
10283                .flatten()
10284                .is_some(),
10285            "reset_all must not retire hidden live members"
10286        );
10287
10288        let _ = runtime.handle().stop().await;
10289        Ok(())
10290    }
10291
10292    #[tokio::test]
10293    async fn reset_all_skips_durable_identity_with_hidden_bound_member()
10294    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10295        let (_temp_dir, runtime) =
10296            build_empty_console_test_runtime("console-reset-all-hidden-durable-bound").await?;
10297        runtime
10298            .handle()
10299            .spawn_spec(SpawnMemberSpec::from_wire(
10300                "worker".to_string(),
10301                // meerkat 0.7: roster ids are comms-safe encodings of the
10302                // public alias (MemberCommsName rejects ":").
10303                crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10304                Some("You are the hidden Review Agent.".into()),
10305                None,
10306                None,
10307            ))
10308            .await?;
10309
10310        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10311            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10312            lease_provider: Arc::new(LocalLeaseProvider::new()),
10313            runtime_instance_id: "console-reset-all-hidden-durable-bound".to_string(),
10314            has_runtime_store: true,
10315            durability_policy: DurabilityPolicy::SyncWriteThrough,
10316            bridge: None,
10317            default_timeout: None,
10318        }));
10319        let identity = AgentIdentity::parse("review:singleton")?;
10320        identity_runtime
10321            .register(
10322                DurableAgentSpec {
10323                    identity: identity.clone(),
10324                    profile: ProfileName::from("worker"),
10325                    addressability: AgentAddressability::Addressable,
10326                    display_name: None,
10327                    labels: BTreeMap::new(),
10328                    context: None,
10329                    additional_instructions: Vec::new(),
10330                    initial_message: None,
10331                    runtime_mode_override: None,
10332                    backend: None,
10333                    binding: None,
10334                },
10335                IdentityLifecycleState::Active,
10336                Some(ContinuityRecord {
10337                    identity: identity.clone(),
10338                    agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10339                    session_id: meerkat_core::types::SessionId::new(),
10340                    generation: ContinuityGeneration::new(0),
10341                    checkpoint_version: CheckpointVersion::new(0),
10342                }),
10343                Some(LeaseGrant {
10344                    identity: identity.clone(),
10345                    fencing_token: FencingToken::new(9),
10346                    ttl: Duration::from_mins(1),
10347                }),
10348            )
10349            .await;
10350
10351        let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10352            &runtime,
10353            None,
10354            None,
10355            None,
10356            Some(ConsoleEventStore::new()),
10357            None,
10358            Some(identity_runtime.clone()),
10359            None,
10360            None,
10361            &HideOnlyMemberPolicy("rt:review:singleton:0"),
10362            rpc_request("mobkit/reset_all"),
10363            true,
10364            false,
10365            None,
10366            None,
10367            None,
10368            None,
10369        ))
10370        .await;
10371        assert_eq!(
10372            response["error"],
10373            Value::Null,
10374            "hidden durable bound member should be outside reset_all target set: {response:#?}"
10375        );
10376        assert_eq!(
10377            identity_runtime.status(&identity).await?.state,
10378            IdentityLifecycleState::Active
10379        );
10380
10381        let _ = runtime.handle().stop().await;
10382        Ok(())
10383    }
10384
10385    #[tokio::test]
10386    async fn identity_lifecycle_cleanup_skips_hidden_projected_duplicates()
10387    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10388        let (_temp_dir, runtime) =
10389            build_empty_console_test_runtime("console-hidden-stale-duplicate-cleanup").await?;
10390        for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
10391            runtime
10392                .handle()
10393                .spawn_spec(
10394                    SpawnMemberSpec::from_wire(
10395                        "worker".to_string(),
10396                        // meerkat 0.7: roster ids are comms-safe encodings of
10397                        // the public alias (MemberCommsName rejects ":").
10398                        crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
10399                        Some("You are a Review Agent candidate.".into()),
10400                        None,
10401                        None,
10402                    )
10403                    .with_labels(BTreeMap::from([(
10404                        "agent_identity".to_string(),
10405                        "review:singleton".to_string(),
10406                    )])),
10407                )
10408                .await?;
10409        }
10410
10411        let store = Arc::new(LocalContinuityStore::in_memory()?);
10412        let lease_provider = Arc::new(LocalLeaseProvider::new());
10413        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10414            continuity_store: store.clone(),
10415            lease_provider: lease_provider.clone(),
10416            runtime_instance_id: "console-hidden-stale-duplicate-cleanup".to_string(),
10417            has_runtime_store: true,
10418            durability_policy: DurabilityPolicy::SyncWriteThrough,
10419            bridge: None,
10420            default_timeout: None,
10421        }));
10422        let identity = AgentIdentity::parse("review:singleton")?;
10423        let record = ContinuityRecord {
10424            identity: identity.clone(),
10425            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10426            session_id: runtime
10427                .handle()
10428                .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
10429                    "rt:review:singleton:0",
10430                ))
10431                .await
10432                .unwrap_or_else(meerkat_core::types::SessionId::new),
10433            generation: ContinuityGeneration::new(0),
10434            checkpoint_version: CheckpointVersion::new(0),
10435        };
10436        let grants = lease_provider
10437            .acquire_leases(
10438                std::slice::from_ref(&identity),
10439                "console-hidden-stale-duplicate-cleanup",
10440            )
10441            .await?;
10442        let grant = match grants.get(&identity).cloned() {
10443            Some(LeaseAcquireResult::Acquired(grant)) => grant,
10444            other => return Err(format!("expected acquired lease, got {other:?}").into()),
10445        };
10446        store
10447            .upsert_continuity_record(&record, grant.fencing_token)
10448            .await?;
10449        identity_runtime
10450            .register(
10451                DurableAgentSpec {
10452                    identity: identity.clone(),
10453                    profile: ProfileName::from("worker"),
10454                    addressability: AgentAddressability::Addressable,
10455                    display_name: None,
10456                    labels: BTreeMap::new(),
10457                    context: None,
10458                    additional_instructions: Vec::new(),
10459                    initial_message: None,
10460                    runtime_mode_override: None,
10461                    backend: None,
10462                    binding: None,
10463                },
10464                IdentityLifecycleState::Active,
10465                Some(record),
10466                Some(grant),
10467            )
10468            .await;
10469
10470        let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10471            &runtime,
10472            None,
10473            None,
10474            None,
10475            Some(ConsoleEventStore::new()),
10476            None,
10477            Some(identity_runtime),
10478            None,
10479            None,
10480            &HideOnlyMemberPolicy("rt:review:singleton:1"),
10481            rpc_request_with_params("mobkit/retire", json!({ "identity": "review:singleton" })),
10482            true,
10483            false,
10484            None,
10485            None,
10486            None,
10487            None,
10488        ))
10489        .await;
10490        assert_eq!(
10491            response["error"],
10492            Value::Null,
10493            "visible durable retire should succeed without touching hidden duplicate: {response:#?}"
10494        );
10495        assert!(
10496            runtime
10497                .handle()
10498                .get_member(&crate::member_comms_id::mob_member_id(
10499                    "rt:review:singleton:1"
10500                ))
10501                .await
10502                .ok()
10503                .flatten()
10504                .is_some(),
10505            "post-mutation stale cleanup must not retire member-hidden projected duplicates"
10506        );
10507
10508        let _ = runtime.handle().stop().await;
10509        Ok(())
10510    }
10511
10512    #[tokio::test]
10513    async fn console_runtime_capabilities_advertise_identity_controls_when_identity_runtime_exists()
10514    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10515        let (_temp_dir, runtime) =
10516            build_empty_console_test_runtime("console-identity-capabilities").await?;
10517        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10518            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10519            lease_provider: Arc::new(LocalLeaseProvider::new()),
10520            runtime_instance_id: "console-identity-capabilities".to_string(),
10521            has_runtime_store: true,
10522            durability_policy: DurabilityPolicy::SyncWriteThrough,
10523            bridge: None,
10524            default_timeout: None,
10525        }));
10526
10527        let response = Box::pin(handle_console_runtime_rpc(
10528            &runtime,
10529            None,
10530            None,
10531            None,
10532            None,
10533            None,
10534            Some(identity_runtime.clone()),
10535            None,
10536            None,
10537            rpc_request("mobkit/capabilities"),
10538            true,
10539        ))
10540        .await;
10541
10542        assert_eq!(response["error"], Value::Null, "{response:#?}");
10543        let methods = response["result"]["methods"]
10544            .as_array()
10545            .ok_or("capabilities methods should be an array")?;
10546        for method in [
10547            "mobkit/status_identity",
10548            "mobkit/inspect_identity",
10549            "mobkit/respawn",
10550            "mobkit/reset",
10551            "mobkit/delete_identity",
10552        ] {
10553            assert!(
10554                methods.iter().any(|candidate| candidate == method),
10555                "identity runtime capabilities should advertise {method}: {methods:#?}"
10556            );
10557        }
10558        for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
10559            assert!(
10560                !methods.iter().any(|candidate| candidate == method),
10561                "console runtime capabilities must not advertise flow-editor method {method}: {methods:#?}"
10562            );
10563        }
10564
10565        let mobpack_response = Box::pin(handle_console_runtime_rpc(
10566            &runtime,
10567            None,
10568            None,
10569            None,
10570            None,
10571            None,
10572            Some(identity_runtime),
10573            None,
10574            None,
10575            rpc_request("mobkit/mobpacks/schema"),
10576            true,
10577        ))
10578        .await;
10579        assert_eq!(
10580            mobpack_response["error"]["code"],
10581            json!(-32601),
10582            "console runtime RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
10583        );
10584
10585        let _ = runtime.handle().stop().await;
10586        Ok(())
10587    }
10588
10589    #[tokio::test]
10590    async fn console_runtime_capabilities_advertise_agent_memory_with_read_only_gating()
10591    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10592        let (_temp_dir, runtime) =
10593            build_empty_console_test_runtime("console-agent-memory-capabilities").await?;
10594        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10595            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10596            lease_provider: Arc::new(LocalLeaseProvider::new()),
10597            runtime_instance_id: "console-agent-memory-capabilities".to_string(),
10598            has_runtime_store: true,
10599            durability_policy: DurabilityPolicy::SyncWriteThrough,
10600            bridge: None,
10601            default_timeout: None,
10602        }));
10603        let memory_dir = tempfile::tempdir()?;
10604        let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
10605        identity_runtime
10606            .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
10607                memory_store,
10608                AgentMemoryConfig::default(),
10609            )))
10610            .await;
10611
10612        let response = Box::pin(handle_console_runtime_rpc(
10613            &runtime,
10614            None,
10615            None,
10616            None,
10617            None,
10618            None,
10619            Some(identity_runtime.clone()),
10620            None,
10621            None,
10622            rpc_request("mobkit/capabilities"),
10623            true,
10624        ))
10625        .await;
10626        assert_eq!(response["error"], Value::Null, "{response:#?}");
10627        let methods = response["result"]["methods"]
10628            .as_array()
10629            .ok_or("capabilities methods should be an array")?;
10630        for method in [
10631            "mobkit/agent_memory/recall",
10632            "mobkit/agent_memory/remember",
10633            "mobkit/agent_memory/forget",
10634        ] {
10635            assert!(
10636                methods.iter().any(|candidate| candidate == method),
10637                "authenticated capabilities should advertise {method}: {methods:#?}"
10638            );
10639        }
10640
10641        let read_only_response = Box::pin(handle_console_runtime_rpc(
10642            &runtime,
10643            None,
10644            None,
10645            None,
10646            None,
10647            None,
10648            Some(identity_runtime),
10649            None,
10650            None,
10651            rpc_request("mobkit/capabilities"),
10652            false,
10653        ))
10654        .await;
10655        assert_eq!(
10656            read_only_response["error"],
10657            Value::Null,
10658            "{read_only_response:#?}"
10659        );
10660        let read_only_methods = read_only_response["result"]["methods"]
10661            .as_array()
10662            .ok_or("read-only capabilities methods should be an array")?;
10663        assert!(
10664            read_only_methods
10665                .iter()
10666                .any(|candidate| candidate == "mobkit/agent_memory/recall"),
10667            "read-only capabilities should keep recall: {read_only_methods:#?}"
10668        );
10669        for method in ["mobkit/agent_memory/remember", "mobkit/agent_memory/forget"] {
10670            assert!(
10671                !read_only_methods
10672                    .iter()
10673                    .any(|candidate| candidate == method),
10674                "read-only capabilities must omit {method}: {read_only_methods:#?}"
10675            );
10676        }
10677
10678        let _ = runtime.handle().stop().await;
10679        Ok(())
10680    }
10681
10682    #[tokio::test]
10683    async fn console_runtime_agent_memory_methods_round_trip()
10684    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10685        let (_temp_dir, runtime) =
10686            build_empty_console_test_runtime("console-agent-memory-round-trip").await?;
10687        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10688            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10689            lease_provider: Arc::new(LocalLeaseProvider::new()),
10690            runtime_instance_id: "console-agent-memory-round-trip".to_string(),
10691            has_runtime_store: true,
10692            durability_policy: DurabilityPolicy::SyncWriteThrough,
10693            bridge: None,
10694            default_timeout: None,
10695        }));
10696        let identity = AgentIdentity::parse("identity:memory-console")?;
10697        identity_runtime
10698            .register(
10699                DurableAgentSpec {
10700                    identity: identity.clone(),
10701                    profile: ProfileName::from("default"),
10702                    addressability: AgentAddressability::Addressable,
10703                    display_name: None,
10704                    labels: BTreeMap::new(),
10705                    context: None,
10706                    additional_instructions: Vec::new(),
10707                    initial_message: None,
10708                    runtime_mode_override: None,
10709                    backend: None,
10710                    binding: None,
10711                },
10712                IdentityLifecycleState::Active,
10713                None,
10714                None,
10715            )
10716            .await;
10717        let memory_dir = tempfile::tempdir()?;
10718        let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
10719        identity_runtime
10720            .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
10721                memory_store,
10722                AgentMemoryConfig::default(),
10723            )))
10724            .await;
10725
10726        let remember = Box::pin(handle_console_runtime_rpc(
10727            &runtime,
10728            None,
10729            None,
10730            None,
10731            None,
10732            None,
10733            Some(identity_runtime.clone()),
10734            None,
10735            None,
10736            rpc_request_with_params(
10737                "mobkit/agent_memory/remember",
10738                json!({
10739                    "identity": "identity:memory-console",
10740                    "title": "Console memory token",
10741                    "body": "The console memory token is CONSOLE-MEM-17.",
10742                    "tags": ["console", "memory"]
10743                }),
10744            ),
10745            true,
10746        ))
10747        .await;
10748        assert_eq!(remember["error"], Value::Null, "{remember:#?}");
10749        let memory_id = remember["result"]["memory_id"]
10750            .as_str()
10751            .ok_or("remember result should include memory_id")?
10752            .to_string();
10753
10754        let recall = Box::pin(handle_console_runtime_rpc(
10755            &runtime,
10756            None,
10757            None,
10758            None,
10759            None,
10760            None,
10761            Some(identity_runtime.clone()),
10762            None,
10763            None,
10764            rpc_request_with_params(
10765                "mobkit/agent_memory/recall",
10766                json!({
10767                    "identity": "identity:memory-console",
10768                    "selection": "contextual",
10769                    "query_text": "Where is CONSOLE-MEM-17?",
10770                    "query_terms": ["CONSOLE-MEM-17"]
10771                }),
10772            ),
10773            false,
10774        ))
10775        .await;
10776        assert_eq!(recall["error"], Value::Null, "{recall:#?}");
10777        assert_eq!(
10778            recall["result"]["records"]
10779                .as_array()
10780                .map(std::vec::Vec::len),
10781            Some(1)
10782        );
10783        assert_eq!(
10784            recall["result"]["records"][0]["memory_id"],
10785            json!(memory_id)
10786        );
10787
10788        let read_only_remember = Box::pin(handle_console_runtime_rpc(
10789            &runtime,
10790            None,
10791            None,
10792            None,
10793            None,
10794            None,
10795            Some(identity_runtime.clone()),
10796            None,
10797            None,
10798            rpc_request_with_params(
10799                "mobkit/agent_memory/remember",
10800                json!({
10801                    "identity": "identity:memory-console",
10802                    "title": "Denied",
10803                    "body": "Denied"
10804                }),
10805            ),
10806            false,
10807        ))
10808        .await;
10809        assert_eq!(
10810            read_only_remember["error"]["data"]["kind"],
10811            json!("read_only"),
10812            "{read_only_remember:#?}"
10813        );
10814
10815        let access_controller = AccessController::new(crate::access::AccessControlConfig {
10816            enabled: true,
10817            admins: vec!["admin@example.test".to_string()],
10818            ..crate::access::AccessControlConfig::default()
10819        })?;
10820        let denied_view = access_controller.view_for_subject(Some("viewer@example.test"));
10821        let denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
10822            &runtime,
10823            None,
10824            None,
10825            None,
10826            None,
10827            None,
10828            Some(identity_runtime.clone()),
10829            None,
10830            None,
10831            &AllowAllConsoleVisibilityPolicy,
10832            rpc_request_with_params(
10833                "mobkit/agent_memory/recall",
10834                json!({
10835                    "identity": "identity:memory-console",
10836                    "selection": "always"
10837                }),
10838            ),
10839            true,
10840            false,
10841            None,
10842            Some(&access_controller),
10843            Some(&denied_view),
10844            None,
10845        ))
10846        .await;
10847        assert_eq!(
10848            denied_recall["error"]["data"]["kind"],
10849            json!("access_denied"),
10850            "{denied_recall:#?}"
10851        );
10852        // §10.3: recall requires agent.memory.read AND agent.view; the read
10853        // action is checked (and reported) first.
10854        assert_eq!(
10855            denied_recall["error"]["data"]["action"],
10856            json!(ACTION_AGENT_MEMORY_READ),
10857            "{denied_recall:#?}"
10858        );
10859
10860        let send_only_controller = AccessController::new(crate::access::AccessControlConfig {
10861            enabled: true,
10862            admins: vec!["admin@example.test".to_string()],
10863            rules: vec![crate::access::AccessRule {
10864                id: "send-only-memory-console".to_string(),
10865                subjects: vec!["sender@example.test".to_string()],
10866                actions: vec![ACTION_AGENT_SEND.to_string()],
10867                agents: vec!["identity:memory-console".to_string()],
10868                ..crate::access::AccessRule::default()
10869            }],
10870            ..crate::access::AccessControlConfig::default()
10871        })?;
10872        let send_only_view = send_only_controller.view_for_subject(Some("sender@example.test"));
10873        let send_only_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
10874            &runtime,
10875            None,
10876            None,
10877            None,
10878            None,
10879            None,
10880            Some(identity_runtime.clone()),
10881            None,
10882            None,
10883            &AllowAllConsoleVisibilityPolicy,
10884            rpc_request_with_params(
10885                "mobkit/agent_memory/remember",
10886                json!({
10887                    "identity": "identity:memory-console",
10888                    "title": "Send-only denied",
10889                    "body": "Send-only users must not persist durable memory."
10890                }),
10891            ),
10892            true,
10893            false,
10894            None,
10895            Some(&send_only_controller),
10896            Some(&send_only_view),
10897            None,
10898        ))
10899        .await;
10900        assert_eq!(
10901            send_only_remember["error"]["data"]["kind"],
10902            json!("access_denied"),
10903            "{send_only_remember:#?}"
10904        );
10905        assert_eq!(
10906            send_only_remember["error"]["data"]["action"],
10907            json!(ACTION_AGENT_MEMORY_WRITE),
10908            "{send_only_remember:#?}"
10909        );
10910
10911        let send_only_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
10912            &runtime,
10913            None,
10914            None,
10915            None,
10916            None,
10917            None,
10918            Some(identity_runtime.clone()),
10919            None,
10920            None,
10921            &AllowAllConsoleVisibilityPolicy,
10922            rpc_request_with_params(
10923                "mobkit/agent_memory/forget",
10924                json!({
10925                    "identity": "identity:memory-console",
10926                    "memory_id": memory_id.clone()
10927                }),
10928            ),
10929            true,
10930            false,
10931            None,
10932            Some(&send_only_controller),
10933            Some(&send_only_view),
10934            None,
10935        ))
10936        .await;
10937        assert_eq!(
10938            send_only_forget["error"]["data"]["kind"],
10939            json!("access_denied"),
10940            "{send_only_forget:#?}"
10941        );
10942        assert_eq!(
10943            send_only_forget["error"]["data"]["action"],
10944            json!(ACTION_AGENT_MEMORY_DELETE),
10945            "{send_only_forget:#?}"
10946        );
10947
10948        let wildcard_allow_exact_deny_controller =
10949            AccessController::new(crate::access::AccessControlConfig {
10950                enabled: true,
10951                admins: vec!["admin@example.test".to_string()],
10952                rules: vec![
10953                    crate::access::AccessRule {
10954                        id: "deny-canonical-memory-console".to_string(),
10955                        effect: crate::access::AccessEffect::Deny,
10956                        subjects: vec!["wildcard@example.test".to_string()],
10957                        actions: vec![
10958                            ACTION_AGENT_VIEW.to_string(),
10959                            ACTION_AGENT_MEMORY_WRITE.to_string(),
10960                            ACTION_AGENT_MEMORY_DELETE.to_string(),
10961                        ],
10962                        agents: vec!["identity:memory-console".to_string()],
10963                        ..crate::access::AccessRule::default()
10964                    },
10965                    crate::access::AccessRule {
10966                        id: "allow-wildcard-memory-console".to_string(),
10967                        subjects: vec!["wildcard@example.test".to_string()],
10968                        actions: vec![
10969                            ACTION_AGENT_VIEW.to_string(),
10970                            ACTION_AGENT_MEMORY_WRITE.to_string(),
10971                            ACTION_AGENT_MEMORY_DELETE.to_string(),
10972                        ],
10973                        agents: vec!["*".to_string()],
10974                        ..crate::access::AccessRule::default()
10975                    },
10976                ],
10977                ..crate::access::AccessControlConfig::default()
10978            })?;
10979        let wildcard_allow_exact_deny_view =
10980            wildcard_allow_exact_deny_controller.view_for_subject(Some("wildcard@example.test"));
10981
10982        let whitespace_denied_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
10983            &runtime,
10984            None,
10985            None,
10986            None,
10987            None,
10988            None,
10989            Some(identity_runtime.clone()),
10990            None,
10991            None,
10992            &AllowAllConsoleVisibilityPolicy,
10993            rpc_request_with_params(
10994                "mobkit/agent_memory/remember",
10995                json!({
10996                    "identity": " identity:memory-console ",
10997                    "title": "Whitespace denied",
10998                    "body": "Whitespace-wrapped identities must be authorized canonically."
10999                }),
11000            ),
11001            true,
11002            false,
11003            None,
11004            Some(&wildcard_allow_exact_deny_controller),
11005            Some(&wildcard_allow_exact_deny_view),
11006            None,
11007        ))
11008        .await;
11009        assert_eq!(
11010            whitespace_denied_remember["error"]["data"]["kind"],
11011            json!("access_denied"),
11012            "{whitespace_denied_remember:#?}"
11013        );
11014        assert_eq!(
11015            whitespace_denied_remember["error"]["data"]["action"],
11016            json!(ACTION_AGENT_MEMORY_WRITE),
11017            "{whitespace_denied_remember:#?}"
11018        );
11019
11020        let whitespace_denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
11021            &runtime,
11022            None,
11023            None,
11024            None,
11025            None,
11026            None,
11027            Some(identity_runtime.clone()),
11028            None,
11029            None,
11030            &AllowAllConsoleVisibilityPolicy,
11031            rpc_request_with_params(
11032                "mobkit/agent_memory/recall",
11033                json!({
11034                    "identity": " identity:memory-console ",
11035                    "selection": "always"
11036                }),
11037            ),
11038            true,
11039            false,
11040            None,
11041            Some(&wildcard_allow_exact_deny_controller),
11042            Some(&wildcard_allow_exact_deny_view),
11043            None,
11044        ))
11045        .await;
11046        assert_eq!(
11047            whitespace_denied_recall["error"]["data"]["kind"],
11048            json!("access_denied"),
11049            "{whitespace_denied_recall:#?}"
11050        );
11051        // The exact-identity deny matches agent.memory.read through its
11052        // `agent.*` pattern; read is checked first (§10.3 mapping).
11053        assert_eq!(
11054            whitespace_denied_recall["error"]["data"]["action"],
11055            json!(ACTION_AGENT_MEMORY_READ),
11056            "{whitespace_denied_recall:#?}"
11057        );
11058
11059        let whitespace_denied_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
11060            &runtime,
11061            None,
11062            None,
11063            None,
11064            None,
11065            None,
11066            Some(identity_runtime.clone()),
11067            None,
11068            None,
11069            &AllowAllConsoleVisibilityPolicy,
11070            rpc_request_with_params(
11071                "mobkit/agent_memory/forget",
11072                json!({
11073                    "identity": " identity:memory-console ",
11074                    "memory_id": memory_id.clone()
11075                }),
11076            ),
11077            true,
11078            false,
11079            None,
11080            Some(&wildcard_allow_exact_deny_controller),
11081            Some(&wildcard_allow_exact_deny_view),
11082            None,
11083        ))
11084        .await;
11085        assert_eq!(
11086            whitespace_denied_forget["error"]["data"]["kind"],
11087            json!("access_denied"),
11088            "{whitespace_denied_forget:#?}"
11089        );
11090        assert_eq!(
11091            whitespace_denied_forget["error"]["data"]["action"],
11092            json!(ACTION_AGENT_MEMORY_DELETE),
11093            "{whitespace_denied_forget:#?}"
11094        );
11095
11096        let forget = Box::pin(handle_console_runtime_rpc(
11097            &runtime,
11098            None,
11099            None,
11100            None,
11101            None,
11102            None,
11103            Some(identity_runtime.clone()),
11104            None,
11105            None,
11106            rpc_request_with_params(
11107                "mobkit/agent_memory/forget",
11108                json!({
11109                    "identity": "identity:memory-console",
11110                    "memory_id": memory_id
11111                }),
11112            ),
11113            true,
11114        ))
11115        .await;
11116        assert_eq!(forget["error"], Value::Null, "{forget:#?}");
11117        assert_eq!(forget["result"]["deleted"], json!(true));
11118
11119        let after_forget = Box::pin(handle_console_runtime_rpc(
11120            &runtime,
11121            None,
11122            None,
11123            None,
11124            None,
11125            None,
11126            Some(identity_runtime),
11127            None,
11128            None,
11129            rpc_request_with_params(
11130                "mobkit/agent_memory/recall",
11131                json!({
11132                    "identity": "identity:memory-console",
11133                    "selection": "always"
11134                }),
11135            ),
11136            false,
11137        ))
11138        .await;
11139        assert_eq!(after_forget["error"], Value::Null, "{after_forget:#?}");
11140        assert_eq!(
11141            after_forget["result"]["records"]
11142                .as_array()
11143                .map(std::vec::Vec::len),
11144            Some(0)
11145        );
11146
11147        let _ = runtime.handle().stop().await;
11148        Ok(())
11149    }
11150
11151    #[tokio::test]
11152    async fn console_runtime_capabilities_advertise_respawn_and_reset_without_identity_runtime()
11153    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11154        let (_temp_dir, runtime) =
11155            build_empty_console_test_runtime("console-plain-lifecycle-capabilities").await?;
11156
11157        // Mutating caller, no identity runtime: respawn/reset dispatch via the
11158        // live-member fallback, so they must be advertised (the console gates
11159        // its Respawn/Reset buttons on this list).
11160        let response = Box::pin(handle_console_runtime_rpc(
11161            &runtime,
11162            None,
11163            None,
11164            None,
11165            None,
11166            None,
11167            None,
11168            None,
11169            None,
11170            rpc_request("mobkit/capabilities"),
11171            true,
11172        ))
11173        .await;
11174        assert_eq!(response["error"], Value::Null, "{response:#?}");
11175        let methods = response["result"]["methods"]
11176            .as_array()
11177            .ok_or("capabilities methods should be an array")?;
11178        for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
11179            assert!(
11180                methods.iter().any(|candidate| candidate == method),
11181                "plain runtime capabilities should advertise {method}: {methods:#?}"
11182            );
11183        }
11184        assert!(
11185            !methods
11186                .iter()
11187                .any(|candidate| candidate == "mobkit/delete_identity"),
11188            "delete_identity requires an identity runtime: {methods:#?}"
11189        );
11190
11191        // Unauthenticated callers keep the read-only projection: no lifecycle
11192        // mutations advertised.
11193        let read_only_response = Box::pin(handle_console_runtime_rpc(
11194            &runtime,
11195            None,
11196            None,
11197            None,
11198            None,
11199            None,
11200            None,
11201            None,
11202            None,
11203            rpc_request("mobkit/capabilities"),
11204            false,
11205        ))
11206        .await;
11207        assert_eq!(
11208            read_only_response["error"],
11209            Value::Null,
11210            "{read_only_response:#?}"
11211        );
11212        let read_only_methods = read_only_response["result"]["methods"]
11213            .as_array()
11214            .ok_or("capabilities methods should be an array")?;
11215        for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
11216            assert!(
11217                !read_only_methods
11218                    .iter()
11219                    .any(|candidate| candidate == method),
11220                "unauthenticated capabilities must omit {method}: {read_only_methods:#?}"
11221            );
11222        }
11223
11224        let _ = runtime.handle().stop().await;
11225        Ok(())
11226    }
11227
11228    #[tokio::test]
11229    async fn console_aggregator_rpc_does_not_expose_flow_editor_authoring_methods() {
11230        let response = Box::pin(handle_console_aggregator_rpc(
11231            None,
11232            rpc_request("mobkit/capabilities"),
11233            true,
11234            false,
11235            None,
11236            None,
11237        ))
11238        .await;
11239
11240        assert_eq!(response["error"], Value::Null, "{response:#?}");
11241        let methods = response["result"]["methods"]
11242            .as_array()
11243            .expect("capabilities methods should be an array");
11244        for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
11245            assert!(
11246                !methods.iter().any(|candidate| candidate == method),
11247                "console aggregator capabilities must not advertise flow-editor method {method}: {methods:#?}"
11248            );
11249        }
11250
11251        let mobpack_response = Box::pin(handle_console_aggregator_rpc(
11252            None,
11253            rpc_request("mobkit/mobpacks/schema"),
11254            true,
11255            false,
11256            None,
11257            None,
11258        ))
11259        .await;
11260        assert_eq!(
11261            mobpack_response["error"]["code"],
11262            json!(-32601),
11263            "console aggregator RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
11264        );
11265    }
11266
11267    #[tokio::test]
11268    async fn console_runtime_identity_reads_reject_stale_runtime_aliases()
11269    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11270        let (_temp_dir, runtime) =
11271            build_empty_console_test_runtime("console-identity-stale-read-alias").await?;
11272        let mut labels = BTreeMap::new();
11273        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11274        runtime
11275            .handle()
11276            .spawn_spec(
11277                SpawnMemberSpec::from_wire(
11278                    "worker".to_string(),
11279                    // meerkat 0.7: roster ids are comms-safe encodings of the
11280                    // public alias (MemberCommsName rejects ":").
11281                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11282                    Some("You are the stale Review Agent.".into()),
11283                    None,
11284                    None,
11285                )
11286                .with_labels(labels),
11287            )
11288            .await?;
11289
11290        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11291            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11292            lease_provider: Arc::new(LocalLeaseProvider::new()),
11293            runtime_instance_id: "console-identity-stale-read-alias".to_string(),
11294            has_runtime_store: true,
11295            durability_policy: DurabilityPolicy::SyncWriteThrough,
11296            bridge: None,
11297            default_timeout: None,
11298        }));
11299        let identity = AgentIdentity::parse("review:singleton")?;
11300        let record = ContinuityRecord {
11301            identity: identity.clone(),
11302            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:1")?,
11303            session_id: meerkat_core::types::SessionId::new(),
11304            generation: ContinuityGeneration::new(1),
11305            checkpoint_version: CheckpointVersion::new(0),
11306        };
11307        identity_runtime
11308            .register(
11309                DurableAgentSpec {
11310                    identity: identity.clone(),
11311                    profile: ProfileName::from("worker"),
11312                    addressability: AgentAddressability::Addressable,
11313                    display_name: None,
11314                    labels: BTreeMap::new(),
11315                    context: None,
11316                    additional_instructions: Vec::new(),
11317                    initial_message: None,
11318                    runtime_mode_override: None,
11319                    backend: None,
11320                    binding: None,
11321                },
11322                IdentityLifecycleState::Active,
11323                Some(record),
11324                Some(LeaseGrant {
11325                    identity,
11326                    fencing_token: FencingToken::new(7),
11327                    ttl: Duration::from_mins(1),
11328                }),
11329            )
11330            .await;
11331
11332        for requested_identity in ["rt:review:singleton:0", "review:singleton"] {
11333            for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
11334                let response = Box::pin(handle_console_runtime_rpc(
11335                    &runtime,
11336                    None,
11337                    None,
11338                    None,
11339                    None,
11340                    None,
11341                    Some(identity_runtime.clone()),
11342                    None,
11343                    None,
11344                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
11345                    true,
11346                ))
11347                .await;
11348                assert_ne!(
11349                    response["error"],
11350                    Value::Null,
11351                    "{method} must reject stale alias for {requested_identity}"
11352                );
11353                let message = response["error"]["message"].as_str().unwrap_or_default();
11354                assert!(
11355                    message.contains(
11356                        "identity runtime binding for review:singleton points at rt:review:singleton:1"
11357                    ),
11358                    "unexpected stale-alias message for {method}/{requested_identity}: {message}"
11359                );
11360                assert_eq!(
11361                    response["error"]["data"]["kind"],
11362                    json!("stale_identity_runtime_binding")
11363                );
11364                assert_eq!(
11365                    response["error"]["data"]["registered_runtime_member_id"],
11366                    json!("rt:review:singleton:1")
11367                );
11368                assert_eq!(
11369                    response["error"]["data"]["live_runtime_member_id"],
11370                    json!("rt:review:singleton:0")
11371                );
11372            }
11373        }
11374
11375        let list_response = Box::pin(handle_console_runtime_rpc(
11376            &runtime,
11377            None,
11378            None,
11379            None,
11380            None,
11381            None,
11382            Some(identity_runtime.clone()),
11383            None,
11384            None,
11385            rpc_request("mobkit/list_members"),
11386            true,
11387        ))
11388        .await;
11389        let listed = list_response["result"].as_array().expect("list members");
11390        assert!(
11391            listed
11392                .iter()
11393                .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
11394            "list_members must filter stale runtime aliases: {list_response:#?}"
11395        );
11396
11397        let find_response = Box::pin(handle_console_runtime_rpc(
11398            &runtime,
11399            None,
11400            None,
11401            None,
11402            None,
11403            None,
11404            Some(identity_runtime.clone()),
11405            None,
11406            None,
11407            rpc_request_with_params(
11408                "mobkit/find_members",
11409                json!({ "label_key": "agent_identity", "label_value": "review:singleton" }),
11410            ),
11411            true,
11412        ))
11413        .await;
11414        let found = find_response["result"].as_array().expect("find members");
11415        assert!(
11416            found
11417                .iter()
11418                .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
11419            "find_members must filter stale runtime aliases: {find_response:#?}"
11420        );
11421
11422        for method in [
11423            "mobkit/get_member",
11424            "mobkit/member_status",
11425            "mobkit/identity/resolved_tools",
11426            "mobkit/retire_member",
11427            "mobkit/respawn_member",
11428            "mobkit/force_cancel_member",
11429        ] {
11430            let param_name = if method == "mobkit/identity/resolved_tools" {
11431                "identity"
11432            } else {
11433                "member_id"
11434            };
11435            let response = Box::pin(handle_console_runtime_rpc(
11436                &runtime,
11437                None,
11438                None,
11439                None,
11440                None,
11441                None,
11442                Some(identity_runtime.clone()),
11443                None,
11444                None,
11445                rpc_request_with_params(method, json!({ param_name: "rt:review:singleton:0" })),
11446                true,
11447            ))
11448            .await;
11449            assert_ne!(
11450                response["error"],
11451                Value::Null,
11452                "{method} must reject stale runtime alias"
11453            );
11454            assert_eq!(
11455                response["error"]["data"]["kind"],
11456                json!("stale_identity_runtime_binding")
11457            );
11458            assert_eq!(
11459                response["error"]["data"]["live_runtime_member_id"],
11460                json!("rt:review:singleton:0")
11461            );
11462        }
11463
11464        let (_temp_dir_without_stale, runtime_without_stale) =
11465            build_empty_console_test_runtime("console-identity-no-live-stale-alias").await?;
11466
11467        for method in [
11468            "mobkit/status_identity",
11469            "mobkit/inspect_identity",
11470            "mobkit/retire",
11471            "mobkit/respawn",
11472            "mobkit/reset",
11473        ] {
11474            let response = Box::pin(handle_console_runtime_rpc(
11475                &runtime_without_stale,
11476                None,
11477                None,
11478                None,
11479                Some(ConsoleEventStore::new()),
11480                None,
11481                Some(identity_runtime.clone()),
11482                None,
11483                None,
11484                rpc_request_with_params(method, json!({ "identity": "rt:review:singleton:0" })),
11485                true,
11486            ))
11487            .await;
11488            assert_ne!(
11489                response["error"],
11490                Value::Null,
11491                "{method} must reject stale synthetic runtime alias"
11492            );
11493            assert!(
11494                response["error"]["message"]
11495                    .as_str()
11496                    .unwrap_or_default()
11497                    .contains("identity not found: rt:review:singleton:0"),
11498                "unexpected no-live stale-alias response for {method}: {response:#?}"
11499            );
11500        }
11501        let _ = runtime_without_stale.handle().stop().await;
11502
11503        let _ = runtime.handle().stop().await;
11504        Ok(())
11505    }
11506
11507    #[tokio::test]
11508    async fn aggregator_live_snapshot_projects_identity_first_topology_peers()
11509    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11510        let (_temp_dir, mob_runtime) =
11511            build_empty_console_test_runtime("identity-topology-snapshot-test").await?;
11512        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11513            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11514            lease_provider: Arc::new(LocalLeaseProvider::new()),
11515            runtime_instance_id: "console-topology-snapshot-test".to_string(),
11516            has_runtime_store: true,
11517            durability_policy: DurabilityPolicy::SyncWriteThrough,
11518            bridge: None,
11519            default_timeout: None,
11520        }));
11521
11522        for name in ["agent:alpha", "agent:beta"] {
11523            let identity = AgentIdentity::parse(name)?;
11524            let record = ContinuityRecord {
11525                identity: identity.clone(),
11526                agent_runtime_id: AgentRuntimeId::parse(&format!("rt:{name}:0"))?,
11527                session_id: meerkat_core::types::SessionId::new(),
11528                generation: ContinuityGeneration::new(0),
11529                checkpoint_version: CheckpointVersion::new(0),
11530            };
11531            identity_runtime
11532                .register(
11533                    DurableAgentSpec {
11534                        identity: identity.clone(),
11535                        profile: ProfileName::from("default"),
11536                        addressability: AgentAddressability::Addressable,
11537                        display_name: None,
11538                        labels: BTreeMap::new(),
11539                        context: None,
11540                        additional_instructions: Vec::new(),
11541                        initial_message: None,
11542                        runtime_mode_override: None,
11543                        backend: None,
11544                        binding: None,
11545                    },
11546                    IdentityLifecycleState::Active,
11547                    Some(record),
11548                    Some(LeaseGrant {
11549                        identity,
11550                        fencing_token: FencingToken::new(7),
11551                        ttl: Duration::from_mins(1),
11552                    }),
11553                )
11554                .await;
11555        }
11556        identity_runtime
11557            .set_desired_peer_edges(vec![ManagedPeerEdge::new(
11558                AgentIdentity::parse("agent:alpha")?,
11559                AgentIdentity::parse("agent:beta")?,
11560            )?])
11561            .await;
11562
11563        let aggregator = MobKitConsoleAggregator::in_memory();
11564        aggregator.register_runtime_handles_with_policy(
11565            "identity-first",
11566            "",
11567            mob_runtime.clone(),
11568            Some(identity_runtime),
11569            ConsoleEventStore::new(),
11570            Arc::new(AllowAllConsoleVisibilityPolicy),
11571        );
11572
11573        let snapshot = build_aggregator_live_snapshot(&aggregator, &[]).await?;
11574        let alpha = snapshot
11575            .members
11576            .iter()
11577            .find(|member| member.agent_identity == "agent:alpha")
11578            .ok_or("agent:alpha missing from live snapshot")?;
11579        assert_eq!(alpha.wired_to, vec!["agent:beta".to_string()]);
11580
11581        let _ = mob_runtime.handle().stop().await;
11582        Ok(())
11583    }
11584
11585    #[tokio::test]
11586    async fn identity_first_console_send_reserves_timeline_and_uses_identity_runtime()
11587    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11588        let (_temp_dir, mob_runtime) =
11589            build_empty_console_test_runtime("identity-send-runtime-key-test").await?;
11590        let identity = AgentIdentity::parse("agent:console")?;
11591        let record = ContinuityRecord {
11592            identity: identity.clone(),
11593            agent_runtime_id: AgentRuntimeId::parse("rt:agent:console:0")?,
11594            session_id: meerkat_core::types::SessionId::new(),
11595            generation: ContinuityGeneration::new(0),
11596            checkpoint_version: CheckpointVersion::new(0),
11597        };
11598        let runtime = IdentityRuntime::new(IdentityRuntimeConfig {
11599            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11600            lease_provider: Arc::new(LocalLeaseProvider::new()),
11601            runtime_instance_id: "console-test".to_string(),
11602            has_runtime_store: true,
11603            durability_policy: DurabilityPolicy::SyncWriteThrough,
11604            bridge: None,
11605            default_timeout: None,
11606        });
11607        runtime
11608            .register(
11609                DurableAgentSpec {
11610                    identity: identity.clone(),
11611                    profile: ProfileName::from("default"),
11612                    addressability: AgentAddressability::Addressable,
11613                    display_name: None,
11614                    labels: BTreeMap::new(),
11615                    context: None,
11616                    additional_instructions: Vec::new(),
11617                    initial_message: None,
11618                    runtime_mode_override: None,
11619                    backend: None,
11620                    binding: None,
11621                },
11622                IdentityLifecycleState::Active,
11623                Some(record.clone()),
11624                Some(LeaseGrant {
11625                    identity: identity.clone(),
11626                    fencing_token: FencingToken::new(7),
11627                    ttl: Duration::from_mins(1),
11628                }),
11629            )
11630            .await;
11631
11632        let aggregator = MobKitConsoleAggregator::in_memory();
11633        let events = ConsoleEventStore::new();
11634        let runtime = Arc::new(runtime);
11635        aggregator.register_runtime_handles_with_policy(
11636            "default",
11637            "",
11638            mob_runtime.clone(),
11639            Some(runtime.clone()),
11640            events.clone(),
11641            Arc::new(AllowAllConsoleVisibilityPolicy),
11642        );
11643        let accepted = console_send_identity_first(
11644            &aggregator,
11645            runtime.clone(),
11646            Some(&mob_runtime),
11647            Some(&events),
11648            crate::console_aggregator::ConsoleSendRequest {
11649                identity: identity.as_str().to_string(),
11650                content: serde_json::to_value(meerkat_core::ContentInput::Text(
11651                    "hello".to_string(),
11652                ))?,
11653                origin: "test".to_string(),
11654                idempotency_key: "idem-1".to_string(),
11655                handling_mode: None,
11656            },
11657        )
11658        .await?;
11659
11660        assert_eq!(accepted.identity, identity.as_str());
11661        assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
11662        assert_eq!(accepted.session_id, Some(record.session_id.to_string()));
11663
11664        let page = aggregator
11665            .query_timeline(ConsoleTimelineQuery {
11666                identity: Some(identity.as_str().to_string()),
11667                ..ConsoleTimelineQuery::default()
11668            })
11669            .await?;
11670        assert_eq!(page.frames.len(), 1);
11671        assert_eq!(page.frames[0].runtime_key, "default");
11672        assert_eq!(page.frames[0].status, ConsoleFrameStatus::Accepted);
11673        assert_eq!(
11674            page.frames[0].session_id,
11675            Some(record.session_id.to_string())
11676        );
11677        let _ = mob_runtime.handle().stop().await;
11678        Ok(())
11679    }
11680
11681    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11682    async fn identity_first_console_send_falls_back_to_member_only_spawned_worker()
11683    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11684        let (_temp_dir, mob_runtime) =
11685            build_empty_console_test_runtime("identity-send-member-only-test").await?;
11686        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11687            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11688            lease_provider: Arc::new(LocalLeaseProvider::new()),
11689            runtime_instance_id: "console-member-only-send-test".to_string(),
11690            has_runtime_store: true,
11691            durability_policy: DurabilityPolicy::SyncWriteThrough,
11692            bridge: None,
11693            default_timeout: None,
11694        }));
11695
11696        let aggregator = MobKitConsoleAggregator::in_memory();
11697        let events = ConsoleEventStore::new();
11698        aggregator.register_runtime_handles_with_policy(
11699            "identity-first",
11700            "",
11701            mob_runtime.clone(),
11702            Some(identity_runtime.clone()),
11703            events.clone(),
11704            Arc::new(AllowAllConsoleVisibilityPolicy),
11705        );
11706
11707        mob_runtime
11708            .handle()
11709            .spawn_spec(SpawnMemberSpec::from_wire(
11710                "worker".to_string(),
11711                // meerkat 0.7: roster ids are comms-safe encodings of the
11712                // public alias (MemberCommsName rejects ":").
11713                crate::member_comms_id::mob_member_id_str("agent:member-only").into_owned(),
11714                Some("You are a member-only spawned worker.".into()),
11715                None,
11716                None,
11717            ))
11718            .await?;
11719
11720        let accepted = console_send_with_identity_first_fallback(
11721            &aggregator,
11722            identity_runtime,
11723            Some(&mob_runtime),
11724            Some(&events),
11725            crate::console_aggregator::ConsoleSendRequest {
11726                identity: "agent:member-only".to_string(),
11727                content: serde_json::to_value(meerkat_core::ContentInput::Text(
11728                    "hello spawned worker".to_string(),
11729                ))?,
11730                origin: "test".to_string(),
11731                idempotency_key: "member-only-idem-1".to_string(),
11732                handling_mode: None,
11733            },
11734        )
11735        .await?;
11736
11737        assert_eq!(accepted.identity, "agent:member-only");
11738        assert!(accepted.session_id.is_some());
11739
11740        let page = aggregator
11741            .query_timeline(ConsoleTimelineQuery {
11742                identity: Some("agent:member-only".to_string()),
11743                ..ConsoleTimelineQuery::default()
11744            })
11745            .await?;
11746        assert!(
11747            page.frames.iter().any(|frame| frame.kind == "user_input"),
11748            "fallback send should persist a user input frame for the member-only worker: {page:#?}"
11749        );
11750
11751        let _ = mob_runtime.handle().stop().await;
11752        Ok(())
11753    }
11754
11755    #[tokio::test]
11756    async fn identity_first_console_send_returns_before_bridge_delivery_completes()
11757    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11758        let identity = AgentIdentity::parse("agent:slow-console")?;
11759        let record = ContinuityRecord {
11760            identity: identity.clone(),
11761            agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-console:0")?,
11762            session_id: meerkat_core::types::SessionId::new(),
11763            generation: ContinuityGeneration::new(0),
11764            checkpoint_version: CheckpointVersion::new(0),
11765        };
11766        let deliver_calls = Arc::new(AtomicUsize::new(0));
11767        let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11768            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11769            lease_provider: Arc::new(LocalLeaseProvider::new()),
11770            runtime_instance_id: "console-slow-send-test".to_string(),
11771            has_runtime_store: true,
11772            durability_policy: DurabilityPolicy::SyncWriteThrough,
11773            bridge: Some(Arc::new(BlockingIdentityBridge {
11774                deliver_calls: deliver_calls.clone(),
11775            })),
11776            default_timeout: None,
11777        }));
11778        runtime
11779            .register(
11780                DurableAgentSpec {
11781                    identity: identity.clone(),
11782                    profile: ProfileName::from("default"),
11783                    addressability: AgentAddressability::Addressable,
11784                    display_name: None,
11785                    labels: BTreeMap::new(),
11786                    context: None,
11787                    additional_instructions: Vec::new(),
11788                    initial_message: None,
11789                    runtime_mode_override: None,
11790                    backend: None,
11791                    binding: None,
11792                },
11793                IdentityLifecycleState::Active,
11794                Some(record.clone()),
11795                Some(LeaseGrant {
11796                    identity: identity.clone(),
11797                    fencing_token: FencingToken::new(7),
11798                    ttl: Duration::from_mins(1),
11799                }),
11800            )
11801            .await;
11802
11803        let aggregator = MobKitConsoleAggregator::in_memory();
11804        let accepted = match tokio::time::timeout(
11805            Duration::from_millis(100),
11806            console_send_identity_first(
11807                &aggregator,
11808                runtime,
11809                None,
11810                None,
11811                crate::console_aggregator::ConsoleSendRequest {
11812                    identity: identity.as_str().to_string(),
11813                    content: serde_json::to_value(meerkat_core::ContentInput::Text(
11814                        "hello slow bridge".to_string(),
11815                    ))?,
11816                    origin: "test".to_string(),
11817                    idempotency_key: "idem-slow-bridge".to_string(),
11818                    handling_mode: None,
11819                },
11820            ),
11821        )
11822        .await
11823        {
11824            Ok(Ok(accepted)) => accepted,
11825            Ok(Err(err)) => return Err(format!("send should be accepted: {err}").into()),
11826            Err(err) => {
11827                return Err(
11828                    format!("console send should not wait for bridge delivery: {err}").into(),
11829                );
11830            }
11831        };
11832
11833        assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
11834        if tokio::time::timeout(Duration::from_millis(100), async {
11835            while deliver_calls.load(Ordering::SeqCst) == 0 {
11836                tokio::time::sleep(Duration::from_millis(5)).await;
11837            }
11838        })
11839        .await
11840        .is_err()
11841        {
11842            return Err("delivery should be spawned in the background".into());
11843        }
11844        Ok(())
11845    }
11846
11847    #[tokio::test]
11848    async fn identity_first_console_steer_waits_for_bridge_delivery()
11849    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11850        let identity = AgentIdentity::parse("agent:slow-steer-console")?;
11851        let record = ContinuityRecord {
11852            identity: identity.clone(),
11853            agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-steer-console:0")?,
11854            session_id: meerkat_core::types::SessionId::new(),
11855            generation: ContinuityGeneration::new(0),
11856            checkpoint_version: CheckpointVersion::new(0),
11857        };
11858        let deliver_calls = Arc::new(AtomicUsize::new(0));
11859        let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11860            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11861            lease_provider: Arc::new(LocalLeaseProvider::new()),
11862            runtime_instance_id: "console-slow-steer-send-test".to_string(),
11863            has_runtime_store: true,
11864            durability_policy: DurabilityPolicy::SyncWriteThrough,
11865            bridge: Some(Arc::new(BlockingIdentityBridge {
11866                deliver_calls: deliver_calls.clone(),
11867            })),
11868            default_timeout: None,
11869        }));
11870        runtime
11871            .register(
11872                DurableAgentSpec {
11873                    identity: identity.clone(),
11874                    profile: ProfileName::from("default"),
11875                    addressability: AgentAddressability::Addressable,
11876                    display_name: None,
11877                    labels: BTreeMap::new(),
11878                    context: None,
11879                    additional_instructions: Vec::new(),
11880                    initial_message: None,
11881                    runtime_mode_override: None,
11882                    backend: None,
11883                    binding: None,
11884                },
11885                IdentityLifecycleState::Active,
11886                Some(record),
11887                Some(LeaseGrant {
11888                    identity: identity.clone(),
11889                    fencing_token: FencingToken::new(7),
11890                    ttl: Duration::from_mins(1),
11891                }),
11892            )
11893            .await;
11894
11895        let aggregator = MobKitConsoleAggregator::in_memory();
11896        let result = tokio::time::timeout(
11897            Duration::from_millis(100),
11898            console_send_identity_first(
11899                &aggregator,
11900                runtime,
11901                None,
11902                None,
11903                crate::console_aggregator::ConsoleSendRequest {
11904                    identity: identity.as_str().to_string(),
11905                    content: serde_json::to_value(meerkat_core::ContentInput::Text(
11906                        "hello slow steer bridge".to_string(),
11907                    ))?,
11908                    origin: "test".to_string(),
11909                    idempotency_key: "idem-slow-steer-bridge".to_string(),
11910                    handling_mode: Some("steer".to_string()),
11911                },
11912            ),
11913        )
11914        .await;
11915
11916        if result.is_ok() {
11917            return Err("steer send must wait for bridge delivery admission".into());
11918        }
11919        assert_eq!(
11920            deliver_calls.load(Ordering::SeqCst),
11921            1,
11922            "steer delivery should have reached the bridge before the console response waits"
11923        );
11924        Ok(())
11925    }
11926
11927    #[tokio::test]
11928    async fn identity_first_console_send_forwards_handling_mode()
11929    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11930        let identity = AgentIdentity::parse("agent:mode-console")?;
11931        let record = ContinuityRecord {
11932            identity: identity.clone(),
11933            agent_runtime_id: AgentRuntimeId::parse("rt:agent:mode-console:0")?,
11934            session_id: meerkat_core::types::SessionId::new(),
11935            generation: ContinuityGeneration::new(0),
11936            checkpoint_version: CheckpointVersion::new(0),
11937        };
11938        let handling_modes = Arc::new(Mutex::new(Vec::new()));
11939        let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11940            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11941            lease_provider: Arc::new(LocalLeaseProvider::new()),
11942            runtime_instance_id: "console-mode-send-test".to_string(),
11943            has_runtime_store: true,
11944            durability_policy: DurabilityPolicy::SyncWriteThrough,
11945            bridge: Some(Arc::new(RecordingIdentityBridge {
11946                session_id: record.session_id.clone(),
11947                handling_modes: handling_modes.clone(),
11948            })),
11949            default_timeout: None,
11950        }));
11951        runtime
11952            .register(
11953                DurableAgentSpec {
11954                    identity: identity.clone(),
11955                    profile: ProfileName::from("default"),
11956                    addressability: AgentAddressability::Addressable,
11957                    display_name: None,
11958                    labels: BTreeMap::new(),
11959                    context: None,
11960                    additional_instructions: Vec::new(),
11961                    initial_message: None,
11962                    runtime_mode_override: None,
11963                    backend: None,
11964                    binding: None,
11965                },
11966                IdentityLifecycleState::Active,
11967                Some(record),
11968                Some(LeaseGrant {
11969                    identity: identity.clone(),
11970                    fencing_token: FencingToken::new(7),
11971                    ttl: Duration::from_mins(1),
11972                }),
11973            )
11974            .await;
11975
11976        let aggregator = MobKitConsoleAggregator::in_memory();
11977        let accepted = console_send_identity_first(
11978            &aggregator,
11979            runtime,
11980            None,
11981            None,
11982            crate::console_aggregator::ConsoleSendRequest {
11983                identity: identity.as_str().to_string(),
11984                content: serde_json::to_value(meerkat_core::ContentInput::Text(
11985                    "hello steer bridge".to_string(),
11986                ))?,
11987                origin: "test".to_string(),
11988                idempotency_key: "idem-steer-bridge".to_string(),
11989                handling_mode: Some("steer".to_string()),
11990            },
11991        )
11992        .await?;
11993
11994        if tokio::time::timeout(Duration::from_millis(100), async {
11995            loop {
11996                if handling_modes
11997                    .lock()
11998                    .map(|modes| modes.contains(&HandlingMode::Steer))
11999                    .unwrap_or(false)
12000                {
12001                    break;
12002                }
12003                tokio::time::sleep(Duration::from_millis(5)).await;
12004            }
12005        })
12006        .await
12007        .is_err()
12008        {
12009            return Err("identity-first console send should forward steer mode".into());
12010        }
12011
12012        let terminal_frame = tokio::time::timeout(Duration::from_millis(500), async {
12013            loop {
12014                let page = aggregator
12015                    .query_timeline(ConsoleTimelineQuery {
12016                        identity: Some(identity.as_str().to_string()),
12017                        ..ConsoleTimelineQuery::default()
12018                    })
12019                    .await
12020                    .map_err(|err| format!("query timeline: {err}"))?;
12021                if page.frames.iter().any(|frame| {
12022                    frame.kind == "interaction_complete"
12023                        && frame.interaction_id.as_deref() == Some(accepted.interaction_id.as_str())
12024                        && frame.payload.get("reason").and_then(Value::as_str)
12025                            == Some("steer_delivered")
12026                }) {
12027                    return Ok::<(), String>(());
12028                }
12029                tokio::time::sleep(Duration::from_millis(5)).await;
12030            }
12031        })
12032        .await;
12033        match terminal_frame {
12034            Ok(Ok(())) => {}
12035            Ok(Err(err)) => return Err(err.into()),
12036            Err(_) => {
12037                return Err(
12038                    "identity-first steer send should terminalize its console interaction".into(),
12039                );
12040            }
12041        }
12042        Ok(())
12043    }
12044
12045    #[test]
12046    fn multipart_body_limit_covers_configured_image_limit() {
12047        const _: () = assert!(MAX_MULTIPART_BODY_BYTES > MAX_MULTIPART_IMAGE_BYTES);
12048        const _: () = assert!(MAX_MULTIPART_BODY_BYTES > 2 * 1024 * 1024);
12049    }
12050
12051    /// Cold-cache contract: a `prime_now` waiter that arrives while
12052    /// another task holds `refresh_lock` must park on the lock and
12053    /// resume after that task releases it. No race-prone signaling
12054    /// involved — the lock acquisition itself IS the signal that the
12055    /// in-flight refresh has finished and `primed` is true.
12056    ///
12057    /// Test shape: hold `refresh_lock` from the test thread (no real
12058    /// refresh task), spawn a `prime_now`-style waiter, then set
12059    /// `primed` + drop the lock. The waiter must observe `primed`
12060    /// after acquiring the lock and return without redoing the
12061    /// refresh (we'd otherwise deadlock since we don't supply a
12062    /// real `MobRuntime`).
12063    #[tokio::test]
12064    async fn cold_cache_waiter_resumes_when_refresh_lock_drops()
12065    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12066        use std::sync::atomic::Ordering;
12067        use tokio::time::Duration;
12068
12069        let model = ConsoleSnapshotReadModel::default();
12070        let guard = model
12071            .refresh_lock
12072            .clone()
12073            .try_lock_owned()
12074            .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
12075
12076        let model_for_waiter = model.clone();
12077        let waiter = tokio::spawn(async move {
12078            // Inlined `prime_now` shape (skips the runtime call,
12079            // since the test will set `primed` before this acquires).
12080            if model_for_waiter
12081                .primed
12082                .load(std::sync::atomic::Ordering::Acquire)
12083            {
12084                return;
12085            }
12086            let _wait_guard = model_for_waiter.refresh_lock.clone().lock_owned().await;
12087            // After acquiring, `primed` must be true (the "refresher"
12088            // — i.e., the test thread — set it before releasing).
12089            assert!(
12090                model_for_waiter
12091                    .primed
12092                    .load(std::sync::atomic::Ordering::Acquire),
12093                "waiter acquired lock but primed is still false"
12094            );
12095        });
12096
12097        // Give the waiter time to reach `lock_owned().await`.
12098        tokio::time::sleep(Duration::from_millis(20)).await;
12099
12100        // Set primed, then release the lock. The waiter parked on
12101        // `lock_owned()` should acquire it immediately.
12102        model.primed.store(true, Ordering::Release);
12103        drop(guard);
12104
12105        let result = tokio::time::timeout(Duration::from_secs(1), waiter).await;
12106        assert!(
12107            result.is_ok(),
12108            "waiter should resume once the refresh lock drops"
12109        );
12110        Ok(())
12111    }
12112
12113    /// Companion: when `primed` is already set, `snapshot()` returns
12114    /// without touching the refresh lock at all. Guards against an
12115    /// over-eager `prime_now` that would deadlock during normal
12116    /// (hot-cache) traffic.
12117    #[tokio::test]
12118    async fn snapshot_skips_refresh_lock_when_already_primed()
12119    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12120        use std::sync::atomic::Ordering;
12121        use tokio::time::Duration;
12122
12123        let model = ConsoleSnapshotReadModel::default();
12124        model.primed.store(true, Ordering::Release);
12125        // Pre-acquire the refresh lock to prove it isn't touched.
12126        let _guard = model
12127            .refresh_lock
12128            .clone()
12129            .try_lock_owned()
12130            .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
12131
12132        // `snapshot()` calls `prime_now` only on cold cache; with
12133        // primed=true the lock-await branch must not be reached.
12134        // We test the contract via direct inspection: if `prime_now`
12135        // accidentally tried `lock_owned().await` here, this would
12136        // hang. The timeout below is the deadlock guard.
12137        let snap_fast_path = async {
12138            assert!(
12139                model.primed.load(Ordering::Acquire),
12140                "primed precondition for hot-cache path"
12141            );
12142        };
12143        let result = tokio::time::timeout(Duration::from_millis(100), snap_fast_path).await;
12144        assert!(result.is_ok(), "hot-cache snapshot path should not block");
12145        Ok(())
12146    }
12147
12148    #[tokio::test]
12149    async fn console_aggregator_reset_all_rpc_rejects_destructive_retire_all_semantics()
12150    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12151        let (_temp_dir, runtime) =
12152            build_empty_console_test_runtime("console-reset-fresh-identity-cache").await?;
12153        let aggregator = MobKitConsoleAggregator::in_memory();
12154        aggregator.register_runtime_handles_with_policy(
12155            "runtime-reset",
12156            "reset",
12157            runtime.clone(),
12158            None,
12159            ConsoleEventStore::new(),
12160            Arc::new(AllowAllConsoleVisibilityPolicy),
12161        );
12162        let primed_empty = aggregator.list_identities().await?;
12163        assert!(
12164            primed_empty.is_empty(),
12165            "test precondition: identity cache should be primed empty before late spawn"
12166        );
12167
12168        runtime
12169            .handle()
12170            .spawn_spec(SpawnMemberSpec::from_wire(
12171                "worker".to_string(),
12172                "agent-reset".to_string(),
12173                Some("You are agent-reset.".into()),
12174                None,
12175                None,
12176            ))
12177            .await?;
12178
12179        let response = Box::pin(handle_console_aggregator_rpc(
12180            Some(aggregator),
12181            rpc_request("mobkit/reset_all"),
12182            true,
12183            false,
12184            None,
12185            None,
12186        ))
12187        .await;
12188
12189        assert_eq!(response["result"], Value::Null);
12190        assert_eq!(
12191            response["error"]["data"]["kind"],
12192            json!("unsupported_reset_all_surface")
12193        );
12194        assert!(
12195            runtime
12196                .handle()
12197                .get_member(&meerkat_mob::ids::AgentIdentity::from("agent-reset"))
12198                .await
12199                .ok()
12200                .flatten()
12201                .is_some(),
12202            "aggregator reset_all must not retire live members while reporting unsupported"
12203        );
12204        let _ = runtime.handle().stop().await;
12205        Ok(())
12206    }
12207
12208    #[test]
12209    fn timeline_stream_cursor_filter_uses_numeric_console_sequence() {
12210        assert!(cursor_is_after(
12211            &ConsoleCursor::from("console:10"),
12212            &ConsoleCursor::from("console:9")
12213        ));
12214        assert!(!cursor_is_after(
12215            &ConsoleCursor::from("console:9"),
12216            &ConsoleCursor::from("console:10")
12217        ));
12218    }
12219
12220    #[test]
12221    fn console_live_snapshot_dedupes_repeated_delegate_identities() {
12222        let mut members = vec![
12223            ConsoleMember {
12224                agent_identity: "incident-commander".to_string(),
12225                role: "commander".to_string(),
12226                state: "active".to_string(),
12227                model_capabilities: Default::default(),
12228                runtime_mode: None,
12229                session_id: None,
12230                wired_to: Vec::new(),
12231                labels: BTreeMap::new(),
12232            },
12233            ConsoleMember {
12234                agent_identity: "qa-child".to_string(),
12235                role: "delegate".to_string(),
12236                state: "active".to_string(),
12237                model_capabilities: Default::default(),
12238                runtime_mode: None,
12239                session_id: Some("first".to_string()),
12240                wired_to: vec!["qa-parent".to_string()],
12241                labels: BTreeMap::from([(
12242                    "delegate_host_identity".to_string(),
12243                    "qa-parent".to_string(),
12244                )]),
12245            },
12246            ConsoleMember {
12247                agent_identity: "qa-child".to_string(),
12248                role: "delegate".to_string(),
12249                state: "active".to_string(),
12250                model_capabilities: Default::default(),
12251                runtime_mode: None,
12252                session_id: Some("second".to_string()),
12253                wired_to: vec!["qa-parent".to_string()],
12254                labels: BTreeMap::from([(
12255                    "delegate_host_identity".to_string(),
12256                    "qa-parent".to_string(),
12257                )]),
12258            },
12259        ];
12260
12261        dedupe_console_members_by_identity(&mut members);
12262
12263        assert_eq!(
12264            members
12265                .iter()
12266                .map(|member| member.agent_identity.as_str())
12267                .collect::<Vec<_>>(),
12268            vec!["incident-commander", "qa-child"]
12269        );
12270        assert_eq!(members[1].session_id.as_deref(), Some("first"));
12271    }
12272
12273    #[test]
12274    fn console_visibility_policy_hides_implicit_delegate_members_from_snapshot() {
12275        let mut snapshot = ConsoleLiveSnapshot::new(
12276            Some("runtime".to_string()),
12277            true,
12278            vec!["incident-commander".to_string(), "qa-child".to_string()],
12279            vec![
12280                ConsoleAgentLiveSnapshot {
12281                    agent_id: "incident-commander".to_string(),
12282                    member_id: "incident-commander".to_string(),
12283                    label: "Incident Commander".to_string(),
12284                    kind: "meerkat".to_string(),
12285                    identity: Some("incident-commander".to_string()),
12286                    role: Some("commander".to_string()),
12287                    state: Some("active".to_string()),
12288                    session_id: None,
12289                    model_capabilities: Default::default(),
12290                    response_phase: None,
12291                    watched: None,
12292                    alert_level: None,
12293                    degraded: None,
12294                    degraded_reason: None,
12295                },
12296                ConsoleAgentLiveSnapshot {
12297                    agent_id: "qa-child".to_string(),
12298                    member_id: "qa-child".to_string(),
12299                    label: "QA Child".to_string(),
12300                    kind: "meerkat".to_string(),
12301                    identity: Some("qa-child".to_string()),
12302                    role: Some("delegate".to_string()),
12303                    state: Some("active".to_string()),
12304                    session_id: Some("delegate-session".to_string()),
12305                    model_capabilities: Default::default(),
12306                    response_phase: None,
12307                    watched: None,
12308                    alert_level: None,
12309                    degraded: None,
12310                    degraded_reason: None,
12311                },
12312            ],
12313            vec![
12314                ConsoleMember {
12315                    agent_identity: "incident-commander".to_string(),
12316                    role: "commander".to_string(),
12317                    state: "active".to_string(),
12318                    model_capabilities: Default::default(),
12319                    runtime_mode: None,
12320                    session_id: None,
12321                    wired_to: Vec::new(),
12322                    labels: BTreeMap::new(),
12323                },
12324                ConsoleMember {
12325                    agent_identity: "qa-child".to_string(),
12326                    role: "delegate".to_string(),
12327                    state: "active".to_string(),
12328                    model_capabilities: Default::default(),
12329                    runtime_mode: None,
12330                    session_id: Some("delegate-session".to_string()),
12331                    wired_to: vec!["qa-parent".to_string()],
12332                    labels: BTreeMap::from([(
12333                        "source_mob_id".to_string(),
12334                        "implicit-qa-mob".to_string(),
12335                    )]),
12336                },
12337            ],
12338            true,
12339        );
12340
12341        apply_console_visibility_policy(
12342            &mut snapshot,
12343            &HideImplicitDelegateMembersConsoleVisibilityPolicy,
12344        );
12345
12346        assert_eq!(
12347            snapshot
12348                .members
12349                .iter()
12350                .map(|member| member.agent_identity.as_str())
12351                .collect::<Vec<_>>(),
12352            vec!["incident-commander"]
12353        );
12354        assert_eq!(
12355            snapshot
12356                .agents
12357                .iter()
12358                .map(|agent| agent.agent_id.as_str())
12359                .collect::<Vec<_>>(),
12360            vec!["incident-commander"]
12361        );
12362        assert_eq!(snapshot.loaded_modules, vec!["incident-commander"]);
12363    }
12364
12365    #[tokio::test]
12366    async fn live_snapshot_member_projection_uses_roster_profile_capabilities()
12367    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12368        let temp_dir = tempfile::tempdir()?;
12369        let session_path = temp_dir.path().join("sessions");
12370        std::fs::create_dir_all(&session_path)?;
12371        let factory = AgentFactory::new(&session_path).comms(true);
12372        let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
12373        let definition = MobDefinition::from_toml(
12374            r#"
12375[mob]
12376id = "console-snapshot-test"
12377
12378[profiles.worker]
12379model = "gpt-5.5"
12380
12381[profiles.worker.tools]
12382comms = true
12383"#,
12384        )?;
12385        let expected = model_capabilities_for_role(&definition, "worker");
12386        let runtime = MobRuntime::bootstrap(
12387            MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
12388                .with_options(MobBootstrapOptions {
12389                    allow_ephemeral_sessions: true,
12390                    notify_orchestrator_on_resume: true,
12391                    default_llm_client: Some(Arc::new(TestClient::default())),
12392                }),
12393        )
12394        .await?;
12395        runtime
12396            .handle()
12397            .spawn_spec(SpawnMemberSpec::from_wire(
12398                "worker".to_string(),
12399                // meerkat 0.7: roster ids are comms-safe encodings of the
12400                // public alias (MemberCommsName rejects ":").
12401                crate::member_comms_id::mob_member_id_str("worker:one").into_owned(),
12402                Some("You are worker one.".into()),
12403                None,
12404                None,
12405            ))
12406            .await?;
12407
12408        let empty_read_model = ConsoleSnapshotReadModelState::default();
12409        let (members, session_owner_by_id) = project_console_members_from_handle(
12410            &runtime.handle(),
12411            None,
12412            None,
12413            &empty_read_model,
12414            &BTreeMap::new(),
12415        )
12416        .await;
12417
12418        assert_eq!(members.len(), 1);
12419        assert_eq!(members[0].model_capabilities, expected);
12420        assert_eq!(members[0].session_id, None);
12421        assert!(session_owner_by_id.is_empty());
12422
12423        let refreshed_read_model = collect_console_snapshot_read_model(&runtime).await;
12424        let (members, session_owner_by_id) = project_console_members_from_handle(
12425            &runtime.handle(),
12426            None,
12427            None,
12428            &refreshed_read_model,
12429            &BTreeMap::new(),
12430        )
12431        .await;
12432        assert_eq!(
12433            members[0].session_id.as_ref(),
12434            session_owner_by_id.keys().next()
12435        );
12436
12437        // Materialized cache: the refresh should have populated
12438        // `primary_members` with exactly the same shape that the
12439        // synchronous projection produces. `build_live_snapshot` reads
12440        // straight from this slot — never calls `handle.list_all_members`
12441        // — so this assertion is the cache's contract.
12442        assert_eq!(
12443            refreshed_read_model.primary_members.len(),
12444            members.len(),
12445            "primary_members cache should hold the same members as live projection"
12446        );
12447        assert_eq!(
12448            refreshed_read_model.primary_members[0].agent_identity,
12449            members[0].agent_identity
12450        );
12451        assert_eq!(
12452            refreshed_read_model.primary_members[0].session_id,
12453            members[0].session_id
12454        );
12455        Ok(())
12456    }
12457
12458    #[tokio::test]
12459    async fn fresh_timeline_snapshot_reads_tail_without_full_log_replay()
12460    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12461        let aggregator = MobKitConsoleAggregator::in_memory();
12462        for idx in 0..250_000 {
12463            aggregator
12464                .store()
12465                .append_if_absent(NewConsoleFrame {
12466                    id: None,
12467                    dedupe_key: format!("event-{idx}"),
12468                    timestamp_ms: idx,
12469                    runtime_key: "runtime-a".to_string(),
12470                    identity: "agent-a".to_string(),
12471                    conversation_id: Some("agent-a".to_string()),
12472                    session_id: None,
12473                    kind: "text_delta".to_string(),
12474                    status: ConsoleFrameStatus::Completed,
12475                    payload: json!({ "delta": idx }),
12476                    source: ConsoleFrameSource {
12477                        kind: ConsoleFrameSourceKind::ConsoleEvent,
12478                        source_cursor: None,
12479                    },
12480                    source_event_id: Some(format!("event-{idx}")),
12481                    interaction_id: None,
12482                    turn_id: None,
12483                    run_id: None,
12484                    parent_frame_id: None,
12485                    caused_by_frame_id: None,
12486                })
12487                .await?;
12488        }
12489
12490        let (frames, cursor) = query_timeline_snapshot(
12491            &aggregator,
12492            ConsoleTimelineWindowQuery {
12493                identity: Some("agent-a".to_string()),
12494                after: None,
12495                limit: 200,
12496                ..ConsoleTimelineWindowQuery::default()
12497            },
12498        )
12499        .await?;
12500
12501        assert!(!frames.is_empty());
12502        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(250_000));
12503        assert_eq!(
12504            frames.last().and_then(|frame| frame.cursor.seq()),
12505            Some(250_000)
12506        );
12507        Ok(())
12508    }
12509
12510    #[tokio::test]
12511    async fn fresh_timeline_snapshot_keeps_sparse_identity_frames()
12512    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12513        let aggregator = MobKitConsoleAggregator::in_memory();
12514        aggregator
12515            .store()
12516            .append_if_absent(NewConsoleFrame {
12517                id: None,
12518                dedupe_key: "sparse-event".to_string(),
12519                timestamp_ms: 1,
12520                runtime_key: "runtime-a".to_string(),
12521                identity: "sparse-agent".to_string(),
12522                conversation_id: Some("sparse-agent".to_string()),
12523                session_id: None,
12524                kind: "text_complete".to_string(),
12525                status: ConsoleFrameStatus::Completed,
12526                payload: json!({ "text": "still visible" }),
12527                source: ConsoleFrameSource {
12528                    kind: ConsoleFrameSourceKind::ConsoleEvent,
12529                    source_cursor: None,
12530                },
12531                source_event_id: Some("sparse-event".to_string()),
12532                interaction_id: None,
12533                turn_id: None,
12534                run_id: None,
12535                parent_frame_id: None,
12536                caused_by_frame_id: None,
12537            })
12538            .await?;
12539        for idx in 0..25_000 {
12540            aggregator
12541                .store()
12542                .append_if_absent(NewConsoleFrame {
12543                    id: None,
12544                    dedupe_key: format!("other-event-{idx}"),
12545                    timestamp_ms: idx + 2,
12546                    runtime_key: "runtime-a".to_string(),
12547                    identity: "busy-agent".to_string(),
12548                    conversation_id: Some("busy-agent".to_string()),
12549                    session_id: None,
12550                    kind: "text_delta".to_string(),
12551                    status: ConsoleFrameStatus::Completed,
12552                    payload: json!({ "delta": idx }),
12553                    source: ConsoleFrameSource {
12554                        kind: ConsoleFrameSourceKind::ConsoleEvent,
12555                        source_cursor: None,
12556                    },
12557                    source_event_id: Some(format!("other-event-{idx}")),
12558                    interaction_id: None,
12559                    turn_id: None,
12560                    run_id: None,
12561                    parent_frame_id: None,
12562                    caused_by_frame_id: None,
12563                })
12564                .await?;
12565        }
12566
12567        let (frames, cursor) = query_timeline_snapshot(
12568            &aggregator,
12569            ConsoleTimelineWindowQuery {
12570                identity: Some("sparse-agent".to_string()),
12571                after: None,
12572                limit: 200,
12573                ..ConsoleTimelineWindowQuery::default()
12574            },
12575        )
12576        .await?;
12577
12578        assert_eq!(frames.len(), 1);
12579        assert_eq!(frames[0].identity, "sparse-agent");
12580        assert_eq!(frames[0].payload["text"], json!("still visible"));
12581        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1));
12582        Ok(())
12583    }
12584
12585    #[tokio::test]
12586    async fn fresh_identity_snapshot_keeps_user_input_anchor_before_noisy_tail()
12587    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12588        let aggregator = MobKitConsoleAggregator::in_memory();
12589        aggregator
12590            .store()
12591            .append_if_absent(NewConsoleFrame {
12592                id: None,
12593                dedupe_key: "worker-kickoff".to_string(),
12594                timestamp_ms: 1,
12595                runtime_key: "runtime-a".to_string(),
12596                identity: "review-worker-a".to_string(),
12597                conversation_id: Some("review-worker-a".to_string()),
12598                session_id: None,
12599                kind: "user_input".to_string(),
12600                status: ConsoleFrameStatus::Delivered,
12601                payload: json!({
12602                    "content": [
12603                        {
12604                            "type": "text",
12605                            "text": "Console chat smoke: review this initiative"
12606                        }
12607                    ]
12608                }),
12609                source: ConsoleFrameSource {
12610                    kind: ConsoleFrameSourceKind::Synthetic,
12611                    source_cursor: None,
12612                },
12613                source_event_id: Some("worker-kickoff".to_string()),
12614                interaction_id: Some("kickoff-1".to_string()),
12615                turn_id: None,
12616                run_id: None,
12617                parent_frame_id: None,
12618                caused_by_frame_id: None,
12619            })
12620            .await?;
12621        for idx in 0..1_500 {
12622            aggregator
12623                .store()
12624                .append_if_absent(NewConsoleFrame {
12625                    id: None,
12626                    dedupe_key: format!("worker-delta-{idx}"),
12627                    timestamp_ms: idx + 2,
12628                    runtime_key: "runtime-a".to_string(),
12629                    identity: "review-worker-a".to_string(),
12630                    conversation_id: Some("review-worker-a".to_string()),
12631                    session_id: None,
12632                    kind: "reasoning_delta".to_string(),
12633                    status: ConsoleFrameStatus::Delivered,
12634                    payload: json!({ "delta": idx }),
12635                    source: ConsoleFrameSource {
12636                        kind: ConsoleFrameSourceKind::ConsoleEvent,
12637                        source_cursor: None,
12638                    },
12639                    source_event_id: Some(format!("worker-delta-{idx}")),
12640                    interaction_id: Some("kickoff-1".to_string()),
12641                    turn_id: None,
12642                    run_id: None,
12643                    parent_frame_id: None,
12644                    caused_by_frame_id: None,
12645                })
12646                .await?;
12647        }
12648
12649        let (frames, cursor) = query_timeline_snapshot(
12650            &aggregator,
12651            ConsoleTimelineWindowQuery {
12652                identity: Some("review-worker-a".to_string()),
12653                after: None,
12654                limit: 200,
12655                ..ConsoleTimelineWindowQuery::default()
12656            },
12657        )
12658        .await?;
12659
12660        assert!(
12661            frames.iter().any(|frame| {
12662                frame.kind == "user_input"
12663                    && frame.payload.to_string().contains("Console chat smoke")
12664            }),
12665            "identity chat snapshot must keep the worker kickoff prompt before a noisy tail: {frames:#?}",
12666        );
12667        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1_501));
12668        assert_eq!(
12669            frames.last().and_then(|frame| frame.cursor.seq()),
12670            Some(1_501)
12671        );
12672        Ok(())
12673    }
12674
12675    #[tokio::test]
12676    async fn timeline_snapshot_drains_since_backlog_across_store_pages()
12677    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12678        let aggregator = MobKitConsoleAggregator::in_memory();
12679        for idx in 0..2_500 {
12680            aggregator
12681                .store()
12682                .append_if_absent(NewConsoleFrame {
12683                    id: None,
12684                    dedupe_key: format!("clamp-event-{idx}"),
12685                    timestamp_ms: idx,
12686                    runtime_key: "runtime-a".to_string(),
12687                    identity: "agent-a".to_string(),
12688                    conversation_id: Some("agent-a".to_string()),
12689                    session_id: None,
12690                    kind: "text_delta".to_string(),
12691                    status: ConsoleFrameStatus::Completed,
12692                    payload: json!({ "delta": idx }),
12693                    source: ConsoleFrameSource {
12694                        kind: ConsoleFrameSourceKind::ConsoleEvent,
12695                        source_cursor: None,
12696                    },
12697                    source_event_id: Some(format!("clamp-event-{idx}")),
12698                    interaction_id: None,
12699                    turn_id: None,
12700                    run_id: None,
12701                    parent_frame_id: None,
12702                    caused_by_frame_id: None,
12703                })
12704                .await?;
12705        }
12706
12707        let (frames, cursor) = query_timeline_snapshot(
12708            &aggregator,
12709            ConsoleTimelineWindowQuery {
12710                identity: Some("agent-a".to_string()),
12711                after: Some(ConsoleCursor::from("console:100")),
12712                limit: 5_000,
12713                ..ConsoleTimelineWindowQuery::default()
12714            },
12715        )
12716        .await?;
12717
12718        assert_eq!(frames.len(), 2_400);
12719        assert_eq!(
12720            frames.first().and_then(|frame| frame.cursor.seq()),
12721            Some(101)
12722        );
12723        assert_eq!(
12724            frames.last().and_then(|frame| frame.cursor.seq()),
12725            Some(2_500)
12726        );
12727        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(2_500));
12728        Ok(())
12729    }
12730
12731    #[tokio::test]
12732    async fn timeline_snapshot_drains_since_backlog_beyond_old_page_budget()
12733    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12734        let aggregator = MobKitConsoleAggregator::in_memory();
12735        for idx in 1..=150 {
12736            aggregator
12737                .store()
12738                .append_if_absent(NewConsoleFrame {
12739                    id: None,
12740                    dedupe_key: format!("deep-backlog-event-{idx}"),
12741                    timestamp_ms: idx,
12742                    runtime_key: "runtime-a".to_string(),
12743                    identity: "agent-a".to_string(),
12744                    conversation_id: Some("agent-a".to_string()),
12745                    session_id: None,
12746                    kind: "text_delta".to_string(),
12747                    status: ConsoleFrameStatus::Completed,
12748                    payload: json!({ "delta": idx }),
12749                    source: ConsoleFrameSource {
12750                        kind: ConsoleFrameSourceKind::ConsoleEvent,
12751                        source_cursor: None,
12752                    },
12753                    source_event_id: Some(format!("deep-backlog-event-{idx}")),
12754                    interaction_id: None,
12755                    turn_id: None,
12756                    run_id: None,
12757                    parent_frame_id: None,
12758                    caused_by_frame_id: None,
12759                })
12760                .await?;
12761        }
12762
12763        let (frames, cursor) = query_timeline_snapshot(
12764            &aggregator,
12765            ConsoleTimelineWindowQuery {
12766                identity: Some("agent-a".to_string()),
12767                after: Some(ConsoleCursor::from_seq(1)),
12768                limit: 1,
12769                ..ConsoleTimelineWindowQuery::default()
12770            },
12771        )
12772        .await?;
12773
12774        assert_eq!(frames.len(), 149);
12775        assert_eq!(frames.first().and_then(|frame| frame.cursor.seq()), Some(2));
12776        assert_eq!(
12777            frames.last().and_then(|frame| frame.cursor.seq()),
12778            Some(150)
12779        );
12780        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(150));
12781        Ok(())
12782    }
12783
12784    #[tokio::test]
12785    async fn timeline_snapshot_rejects_after_cursor_beyond_store_frontier()
12786    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12787        let aggregator = MobKitConsoleAggregator::in_memory();
12788        aggregator
12789            .store()
12790            .append_if_absent(NewConsoleFrame {
12791                id: None,
12792                dedupe_key: "stale-frontier-event".to_string(),
12793                timestamp_ms: 1,
12794                runtime_key: "runtime-a".to_string(),
12795                identity: "agent-a".to_string(),
12796                conversation_id: Some("agent-a".to_string()),
12797                session_id: None,
12798                kind: "text_delta".to_string(),
12799                status: ConsoleFrameStatus::Completed,
12800                payload: json!({ "delta": 1 }),
12801                source: ConsoleFrameSource {
12802                    kind: ConsoleFrameSourceKind::ConsoleEvent,
12803                    source_cursor: None,
12804                },
12805                source_event_id: Some("stale-frontier-event".to_string()),
12806                interaction_id: None,
12807                turn_id: None,
12808                run_id: None,
12809                parent_frame_id: None,
12810                caused_by_frame_id: None,
12811            })
12812            .await?;
12813
12814        let err = match query_timeline_snapshot(
12815            &aggregator,
12816            ConsoleTimelineWindowQuery {
12817                after: Some(ConsoleCursor::from("console:99")),
12818                limit: 200,
12819                ..ConsoleTimelineWindowQuery::default()
12820            },
12821        )
12822        .await
12823        {
12824            Ok(_) => {
12825                return Err(
12826                    std::io::Error::other("future cursor must be replay-unavailable").into(),
12827                );
12828            }
12829            Err(err) => err,
12830        };
12831
12832        assert!(
12833            err.to_string()
12834                .contains("beyond the current store frontier"),
12835            "unexpected error: {err}"
12836        );
12837        Ok(())
12838    }
12839
12840    #[tokio::test]
12841    async fn timeline_snapshot_rejects_after_cursor_beyond_empty_store_frontier()
12842    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12843        let aggregator = MobKitConsoleAggregator::in_memory();
12844
12845        let err = match query_timeline_snapshot(
12846            &aggregator,
12847            ConsoleTimelineWindowQuery {
12848                after: Some(ConsoleCursor::from("console:99")),
12849                limit: 200,
12850                ..ConsoleTimelineWindowQuery::default()
12851            },
12852        )
12853        .await
12854        {
12855            Ok(_) => {
12856                return Err(std::io::Error::other("empty store future cursor must fail").into());
12857            }
12858            Err(err) => err,
12859        };
12860
12861        assert!(
12862            err.to_string()
12863                .contains("beyond the current store frontier"),
12864            "unexpected error: {err}"
12865        );
12866        Ok(())
12867    }
12868
12869    #[test]
12870    fn timeline_query_prefers_last_event_id_over_url_after_cursor() {
12871        let query = timeline_query_from_http(
12872            ConsoleTimelineHttpQuery {
12873                identity: None,
12874                conversation_id: None,
12875                after: Some("console:100".to_string()),
12876                before: None,
12877                mode: None,
12878                limit: None,
12879            },
12880            Some("console:150".to_string()),
12881        );
12882
12883        assert_eq!(query.after.as_ref().and_then(ConsoleCursor::seq), Some(150));
12884    }
12885
12886    #[test]
12887    fn console_timeline_replay_unavailable_rpc_uses_dedicated_error_code() {
12888        let response = console_timeline_replay_unavailable_response(
12889            json!("rid"),
12890            std::io::Error::other("timeline replay cursor is beyond the current store frontier")
12891                .into(),
12892            Some(&ConsoleCursor::from_seq(100)),
12893            Some(ConsoleCursor::from_seq(42)),
12894        );
12895
12896        assert_eq!(response["error"]["code"], json!(-32013));
12897        assert_eq!(
12898            response["error"]["data"],
12899            json!({
12900                "error": "replay_unavailable",
12901                "stream": "timeline",
12902                "requested_cursor": "console:100",
12903                "latest_cursor": "console:42",
12904            })
12905        );
12906    }
12907
12908    #[tokio::test]
12909    async fn multipart_blob_upload_stores_one_file() -> Result<(), Box<dyn std::error::Error>> {
12910        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
12911        let mut files = BTreeMap::new();
12912        files.insert(
12913            "upload-1".to_string(),
12914            MultipartImageUpload {
12915                media_type: "image/png".to_string(),
12916                bytes: Bytes::from_static(b"png-data"),
12917            },
12918        );
12919        let result = externalize_single_image_upload(
12920            &json!({
12921                "upload": {
12922                    "type": "image_upload",
12923                    "upload_id": "upload-1",
12924                    "media_type": "image/png"
12925                }
12926            }),
12927            files,
12928            store.clone(),
12929        )
12930        .await
12931        .map_err(std::io::Error::other)?;
12932
12933        assert_eq!(result["media_type"], json!("image/png"));
12934        assert_eq!(result["size"], json!(8));
12935        let Some(blob_id) = result["blob_id"].as_str() else {
12936            return Err(std::io::Error::other("blob id").into());
12937        };
12938        let payload = store
12939            .get_bytes(&meerkat_core::BlobId::from(blob_id))
12940            .await?;
12941        assert_eq!(payload.data.as_ref(), b"png-data");
12942        Ok(())
12943    }
12944
12945    #[tokio::test]
12946    async fn multipart_blob_upload_accepts_part_name_alias()
12947    -> Result<(), Box<dyn std::error::Error>> {
12948        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
12949        let mut files = BTreeMap::new();
12950        files.insert(
12951            "image-field".to_string(),
12952            MultipartImageUpload {
12953                media_type: "image/png".to_string(),
12954                bytes: Bytes::from_static(b"png-data"),
12955            },
12956        );
12957        let result = externalize_single_image_upload(
12958            &json!({
12959                "upload": {
12960                    "type": "image_upload",
12961                    "part_name": "image-field",
12962                    "media_type": "image/png"
12963                }
12964            }),
12965            files,
12966            store,
12967        )
12968        .await
12969        .map_err(std::io::Error::other)?;
12970
12971        assert_eq!(result["media_type"], json!("image/png"));
12972        assert!(
12973            result["blob_id"]
12974                .as_str()
12975                .is_some_and(|value| value.starts_with("sha256:"))
12976        );
12977        Ok(())
12978    }
12979
12980    #[tokio::test]
12981    async fn multipart_blob_upload_rejects_media_mismatch() -> Result<(), Box<dyn std::error::Error>>
12982    {
12983        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
12984        let mut files = BTreeMap::new();
12985        files.insert(
12986            "upload-1".to_string(),
12987            MultipartImageUpload {
12988                media_type: "image/jpeg".to_string(),
12989                bytes: Bytes::from_static(b"jpeg-data"),
12990            },
12991        );
12992        let err = match externalize_single_image_upload(
12993            &json!({
12994                "upload": {
12995                    "type": "image_upload",
12996                    "upload_id": "upload-1",
12997                    "media_type": "image/png"
12998                }
12999            }),
13000            files,
13001            store,
13002        )
13003        .await
13004        {
13005            Ok(_) => return Err(std::io::Error::other("media mismatch").into()),
13006            Err(err) => err,
13007        };
13008        assert!(
13009            err.contains("media type mismatch"),
13010            "unexpected error: {err}"
13011        );
13012        Ok(())
13013    }
13014
13015    #[tokio::test]
13016    async fn multipart_blob_upload_rejects_extra_file() -> Result<(), Box<dyn std::error::Error>> {
13017        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13018        let mut files = BTreeMap::new();
13019        for id in ["upload-1", "upload-2"] {
13020            files.insert(
13021                id.to_string(),
13022                MultipartImageUpload {
13023                    media_type: "image/png".to_string(),
13024                    bytes: Bytes::from_static(b"png"),
13025                },
13026            );
13027        }
13028        let err = match externalize_single_image_upload(
13029            &json!({
13030                "upload": {
13031                    "type": "image_upload",
13032                    "upload_id": "upload-1",
13033                    "media_type": "image/png"
13034                }
13035            }),
13036            files,
13037            store,
13038        )
13039        .await
13040        {
13041            Ok(_) => return Err(std::io::Error::other("one file only").into()),
13042            Err(err) => err,
13043        };
13044        assert!(
13045            err.contains("exactly one file part"),
13046            "unexpected error: {err}"
13047        );
13048        Ok(())
13049    }
13050
13051    #[tokio::test]
13052    async fn multipart_send_replaces_placeholders_and_removes_shadow_message()
13053    -> Result<(), Box<dyn std::error::Error>> {
13054        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13055        let mut files = BTreeMap::new();
13056        files.insert(
13057            "upload-1".to_string(),
13058            MultipartImageUpload {
13059                media_type: "image/webp".to_string(),
13060                bytes: Bytes::from_static(b"webp-data"),
13061            },
13062        );
13063        let mut params = json!({
13064            "member_id": "artist",
13065            "message": "stale shadow text",
13066            "content": [
13067                { "type": "text", "text": "describe" },
13068                {
13069                    "type": "image_upload",
13070                    "upload_id": "upload-1",
13071                    "media_type": "image/webp"
13072                }
13073            ]
13074        });
13075        externalize_image_upload_placeholders(&mut params, files, store)
13076            .await
13077            .map_err(std::io::Error::other)?;
13078
13079        assert!(params.get("message").is_none());
13080        assert_eq!(params["content"][1]["type"], json!("image"));
13081        assert_eq!(params["content"][1]["source"], json!("blob"));
13082        assert_eq!(params["content"][1]["media_type"], json!("image/webp"));
13083        assert!(
13084            params["content"][1]["blob_id"]
13085                .as_str()
13086                .is_some_and(|value| value.starts_with("sha256:"))
13087        );
13088        Ok(())
13089    }
13090
13091    #[tokio::test]
13092    async fn multipart_send_accepts_part_name_placeholder() -> Result<(), Box<dyn std::error::Error>>
13093    {
13094        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13095        let mut files = BTreeMap::new();
13096        files.insert(
13097            "image-field".to_string(),
13098            MultipartImageUpload {
13099                media_type: "image/png".to_string(),
13100                bytes: Bytes::from_static(b"png-data"),
13101            },
13102        );
13103        let mut params = json!({
13104            "member_id": "analyst",
13105            "content": [
13106                { "type": "text", "text": "describe" },
13107                {
13108                    "type": "image_upload",
13109                    "part_name": "image-field",
13110                    "media_type": "image/png"
13111                }
13112            ]
13113        });
13114
13115        externalize_image_upload_placeholders(&mut params, files, store)
13116            .await
13117            .map_err(std::io::Error::other)?;
13118
13119        assert_eq!(params["content"][1]["type"], json!("image"));
13120        assert_eq!(params["content"][1]["source"], json!("blob"));
13121        assert_eq!(params["content"][1]["media_type"], json!("image/png"));
13122        Ok(())
13123    }
13124
13125    #[tokio::test]
13126    async fn multipart_send_rejects_placeholder_without_file()
13127    -> Result<(), Box<dyn std::error::Error>> {
13128        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13129        let mut params = json!({
13130            "content": [{
13131                "type": "image_upload",
13132                "upload_id": "missing",
13133                "media_type": "image/png"
13134            }]
13135        });
13136        let err = match externalize_image_upload_placeholders(&mut params, BTreeMap::new(), store)
13137            .await
13138        {
13139            Ok(()) => return Err(std::io::Error::other("missing file").into()),
13140            Err(err) => err,
13141        };
13142        assert!(err.contains("missing file part"), "unexpected error: {err}");
13143        Ok(())
13144    }
13145
13146    #[test]
13147    fn generated_runtime_ids_do_not_match_sibling_colon_identities() {
13148        assert!(!member_id_matches_durable_identity(
13149            "rt:review:singleton:0",
13150            "review:singleton"
13151        ));
13152        assert!(!member_id_matches_durable_identity(
13153            "review:singleton:gen1",
13154            "review:singleton"
13155        ));
13156        assert!(!member_id_matches_durable_identity(
13157            "review:singleton:1",
13158            "review:singleton"
13159        ));
13160        assert!(!member_id_matches_durable_identity(
13161            "rt:review:singleton:qa:0",
13162            "review:singleton"
13163        ));
13164        assert!(!member_id_matches_durable_identity(
13165            "review:singleton:qa",
13166            "review:singleton"
13167        ));
13168    }
13169}