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, AgentRuntimeId, FenceToken, 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, ACTION_WORKGRAPH_MANAGE,
42    ACTION_WORKGRAPH_VIEW, AccessController, AccessGroup, AccessResource, AccessRule, AccessView,
43    AgentResourceAttributes,
44};
45use crate::blob_store::{BinaryBlobPayload, BinaryBlobStore, is_valid_blob_id_value};
46use crate::console_aggregator::{
47    ConsoleCursor, ConsoleFrame, ConsoleIdentityRecord, ConsoleLogError, ConsoleLogResult,
48    ConsoleLogStore, ConsoleReplayUnavailable, ConsoleSendError, ConsoleSendRequest,
49    ConsoleTimelineEvent, ConsoleTimelineMode, ConsoleTimelineQuery, ConsoleTimelineWindowPage,
50    ConsoleTimelineWindowQuery, ConsoleVisibility, ConsoleVisibilityPolicy,
51    HideImplicitDelegateMembersConsoleVisibilityPolicy, MobKitConsoleAggregator,
52};
53use crate::contact_directory::ContactDirectory;
54use crate::http_sse::{DEFAULT_KEEP_ALIVE_INTERVAL, KEEP_ALIVE_TEXT};
55use crate::mob_handle_runtime::{
56    MEMBER_STATE_ACTIVE, MEMBER_STATE_RETIRING, MobRuntime, member_status_state_string,
57};
58use crate::rpc::memory_methods::{
59    parse_agent_memory_forget_params, parse_agent_memory_manifest_params,
60    parse_agent_memory_recall_params, parse_agent_memory_remember_params,
61    parse_agent_memory_update_params,
62};
63use crate::rpc::{JSONRPC_VERSION, JsonRpcError, JsonRpcRequest, JsonRpcResponse};
64use crate::runtime::MobkitRuntimeHandle;
65use crate::runtime::{
66    ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember, ConsoleModelCapabilities,
67    ConsoleRestJsonRequest, DeliveryHistoryRequest, GatingDecideRequest, GatingDecision,
68    RuntimeDecisionState, extract_bearer_token_from_header,
69    handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph,
70    resolve_authorized_console_auth_from_token,
71};
72use crate::runtime::{MetadataScope, RuntimeMetadataTable, labels_to_json_value};
73use crate::types::{EventEnvelope, UnifiedEvent};
74use crate::unified_runtime::console_events::ConsoleEventStore;
75use crate::unified_runtime::mob_events::MobEventsStore;
76use crate::unified_runtime::{EventLogStore, EventQuery};
77
78#[derive(Clone)]
79pub struct ConsoleJsonState {
80    pub decisions: RuntimeDecisionState,
81    pub runtime: Option<MobRuntime>,
82    pub module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
83    pub contact_directory: Option<ContactDirectory>,
84    pub event_log: Option<std::sync::Arc<dyn EventLogStore>>,
85    /// Local gateway signing identity. Plumbed in so the console RPC
86    /// dispatch can answer `mobkit/peer_pubkey` and stamp non-inproc
87    /// `cross_mob/wire_local` descriptors with a real pubkey.
88    pub gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
89    pub(crate) identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
90    pub(crate) console_events: Option<ConsoleEventStore>,
91    pub(crate) console_aggregator: Option<MobKitConsoleAggregator>,
92    pub(crate) mob_events: Option<MobEventsStore>,
93    pub(crate) metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
94    pub(crate) visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
95    pub(crate) snapshot_read_model: ConsoleSnapshotReadModel,
96    /// Optional ABAC enforcement. `None` (or a disabled config) keeps every
97    /// console surface byte-for-byte compatible with the pre-access world.
98    pub(crate) access: Option<AccessController>,
99    /// Optional panel-capable store handle for the console Memory panel's
100    /// read-only `mobkit/memory/panel/*` RPCs (§9.3). `None` (recall-only
101    /// provider, no memory configured) leaves those methods unadvertised.
102    pub(crate) memory_panel: Option<Arc<dyn crate::memory::capabilities::MemoryPanelStore>>,
103    /// §16 Q1 provisional operator keying: the console send path notes
104    /// "authenticated principal P addressed identity I" through this
105    /// resolver; the memory coordinator reads it for operator-scope recall.
106    /// `None` when the operator scope is off (or memory is unconfigured).
107    pub(crate) operator_resolver:
108        Option<Arc<crate::memory::coordinator::ConsolePrincipalOperatorResolver>>,
109    /// Identity-first gateways: the mutable desired-identity roster that
110    /// `mobkit/ensure_member` extends (ask K0). `None` on session-owned
111    /// deployments.
112    pub(crate) identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
113    /// Realm-scoped WorkGraph service backing the console
114    /// `mobkit/workgraph/*` RPCs and the experience `workgraph` section.
115    /// `None` leaves the group unadvertised. The admission authority for the
116    /// duplicate-binding guards is NOT captured here: the dispatch arm takes
117    /// it from the mob runtime, so the console and the unified stdin surface
118    /// always serialize their check-then-act windows against the SAME
119    /// instance whatever constructed the service.
120    pub(crate) workgraph: Option<meerkat::WorkGraphService>,
121    /// Optional topology control-plane handle. Thin/aggregator-only routers
122    /// leave this absent; unified runtimes always pass their handle, even
123    /// while policy mode is disabled, so query remains available.
124    pub(crate) topology: Option<crate::topology_control::TopologyRuntimeHandle>,
125    /// Rebuildable detached-job observability supplied by the unified host.
126    pub(crate) job_health_projection: Option<Arc<std::sync::RwLock<Option<serde_json::Value>>>>,
127}
128
129#[derive(Debug, Clone)]
130struct ConsoleHttpAuthContext {
131    principal: Option<String>,
132    /// Per-request access snapshot when an [`AccessController`] is wired.
133    access_view: Option<AccessView>,
134}
135
136#[derive(Clone, Default)]
137pub(crate) struct ConsoleSnapshotReadModel {
138    inner: Arc<tokio::sync::RwLock<ConsoleSnapshotReadModelState>>,
139    /// Mutex held by whichever task is currently running a refresh.
140    /// Background refreshes (from `refresh_soon`) skip when the lock
141    /// is contended; cold-cache request waiters acquire it via
142    /// `lock_owned().await`, which is the actual "the in-flight
143    /// refresh has finished" signal. See `prime_now`.
144    refresh_lock: Arc<tokio::sync::Mutex<()>>,
145    /// `true` once at least one refresh has populated `inner` with
146    /// real data. Snapshot reads gate on this so a cold cache never
147    /// returns an empty member list to the first request.
148    primed: Arc<std::sync::atomic::AtomicBool>,
149}
150
151#[derive(Clone, Default)]
152struct ConsoleSnapshotReadModelState {
153    running: Option<bool>,
154    session_id_by_identity: BTreeMap<String, String>,
155    session_owner_by_id: BTreeMap<String, String>,
156    /// Pre-projected primary-mob console members. The background refresh
157    /// populates this from `handle.list_all_members()` + projection; the
158    /// snapshot hot path just clones from here so it never touches
159    /// `MobHandle` async methods.
160    primary_members: Vec<ConsoleMember>,
161    /// Pre-projected delegate-mob member groups, one Vec per delegate mob,
162    /// each already carrying its host_identity / source_mob_id label
163    /// fixups. The snapshot hot path extends `members` with these instead
164    /// of walking delegate handles per-request.
165    delegate_member_groups: Vec<Vec<ConsoleMember>>,
166}
167
168impl ConsoleSnapshotReadModel {
169    /// Returns the current cached snapshot. On a cold cache (no
170    /// refresh has completed yet) the request thread drives the
171    /// first refresh inline — or, if a background refresh task
172    /// holds the lock, waits for it to finish before reading.
173    /// Either way, snapshot endpoints never see an empty member
174    /// list before the read model has been populated.
175    async fn snapshot(&self, runtime: &MobRuntime) -> ConsoleSnapshotReadModelState {
176        if !self.primed.load(std::sync::atomic::Ordering::Acquire) {
177            Box::pin(self.prime_now(runtime)).await;
178        }
179        self.inner.read().await.clone()
180    }
181
182    /// Cold-cache priming. Acquires `refresh_lock` via the awaiting
183    /// (FIFO) path:
184    ///
185    /// - If no refresh task currently holds the lock, we acquire
186    ///   it immediately and run the refresh inline. Subsequent
187    ///   waiters that come in while we're running will queue
188    ///   behind us in the same lock.
189    /// - If a refresh task (spawned by `refresh_soon`) holds the
190    ///   lock, our `lock_owned().await` parks until the task
191    ///   drops the guard. By construction, the task only drops
192    ///   the guard *after* writing `inner` and setting `primed`.
193    ///   So when we acquire the lock, the cache is already
194    ///   populated and the second `primed` check returns early
195    ///   without redoing the work.
196    ///
197    /// No `Notify` is involved, so there's no lost-wake race to
198    /// reason about: the lock release is the signal, and `tokio`'s
199    /// Mutex enforces FIFO acquisition fairness so a `try_lock`
200    /// caller can't barge past a queued `lock_owned` waiter.
201    async fn prime_now(&self, runtime: &MobRuntime) {
202        if self.primed.load(std::sync::atomic::Ordering::Acquire) {
203            return;
204        }
205        let _guard = self.refresh_lock.clone().lock_owned().await;
206        if self.primed.load(std::sync::atomic::Ordering::Acquire) {
207            return;
208        }
209        let refreshed = Box::pin(collect_console_snapshot_read_model(runtime)).await;
210        *self.inner.write().await = refreshed;
211        self.primed
212            .store(true, std::sync::atomic::Ordering::Release);
213        // _guard drops here, releasing the lock and waking the next
214        // queued cold-cache waiter (if any). They'll see `primed`
215        // true after acquiring and return early.
216    }
217
218    /// Fire-and-forget background refresh. If a refresh is already
219    /// in flight (lock contended) we skip — the in-flight one is
220    /// enough. The request hot path doesn't call this; it goes
221    /// through `prime_now` on cold cache so it always gets a
222    /// populated snapshot. `refresh_soon` exists to keep a hot
223    /// cache fresh over time without blocking response requests.
224    fn refresh_soon(&self, runtime: MobRuntime) {
225        let Ok(runtime_handle) = tokio::runtime::Handle::try_current() else {
226            return;
227        };
228        let Ok(guard) = self.refresh_lock.clone().try_lock_owned() else {
229            return;
230        };
231        let inner = Arc::clone(&self.inner);
232        let primed = Arc::clone(&self.primed);
233        runtime_handle.spawn(async move {
234            let _guard = guard;
235            let refreshed = Box::pin(collect_console_snapshot_read_model(&runtime)).await;
236            *inner.write().await = refreshed;
237            primed.store(true, std::sync::atomic::Ordering::Release);
238            // _guard drops; cold-cache waiters parked on
239            // `lock_owned().await` in `prime_now` wake here and
240            // observe `primed = true` after acquiring.
241        });
242    }
243}
244
245const CONSOLE_FRONTEND_INDEX_HTML: &str = include_str!("../console-dist/index.html");
246const CONSOLE_FRONTEND_APP_JS: &str = include_str!("../console-dist/console-app.js");
247const CONSOLE_FRONTEND_APP_CSS: &str = include_str!("../console-dist/console-app.css");
248const MAX_MULTIPART_IMAGE_BYTES: usize = 25 * 1024 * 1024;
249const MAX_MULTIPART_IMAGES: usize = 4;
250const MAX_MULTIPART_BODY_BYTES: usize =
251    (MAX_MULTIPART_IMAGE_BYTES * MAX_MULTIPART_IMAGES) + 1024 * 1024;
252
253pub fn console_json_router(decisions: RuntimeDecisionState) -> Router {
254    console_json_router_with_state(ConsoleJsonState {
255        decisions,
256        runtime: None,
257        module_runtime: None,
258        contact_directory: None,
259        event_log: None,
260        gateway_peer_keys: None,
261        identity_runtime: None,
262        console_events: None,
263        console_aggregator: None,
264        mob_events: None,
265        metadata_table: None,
266        visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
267        snapshot_read_model: ConsoleSnapshotReadModel::default(),
268        access: None,
269        memory_panel: None,
270        operator_resolver: None,
271        identity_roster: None,
272        workgraph: None,
273        topology: None,
274        job_health_projection: None,
275    })
276}
277
278pub fn console_json_router_with_aggregator(
279    decisions: RuntimeDecisionState,
280    console_aggregator: MobKitConsoleAggregator,
281) -> Router {
282    console_json_router_with_aggregator_and_access(decisions, console_aggregator, None)
283}
284
285pub fn console_json_router_with_aggregator_and_access(
286    decisions: RuntimeDecisionState,
287    console_aggregator: MobKitConsoleAggregator,
288    access: Option<AccessController>,
289) -> Router {
290    console_json_router_with_state(ConsoleJsonState {
291        decisions,
292        runtime: None,
293        module_runtime: None,
294        contact_directory: None,
295        event_log: None,
296        gateway_peer_keys: None,
297        identity_runtime: None,
298        console_events: None,
299        console_aggregator: Some(console_aggregator),
300        mob_events: None,
301        metadata_table: None,
302        visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
303        snapshot_read_model: ConsoleSnapshotReadModel::default(),
304        access,
305        memory_panel: None,
306        operator_resolver: None,
307        identity_roster: None,
308        workgraph: None,
309        topology: None,
310        job_health_projection: None,
311    })
312}
313
314pub fn console_json_router_with_runtime(
315    decisions: RuntimeDecisionState,
316    runtime: MobRuntime,
317    contact_directory: Option<ContactDirectory>,
318    event_log: Option<std::sync::Arc<dyn EventLogStore>>,
319) -> Router {
320    console_json_router_with_runtime_and_events(
321        decisions,
322        runtime,
323        None,
324        contact_directory,
325        event_log,
326        None,
327        None,
328        None,
329        None,
330        None,
331        None,
332    )
333}
334
335#[allow(clippy::too_many_arguments)]
336pub(crate) fn console_json_router_with_runtime_and_events(
337    decisions: RuntimeDecisionState,
338    runtime: MobRuntime,
339    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
340    contact_directory: Option<ContactDirectory>,
341    event_log: Option<std::sync::Arc<dyn EventLogStore>>,
342    gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
343    console_events: Option<ConsoleEventStore>,
344    console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
345    mob_events: Option<MobEventsStore>,
346    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
347    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
348) -> Router {
349    console_json_router_with_runtime_events_and_policy(
350        decisions,
351        runtime,
352        module_runtime,
353        contact_directory,
354        event_log,
355        gateway_peer_keys,
356        console_events,
357        console_log_store,
358        mob_events,
359        metadata_table,
360        identity_runtime,
361        Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
362        None,
363        None,
364        None,
365        None,
366        None,
367        None,
368        None,
369    )
370}
371
372#[allow(clippy::too_many_arguments)]
373pub(crate) fn console_json_router_with_runtime_events_and_policy(
374    decisions: RuntimeDecisionState,
375    runtime: MobRuntime,
376    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
377    contact_directory: Option<ContactDirectory>,
378    event_log: Option<std::sync::Arc<dyn EventLogStore>>,
379    gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
380    console_events: Option<ConsoleEventStore>,
381    console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
382    mob_events: Option<MobEventsStore>,
383    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
384    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
385    visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
386    access: Option<AccessController>,
387    memory_panel: Option<Arc<dyn crate::memory::capabilities::MemoryPanelStore>>,
388    operator_resolver: Option<Arc<crate::memory::coordinator::ConsolePrincipalOperatorResolver>>,
389    identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
390    workgraph: Option<meerkat::WorkGraphService>,
391    topology: Option<crate::topology_control::TopologyRuntimeHandle>,
392    job_health_projection: Option<Arc<std::sync::RwLock<Option<serde_json::Value>>>>,
393) -> Router {
394    let console_aggregator = console_events.clone().map(|events| {
395        if let Some(store) = console_log_store {
396            let aggregator = MobKitConsoleAggregator::new(store);
397            aggregator.register_runtime_handles_with_policy(
398                "default",
399                "",
400                runtime.clone(),
401                identity_runtime.clone(),
402                events,
403                visibility_policy.clone(),
404            );
405            aggregator
406        } else {
407            let aggregator = MobKitConsoleAggregator::in_memory();
408            aggregator.register_runtime_handles_with_policy(
409                "default",
410                "",
411                runtime.clone(),
412                identity_runtime.clone(),
413                events,
414                visibility_policy.clone(),
415            );
416            aggregator
417        }
418    });
419    let snapshot_read_model = ConsoleSnapshotReadModel::default();
420    snapshot_read_model.refresh_soon(runtime.clone());
421    // Fall back to the mob runtime's bootstrap-time service so routers built
422    // through the thinner constructors still expose workgraph.
423    let workgraph = workgraph.or_else(|| runtime.workgraph_service());
424    console_json_router_with_state(ConsoleJsonState {
425        decisions,
426        runtime: Some(runtime),
427        module_runtime,
428        contact_directory,
429        event_log,
430        gateway_peer_keys,
431        identity_runtime,
432        console_events,
433        console_aggregator,
434        mob_events,
435        metadata_table,
436        visibility_policy,
437        snapshot_read_model,
438        access,
439        memory_panel,
440        operator_resolver,
441        identity_roster,
442        workgraph,
443        topology,
444        job_health_projection,
445    })
446}
447
448pub fn console_frontend_router() -> Router {
449    Router::new()
450        .route("/", get(|| async { Redirect::temporary("/console") }))
451        .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
452        .route("/console", get(console_frontend_index_handler))
453        .route("/console/", get(console_frontend_index_handler))
454        .route(
455            "/console/assets/console-app.js",
456            get(console_frontend_app_js_handler),
457        )
458        .route(
459            "/console/assets/console-app.css",
460            get(console_frontend_app_css_handler),
461        )
462}
463
464fn console_json_router_with_state(state: ConsoleJsonState) -> Router {
465    let router = Router::new()
466        .route("/console/experience", get(console_json_handler))
467        .route("/console/modules", get(console_json_handler))
468        .route("/console/identities", get(console_identities_handler))
469        .route("/console/timeline", get(console_timeline_handler))
470        .route(
471            "/console/timeline/stream",
472            get(console_timeline_stream_handler),
473        )
474        .route(
475            "/console/identity/{identity}/stream",
476            get(console_identity_timeline_stream_handler),
477        )
478        .route("/console/send", post(console_send_handler))
479        .route("/console/rpc", post(console_rpc_handler))
480        .route(
481            "/console/rpc/multipart",
482            post(console_rpc_multipart_handler)
483                .layer(DefaultBodyLimit::max(MAX_MULTIPART_BODY_BYTES)),
484        )
485        .route("/blobs/{blob_id}", get(blob_get_handler));
486    router.with_state(state)
487}
488
489pub async fn console_json_handler(
490    State(state): State<ConsoleJsonState>,
491    headers: HeaderMap,
492    uri: Uri,
493) -> impl IntoResponse {
494    let mut path = uri
495        .path_and_query()
496        .map(|path_and_query| path_and_query.as_str().to_string())
497        .unwrap_or_else(|| uri.path().to_string());
498
499    // If the request carries a Bearer token and the URL doesn't already have
500    // an auth_token query param, inject it so the console-ingress auth
501    // resolver can validate it through the existing query-param path.
502    //
503    // JWT tokens use base64url characters (A-Za-z0-9_-.) plus optional '='
504    // padding. We percent-encode the bearer when injecting so opaque
505    // bearer tokens containing `&`, `=`, `+`, `%`, etc. (legal under
506    // RFC 6750 §2.1) survive the round trip — pre-fix, an `&` made
507    // injection skip and authentication fail. Substring detection of
508    // an existing `auth_token=` is now key-aware via form_urlencoded
509    // so `xauth_token=` doesn't masquerade as the real key.
510    let already_has_token = path
511        .split_once('?')
512        .map(|(_, q)| form_urlencoded::parse(q.as_bytes()).any(|(key, _)| key == "auth_token"))
513        .unwrap_or(false);
514    if !already_has_token
515        && let Some(bearer) = headers
516            .get(header::AUTHORIZATION)
517            .and_then(|v| v.to_str().ok())
518            .and_then(extract_bearer_token_from_header)
519    {
520        let encoded: String = form_urlencoded::byte_serialize(bearer.as_bytes()).collect();
521        let sep = if path.contains('?') { '&' } else { '?' };
522        path = format!("{path}{sep}auth_token={encoded}");
523    }
524
525    let config_module_ids: Vec<String> = state
526        .decisions
527        .modules
528        .iter()
529        .map(|m| m.id.clone())
530        .collect();
531    let live_snapshot = match &state.runtime {
532        Some(runtime) => {
533            state.snapshot_read_model.refresh_soon(runtime.clone());
534            Some(
535                Box::pin(build_live_snapshot(
536                    runtime,
537                    &config_module_ids,
538                    state.console_events.as_ref(),
539                    state.visibility_policy.as_ref(),
540                    &state.snapshot_read_model,
541                ))
542                .await,
543            )
544        }
545        None => match &state.console_aggregator {
546            Some(aggregator) => Box::pin(build_aggregator_live_snapshot(
547                aggregator,
548                &config_module_ids,
549            ))
550            .await
551            .ok(),
552            None => None,
553        },
554    }
555    .map(|mut snapshot| {
556        apply_console_visibility_policy(&mut snapshot, state.visibility_policy.as_ref());
557        snapshot
558    });
559
560    let response = handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph(
561        &state.decisions,
562        &ConsoleRestJsonRequest {
563            method: "GET".to_string(),
564            path,
565            auth: None,
566        },
567        live_snapshot.as_ref(),
568        state.access.as_ref(),
569        state.memory_panel.is_some(),
570        state.workgraph.is_some(),
571    );
572    let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
573    (status, Json::<Value>(response.body))
574}
575
576pub async fn console_rpc_handler(
577    State(state): State<ConsoleJsonState>,
578    headers: HeaderMap,
579    uri: Uri,
580    Json(request): Json<Value>,
581) -> impl IntoResponse {
582    // Parse the request early so we can check the method for auth gating.
583    let parsed_request = match serde_json::from_value::<JsonRpcRequest>(request) {
584        Ok(req) => req,
585        Err(_) => {
586            return (
587                StatusCode::OK,
588                Json::<Value>(serde_json::json!({
589                    "jsonrpc": JSONRPC_VERSION,
590                    "id": Value::Null,
591                    "error": { "code": -32600, "message": "Invalid Request" }
592                })),
593            );
594        }
595    };
596    let request_method = parsed_request.method.clone();
597
598    // Auth enforcement:
599    // - When require_app_auth is true: validate bearer token (OIDC + allowlist)
600    // - When require_app_auth is false: allow requests without a token.
601    // - When console.read_only is true: deny mutating methods even for
602    //   otherwise authorized callers.
603    let auth_context = match console_request_auth_context(&state, &headers, &uri) {
604        Some(context) => context,
605        None => {
606            return (
607                StatusCode::UNAUTHORIZED,
608                Json::<Value>(serde_json::json!({
609                    "jsonrpc": JSONRPC_VERSION,
610                    "id": parsed_request.id.unwrap_or(Value::Null),
611                    "error": {
612                        "code": -32600,
613                        "message": "unauthorized: console rpc requires a valid auth token",
614                    }
615                })),
616            );
617        }
618    };
619    // By this point the request is always authorized:
620    // - require_app_auth=true: an invalid token already returned 401 above.
621    // - require_app_auth=false: all methods are permitted unconditionally.
622    // Mutating methods may still be blocked by console.read_only.
623    let is_authenticated = true;
624    let read_only = state.decisions.console.read_only;
625    let Some(runtime) = &state.runtime else {
626        let response_value = Box::pin(handle_console_aggregator_rpc(
627            state.console_aggregator.clone(),
628            parsed_request,
629            is_authenticated,
630            read_only,
631            state.access.as_ref(),
632            auth_context.access_view.as_ref(),
633        ))
634        .await;
635        return (StatusCode::OK, Json::<Value>(response_value));
636    };
637
638    let response_value = Box::pin(handle_console_runtime_rpc_with_visibility(
639        runtime,
640        state.module_runtime.clone(),
641        state.contact_directory.as_ref(),
642        state.gateway_peer_keys.as_ref(),
643        state.console_events.clone(),
644        state.console_aggregator.clone(),
645        state.identity_runtime.clone(),
646        state.metadata_table.clone(),
647        state.mob_events.clone(),
648        state.visibility_policy.as_ref(),
649        parsed_request,
650        is_authenticated,
651        read_only,
652        auth_context.principal.as_deref(),
653        state.access.as_ref(),
654        auth_context.access_view.as_ref(),
655        state.memory_panel.as_deref(),
656        state.identity_roster.clone(),
657        state.workgraph.as_ref(),
658        state.topology.as_ref(),
659    ))
660    .await;
661    let response_value = decorate_console_job_projection(
662        response_value,
663        &request_method,
664        state.job_health_projection.as_ref(),
665    );
666    (StatusCode::OK, Json::<Value>(response_value))
667}
668
669fn decorate_console_job_projection(
670    mut response: Value,
671    method: &str,
672    projection_slot: Option<&Arc<std::sync::RwLock<Option<Value>>>>,
673) -> Value {
674    let projection = projection_slot.and_then(|slot| {
675        slot.read()
676            .unwrap_or_else(std::sync::PoisonError::into_inner)
677            .clone()
678    });
679    let Some(projection) = projection else {
680        return response;
681    };
682    match method {
683        "mobkit/status" | "mobkit/capabilities" => {
684            response["result"]["detached_jobs"] = projection
685                .get("detached_jobs")
686                .cloned()
687                .unwrap_or(Value::Null);
688        }
689        "mobkit/member_status" => {
690            if let Some(session_id) = response["result"]["current_session_id"].as_str()
691                && let Some(session_jobs) = projection
692                    .get("by_session")
693                    .and_then(|by_session| by_session.get(session_id))
694            {
695                response["result"]["detached_jobs"] = session_jobs.clone();
696                response["result"]["awaiting_detached"] = session_jobs
697                    .get("awaiting_detached")
698                    .cloned()
699                    .unwrap_or(Value::Bool(false));
700            }
701        }
702        _ => {}
703    }
704    response
705}
706
707#[derive(Debug, serde::Deserialize)]
708struct ConsoleTimelineHttpQuery {
709    #[serde(default)]
710    identity: Option<String>,
711    #[serde(default)]
712    conversation_id: Option<String>,
713    #[serde(default)]
714    after: Option<String>,
715    #[serde(default)]
716    before: Option<String>,
717    #[serde(default)]
718    mode: Option<ConsoleTimelineMode>,
719    #[serde(default)]
720    limit: Option<usize>,
721}
722
723async fn console_identities_handler(
724    State(state): State<ConsoleJsonState>,
725    headers: HeaderMap,
726    uri: Uri,
727) -> impl IntoResponse {
728    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
729        return console_json_error(
730            StatusCode::UNAUTHORIZED,
731            "unauthorized",
732            "console identities require a valid auth token",
733        );
734    };
735    let Some(aggregator) = &state.console_aggregator else {
736        return console_json_error(
737            StatusCode::NOT_FOUND,
738            "unavailable",
739            "console aggregator unavailable",
740        );
741    };
742    let aggregator = aggregator.clone();
743    match Box::pin(aggregator.list_identities()).await {
744        Ok(mut identities) => {
745            retain_visible_identity_records(&mut identities, auth_context.access_view.as_ref());
746            (
747                StatusCode::OK,
748                Json::<Value>(json!({ "identities": identities })),
749            )
750                .into_response()
751        }
752        Err(err) => {
753            tracing::warn!(target: "mobkit::console", error = %err, "console identities request failed");
754            console_json_error(
755                StatusCode::INTERNAL_SERVER_ERROR,
756                "internal_error",
757                "console identities unavailable",
758            )
759        }
760    }
761}
762
763async fn console_timeline_handler(
764    State(state): State<ConsoleJsonState>,
765    headers: HeaderMap,
766    uri: Uri,
767    Query(query): Query<ConsoleTimelineHttpQuery>,
768) -> impl IntoResponse {
769    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
770        return console_json_error(
771            StatusCode::UNAUTHORIZED,
772            "unauthorized",
773            "console timeline requires a valid auth token",
774        );
775    };
776    let Some(aggregator) = &state.console_aggregator else {
777        return console_json_error(
778            StatusCode::NOT_FOUND,
779            "unavailable",
780            "console aggregator unavailable",
781        );
782    };
783    // Prime the attribute cache from the live roster so label/role rules
784    // resolve when filtering frames by `agent.view` — the timeline REST
785    // surface must not depend on a prior `/console/experience` call.
786    if let Some(runtime) = &state.runtime
787        && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
788    {
789        prime_access_cache_from_runtime(runtime, controller).await;
790    }
791    let timeline_query = timeline_query_from_http(query, None);
792    match Box::pin(aggregator.query_timeline_windowed(timeline_query)).await {
793        Ok(mut page) => {
794            if let Some(view) = auth_context.access_view.as_ref() {
795                page.frames
796                    .retain(|frame| view.can_view_agent(frame.identity.as_str()));
797            }
798            (
799                StatusCode::OK,
800                Json::<Value>(
801                    serde_json::to_value(page).unwrap_or_else(|_| json!({ "frames": [] })),
802                ),
803            )
804                .into_response()
805        }
806        Err(err) => {
807            console_json_error(StatusCode::CONFLICT, "replay_unavailable", &err.to_string())
808        }
809    }
810}
811
812async fn console_send_handler(
813    State(state): State<ConsoleJsonState>,
814    headers: HeaderMap,
815    uri: Uri,
816    Json(request): Json<ConsoleSendRequest>,
817) -> impl IntoResponse {
818    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
819        return console_json_error(
820            StatusCode::UNAUTHORIZED,
821            "unauthorized",
822            "console send requires a valid auth token",
823        );
824    };
825    if state.decisions.console.read_only {
826        return console_json_error(StatusCode::FORBIDDEN, "read_only", "console is read-only");
827    }
828    if let Err(message) =
829        crate::member_comms_id::validate_public_member_alias("identity", request.identity.as_str())
830    {
831        return console_json_error(StatusCode::BAD_REQUEST, "invalid_request", &message);
832    }
833    // Prime the attribute cache from the live roster BEFORE the `agent.send`
834    // decision: role/label-scoped rules resolve through the cache, and on a
835    // cold cache the decision degrades to a bare-identity resource — a deny
836    // rule scoped by `roles`/`match_labels` would not match, so a scripted
837    // caller hitting `/console/send` as its first request could reach a
838    // member the rule was meant to exclude (fail-closed requires the
839    // attributes to be present when the rule is evaluated).
840    if let Some(runtime) = &state.runtime
841        && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
842    {
843        prime_access_cache_from_runtime(runtime, controller).await;
844    }
845    if let Some(view) = auth_context.access_view.as_ref()
846        && view.enforced()
847        && !view.allows_agent(ACTION_AGENT_SEND, request.identity.as_str())
848    {
849        return console_json_error(
850            StatusCode::FORBIDDEN,
851            "access_denied",
852            "you are not allowed to send to this agent",
853        );
854    }
855    // §16 Q1 provisional operator keying: an authenticated principal
856    // addressing this identity IS the active operator for its turns.
857    // Unauthenticated consoles (no subject) note nothing — operator-scope
858    // recall stays inert without a real principal.
859    if let Some(resolver) = state.operator_resolver.as_ref()
860        && let Some(subject) = auth_context
861            .access_view
862            .as_ref()
863            .and_then(|view| view.subject())
864    {
865        resolver.note_interaction(request.identity.as_str(), subject);
866    }
867    let Some(aggregator) = &state.console_aggregator else {
868        return console_json_error(
869            StatusCode::NOT_FOUND,
870            "unavailable",
871            "console aggregator unavailable",
872        );
873    };
874    if let Some(identity_runtime) = &state.identity_runtime {
875        return match Box::pin(console_send_with_identity_first_fallback(
876            aggregator,
877            identity_runtime.clone(),
878            state.runtime.as_ref(),
879            state.console_events.as_ref(),
880            request,
881        ))
882        .await
883        {
884            Ok(accepted) => (
885                StatusCode::OK,
886                Json::<Value>(
887                    serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
888                ),
889            )
890                .into_response(),
891            Err(err) => console_send_error_response(err),
892        };
893    }
894    match Box::pin(aggregator.send(request)).await {
895        Ok(accepted) => (
896            StatusCode::OK,
897            Json::<Value>(
898                serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
899            ),
900        )
901            .into_response(),
902        Err(err) => console_send_error_response(err),
903    }
904}
905
906async fn console_send_with_identity_first_fallback(
907    aggregator: &MobKitConsoleAggregator,
908    identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
909    runtime: Option<&MobRuntime>,
910    console_events: Option<&ConsoleEventStore>,
911    request: ConsoleSendRequest,
912) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
913    let member_send_request = request.clone();
914    match Box::pin(console_send_identity_first(
915        aggregator,
916        identity_runtime,
917        runtime,
918        console_events,
919        request,
920    ))
921    .await
922    {
923        Err(ConsoleSendError::UnknownIdentity(_)) => {
924            Box::pin(aggregator.send(member_send_request)).await
925        }
926        result => result,
927    }
928}
929
930async fn console_send_identity_first(
931    aggregator: &MobKitConsoleAggregator,
932    identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
933    runtime: Option<&MobRuntime>,
934    console_events: Option<&ConsoleEventStore>,
935    mut request: ConsoleSendRequest,
936) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
937    let requested_identity = request.identity.clone();
938    let parsed_identity = crate::identity_first::AgentIdentity::parse(request.identity.as_str())
939        .map_err(|err| ConsoleSendError::InvalidRequest(format!("invalid identity: {err}")))?;
940    let content: ContentInput = serde_json::from_value(request.content.clone())
941        .map_err(|err| ConsoleSendError::InvalidContent(err.to_string()))?;
942    if let ContentInput::Text(text) = &content
943        && text.trim().is_empty()
944    {
945        return Err(ConsoleSendError::InvalidContent(
946            "content must be non-empty".to_string(),
947        ));
948    }
949    if let ContentInput::Blocks(blocks) = &content
950        && blocks.is_empty()
951    {
952        return Err(ConsoleSendError::InvalidContent(
953            "content blocks must be non-empty".to_string(),
954        ));
955    }
956    let handling_mode = parse_identity_first_handling_mode(request.handling_mode.as_deref())?;
957
958    let (identity, status) = match identity_runtime.status(&parsed_identity).await {
959        Ok(status) => (parsed_identity, status),
960        Err(original_err) => {
961            let Some(canonical_identity) = Box::pin(resolve_console_send_identity_alias(
962                aggregator,
963                &requested_identity,
964            ))
965            .await
966            else {
967                return Err(identity_runtime_error_to_console_send_error(
968                    requested_identity.as_str(),
969                    original_err,
970                ));
971            };
972            let identity = crate::identity_first::AgentIdentity::parse(canonical_identity.as_str())
973                .map_err(|err| {
974                    ConsoleSendError::InvalidRequest(format!("invalid aliased identity: {err}"))
975                })?;
976            let status = identity_runtime.status(&identity).await.map_err(|_| {
977                identity_runtime_error_to_console_send_error(
978                    requested_identity.as_str(),
979                    original_err,
980                )
981            })?;
982            request.identity = canonical_identity;
983            (identity, status)
984        }
985    };
986    let session_id = status
987        .session_id
988        .as_ref()
989        .map(std::string::ToString::to_string);
990    let runtime_member_id = status
991        .agent_runtime_id
992        .as_ref()
993        .map(|id| id.as_str().to_string());
994    let accepted = Box::pin(
995        aggregator.reserve_identity_first_interaction(request.clone(), session_id.as_deref()),
996    )
997    .await?;
998
999    if let Some(events) = console_events {
1000        events
1001            .reserve_interaction_value(
1002                identity.as_str(),
1003                runtime_member_id.as_deref(),
1004                &accepted.interaction_id,
1005                &request.origin,
1006                request.content.clone(),
1007            )
1008            .await
1009            .map_err(ConsoleSendError::State)?;
1010    }
1011
1012    if let (Some(runtime), Some(events), Some(runtime_member_id)) = (
1013        runtime,
1014        console_events.cloned(),
1015        runtime_member_id.as_deref(),
1016    ) {
1017        start_identity_first_console_live_projection(
1018            runtime,
1019            events,
1020            identity.as_str(),
1021            runtime_member_id,
1022        )
1023        .await;
1024    }
1025
1026    if handling_mode == meerkat_core::types::HandlingMode::Steer {
1027        let send_result =
1028            if crate::member_comms_id::is_reserved_generated_alias(&requested_identity) {
1029                identity_runtime
1030                    .send_with_mode_and_interaction_member_alias_tracked(
1031                        &identity,
1032                        requested_identity.as_str(),
1033                        &content,
1034                        handling_mode,
1035                        Some(accepted.interaction_id.as_str()),
1036                    )
1037                    .await
1038            } else {
1039                identity_runtime
1040                    .send_with_mode_and_interaction_tracked(
1041                        &identity,
1042                        &content,
1043                        handling_mode,
1044                        Some(accepted.interaction_id.as_str()),
1045                    )
1046                    .await
1047            };
1048        match send_result {
1049            Ok(_) => {
1050                if let Err(err) = aggregator
1051                    .mark_steer_interaction_delivered(
1052                        &accepted.input_frame_id,
1053                        &accepted.interaction_id,
1054                    )
1055                    .await
1056                {
1057                    tracing::warn!(
1058                        identity = %identity,
1059                        error = %err,
1060                        "console identity-first steer was admitted but delivery status projection failed"
1061                    );
1062                }
1063            }
1064            Err(err) => {
1065                let _ = aggregator
1066                    .mark_interaction_delivery_failed(&accepted.input_frame_id)
1067                    .await;
1068                if let Some(events) = console_events {
1069                    events
1070                        .record_interaction_failure(
1071                            identity.as_str(),
1072                            accepted.interaction_id.as_str(),
1073                            json!({
1074                                "origin": request.origin,
1075                                "error": err.to_string(),
1076                            }),
1077                        )
1078                        .await;
1079                }
1080                tracing::warn!(
1081                    identity = %identity,
1082                    error = %err,
1083                    "console identity-first steer was accepted but delivery failed"
1084                );
1085                return Err(identity_runtime_error_to_console_send_error(
1086                    identity.as_str(),
1087                    err,
1088                ));
1089            }
1090        }
1091        return Ok(accepted);
1092    }
1093
1094    let dispatch_aggregator = aggregator.clone();
1095    let dispatch_events = console_events.cloned();
1096    let dispatch_identity = identity.clone();
1097    let dispatch_content = content.clone();
1098    let dispatch_origin = request.origin.clone();
1099    let dispatch_accepted = accepted.clone();
1100    let dispatch_expected_alias =
1101        crate::member_comms_id::is_reserved_generated_alias(&requested_identity)
1102            .then_some(requested_identity);
1103    tokio::spawn(async move {
1104        let send_result = if let Some(expected_alias) = dispatch_expected_alias.as_deref() {
1105            identity_runtime
1106                .send_with_mode_and_interaction_member_alias_tracked(
1107                    &dispatch_identity,
1108                    expected_alias,
1109                    &dispatch_content,
1110                    handling_mode,
1111                    Some(dispatch_accepted.interaction_id.as_str()),
1112                )
1113                .await
1114        } else {
1115            identity_runtime
1116                .send_with_mode_and_interaction_tracked(
1117                    &dispatch_identity,
1118                    &dispatch_content,
1119                    handling_mode,
1120                    Some(dispatch_accepted.interaction_id.as_str()),
1121                )
1122                .await
1123        };
1124        match send_result {
1125            Ok(_) => {
1126                if let Err(err) = dispatch_aggregator
1127                    .mark_interaction_delivered(&dispatch_accepted.input_frame_id)
1128                    .await
1129                {
1130                    tracing::warn!(
1131                        identity = %dispatch_identity,
1132                        error = %err,
1133                        "console identity-first send was accepted but delivery status projection failed"
1134                    );
1135                }
1136            }
1137            Err(err) => {
1138                let _ = dispatch_aggregator
1139                    .mark_interaction_delivery_failed(&dispatch_accepted.input_frame_id)
1140                    .await;
1141                if let Some(events) = dispatch_events {
1142                    events
1143                        .record_interaction_failure(
1144                            dispatch_identity.as_str(),
1145                            dispatch_accepted.interaction_id.as_str(),
1146                            json!({
1147                                "origin": dispatch_origin,
1148                                "error": err.to_string(),
1149                            }),
1150                        )
1151                        .await;
1152                }
1153                tracing::warn!(
1154                    identity = %dispatch_identity,
1155                    error = %err,
1156                    "console identity-first send was accepted but delivery failed"
1157                );
1158            }
1159        }
1160    });
1161    Ok(accepted)
1162}
1163
1164async fn start_identity_first_console_live_projection(
1165    runtime: &MobRuntime,
1166    console_events: ConsoleEventStore,
1167    identity: &str,
1168    runtime_member_id: &str,
1169) {
1170    let identity = identity.to_string();
1171    let runtime_member_id = runtime_member_id.to_string();
1172    let mut stream = match runtime
1173        .handle()
1174        .subscribe_agent_events(&crate::member_comms_id::mob_member_id(
1175            runtime_member_id.as_str(),
1176        ))
1177        .await
1178    {
1179        Ok(stream) => stream,
1180        Err(err) => {
1181            tracing::warn!(
1182                identity = %identity,
1183                runtime_member_id = %runtime_member_id,
1184                error = %err,
1185                "console identity-first live projection could not subscribe to agent events"
1186            );
1187            return;
1188        }
1189    };
1190
1191    tokio::spawn(async move {
1192        while let Some(envelope) = stream.next().await {
1193            let event_type = agent_event_type(&envelope.payload).to_string();
1194            let unified = EventEnvelope {
1195                event_id: format!("evt-agent-{}", envelope.event_id),
1196                source: "agent".to_string(),
1197                timestamp_ms: envelope.timestamp_ms,
1198                event: UnifiedEvent::Agent {
1199                    agent_id: runtime_member_id.clone(),
1200                    event_type: event_type.clone(),
1201                    // Console wire shape, not the raw 0.7 event: keeps the
1202                    // derived `result` text (and `tool_call_id` mirror) that
1203                    // the embedded console's adapters and image-frame
1204                    // projection consume.
1205                    payload: Some(crate::mob_handle_runtime::console_agent_event_payload(
1206                        &envelope.payload,
1207                    )),
1208                },
1209            };
1210            console_events.project_unified_event(&unified).await;
1211            if matches!(event_type.as_str(), "run_completed" | "run_failed") {
1212                break;
1213            }
1214        }
1215    });
1216}
1217
1218fn parse_identity_first_handling_mode(
1219    value: Option<&str>,
1220) -> Result<meerkat_core::types::HandlingMode, ConsoleSendError> {
1221    match value.unwrap_or("queue") {
1222        "queue" => Ok(meerkat_core::types::HandlingMode::Queue),
1223        "steer" => Ok(meerkat_core::types::HandlingMode::Steer),
1224        other => Err(ConsoleSendError::InvalidHandlingMode(other.to_string())),
1225    }
1226}
1227
1228async fn resolve_console_send_identity_alias(
1229    aggregator: &MobKitConsoleAggregator,
1230    requested_identity: &str,
1231) -> Option<String> {
1232    let identities = Box::pin(aggregator.list_identities()).await.ok()?;
1233    identities
1234        .into_iter()
1235        .find(|record| {
1236            record.identity == requested_identity || record.runtime_member_id == requested_identity
1237        })
1238        .map(|record| record.identity)
1239}
1240
1241fn identity_runtime_error_to_console_send_error(
1242    identity: &str,
1243    err: crate::identity_first::IdentityRuntimeError,
1244) -> ConsoleSendError {
1245    match err {
1246        crate::identity_first::IdentityRuntimeError::UnknownIdentity(_) => {
1247            ConsoleSendError::UnknownIdentity(identity.to_string())
1248        }
1249        crate::identity_first::IdentityRuntimeError::NotAddressable(_) => {
1250            ConsoleSendError::NotAddressable(identity.to_string())
1251        }
1252        crate::identity_first::IdentityRuntimeError::InvalidState { .. } => {
1253            ConsoleSendError::Retired(identity.to_string())
1254        }
1255        other => ConsoleSendError::Dispatch(other.to_string()),
1256    }
1257}
1258
1259async fn console_timeline_stream_handler(
1260    State(state): State<ConsoleJsonState>,
1261    headers: HeaderMap,
1262    uri: Uri,
1263    Query(query): Query<ConsoleTimelineHttpQuery>,
1264) -> impl IntoResponse {
1265    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
1266        return console_json_error(
1267            StatusCode::UNAUTHORIZED,
1268            "unauthorized",
1269            "console timeline stream requires a valid auth token",
1270        );
1271    };
1272    let access_view = auth_context.access_view.filter(AccessView::enforced);
1273    // Prime the attribute cache from the live roster before any per-frame
1274    // `agent.view` filter so label/role rules resolve here exactly as on the
1275    // windowed timeline handler and the RPC/SSE seams — the stream must not
1276    // depend on a prior `/console/experience` call. Primed once at open; an
1277    // agent spawned mid-stream is filtered against the open-time roster, which
1278    // matches the windowed handler's contract.
1279    if access_view.is_some()
1280        && let Some(runtime) = &state.runtime
1281        && let Some(controller) = state
1282            .access
1283            .as_ref()
1284            .filter(|controller| controller.enabled())
1285    {
1286        prime_access_cache_from_runtime(runtime, controller).await;
1287    }
1288    let Some(aggregator) = &state.console_aggregator else {
1289        return console_json_error(
1290            StatusCode::NOT_FOUND,
1291            "unavailable",
1292            "console aggregator unavailable",
1293        );
1294    };
1295    let aggregator = aggregator.clone();
1296    let last_event_id = headers
1297        .get("last-event-id")
1298        .and_then(|value| value.to_str().ok())
1299        .map(str::trim)
1300        .filter(|value| !value.is_empty())
1301        .map(ToString::to_string);
1302    let timeline_query = timeline_query_from_http(query, last_event_id);
1303    let mut rx = aggregator.subscribe();
1304    let (snapshot_frames, snapshot_cursor) =
1305        match Box::pin(query_timeline_snapshot(&aggregator, timeline_query.clone())).await {
1306            Ok(snapshot) => snapshot,
1307            Err(_) => {
1308                let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
1309                let requested_cursor = timeline_query
1310                    .after
1311                    .as_ref()
1312                    .map(ToString::to_string)
1313                    .unwrap_or_default();
1314                return (
1315                    StatusCode::CONFLICT,
1316                    Json::<Value>(
1317                        serde_json::to_value(ConsoleReplayUnavailable {
1318                            error: "replay_unavailable".to_string(),
1319                            requested_cursor,
1320                            latest_cursor,
1321                        })
1322                        .unwrap_or_else(|_| json!({ "error": "replay_unavailable" })),
1323                    ),
1324                )
1325                    .into_response();
1326            }
1327        };
1328    let identity = timeline_query.identity.clone();
1329    let conversation_id = timeline_query.conversation_id.clone();
1330    let snapshot_after = timeline_query.after.clone();
1331    let stream = stream! {
1332        if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotStarted { after: snapshot_after }) {
1333            yield Ok::<Event, Infallible>(event);
1334        }
1335        let mut latest_cursor = snapshot_cursor;
1336        for frame in snapshot_frames {
1337            latest_cursor = Some(frame.cursor.clone());
1338            if access_view.as_ref().is_some_and(|view| !view.can_view_agent(frame.identity.as_str())) {
1339                continue;
1340            }
1341            if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::ConsoleFrame { frame }) {
1342                yield Ok::<Event, Infallible>(event);
1343            }
1344        }
1345        if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotComplete { cursor: latest_cursor.clone() }) {
1346            yield Ok::<Event, Infallible>(event);
1347        }
1348        loop {
1349            match rx.recv().await {
1350                Ok(event) if timeline_event_matches(&event, identity.as_deref(), conversation_id.as_deref()) => {
1351                    if !Box::pin(
1352                        aggregator
1353                            .timeline_event_visible_for_subscriber(&event, identity.as_deref()),
1354                    )
1355                    .await
1356                    {
1357                        continue;
1358                    }
1359                    if let Some(view) = access_view.as_ref()
1360                        && let Some(frame_identity) = timeline_event_identity(&event)
1361                        && !view.can_view_agent(frame_identity)
1362                    {
1363                        continue;
1364                    }
1365                    if let Some(event_cursor) = timeline_event_cursor(&event)
1366                        && let Some(current_cursor) = latest_cursor.as_ref()
1367                        && !cursor_is_after(event_cursor, current_cursor)
1368                    {
1369                        continue;
1370                    }
1371                    if let Some(sse) = sse_event_from_timeline_event(&event) {
1372                        if let Some(event_cursor) = timeline_event_cursor(&event) {
1373                            latest_cursor = Some(event_cursor.clone());
1374                        }
1375                        yield Ok::<Event, Infallible>(sse);
1376                    }
1377                }
1378                Ok(_) => {}
1379                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
1380                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1381            }
1382        }
1383    };
1384    Sse::new(stream)
1385        .keep_alive(
1386            KeepAlive::new()
1387                .interval(DEFAULT_KEEP_ALIVE_INTERVAL)
1388                .text(KEEP_ALIVE_TEXT),
1389        )
1390        .into_response()
1391}
1392
1393async fn console_identity_timeline_stream_handler(
1394    State(state): State<ConsoleJsonState>,
1395    headers: HeaderMap,
1396    uri: Uri,
1397    AxumPath(identity): AxumPath<String>,
1398    Query(mut query): Query<ConsoleTimelineHttpQuery>,
1399) -> impl IntoResponse {
1400    query.identity = Some(identity);
1401    Box::pin(console_timeline_stream_handler(
1402        State(state),
1403        headers,
1404        uri,
1405        Query(query),
1406    ))
1407    .await
1408    .into_response()
1409}
1410
1411fn timeline_query_from_http(
1412    query: ConsoleTimelineHttpQuery,
1413    fallback_after: Option<String>,
1414) -> ConsoleTimelineWindowQuery {
1415    let after = fallback_after.or(query.after).map(ConsoleCursor::from);
1416    let before = query.before.map(ConsoleCursor::from);
1417    ConsoleTimelineWindowQuery {
1418        identity: query
1419            .identity
1420            .map(|value| value.trim().to_string())
1421            .filter(|value| !value.is_empty()),
1422        conversation_id: query
1423            .conversation_id
1424            .map(|value| value.trim().to_string())
1425            .filter(|value| !value.is_empty()),
1426        after,
1427        before,
1428        mode: query.mode.unwrap_or_default(),
1429        limit: query.limit.unwrap_or(200),
1430    }
1431}
1432
1433async fn query_timeline_snapshot(
1434    aggregator: &MobKitConsoleAggregator,
1435    mut query: ConsoleTimelineWindowQuery,
1436) -> ConsoleLogResult<(Vec<ConsoleFrame>, Option<ConsoleCursor>)> {
1437    const DEFAULT_SNAPSHOT_LIMIT: usize = 200;
1438    query.limit = if query.limit == 0 {
1439        DEFAULT_SNAPSHOT_LIMIT
1440    } else {
1441        query.limit
1442    };
1443    if query.after.is_none() && query.mode == ConsoleTimelineMode::Since {
1444        query.mode = ConsoleTimelineMode::Recent;
1445    }
1446    let mode = query.mode;
1447    match mode {
1448        ConsoleTimelineMode::Recent => {
1449            let page = Box::pin(aggregator.query_timeline_windowed(query)).await?;
1450            Ok((page.frames, page.latest_cursor.or(page.next_cursor)))
1451        }
1452        ConsoleTimelineMode::Since => {
1453            if let (Some(after), Some(latest)) =
1454                (query.after.as_ref(), aggregator.latest_cursor().await?)
1455                && let (Some(after_seq), Some(latest_seq)) = (after.seq(), latest.seq())
1456                && after_seq > latest_seq
1457            {
1458                return Err(std::io::Error::other(
1459                    "timeline replay cursor is beyond the current store frontier",
1460                )
1461                .into());
1462            }
1463            let mut frames = Vec::new();
1464            let mut cursor = query.after.clone();
1465            let mut latest_cursor = None;
1466            loop {
1467                let page = Box::pin(aggregator.query_timeline_windowed(query.clone())).await?;
1468                latest_cursor = page.latest_cursor.clone().or(latest_cursor);
1469                if !page.frames.is_empty() {
1470                    cursor = page
1471                        .next_cursor
1472                        .clone()
1473                        .or_else(|| page.frames.last().map(|frame| frame.cursor.clone()));
1474                    frames.extend(page.frames);
1475                } else if page.next_cursor.is_some() {
1476                    cursor = page.next_cursor.clone();
1477                }
1478                if page.exhausted || page.next_cursor.is_none() {
1479                    return Ok((frames, cursor.or(latest_cursor)));
1480                }
1481                if page.next_cursor == query.after {
1482                    return Err(
1483                        std::io::Error::other("timeline replay made no cursor progress").into(),
1484                    );
1485                }
1486                query.after = page.next_cursor;
1487            }
1488        }
1489    }
1490}
1491
1492fn console_json_error(status: StatusCode, error: &str, message: &str) -> axum::response::Response {
1493    (
1494        status,
1495        Json::<Value>(json!({
1496            "error": error,
1497            "message": message,
1498        })),
1499    )
1500        .into_response()
1501}
1502
1503fn is_console_mutating_rpc_method(method: &str) -> bool {
1504    if crate::rpc::topology_methods::is_topology_mutating_method(method) {
1505        return true;
1506    }
1507    if crate::rpc::workgraph_methods::is_workgraph_mutating_method(method) {
1508        return true;
1509    }
1510    matches!(
1511        method,
1512        "mobkit/retire"
1513            | "mobkit/reset_all"
1514            | "mobkit/console/send"
1515            | "mobkit/blob/upload"
1516            | "mobkit/ensure_member"
1517            | "mobkit/retire_member"
1518            | "mobkit/respawn_member"
1519            | "mobkit/force_cancel_member"
1520            | "mobkit/cancel_flow"
1521            | "mobkit/collect_completed"
1522            | "mobkit/run_flow"
1523            | "mobkit/spawn_helper"
1524            | "mobkit/fork_helper"
1525            | "mobkit/attach_existing_session"
1526            | "mobkit/reconcile_edges"
1527            | "mobkit/cross_mob/wire_local"
1528            | "mobkit/cross_mob/unwire_local"
1529            | "mobkit/respawn"
1530            | "mobkit/reset"
1531            | "mobkit/delete_identity"
1532            | "mobkit/agent_memory/remember"
1533            | "mobkit/agent_memory/update"
1534            | "mobkit/agent_memory/forget"
1535            | "mobkit/gating/decide"
1536            | "mobkit/mob_labels/set"
1537            | "mobkit/mob_labels/delete"
1538            | "mobkit/run_labels/set"
1539            | "mobkit/run_labels/delete"
1540            | "mobkit/access/set"
1541            | "mobkit/access/enable"
1542            | "mobkit/access/rules/upsert"
1543            | "mobkit/access/rules/delete"
1544            | "mobkit/access/groups/set"
1545            | "mobkit/access/groups/delete"
1546    )
1547}
1548
1549fn console_read_only_rpc_error(response_id: Value) -> Value {
1550    response_value(
1551        response_id,
1552        None,
1553        Some(JsonRpcError {
1554            code: -32010,
1555            message: "console is read-only".to_string(),
1556            data: Some(json!({ "kind": "read_only" })),
1557        }),
1558    )
1559}
1560
1561fn console_send_error_response(err: ConsoleSendError) -> axum::response::Response {
1562    let (status, code) = match &err {
1563        ConsoleSendError::UnknownIdentity(_) => (StatusCode::NOT_FOUND, "unknown_identity"),
1564        ConsoleSendError::AmbiguousIdentity { .. } => {
1565            (StatusCode::CONFLICT, "ambiguous_live_identity_alias")
1566        }
1567        ConsoleSendError::NotAddressable(_) => (StatusCode::CONFLICT, "not_addressable"),
1568        ConsoleSendError::Retired(_) => (StatusCode::CONFLICT, "retired"),
1569        ConsoleSendError::InvalidContent(_)
1570        | ConsoleSendError::InvalidHandlingMode(_)
1571        | ConsoleSendError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
1572        ConsoleSendError::IdempotencyConflict(_) => (StatusCode::CONFLICT, "idempotency_conflict"),
1573        ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1574            (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
1575        }
1576    };
1577    console_json_error(status, code, &console_send_public_message(&err))
1578}
1579
1580fn console_send_rpc_code(err: &ConsoleSendError) -> i64 {
1581    match err {
1582        ConsoleSendError::UnknownIdentity(_) => -32001,
1583        ConsoleSendError::AmbiguousIdentity { .. } => -32602,
1584        ConsoleSendError::NotAddressable(_) => -32002,
1585        ConsoleSendError::InvalidContent(_)
1586        | ConsoleSendError::InvalidHandlingMode(_)
1587        | ConsoleSendError::InvalidRequest(_) => -32602,
1588        ConsoleSendError::IdempotencyConflict(_) => -32009,
1589        ConsoleSendError::Retired(_) => -32004,
1590        ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1591            -32000
1592        }
1593    }
1594}
1595
1596fn console_send_rpc_error(response_id: Value, err: ConsoleSendError) -> Value {
1597    response_value(response_id, None, Some(console_send_json_rpc_error(err)))
1598}
1599
1600fn console_send_json_rpc_error(err: ConsoleSendError) -> JsonRpcError {
1601    let code = console_send_rpc_code(&err);
1602    JsonRpcError {
1603        code,
1604        message: console_send_public_message(&err),
1605        data: None,
1606    }
1607}
1608
1609fn console_send_public_message(err: &ConsoleSendError) -> String {
1610    match err {
1611        ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1612            tracing::warn!(target: "mobkit::console", error = %err, "console send internal error");
1613            "console send failed".to_string()
1614        }
1615        _ => err.to_string(),
1616    }
1617}
1618
1619fn timeline_event_matches(
1620    event: &ConsoleTimelineEvent,
1621    identity: Option<&str>,
1622    conversation_id: Option<&str>,
1623) -> bool {
1624    let frame = match event {
1625        ConsoleTimelineEvent::ConsoleFrame { frame }
1626        | ConsoleTimelineEvent::FrameUpdated { frame } => frame,
1627        ConsoleTimelineEvent::SnapshotStarted { .. }
1628        | ConsoleTimelineEvent::SnapshotComplete { .. }
1629        | ConsoleTimelineEvent::ReplayUnavailable { .. } => return true,
1630    };
1631    if identity.is_some_and(|value| frame.identity != value) {
1632        return false;
1633    }
1634    if conversation_id.is_some_and(|value| frame.conversation_id.as_deref() != Some(value)) {
1635        return false;
1636    }
1637    true
1638}
1639
1640fn timeline_event_cursor(event: &ConsoleTimelineEvent) -> Option<&ConsoleCursor> {
1641    match event {
1642        ConsoleTimelineEvent::ConsoleFrame { frame }
1643        | ConsoleTimelineEvent::FrameUpdated { frame } => Some(&frame.cursor),
1644        ConsoleTimelineEvent::SnapshotStarted { .. }
1645        | ConsoleTimelineEvent::SnapshotComplete { .. }
1646        | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1647    }
1648}
1649
1650fn timeline_event_identity(event: &ConsoleTimelineEvent) -> Option<&str> {
1651    match event {
1652        ConsoleTimelineEvent::ConsoleFrame { frame }
1653        | ConsoleTimelineEvent::FrameUpdated { frame } => Some(frame.identity.as_str()),
1654        ConsoleTimelineEvent::SnapshotStarted { .. }
1655        | ConsoleTimelineEvent::SnapshotComplete { .. }
1656        | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1657    }
1658}
1659
1660// ---------------------------------------------------------------------------
1661// ABAC enforcement for console surfaces
1662// ---------------------------------------------------------------------------
1663
1664pub(crate) const ACCESS_DENIED_RPC_CODE: i64 = -32030;
1665
1666fn retain_visible_timeline_frames(page: &mut ConsoleTimelineWindowPage, view: Option<&AccessView>) {
1667    let Some(view) = view.filter(|view| view.enforced()) else {
1668        return;
1669    };
1670    page.frames
1671        .retain(|frame| view.can_view_agent(frame.identity.as_str()));
1672}
1673
1674/// Filter serialized member rows (`mobkit/list_members`, `find_members`)
1675/// down to the agents the caller may view. Field names cover both the
1676/// meerkat roster entry shape and the console projection shape.
1677fn retain_visible_member_rows(rows: &mut Vec<Value>, view: Option<&AccessView>) {
1678    let Some(view) = view.filter(|view| view.enforced()) else {
1679        return;
1680    };
1681    rows.retain(|row| {
1682        let labels: Option<std::collections::BTreeMap<String, String>> = row
1683            .get("labels")
1684            .and_then(|value| serde_json::from_value(value.clone()).ok());
1685        let agent_id = row
1686            .get("agent_identity")
1687            .or_else(|| row.get("member_id"))
1688            .or_else(|| row.get("agent_id"))
1689            .and_then(Value::as_str);
1690        let identity = labels
1691            .as_ref()
1692            .and_then(crate::member_comms_id::durable_identity_label)
1693            .or_else(|| row.get("identity").and_then(Value::as_str))
1694            .or(agent_id);
1695        let role = row
1696            .get("role")
1697            .or_else(|| row.get("profile"))
1698            .and_then(Value::as_str);
1699        view.decide(
1700            ACTION_AGENT_VIEW,
1701            &AccessResource {
1702                identity,
1703                agent_id,
1704                role,
1705                labels: labels.as_ref(),
1706            },
1707        )
1708        .is_allow()
1709    });
1710}
1711
1712fn retain_visible_identity_records(
1713    identities: &mut Vec<ConsoleIdentityRecord>,
1714    view: Option<&AccessView>,
1715) {
1716    let Some(view) = view.filter(|view| view.enforced()) else {
1717        return;
1718    };
1719    identities.retain(|record| {
1720        view.decide(
1721            ACTION_AGENT_VIEW,
1722            &AccessResource {
1723                identity: Some(record.identity.as_str()),
1724                agent_id: Some(record.runtime_member_id.as_str()),
1725                role: None,
1726                labels: Some(&record.labels),
1727            },
1728        )
1729        .is_allow()
1730    });
1731}
1732
1733/// Maps a console RPC method to the access actions it requires, each with
1734/// the targeted agent (when the check is agent-scoped). Every listed action
1735/// must be allowed (logical AND). Methods not listed are either pure read
1736/// surfaces whose *results* are filtered per caller, or non-sensitive
1737/// metadata.
1738fn console_rpc_access_requirements(
1739    method: &str,
1740    params: &Value,
1741) -> Option<Vec<(&'static str, Option<String>)>> {
1742    let identity = normalized_console_rpc_string_param(params, "identity");
1743    let target = identity
1744        .clone()
1745        .or_else(|| normalized_console_rpc_string_param(params, "member_id"))
1746        .or_else(|| normalized_console_rpc_string_param(params, "agent_id"));
1747    let one = |action: &'static str, target: Option<String>| Some(vec![(action, target)]);
1748    match method {
1749        "mobkit/console/send" => one(ACTION_AGENT_SEND, identity),
1750        "mobkit/agent_memory/remember" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1751        // Update is a supersede — a write within the record's lineage.
1752        "mobkit/agent_memory/update" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1753        "mobkit/agent_memory/forget" => one(ACTION_AGENT_MEMORY_DELETE, identity),
1754        // §10.3 migration: memory reads moved from bare `agent.view` to the
1755        // per-scope read action, and the console keeps `agent.view` as a
1756        // prerequisite — both are required. Pre-migration configs are
1757        // covered by `normalize_access_config_for_memory_actions`.
1758        "mobkit/agent_memory/recall" | "mobkit/agent_memory/manifest" => Some(vec![
1759            (ACTION_AGENT_MEMORY_READ, identity.clone()),
1760            (ACTION_AGENT_VIEW, identity),
1761        ]),
1762        // Memory panel reads (§9.3). Identity-keyed listings gate up front
1763        // like recall; mob-scope listings need the mob read action;
1764        // unscoped listings carry no entry gate — every returned row is
1765        // filtered per caller scope in the handler
1766        // (`panel_record_visible`). The record-detail method is likewise
1767        // enforced post-load, where the record's scope is known.
1768        "mobkit/memory/panel/records" => match memory_panel_scope_param(params) {
1769            Some(scope) if scope == "mob" => one(ACTION_MOB_MEMORY_READ, None),
1770            Some(scope) if scope == "operator" => one(ACTION_OPERATOR_MEMORY_READ, None),
1771            _ if identity.is_some() => Some(vec![
1772                (ACTION_AGENT_MEMORY_READ, identity.clone()),
1773                (ACTION_AGENT_VIEW, identity),
1774            ]),
1775            _ => None,
1776        },
1777        // Dream history is realm-level steward activity spanning scopes; it
1778        // requires the unscoped read grant (a rule with no resource
1779        // selector), same as realm-scope record reads.
1780        "mobkit/memory/panel/dreams" => one(ACTION_AGENT_MEMORY_READ, None),
1781        // Durable dream verdict sheets + the usage-audit review queue: same
1782        // realm-level read posture as dream history.
1783        "mobkit/memory/panel/dream_runs" => one(ACTION_AGENT_MEMORY_READ, None),
1784        "mobkit/memory/panel/audit_verdicts" => one(ACTION_AGENT_MEMORY_READ, None),
1785        // Console Phase 2 cheap reads (proposal §7): store overview,
1786        // pipeline inputs, and the injection ledger — same realm-level read
1787        // posture. The health snapshot is deliberately NOT here yet: the
1788        // decided distinct runtime affordance lands with its own design.
1789        "mobkit/memory/panel/overview" => one(ACTION_AGENT_MEMORY_READ, None),
1790        "mobkit/memory/panel/proposals" => one(ACTION_AGENT_MEMORY_READ, None),
1791        "mobkit/memory/panel/injections" => one(ACTION_AGENT_MEMORY_READ, None),
1792        "mobkit/memory/panel/harvests" => one(ACTION_AGENT_MEMORY_READ, None),
1793        "mobkit/memory/panel/quarantine" => one(ACTION_MEMORY_QUARANTINE_REVIEW, None),
1794        // `mob.memory.propose` gates future propose surfaces and
1795        // `mob.memory.commit` is reserved for a future direct-commit RPC —
1796        // steward promotions ride the existing gating flow (gating.decide),
1797        // so neither maps to a method yet.
1798        "mobkit/retire"
1799        | "mobkit/retire_member"
1800        | "mobkit/force_cancel_member"
1801        | "mobkit/delete_identity" => one(ACTION_AGENT_RETIRE, target),
1802        "mobkit/respawn" | "mobkit/respawn_member" => one(ACTION_AGENT_RESPAWN, target),
1803        "mobkit/reset" | "mobkit/reset_all" => one(ACTION_AGENT_RESET, target),
1804        "mobkit/ensure_member"
1805        | "mobkit/spawn_helper"
1806        | "mobkit/fork_helper"
1807        | "mobkit/attach_existing_session"
1808        | "mobkit/run_flow"
1809        | "mobkit/cancel_flow"
1810        | "mobkit/collect_completed" => one(ACTION_AGENT_SPAWN, target),
1811        // Flow state reads expose run records (including spawned member
1812        // identities), so they sit in the same tier as running flows.
1813        "mobkit/flow_status" | "mobkit/list_flows" | "mobkit/list_runs" => {
1814            one(ACTION_AGENT_SPAWN, None)
1815        }
1816        "mobkit/gating/decide" => one(ACTION_GATING_DECIDE, None),
1817        "mobkit/gating/pending" | "mobkit/gating/audit" => one(ACTION_GATING_VIEW, None),
1818        "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => one(ACTION_MOB_OBSERVE, None),
1819        "mobkit/reconcile_edges"
1820        | "mobkit/cross_mob/wire_local"
1821        | "mobkit/cross_mob/unwire_local"
1822        | "mobkit/mob_labels/set"
1823        | "mobkit/mob_labels/delete"
1824        | "mobkit/run_labels/set"
1825        | "mobkit/run_labels/delete"
1826        // Plumbing reads: routing tables, delivery records, cross-mob
1827        // contacts, and label tables enumerate agent identities without
1828        // passing through per-agent visibility filtering, so they require
1829        // the same grant as the mutations that shape them.
1830        | "mobkit/routing/routes/list"
1831        | "mobkit/delivery/history"
1832        | "mobkit/cross_mob/directory"
1833        | "mobkit/cross_mob/peer_info"
1834        | "mobkit/mob_labels/get"
1835        | "mobkit/run_labels/get"
1836        // Read-only, but it enumerates every database and identity in a
1837        // state directory the caller names, so it sits in the admin tier.
1838        | "mobkit/storage/doctor" => one(ACTION_RUNTIME_ADMIN, None),
1839        "mobkit/get_member"
1840        | "mobkit/member_status"
1841        | "mobkit/identity/resolved_tools"
1842        | "mobkit/inspect_identity"
1843        | "mobkit/status_identity"
1844        | "mobkit/console/inspect_identity" => one(ACTION_AGENT_VIEW, target),
1845        // WorkGraph spans the whole mob, so both actions are resource-less:
1846        // reads need the view grant, every mutation the manage grant.
1847        method if crate::rpc::workgraph_methods::is_workgraph_mutating_method(method) => {
1848            one(ACTION_WORKGRAPH_MANAGE, None)
1849        }
1850        method if crate::rpc::workgraph_methods::is_workgraph_read_method(method) => {
1851            one(ACTION_WORKGRAPH_VIEW, None)
1852        }
1853        _ => None,
1854    }
1855}
1856
1857fn memory_panel_scope_param(params: &Value) -> Option<String> {
1858    normalized_console_rpc_string_param(params, "scope").filter(|scope| !scope.is_empty())
1859}
1860
1861fn normalized_console_rpc_string_param(params: &Value, key: &str) -> Option<String> {
1862    params
1863        .get(key)
1864        .and_then(Value::as_str)
1865        .map(str::trim)
1866        .map(str::to_string)
1867}
1868
1869/// Returns the denial error when the caller's view forbids the method.
1870/// Every mapped requirement must pass; the first failing one is reported.
1871fn console_rpc_access_violation(
1872    view: Option<&AccessView>,
1873    method: &str,
1874    params: &Value,
1875) -> Option<JsonRpcError> {
1876    let view = view.filter(|view| view.enforced())?;
1877    let requirements = console_rpc_access_requirements(method, params)?;
1878    for (action, target) in requirements {
1879        let allowed = match target {
1880            Some(ref identity) => view.allows_agent(action, identity),
1881            None => view.allows(action),
1882        };
1883        if !allowed {
1884            return Some(JsonRpcError {
1885                code: ACCESS_DENIED_RPC_CODE,
1886                message: format!("access denied: {action}"),
1887                data: Some(json!({
1888                    "kind": "access_denied",
1889                    "action": action,
1890                    "resource": target,
1891                })),
1892            });
1893        }
1894    }
1895    None
1896}
1897
1898fn access_denied_rpc_error(response_id: Value, message: impl Into<String>) -> Value {
1899    response_value(
1900        response_id,
1901        None,
1902        Some(JsonRpcError {
1903            code: ACCESS_DENIED_RPC_CODE,
1904            message: message.into(),
1905            data: Some(json!({ "kind": "access_denied" })),
1906        }),
1907    )
1908}
1909
1910fn access_unavailable_rpc_error(response_id: Value) -> Value {
1911    response_value(
1912        response_id,
1913        None,
1914        Some(JsonRpcError {
1915            code: -32004,
1916            message: "access control is not configured on this runtime".to_string(),
1917            data: Some(json!({ "kind": "access_unavailable" })),
1918        }),
1919    )
1920}
1921
1922fn access_config_rpc_error(response_id: Value, err: crate::access::AccessConfigError) -> Value {
1923    response_value(
1924        response_id,
1925        None,
1926        Some(JsonRpcError {
1927            code: -32602,
1928            message: err.to_string(),
1929            data: Some(json!({ "kind": "invalid_access_config" })),
1930        }),
1931    )
1932}
1933
1934// ---------------------------------------------------------------------------
1935// Console Memory panel (§9.3): read-only `mobkit/memory/panel/*` RPCs
1936// ---------------------------------------------------------------------------
1937
1938const MEMORY_PANEL_DEFAULT_LIMIT: usize = 50;
1939const MEMORY_PANEL_MAX_LIMIT: usize = 200;
1940const MEMORY_PANEL_CHAIN_MAX: usize = 32;
1941const MEMORY_PANEL_INJECTIONS_MAX: usize = 50;
1942const MEMORY_PANEL_DREAMS_DEFAULT: usize = 20;
1943const MEMORY_PANEL_DREAMS_MAX: usize = 100;
1944
1945fn memory_panel_unavailable(response_id: Value) -> Value {
1946    response_value(
1947        response_id,
1948        None,
1949        Some(JsonRpcError {
1950            code: -32601,
1951            message: "memory panel is not configured".to_string(),
1952            data: None,
1953        }),
1954    )
1955}
1956
1957fn memory_panel_store_error(
1958    response_id: Value,
1959    err: crate::identity_first::agent_memory::AgentMemoryError,
1960) -> Value {
1961    response_value(
1962        response_id,
1963        None,
1964        Some(crate::rpc::agent_memory_rpc_error("panel", err)),
1965    )
1966}
1967
1968fn memory_panel_limit_param(params: &Value, default: usize, max: usize) -> usize {
1969    params
1970        .get("limit")
1971        .and_then(Value::as_u64)
1972        .map(|limit| (limit as usize).clamp(1, max))
1973        .unwrap_or(default)
1974}
1975
1976fn encode_memory_panel_cursor(cursor: &(u64, String)) -> String {
1977    format!("{}:{}", cursor.0, cursor.1)
1978}
1979
1980fn parse_memory_panel_cursor(raw: &str) -> Option<(u64, String)> {
1981    let (ms, id) = raw.split_once(':')?;
1982    let ms = ms.parse::<u64>().ok()?;
1983    (!id.is_empty()).then(|| (ms, id.to_string()))
1984}
1985
1986/// The read action guarding a record of this scope on the panel (§10.3).
1987/// Realm-scope reads ride an *unscoped* `agent.memory.read` grant (a rule
1988/// with no resource selector); operator scope (live since P4's provisional
1989/// keying) requires its own explicit `operator.memory.read` grant.
1990fn memory_panel_scope_action(scope: &crate::memory::records::MemoryScope) -> &'static str {
1991    match scope {
1992        crate::memory::records::MemoryScope::Identity { .. } => ACTION_AGENT_MEMORY_READ,
1993        crate::memory::records::MemoryScope::Mob { .. } => ACTION_MOB_MEMORY_READ,
1994        crate::memory::records::MemoryScope::Operator { .. } => ACTION_OPERATOR_MEMORY_READ,
1995        crate::memory::records::MemoryScope::Realm { .. } => ACTION_AGENT_MEMORY_READ,
1996    }
1997}
1998
1999/// Per-row visibility for panel reads. Identity-scope rows require the
2000/// per-scope read action AND `agent.view` on the identity (§10.3: the
2001/// console keeps view as a prerequisite); mob rows require
2002/// `mob.memory.read`; operator rows require the explicit
2003/// `operator.memory.read` grant (cross-mob personal facts — an unscoped
2004/// `agent.memory.read` deliberately does NOT cover them); realm rows
2005/// require the unscoped read grant. Quarantined rows are additionally
2006/// reviewer-only — their bodies are exactly the content the quarantine
2007/// gate exists for.
2008fn memory_panel_record_visible(
2009    view: Option<&AccessView>,
2010    record: &crate::memory::records::MemoryRecord,
2011) -> bool {
2012    let Some(view) = view.filter(|view| view.enforced()) else {
2013        return true;
2014    };
2015    let scope_allowed = match &record.scope {
2016        crate::memory::records::MemoryScope::Identity { identity, .. } => {
2017            view.allows_agent(ACTION_AGENT_MEMORY_READ, identity)
2018                && view.allows_agent(ACTION_AGENT_VIEW, identity)
2019        }
2020        crate::memory::records::MemoryScope::Mob { .. } => view.allows(ACTION_MOB_MEMORY_READ),
2021        crate::memory::records::MemoryScope::Operator { .. } => {
2022            view.allows(ACTION_OPERATOR_MEMORY_READ)
2023        }
2024        crate::memory::records::MemoryScope::Realm { .. } => view.allows(ACTION_AGENT_MEMORY_READ),
2025    };
2026    if !scope_allowed {
2027        return false;
2028    }
2029    if matches!(
2030        record.status,
2031        crate::memory::records::RecordStatus::Quarantined { .. }
2032    ) {
2033        return view.allows(ACTION_MEMORY_QUARANTINE_REVIEW);
2034    }
2035    true
2036}
2037
2038/// Per-row visibility for pending-promotion queue rows, mirroring
2039/// [`memory_panel_record_visible`] on the promotion's *target* scope:
2040/// promotions carry the scope key and steward rationale, so they leak the
2041/// same cross-scope metadata as record rows. `memory.quarantine.review` is
2042/// the queue's entry gate but is deliberately not sufficient per row.
2043/// Unknown scope kinds are denied.
2044fn memory_panel_promotion_visible(
2045    view: Option<&AccessView>,
2046    scope_kind: &str,
2047    scope_key: &str,
2048) -> bool {
2049    let Some(view) = view.filter(|view| view.enforced()) else {
2050        return true;
2051    };
2052    match scope_kind {
2053        "identity" => {
2054            view.allows_agent(ACTION_AGENT_MEMORY_READ, scope_key)
2055                && view.allows_agent(ACTION_AGENT_VIEW, scope_key)
2056        }
2057        "mob" => view.allows(ACTION_MOB_MEMORY_READ),
2058        "operator" => view.allows(ACTION_OPERATOR_MEMORY_READ),
2059        "realm" => view.allows(ACTION_AGENT_MEMORY_READ),
2060        _ => false,
2061    }
2062}
2063
2064/// Serialize a record for the panel. List rows are body-free (`body_bytes`
2065/// stands in); only the record-detail surface carries the body.
2066fn memory_panel_record_json(
2067    record: &crate::memory::records::MemoryRecord,
2068    include_body: bool,
2069) -> Value {
2070    let mut value = serde_json::to_value(record).unwrap_or(Value::Null);
2071    if !include_body && let Some(object) = value.as_object_mut() {
2072        object.remove("body");
2073        object.insert("body_bytes".to_string(), json!(record.body.len()));
2074    }
2075    value
2076}
2077
2078/// Resolve the realms a panel read spans: the explicit `realm` param, or
2079/// every realm with a store file.
2080async fn memory_panel_realms(
2081    store: &dyn crate::memory::capabilities::MemoryPanelStore,
2082    params: &Value,
2083) -> Result<Vec<String>, crate::identity_first::agent_memory::AgentMemoryError> {
2084    match normalized_console_rpc_string_param(params, "realm").filter(|realm| !realm.is_empty()) {
2085        Some(realm) => Ok(vec![realm]),
2086        None => store.panel_realms().await,
2087    }
2088}
2089
2090async fn handle_memory_panel_records(
2091    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2092    view: Option<&AccessView>,
2093    params: &Value,
2094    response_id: Value,
2095) -> Value {
2096    let Some(store) = store else {
2097        return memory_panel_unavailable(response_id);
2098    };
2099    let scope_kind = memory_panel_scope_param(params);
2100    if let Some(kind) = &scope_kind
2101        && !matches!(kind.as_str(), "identity" | "mob" | "operator" | "realm")
2102    {
2103        return invalid_params(
2104            response_id,
2105            format!("Invalid params: unknown scope '{kind}'"),
2106        );
2107    }
2108    let identity = normalized_console_rpc_string_param(params, "identity")
2109        .filter(|identity| !identity.is_empty());
2110    let scope_kind = scope_kind.or_else(|| identity.as_ref().map(|_| "identity".to_string()));
2111    let scope_key = normalized_console_rpc_string_param(params, "scope_key")
2112        .filter(|key| !key.is_empty())
2113        .or(identity);
2114    let status =
2115        normalized_console_rpc_string_param(params, "status").filter(|status| !status.is_empty());
2116    if let Some(status) = &status
2117        && !matches!(
2118            status.as_str(),
2119            "active" | "quarantined" | "superseded" | "tombstoned"
2120        )
2121    {
2122        return invalid_params(
2123            response_id,
2124            format!("Invalid params: unknown status '{status}'"),
2125        );
2126    }
2127    let limit =
2128        memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2129    let cursor = normalized_console_rpc_string_param(params, "cursor")
2130        .filter(|cursor| !cursor.is_empty())
2131        .map(|raw| parse_memory_panel_cursor(&raw));
2132    let cursor = match cursor {
2133        Some(None) => {
2134            return invalid_params(response_id, "Invalid params: malformed cursor".to_string());
2135        }
2136        Some(Some(cursor)) => Some(cursor),
2137        None => None,
2138    };
2139    let realms = match memory_panel_realms(store, params).await {
2140        Ok(realms) => realms,
2141        Err(err) => return memory_panel_store_error(response_id, err),
2142    };
2143    // Keyset continuation is per-realm: honored only when the query names
2144    // one realm. Multi-realm listings merge one bounded page per realm.
2145    let single_realm = realms.len() == 1;
2146    let mut records = Vec::new();
2147    let mut next_cursor = None;
2148    for realm in &realms {
2149        let page = match store
2150            .records_page(
2151                realm,
2152                scope_kind.as_deref(),
2153                scope_key.as_deref(),
2154                status.as_deref(),
2155                limit,
2156                if single_realm { cursor.clone() } else { None },
2157            )
2158            .await
2159        {
2160            Ok(page) => page,
2161            Err(err) => return memory_panel_store_error(response_id, err),
2162        };
2163        if single_realm {
2164            next_cursor = page.next_cursor.as_ref().map(encode_memory_panel_cursor);
2165        }
2166        records.extend(page.records);
2167    }
2168    records.retain(|record| memory_panel_record_visible(view, record));
2169    records.sort_by(|a, b| {
2170        b.updated_at_ms
2171            .cmp(&a.updated_at_ms)
2172            .then_with(|| b.id.cmp(&a.id))
2173    });
2174    records.truncate(limit);
2175    let rows: Vec<Value> = records
2176        .iter()
2177        .map(|record| memory_panel_record_json(record, false))
2178        .collect();
2179    response_value(
2180        response_id,
2181        Some(json!({
2182            "records": rows,
2183            "next_cursor": next_cursor,
2184            "realms": realms,
2185        })),
2186        None,
2187    )
2188}
2189
2190async fn handle_memory_panel_record(
2191    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2192    view: Option<&AccessView>,
2193    params: &Value,
2194    response_id: Value,
2195) -> Value {
2196    let Some(store) = store else {
2197        return memory_panel_unavailable(response_id);
2198    };
2199    let Some(memory_id) = normalized_console_rpc_string_param(params, "memory_id")
2200        .filter(|memory_id| !memory_id.is_empty())
2201    else {
2202        return invalid_params(
2203            response_id,
2204            "Invalid params: memory_id is required".to_string(),
2205        );
2206    };
2207    let realms = match memory_panel_realms(store, params).await {
2208        Ok(realms) => realms,
2209        Err(err) => return memory_panel_store_error(response_id, err),
2210    };
2211    let mut found = None;
2212    for realm in &realms {
2213        match store.record_by_id(realm, &memory_id).await {
2214            Ok(Some(record)) => {
2215                found = Some((realm.clone(), record));
2216                break;
2217            }
2218            Ok(None) => {}
2219            Err(err) => return memory_panel_store_error(response_id, err),
2220        }
2221    }
2222    let Some((realm, record)) = found else {
2223        return response_value(
2224            response_id,
2225            None,
2226            Some(JsonRpcError {
2227                code: -32001,
2228                message: format!("unknown memory record '{memory_id}'"),
2229                data: None,
2230            }),
2231        );
2232    };
2233    // Scope is only known post-load, so the entry gate lives here rather
2234    // than in the requirements table.
2235    if !memory_panel_record_visible(view, &record) {
2236        let action = if matches!(
2237            record.status,
2238            crate::memory::records::RecordStatus::Quarantined { .. }
2239        ) && view
2240            .is_some_and(|view| view.enforced() && !view.allows(ACTION_MEMORY_QUARANTINE_REVIEW))
2241        {
2242            ACTION_MEMORY_QUARANTINE_REVIEW
2243        } else {
2244            memory_panel_scope_action(&record.scope)
2245        };
2246        return response_value(
2247            response_id,
2248            None,
2249            Some(JsonRpcError {
2250                code: ACCESS_DENIED_RPC_CODE,
2251                message: format!("access denied: {action}"),
2252                data: Some(json!({
2253                    "kind": "access_denied",
2254                    "action": action,
2255                    "resource": record.id,
2256                })),
2257            }),
2258        );
2259    }
2260    let chain = match store
2261        .supersede_chain(&realm, &memory_id, MEMORY_PANEL_CHAIN_MAX)
2262        .await
2263    {
2264        Ok(chain) => chain,
2265        Err(err) => return memory_panel_store_error(response_id, err),
2266    };
2267    let chain_rows: Vec<Value> = chain
2268        .iter()
2269        .filter(|link| memory_panel_record_visible(view, link))
2270        .map(|link| memory_panel_record_json(link, false))
2271        .collect();
2272    let injections = match store
2273        .injection_log_for_record(&realm, &memory_id, MEMORY_PANEL_INJECTIONS_MAX)
2274        .await
2275    {
2276        Ok(entries) => entries,
2277        Err(err) => return memory_panel_store_error(response_id, err),
2278    };
2279    response_value(
2280        response_id,
2281        Some(json!({
2282            "realm": realm,
2283            "record": memory_panel_record_json(&record, true),
2284            "chain": chain_rows,
2285            "injections": injections,
2286        })),
2287        None,
2288    )
2289}
2290
2291async fn handle_memory_panel_quarantine(
2292    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2293    view: Option<&AccessView>,
2294    params: &Value,
2295    response_id: Value,
2296) -> Value {
2297    let Some(store) = store else {
2298        return memory_panel_unavailable(response_id);
2299    };
2300    let limit =
2301        memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2302    let realms = match memory_panel_realms(store, params).await {
2303        Ok(realms) => realms,
2304        Err(err) => return memory_panel_store_error(response_id, err),
2305    };
2306    let mut records = Vec::new();
2307    let mut pending = Vec::new();
2308    for realm in &realms {
2309        match store.quarantined_records(realm, limit).await {
2310            Ok(rows) => records.extend(rows),
2311            Err(err) => return memory_panel_store_error(response_id, err),
2312        }
2313        match store.pending_promotions(realm).await {
2314            Ok(rows) => pending.extend(
2315                rows.into_iter()
2316                    .filter(|promotion| {
2317                        memory_panel_promotion_visible(
2318                            view,
2319                            &promotion.scope_kind,
2320                            &promotion.scope_key,
2321                        )
2322                    })
2323                    .map(|promotion| {
2324                        // stage_token is a commit capability — never surfaced.
2325                        json!({
2326                            "realm": realm,
2327                            "pending_id": promotion.pending_id,
2328                            "record_id": promotion.record_id,
2329                            "scope_kind": promotion.scope_kind,
2330                            "scope_key": promotion.scope_key,
2331                            "rationale": promotion.rationale,
2332                            "status": promotion.status,
2333                            "created_at_ms": promotion.created_at_ms,
2334                        })
2335                    }),
2336            ),
2337            Err(err) => return memory_panel_store_error(response_id, err),
2338        }
2339    }
2340    // The reviewer entry gate is necessary but not sufficient: each queue row
2341    // still needs the caller's per-scope read grant, and hidden rows must not
2342    // consume the page budget.
2343    records.retain(|record| memory_panel_record_visible(view, record));
2344    records.sort_by_key(|record| std::cmp::Reverse(record.created_at_ms));
2345    records.truncate(limit);
2346    // Queue rows stay body-free even for reviewers: verdicts are decided on
2347    // the record detail surface, which renders the body with provenance.
2348    let rows: Vec<Value> = records
2349        .iter()
2350        .map(|record| memory_panel_record_json(record, false))
2351        .collect();
2352    response_value(
2353        response_id,
2354        Some(json!({
2355            "records": rows,
2356            "pending_promotions": pending,
2357            "realms": realms,
2358        })),
2359        None,
2360    )
2361}
2362
2363async fn handle_memory_panel_dreams(
2364    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2365    params: &Value,
2366    response_id: Value,
2367) -> Value {
2368    let Some(store) = store else {
2369        return memory_panel_unavailable(response_id);
2370    };
2371    let limit =
2372        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2373    let realms = match memory_panel_realms(store, params).await {
2374        Ok(realms) => realms,
2375        Err(err) => return memory_panel_store_error(response_id, err),
2376    };
2377    let mut runs = Vec::new();
2378    for realm in &realms {
2379        match store.dream_history(realm, limit).await {
2380            Ok(history) => runs.extend(history.into_iter().map(|run| (realm.clone(), run))),
2381            Err(err) => return memory_panel_store_error(response_id, err),
2382        }
2383    }
2384    runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.last_op_at_ms));
2385    runs.truncate(limit);
2386    let rows: Vec<Value> = runs
2387        .iter()
2388        .map(|(realm, run)| {
2389            json!({
2390                "realm": realm,
2391                "run_id": run.run_id,
2392                "first_op_at_ms": run.first_op_at_ms,
2393                "last_op_at_ms": run.last_op_at_ms,
2394                "ops": run.ops,
2395                "op_kinds": run.op_kinds,
2396                "quarantined_ops": run.quarantined_ops,
2397                "memory_ids": run.memory_ids,
2398                "rationales": run.rationales,
2399            })
2400        })
2401        .collect();
2402    response_value(
2403        response_id,
2404        Some(json!({ "runs": rows, "realms": realms })),
2405        None,
2406    )
2407}
2408
2409/// Durable dream verdict sheets (`dream_runs` table): phases, verdict
2410/// counters, skips, and partition label per run — survives restarts, unlike
2411/// the audit-trail reconstruction served by `panel/dreams`.
2412async fn handle_memory_panel_dream_runs(
2413    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2414    params: &Value,
2415    response_id: Value,
2416) -> Value {
2417    let Some(store) = store else {
2418        return memory_panel_unavailable(response_id);
2419    };
2420    let limit =
2421        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2422    let realms = match memory_panel_realms(store, params).await {
2423        Ok(realms) => realms,
2424        Err(err) => return memory_panel_store_error(response_id, err),
2425    };
2426    let mut runs = Vec::new();
2427    for realm in &realms {
2428        match store.dream_runs(realm, limit).await {
2429            Ok(rows) => runs.extend(rows.into_iter().map(|run| (realm.clone(), run))),
2430            Err(err) => return memory_panel_store_error(response_id, err),
2431        }
2432    }
2433    runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.completed_at_ms));
2434    runs.truncate(limit);
2435    let rows: Vec<Value> = runs
2436        .iter()
2437        .map(|(realm, run)| {
2438            let detail: Value =
2439                serde_json::from_str(&run.detail).unwrap_or_else(|_| json!(run.detail));
2440            json!({
2441                "realm": realm,
2442                "run_id": run.run_id,
2443                "partition": run.partition_label,
2444                "started_at_ms": run.started_at_ms,
2445                "completed_at_ms": run.completed_at_ms,
2446                "ops_committed": run.ops_committed,
2447                "detail": detail,
2448            })
2449        })
2450        .collect();
2451    response_value(
2452        response_id,
2453        Some(json!({ "runs": rows, "realms": realms })),
2454        None,
2455    )
2456}
2457
2458/// The open usage-audit review queue: dead-weight verdicts awaiting operator
2459/// action ("memories you might want to correct").
2460async fn handle_memory_panel_audit_verdicts(
2461    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2462    params: &Value,
2463    response_id: Value,
2464) -> Value {
2465    let Some(store) = store else {
2466        return memory_panel_unavailable(response_id);
2467    };
2468    let limit =
2469        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2470    let realms = match memory_panel_realms(store, params).await {
2471        Ok(realms) => realms,
2472        Err(err) => return memory_panel_store_error(response_id, err),
2473    };
2474    let mut verdicts = Vec::new();
2475    for realm in &realms {
2476        match store.open_dream_audit_verdicts(realm, limit).await {
2477            Ok(rows) => verdicts.extend(rows.into_iter().map(|row| (realm.clone(), row))),
2478            Err(err) => return memory_panel_store_error(response_id, err),
2479        }
2480    }
2481    verdicts.sort_by_key(|(_, row)| std::cmp::Reverse(row.created_at_ms));
2482    verdicts.truncate(limit);
2483    let rows: Vec<Value> = verdicts
2484        .iter()
2485        .map(|(realm, row)| {
2486            json!({
2487                "realm": realm,
2488                "run_id": row.run_id,
2489                "record_id": row.record_id,
2490                "verdict": row.verdict,
2491                "rationale": row.rationale,
2492                "created_at_ms": row.created_at_ms,
2493            })
2494        })
2495        .collect();
2496    response_value(
2497        response_id,
2498        Some(json!({ "verdicts": rows, "realms": realms })),
2499        None,
2500    )
2501}
2502
2503/// Store overview: per-scope record counts + bytes — Holdings + the STORE
2504/// FLOOR verdict tile (floors included so the client renders pressure
2505/// without duplicating the thresholds).
2506async fn handle_memory_panel_overview(
2507    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2508    params: &Value,
2509    response_id: Value,
2510) -> Value {
2511    let Some(store) = store else {
2512        return memory_panel_unavailable(response_id);
2513    };
2514    let realms = match memory_panel_realms(store, params).await {
2515        Ok(realms) => realms,
2516        Err(err) => return memory_panel_store_error(response_id, err),
2517    };
2518    let (floor_records, floor_bytes) = store.scope_floors();
2519    let mut scopes = Vec::new();
2520    for realm in &realms {
2521        match store.scope_overview(realm).await {
2522            Ok(rows) => scopes.extend(rows.into_iter().map(|row| {
2523                json!({
2524                    "realm": realm,
2525                    "scope_kind": row.scope.kind_str(),
2526                    "scope_key": row.scope.key(),
2527                    "active": row.active,
2528                    "quarantined": row.quarantined,
2529                    "superseded": row.superseded,
2530                    "tombstoned": row.tombstoned,
2531                    "body_bytes": row.body_bytes,
2532                    "floor_pressure": row.active as usize >= floor_records
2533                        || row.body_bytes as usize >= floor_bytes,
2534                })
2535            })),
2536            Err(err) => return memory_panel_store_error(response_id, err),
2537        }
2538    }
2539    response_value(
2540        response_id,
2541        Some(json!({
2542            "scopes": scopes,
2543            "realms": realms,
2544            "floors": { "records": floor_records, "bytes": floor_bytes },
2545        })),
2546        None,
2547    )
2548}
2549
2550/// Pending mob/operator-scope proposals awaiting a dream verdict — the
2551/// Pipeline view's inbound lane (bodies withheld: titles only; the record
2552/// body stays reachable through panel/record once committed).
2553async fn handle_memory_panel_proposals(
2554    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2555    params: &Value,
2556    response_id: Value,
2557) -> Value {
2558    let Some(store) = store else {
2559        return memory_panel_unavailable(response_id);
2560    };
2561    let limit =
2562        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2563    let realms = match memory_panel_realms(store, params).await {
2564        Ok(realms) => realms,
2565        Err(err) => return memory_panel_store_error(response_id, err),
2566    };
2567    let mut proposals = Vec::new();
2568    for realm in &realms {
2569        match store.pending_proposals(realm, limit).await {
2570            Ok(rows) => proposals.extend(rows.into_iter().map(|row| {
2571                json!({
2572                    "realm": realm,
2573                    "proposal_id": row.proposal_id,
2574                    "scope_kind": row.scope.kind_str(),
2575                    "scope_key": row.scope.key(),
2576                    "title": row.record.title,
2577                    "kind": row.record.kind.as_str(),
2578                    "author": format!("{:?}", row.author),
2579                    "status": row.status,
2580                    "created_at_ms": row.created_at_ms,
2581                    "tainted": row.taint.is_some(),
2582                })
2583            })),
2584            Err(err) => return memory_panel_store_error(response_id, err),
2585        }
2586    }
2587    proposals.truncate(limit);
2588    response_value(
2589        response_id,
2590        Some(json!({ "proposals": proposals, "realms": realms })),
2591        None,
2592    )
2593}
2594
2595/// The injection ledger (most recent first): the Knowledge Lens history and
2596/// the echo-safety/dup diagnostics feed.
2597async fn handle_memory_panel_injections(
2598    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2599    params: &Value,
2600    response_id: Value,
2601) -> Value {
2602    let Some(store) = store else {
2603        return memory_panel_unavailable(response_id);
2604    };
2605    let limit =
2606        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2607    let realms = match memory_panel_realms(store, params).await {
2608        Ok(realms) => realms,
2609        Err(err) => return memory_panel_store_error(response_id, err),
2610    };
2611    let mut injections = Vec::new();
2612    for realm in &realms {
2613        match store.injection_log(realm, limit).await {
2614            Ok(rows) => injections.extend(rows.into_iter().map(|row| {
2615                json!({
2616                    "realm": realm,
2617                    "record_id": row.record_id,
2618                    "identity": row.identity,
2619                    "session_key": row.session_key,
2620                    "surface": row.surface.as_str(),
2621                    "at_ms": row.at_ms,
2622                })
2623            })),
2624            Err(err) => return memory_panel_store_error(response_id, err),
2625        }
2626    }
2627    injections.sort_by_key(|row| std::cmp::Reverse(row["at_ms"].as_u64().unwrap_or(0)));
2628    injections.truncate(limit);
2629    response_value(
2630        response_id,
2631        Some(json!({ "injections": injections, "realms": realms })),
2632        None,
2633    )
2634}
2635
2636/// Pending exit-interview harvests — the Health strip's harvest-queue lane.
2637async fn handle_memory_panel_harvests(
2638    store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2639    params: &Value,
2640    response_id: Value,
2641) -> Value {
2642    let Some(store) = store else {
2643        return memory_panel_unavailable(response_id);
2644    };
2645    let limit =
2646        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2647    let realms = match memory_panel_realms(store, params).await {
2648        Ok(realms) => realms,
2649        Err(err) => return memory_panel_store_error(response_id, err),
2650    };
2651    let mut harvests = Vec::new();
2652    for realm in &realms {
2653        match store.pending_harvests(realm, limit).await {
2654            Ok(rows) => harvests.extend(rows.into_iter().map(|row| {
2655                json!({
2656                    "realm": realm,
2657                    "identity": row.identity,
2658                    "session_key": row.session_key,
2659                    "cause": row.cause,
2660                    "retired_at_ms": row.retired_at_ms,
2661                })
2662            })),
2663            Err(err) => return memory_panel_store_error(response_id, err),
2664        }
2665    }
2666    harvests.truncate(limit);
2667    response_value(
2668        response_id,
2669        Some(json!({ "harvests": harvests, "realms": realms })),
2670        None,
2671    )
2672}
2673
2674fn access_status_result(access: Option<&AccessController>, view: Option<&AccessView>) -> Value {
2675    match (access, view) {
2676        (Some(controller), Some(view)) => {
2677            let (_, revision) = controller.snapshot();
2678            json!({
2679                "available": true,
2680                "enabled": view.enforced(),
2681                "revision": revision,
2682                "subject": view.subject(),
2683                "groups": view.groups().iter().collect::<Vec<_>>(),
2684                "is_admin": view.is_admin(),
2685                "can_administer": view.can_administer(),
2686                "actions": ACCESS_ACTIONS,
2687            })
2688        }
2689        _ => json!({
2690            "available": false,
2691            "enabled": false,
2692            "actions": ACCESS_ACTIONS,
2693        }),
2694    }
2695}
2696
2697/// Handles every `mobkit/access/*` method. Returns `None` when the method
2698/// belongs to another namespace.
2699fn handle_access_admin_rpc(
2700    access: Option<&AccessController>,
2701    view: Option<&AccessView>,
2702    request: &JsonRpcRequest,
2703) -> Option<Value> {
2704    if !request.method.starts_with("mobkit/access/") {
2705        return None;
2706    }
2707    let response_id = request.id.clone().unwrap_or(Value::Null);
2708    let controller = match request.method.as_str() {
2709        "mobkit/access/status" => {
2710            return Some(response_value(
2711                response_id,
2712                Some(access_status_result(access, view)),
2713                None,
2714            ));
2715        }
2716        _ => {
2717            let Some(controller) = access else {
2718                return Some(access_unavailable_rpc_error(response_id));
2719            };
2720            if !view.is_some_and(AccessView::can_administer) {
2721                return Some(access_denied_rpc_error(
2722                    response_id,
2723                    "access denied: access.admin",
2724                ));
2725            }
2726            controller
2727        }
2728    };
2729    let result = match request.method.as_str() {
2730        "mobkit/access/get" => {
2731            let (config, revision) = controller.snapshot();
2732            Ok(json!({ "config": &*config, "revision": revision }))
2733        }
2734        "mobkit/access/set" => {
2735            match serde_json::from_value(request.params.get("config").cloned().unwrap_or_default())
2736            {
2737                Ok(config) => controller
2738                    .replace_config(config)
2739                    .map(|revision| json!({ "revision": revision })),
2740                Err(err) => {
2741                    return Some(invalid_params(
2742                        response_id,
2743                        format!("invalid access config: {err}"),
2744                    ));
2745                }
2746            }
2747        }
2748        "mobkit/access/rules/upsert" => {
2749            match serde_json::from_value::<AccessRule>(
2750                request.params.get("rule").cloned().unwrap_or_default(),
2751            ) {
2752                Ok(rule) => controller
2753                    .upsert_rule(rule)
2754                    .map(|revision| json!({ "revision": revision })),
2755                Err(err) => {
2756                    return Some(invalid_params(
2757                        response_id,
2758                        format!("invalid access rule: {err}"),
2759                    ));
2760                }
2761            }
2762        }
2763        "mobkit/access/rules/delete" => {
2764            let Some(rule_id) = request.params.get("id").and_then(Value::as_str) else {
2765                return Some(invalid_params(response_id, "id required"));
2766            };
2767            controller
2768                .delete_rule(rule_id)
2769                .map(|revision| json!({ "revision": revision }))
2770        }
2771        "mobkit/access/groups/set" => {
2772            let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2773                return Some(invalid_params(response_id, "name required"));
2774            };
2775            match serde_json::from_value::<AccessGroup>(
2776                request.params.get("group").cloned().unwrap_or_default(),
2777            ) {
2778                Ok(group) => controller
2779                    .set_group(name, group)
2780                    .map(|revision| json!({ "revision": revision })),
2781                Err(err) => {
2782                    return Some(invalid_params(
2783                        response_id,
2784                        format!("invalid access group: {err}"),
2785                    ));
2786                }
2787            }
2788        }
2789        "mobkit/access/groups/delete" => {
2790            let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2791                return Some(invalid_params(response_id, "name required"));
2792            };
2793            controller
2794                .delete_group(name)
2795                .map(|revision| json!({ "revision": revision }))
2796        }
2797        "mobkit/access/enable" => {
2798            let Some(enabled) = request.params.get("enabled").and_then(Value::as_bool) else {
2799                return Some(invalid_params(response_id, "enabled (bool) required"));
2800            };
2801            controller
2802                .set_enabled(enabled)
2803                .map(|revision| json!({ "revision": revision }))
2804        }
2805        "mobkit/access/preview" => {
2806            let subject = request.params.get("subject").and_then(Value::as_str);
2807            let Some(action) = request.params.get("action").and_then(Value::as_str) else {
2808                return Some(invalid_params(response_id, "action required"));
2809            };
2810            let identity = request.params.get("identity").and_then(Value::as_str);
2811            let preview_view = controller.view_for_subject(subject);
2812            let decision = match identity {
2813                Some(identity) => preview_view.decide_agent(action, identity),
2814                None => preview_view.decide(action, &AccessResource::none()),
2815            };
2816            Ok(json!({
2817                "subject": subject,
2818                "action": action,
2819                "identity": identity,
2820                "allowed": decision.is_allow(),
2821                "reason": decision.reason(),
2822                "groups": preview_view.groups().iter().collect::<Vec<_>>(),
2823                "is_admin": preview_view.is_admin(),
2824            }))
2825        }
2826        _ => {
2827            return Some(response_value(
2828                response_id,
2829                None,
2830                Some(JsonRpcError {
2831                    code: -32601,
2832                    message: "Method not found".to_string(),
2833                    data: None,
2834                }),
2835            ));
2836        }
2837    };
2838    Some(match result {
2839        Ok(value) => response_value(response_id, Some(value), None),
2840        Err(err) => access_config_rpc_error(response_id, err),
2841    })
2842}
2843
2844fn cursor_is_after(candidate: &ConsoleCursor, current: &ConsoleCursor) -> bool {
2845    match (candidate.seq(), current.seq()) {
2846        (Some(candidate), Some(current)) => candidate > current,
2847        _ => candidate > current,
2848    }
2849}
2850
2851fn sse_event_from_timeline_event(event: &ConsoleTimelineEvent) -> Option<Event> {
2852    let (event_name, id) = match event {
2853        ConsoleTimelineEvent::SnapshotStarted { .. } => ("snapshot_started", None),
2854        ConsoleTimelineEvent::ConsoleFrame { frame } => (
2855            if frame.kind == "frame_updated" {
2856                "frame_updated"
2857            } else {
2858                "console_frame"
2859            },
2860            Some(frame.cursor.to_string()),
2861        ),
2862        ConsoleTimelineEvent::FrameUpdated { frame } => {
2863            ("frame_updated", Some(frame.cursor.to_string()))
2864        }
2865        ConsoleTimelineEvent::SnapshotComplete { cursor } => (
2866            "snapshot_complete",
2867            cursor.as_ref().map(ToString::to_string),
2868        ),
2869        ConsoleTimelineEvent::ReplayUnavailable { .. } => ("replay_unavailable", None),
2870    };
2871    let data = match serde_json::to_string(event) {
2872        Ok(value) => value,
2873        Err(_) => return None,
2874    };
2875    let mut sse = Event::default().event(event_name).data(data);
2876    if let Some(id) = id {
2877        sse = sse.id(id);
2878    }
2879    Some(sse)
2880}
2881
2882pub async fn console_rpc_multipart_handler(
2883    State(state): State<ConsoleJsonState>,
2884    headers: HeaderMap,
2885    uri: Uri,
2886    mut multipart: Multipart,
2887) -> impl IntoResponse {
2888    let auth_context = match console_request_auth_context(&state, &headers, &uri) {
2889        Some(context) => context,
2890        None => {
2891            return (
2892                StatusCode::UNAUTHORIZED,
2893                Json::<Value>(serde_json::json!({
2894                    "jsonrpc": JSONRPC_VERSION,
2895                    "id": Value::Null,
2896                    "error": {
2897                        "code": -32600,
2898                        "message": "unauthorized: console rpc requires a valid auth token",
2899                    }
2900                })),
2901            );
2902        }
2903    };
2904
2905    let mut payload: Option<String> = None;
2906    let mut files: std::collections::BTreeMap<String, MultipartImageUpload> =
2907        std::collections::BTreeMap::new();
2908
2909    while let Some(mut field) = match multipart.next_field().await {
2910        Ok(field) => field,
2911        Err(err) => {
2912            return (
2913                StatusCode::BAD_REQUEST,
2914                Json::<Value>(json_rpc_error_value(
2915                    Value::Null,
2916                    -32602,
2917                    format!("invalid multipart body: {err}"),
2918                )),
2919            );
2920        }
2921    } {
2922        let name = field.name().unwrap_or("").to_string();
2923        if name == "payload" {
2924            if payload.is_some() {
2925                return (
2926                    StatusCode::BAD_REQUEST,
2927                    Json::<Value>(json_rpc_error_value(
2928                        Value::Null,
2929                        -32602,
2930                        "duplicate payload part",
2931                    )),
2932                );
2933            }
2934            payload = match field.text().await {
2935                Ok(text) => Some(text),
2936                Err(err) => {
2937                    return (
2938                        StatusCode::BAD_REQUEST,
2939                        Json::<Value>(json_rpc_error_value(
2940                            Value::Null,
2941                            -32602,
2942                            format!("invalid payload part: {err}"),
2943                        )),
2944                    );
2945                }
2946            };
2947            continue;
2948        }
2949
2950        let Some(upload_id) = name.strip_prefix("file:").filter(|id| !id.is_empty()) else {
2951            return (
2952                StatusCode::BAD_REQUEST,
2953                Json::<Value>(json_rpc_error_value(
2954                    Value::Null,
2955                    -32602,
2956                    format!("unexpected multipart field: {name}"),
2957                )),
2958            );
2959        };
2960        if files.len() >= MAX_MULTIPART_IMAGES {
2961            return (
2962                StatusCode::BAD_REQUEST,
2963                Json::<Value>(json_rpc_error_value(
2964                    Value::Null,
2965                    -32602,
2966                    format!("too many image attachments; max {MAX_MULTIPART_IMAGES}"),
2967                )),
2968            );
2969        }
2970        if files.contains_key(upload_id) {
2971            return (
2972                StatusCode::BAD_REQUEST,
2973                Json::<Value>(json_rpc_error_value(
2974                    Value::Null,
2975                    -32602,
2976                    format!("duplicate file part for upload_id {upload_id}"),
2977                )),
2978            );
2979        }
2980        let media_type = field
2981            .content_type()
2982            .map(str::to_string)
2983            .unwrap_or_else(|| "application/octet-stream".to_string());
2984        if !is_allowed_image_media_type(&media_type) {
2985            return (
2986                StatusCode::BAD_REQUEST,
2987                Json::<Value>(json_rpc_error_value(
2988                    Value::Null,
2989                    -32602,
2990                    format!("unsupported image media type: {media_type}"),
2991                )),
2992            );
2993        }
2994        let mut bytes = bytes::BytesMut::new();
2995        loop {
2996            let chunk = match field.chunk().await {
2997                Ok(chunk) => chunk,
2998                Err(err) => {
2999                    return (
3000                        StatusCode::BAD_REQUEST,
3001                        Json::<Value>(json_rpc_error_value(
3002                            Value::Null,
3003                            -32602,
3004                            format!("invalid file part {upload_id}: {err}"),
3005                        )),
3006                    );
3007                }
3008            };
3009            let Some(chunk) = chunk else {
3010                break;
3011            };
3012            if bytes.len() + chunk.len() > MAX_MULTIPART_IMAGE_BYTES {
3013                return (
3014                    StatusCode::BAD_REQUEST,
3015                    Json::<Value>(json_rpc_error_value(
3016                        Value::Null,
3017                        -32602,
3018                        format!("image attachment {upload_id} exceeds 25 MiB"),
3019                    )),
3020                );
3021            }
3022            bytes.extend_from_slice(&chunk);
3023        }
3024        files.insert(
3025            upload_id.to_string(),
3026            MultipartImageUpload {
3027                media_type,
3028                bytes: bytes.freeze(),
3029            },
3030        );
3031    }
3032
3033    let payload = match payload {
3034        Some(payload) => payload,
3035        None => {
3036            return (
3037                StatusCode::BAD_REQUEST,
3038                Json::<Value>(json_rpc_error_value(
3039                    Value::Null,
3040                    -32602,
3041                    "payload part required",
3042                )),
3043            );
3044        }
3045    };
3046    let mut parsed_request = match serde_json::from_str::<JsonRpcRequest>(&payload) {
3047        Ok(req) => req,
3048        Err(err) => {
3049            return (
3050                StatusCode::OK,
3051                Json::<Value>(json_rpc_error_value(
3052                    Value::Null,
3053                    -32600,
3054                    format!("Invalid Request: {err}"),
3055                )),
3056            );
3057        }
3058    };
3059    let response_id = parsed_request.id.clone().unwrap_or(Value::Null);
3060    if let Err(message) =
3061        crate::member_comms_id::validate_public_rpc_member_aliases(&parsed_request.params)
3062    {
3063        return (
3064            StatusCode::OK,
3065            Json::<Value>(invalid_params(response_id, message)),
3066        );
3067    }
3068    if state.decisions.console.read_only && is_console_mutating_rpc_method(&parsed_request.method) {
3069        return (
3070            StatusCode::OK,
3071            Json::<Value>(console_read_only_rpc_error(response_id)),
3072        );
3073    }
3074    match parsed_request.method.as_str() {
3075        "mobkit/console/send" => {
3076            let Some(aggregator) = &state.console_aggregator else {
3077                return (
3078                    StatusCode::OK,
3079                    Json::<Value>(invalid_params(
3080                        response_id,
3081                        "mobkit/console/send multipart requires a console aggregator",
3082                    )),
3083                );
3084            };
3085            let Some(identity) = parsed_request
3086                .params
3087                .get("identity")
3088                .and_then(Value::as_str)
3089            else {
3090                return (
3091                    StatusCode::OK,
3092                    Json::<Value>(invalid_params(response_id, "identity required")),
3093                );
3094            };
3095            // Gate BEFORE externalizing/persisting uploads, mirroring the JSON
3096            // /console/send gate-first ordering. Without this, a caller denied
3097            // `agent.send` on this identity could still write attacker-supplied
3098            // image bytes into the target identity's blob store (a pre-auth
3099            // side effect / storage-amplification vector) — the send is denied
3100            // later, but the blob write already happened. Prime the attribute
3101            // cache first so role/label-scoped deny rules resolve fail-closed.
3102            if let Some(runtime) = &state.runtime
3103                && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
3104            {
3105                prime_access_cache_from_runtime(runtime, controller).await;
3106            }
3107            if let Some(violation) = console_rpc_access_violation(
3108                auth_context.access_view.as_ref(),
3109                "mobkit/console/send",
3110                &parsed_request.params,
3111            ) {
3112                return (
3113                    StatusCode::OK,
3114                    Json::<Value>(response_value(response_id, None, Some(violation))),
3115                );
3116            }
3117            let binary_blob_store =
3118                match Box::pin(aggregator.binary_blob_store_for_identity(identity)).await {
3119                    Ok(Some(store)) => store,
3120                    Ok(None) => {
3121                        return (
3122                            StatusCode::OK,
3123                            Json::<Value>(invalid_params(
3124                                response_id,
3125                                "binary blob store unavailable for identity",
3126                            )),
3127                        );
3128                    }
3129                    Err(err) => {
3130                        return (
3131                            StatusCode::OK,
3132                            Json::<Value>(console_send_rpc_error(response_id, err)),
3133                        );
3134                    }
3135                };
3136            if let Err(message) = externalize_image_upload_placeholders(
3137                &mut parsed_request.params,
3138                files,
3139                binary_blob_store,
3140            )
3141            .await
3142            {
3143                return (
3144                    StatusCode::OK,
3145                    Json::<Value>(invalid_params(response_id, message)),
3146                );
3147            }
3148        }
3149        "mobkit/blob/upload" => {
3150            let Some(runtime) = &state.runtime else {
3151                return (
3152                    StatusCode::NOT_FOUND,
3153                    Json::<Value>(json_rpc_error_value(
3154                        response_id,
3155                        -32600,
3156                        "mobkit/blob/upload multipart requires a unified runtime",
3157                    )),
3158                );
3159            };
3160            let Some(binary_blob_store) = runtime.binary_blob_store() else {
3161                return (
3162                    StatusCode::INTERNAL_SERVER_ERROR,
3163                    Json::<Value>(json_rpc_error_value(
3164                        response_id,
3165                        -32000,
3166                        "binary blob store unavailable",
3167                    )),
3168                );
3169            };
3170            let result = match externalize_single_image_upload(
3171                &parsed_request.params,
3172                files,
3173                binary_blob_store,
3174            )
3175            .await
3176            {
3177                Ok(result) => result,
3178                Err(message) => {
3179                    return (
3180                        StatusCode::OK,
3181                        Json::<Value>(invalid_params(response_id, message)),
3182                    );
3183                }
3184            };
3185            return (
3186                StatusCode::OK,
3187                Json::<Value>(response_value(response_id, Some(result), None)),
3188            );
3189        }
3190        _ => {
3191            return (
3192                StatusCode::OK,
3193                Json::<Value>(invalid_params(
3194                    response_id,
3195                    "multipart RPC supports mobkit/console/send and mobkit/blob/upload only",
3196                )),
3197            );
3198        }
3199    }
3200    let response_value =
3201        if parsed_request.method == "mobkit/console/send" && state.runtime.is_none() {
3202            Box::pin(handle_console_aggregator_rpc(
3203                state.console_aggregator.clone(),
3204                parsed_request,
3205                true,
3206                state.decisions.console.read_only,
3207                state.access.as_ref(),
3208                auth_context.access_view.as_ref(),
3209            ))
3210            .await
3211        } else {
3212            let Some(runtime) = &state.runtime else {
3213                return (
3214                    StatusCode::NOT_FOUND,
3215                    Json::<Value>(json_rpc_error_value(
3216                        response_id,
3217                        -32600,
3218                        "console rpc multipart requires a unified runtime",
3219                    )),
3220                );
3221            };
3222            Box::pin(handle_console_runtime_rpc_with_visibility(
3223                runtime,
3224                state.module_runtime.clone(),
3225                state.contact_directory.as_ref(),
3226                state.gateway_peer_keys.as_ref(),
3227                state.console_events.clone(),
3228                state.console_aggregator.clone(),
3229                state.identity_runtime.clone(),
3230                state.metadata_table.clone(),
3231                state.mob_events.clone(),
3232                state.visibility_policy.as_ref(),
3233                parsed_request,
3234                true,
3235                state.decisions.console.read_only,
3236                auth_context.principal.as_deref(),
3237                state.access.as_ref(),
3238                auth_context.access_view.as_ref(),
3239                state.memory_panel.as_deref(),
3240                state.identity_roster.clone(),
3241                state.workgraph.as_ref(),
3242                state.topology.as_ref(),
3243            ))
3244            .await
3245        };
3246    (StatusCode::OK, Json::<Value>(response_value))
3247}
3248
3249pub async fn blob_get_handler(
3250    State(state): State<ConsoleJsonState>,
3251    headers: HeaderMap,
3252    uri: Uri,
3253    AxumPath(blob_id): AxumPath<String>,
3254) -> impl IntoResponse {
3255    if !console_request_authorized(&state, &headers, &uri) {
3256        return (
3257            StatusCode::UNAUTHORIZED,
3258            Json::<Value>(serde_json::json!({ "error": "unauthorized" })),
3259        )
3260            .into_response();
3261    }
3262    if !is_valid_blob_id_value(&blob_id) {
3263        return (
3264            StatusCode::BAD_REQUEST,
3265            Json::<Value>(serde_json::json!({ "error": "invalid_blob_id" })),
3266        )
3267            .into_response();
3268    }
3269    let blob_id = meerkat_core::BlobId::from(blob_id.as_str());
3270    let mut stores: Vec<std::sync::Arc<dyn BinaryBlobStore>> = Vec::new();
3271    if let Some(runtime) = &state.runtime
3272        && let Some(store) = runtime.binary_blob_store()
3273    {
3274        stores.push(store);
3275    }
3276    if let Some(aggregator) = &state.console_aggregator {
3277        stores.extend(aggregator.binary_blob_stores());
3278    }
3279    if stores.is_empty() {
3280        return (
3281            StatusCode::NOT_FOUND,
3282            Json::<Value>(serde_json::json!({ "error": "blob_store_unavailable" })),
3283        )
3284            .into_response();
3285    }
3286    for store in stores {
3287        match store.get_bytes(&blob_id).await {
3288            Ok(payload) => return blob_payload_response(payload),
3289            Err(meerkat_core::BlobStoreError::NotFound(_)) => continue,
3290            Err(err) => {
3291                return (
3292                    StatusCode::INTERNAL_SERVER_ERROR,
3293                    Json::<Value>(serde_json::json!({ "error": err.to_string() })),
3294                )
3295                    .into_response();
3296            }
3297        }
3298    }
3299    (
3300        StatusCode::NOT_FOUND,
3301        Json::<Value>(serde_json::json!({ "error": "blob_not_found" })),
3302    )
3303        .into_response()
3304}
3305
3306fn blob_payload_response(payload: BinaryBlobPayload) -> axum::response::Response {
3307    let mut response_headers = HeaderMap::new();
3308    let content_type = HeaderValue::from_str(&payload.media_type)
3309        .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
3310    response_headers.insert(header::CONTENT_TYPE, content_type);
3311    if let Ok(content_length) = HeaderValue::from_str(&payload.size.to_string()) {
3312        response_headers.insert(header::CONTENT_LENGTH, content_length);
3313    }
3314    response_headers.insert(
3315        header::CACHE_CONTROL,
3316        HeaderValue::from_static("private, max-age=31536000, immutable"),
3317    );
3318    (StatusCode::OK, response_headers, payload.data).into_response()
3319}
3320
3321fn console_request_authorized(state: &ConsoleJsonState, headers: &HeaderMap, uri: &Uri) -> bool {
3322    console_request_auth_context(state, headers, uri).is_some()
3323}
3324
3325fn console_request_auth_context(
3326    state: &ConsoleJsonState,
3327    headers: &HeaderMap,
3328    uri: &Uri,
3329) -> Option<ConsoleHttpAuthContext> {
3330    if !state.decisions.console.require_app_auth {
3331        // Open console: identify callers that volunteered a valid token so
3332        // per-user ABAC grants still apply; everyone else is anonymous.
3333        let principal = state.access.as_ref().and_then(|_| {
3334            let token = console_request_token(headers, uri)?;
3335            resolve_authorized_console_auth_from_token(&state.decisions, &token)
3336                .map(|auth| auth.email)
3337        });
3338        return Some(ConsoleHttpAuthContext {
3339            access_view: state
3340                .access
3341                .as_ref()
3342                .map(|controller| controller.view_for_subject(principal.as_deref())),
3343            principal,
3344        });
3345    }
3346    let token = console_request_token(headers, uri)?;
3347    let auth = resolve_authorized_console_auth_from_token(&state.decisions, &token)?;
3348    Some(ConsoleHttpAuthContext {
3349        access_view: state
3350            .access
3351            .as_ref()
3352            .map(|controller| controller.view_for_subject(Some(auth.email.as_str()))),
3353        principal: Some(auth.email),
3354    })
3355}
3356
3357fn console_request_token(headers: &HeaderMap, uri: &Uri) -> Option<String> {
3358    let bearer_token = headers
3359        .get(header::AUTHORIZATION)
3360        .and_then(|v| v.to_str().ok())
3361        .and_then(extract_bearer_token_from_header)
3362        .map(String::from);
3363    // Parse with form_urlencoded so percent-encoded tokens decode and
3364    // `xauth_token=` substring shadowing does NOT match the real key.
3365    let query_token = uri.query().and_then(|q| {
3366        form_urlencoded::parse(q.as_bytes())
3367            .find(|(key, _)| key == "auth_token")
3368            .map(|(_, value)| value.into_owned())
3369    });
3370    bearer_token.or(query_token)
3371}
3372
3373#[derive(Debug)]
3374struct MultipartImageUpload {
3375    media_type: String,
3376    bytes: bytes::Bytes,
3377}
3378
3379fn json_rpc_error_value(id: Value, code: i64, message: impl Into<String>) -> Value {
3380    serde_json::json!({
3381        "jsonrpc": JSONRPC_VERSION,
3382        "id": id,
3383        "error": {
3384            "code": code,
3385            "message": message.into(),
3386        }
3387    })
3388}
3389
3390fn is_allowed_image_media_type(media_type: &str) -> bool {
3391    matches!(
3392        media_type,
3393        "image/png" | "image/jpeg" | "image/webp" | "image/gif"
3394    )
3395}
3396
3397fn image_upload_part_name<'a>(
3398    object: &'a serde_json::Map<String, Value>,
3399    context: &str,
3400) -> Result<&'a str, String> {
3401    object
3402        .get("upload_id")
3403        .or_else(|| object.get("part_name"))
3404        .and_then(Value::as_str)
3405        .map(str::trim)
3406        .filter(|value| !value.is_empty())
3407        .ok_or_else(|| format!("{context}.upload_id or {context}.part_name is required"))
3408}
3409
3410async fn externalize_image_upload_placeholders(
3411    params: &mut Value,
3412    files: std::collections::BTreeMap<String, MultipartImageUpload>,
3413    blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3414) -> Result<(), String> {
3415    let Some(content) = params.get_mut("content") else {
3416        return Err("multipart payload params.content is required".to_string());
3417    };
3418    let mut placeholders = std::collections::BTreeMap::<String, String>::new();
3419    collect_image_upload_placeholders(content, &mut placeholders)?;
3420    if placeholders.is_empty() {
3421        return Err(
3422            "multipart payload must contain at least one image_upload placeholder".to_string(),
3423        );
3424    }
3425    if placeholders.len() > MAX_MULTIPART_IMAGES {
3426        return Err(format!(
3427            "too many image_upload placeholders; max {MAX_MULTIPART_IMAGES}"
3428        ));
3429    }
3430    for upload_id in files.keys() {
3431        if !placeholders.contains_key(upload_id) {
3432            return Err(format!(
3433                "file part has no matching image_upload placeholder: {upload_id}"
3434            ));
3435        }
3436    }
3437    for upload_id in placeholders.keys() {
3438        if !files.contains_key(upload_id) {
3439            return Err(format!(
3440                "image_upload placeholder missing file part: {upload_id}"
3441            ));
3442        }
3443    }
3444
3445    let mut refs = std::collections::BTreeMap::<String, Value>::new();
3446    for (upload_id, file) in files {
3447        let declared_media_type = placeholders
3448            .get(&upload_id)
3449            .cloned()
3450            .unwrap_or_else(|| file.media_type.clone());
3451        if !is_allowed_image_media_type(&declared_media_type) {
3452            return Err(format!(
3453                "unsupported image media type in placeholder {upload_id}: {declared_media_type}"
3454            ));
3455        }
3456        if declared_media_type != file.media_type {
3457            return Err(format!(
3458                "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3459                file.media_type
3460            ));
3461        }
3462        let blob_ref = blob_store
3463            .put_bytes(&file.media_type, file.bytes)
3464            .await
3465            .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3466        refs.insert(
3467            upload_id,
3468            serde_json::json!({
3469                "type": "image",
3470                "media_type": blob_ref.media_type,
3471                "source": "blob",
3472                "blob_id": blob_ref.blob_id,
3473            }),
3474        );
3475    }
3476    replace_image_upload_placeholders(content, &refs)?;
3477    if let Some(object) = params.as_object_mut() {
3478        object.remove("message");
3479    }
3480    Ok(())
3481}
3482
3483async fn externalize_single_image_upload(
3484    params: &Value,
3485    files: std::collections::BTreeMap<String, MultipartImageUpload>,
3486    blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3487) -> Result<Value, String> {
3488    let upload = params.get("upload").unwrap_or(params);
3489    if upload
3490        .get("type")
3491        .and_then(Value::as_str)
3492        .is_some_and(|kind| kind != "image_upload")
3493    {
3494        return Err("upload.type must be image_upload".to_string());
3495    }
3496    let upload_object = upload
3497        .as_object()
3498        .ok_or_else(|| "upload must be an object".to_string())?;
3499    let upload_id = image_upload_part_name(upload_object, "upload")?;
3500    let Some(file) = files.get(upload_id) else {
3501        return Err(format!(
3502            "image_upload placeholder missing file part: {upload_id}"
3503        ));
3504    };
3505    if files.len() != 1 {
3506        return Err("mobkit/blob/upload accepts exactly one file part".to_string());
3507    }
3508    let declared_media_type = upload
3509        .get("media_type")
3510        .and_then(Value::as_str)
3511        .unwrap_or(file.media_type.as_str());
3512    if !is_allowed_image_media_type(declared_media_type) {
3513        return Err(format!(
3514            "unsupported image media type in upload {upload_id}: {declared_media_type}"
3515        ));
3516    }
3517    if declared_media_type != file.media_type {
3518        return Err(format!(
3519            "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3520            file.media_type
3521        ));
3522    }
3523    let size = file.bytes.len() as u64;
3524    let blob_ref = blob_store
3525        .put_bytes(&file.media_type, file.bytes.clone())
3526        .await
3527        .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3528    Ok(json!({
3529        "blob_id": blob_ref.blob_id,
3530        "media_type": blob_ref.media_type,
3531        "size": size,
3532    }))
3533}
3534
3535fn collect_image_upload_placeholders(
3536    value: &Value,
3537    placeholders: &mut std::collections::BTreeMap<String, String>,
3538) -> Result<(), String> {
3539    match value {
3540        Value::Array(items) => {
3541            for item in items {
3542                collect_image_upload_placeholders(item, placeholders)?;
3543            }
3544        }
3545        Value::Object(object) => {
3546            if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3547                let upload_id = image_upload_part_name(object, "image_upload")?;
3548                let media_type = object
3549                    .get("media_type")
3550                    .and_then(Value::as_str)
3551                    .map(str::trim)
3552                    .filter(|value| !value.is_empty())
3553                    .ok_or_else(|| format!("image_upload {upload_id} requires media_type"))?;
3554                if placeholders
3555                    .insert(upload_id.to_string(), media_type.to_string())
3556                    .is_some()
3557                {
3558                    return Err(format!("duplicate image_upload placeholder: {upload_id}"));
3559                }
3560            } else {
3561                for child in object.values() {
3562                    collect_image_upload_placeholders(child, placeholders)?;
3563                }
3564            }
3565        }
3566        _ => {}
3567    }
3568    Ok(())
3569}
3570
3571fn replace_image_upload_placeholders(
3572    value: &mut Value,
3573    refs: &std::collections::BTreeMap<String, Value>,
3574) -> Result<(), String> {
3575    match value {
3576        Value::Array(items) => {
3577            for item in items {
3578                replace_image_upload_placeholders(item, refs)?;
3579            }
3580        }
3581        Value::Object(object) => {
3582            if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3583                let upload_id = image_upload_part_name(object, "image_upload")?;
3584                let replacement = refs
3585                    .get(upload_id)
3586                    .ok_or_else(|| format!("missing blob replacement for {upload_id}"))?;
3587                *value = replacement.clone();
3588            } else {
3589                for child in object.values_mut() {
3590                    replace_image_upload_placeholders(child, refs)?;
3591                }
3592            }
3593        }
3594        _ => {}
3595    }
3596    Ok(())
3597}
3598
3599fn response_value(id: Value, result: Option<Value>, error: Option<JsonRpcError>) -> Value {
3600    serde_json::to_value(JsonRpcResponse {
3601        jsonrpc: JSONRPC_VERSION.to_string(),
3602        id,
3603        result,
3604        error,
3605    })
3606    .unwrap_or_else(|_| {
3607        serde_json::json!({
3608            "jsonrpc": JSONRPC_VERSION,
3609            "id": Value::Null,
3610            "error": {
3611                "code": -32603,
3612                "message": "serialization failed",
3613            }
3614        })
3615    })
3616}
3617
3618fn invalid_params(id: Value, message: impl Into<String>) -> Value {
3619    response_value(
3620        id,
3621        None,
3622        Some(JsonRpcError {
3623            code: -32602,
3624            message: message.into(),
3625            data: None,
3626        }),
3627    )
3628}
3629
3630fn gating_decision_failed_error(id: Value, err: impl std::fmt::Display) -> Value {
3631    tracing::warn!(
3632        target: "mobkit::console",
3633        error = %err,
3634        "console gating decision failed"
3635    );
3636    invalid_params(id, "gating decision failed")
3637}
3638
3639fn runtime_binding_from_wire(
3640    binding: WireRuntimeBinding,
3641) -> Result<meerkat_mob::RuntimeBinding, String> {
3642    match binding {
3643        WireRuntimeBinding::Session => Ok(meerkat_mob::RuntimeBinding::Session),
3644        WireRuntimeBinding::External {
3645            address,
3646            bootstrap_token,
3647            identity,
3648        } => {
3649            let resolved = identity.resolve().map_err(|err| err.to_string())?;
3650            Ok(meerkat_mob::RuntimeBinding::External {
3651                peer_id: resolved.peer_id.to_string(),
3652                address,
3653                bootstrap_token,
3654                pubkey: resolved.pubkey,
3655            })
3656        }
3657    }
3658}
3659
3660fn parse_optional_runtime_mode(params: &Value) -> Result<Option<MobRuntimeMode>, String> {
3661    match params.get("runtime_mode") {
3662        None | Some(Value::Null) => Ok(None),
3663        Some(value) => serde_json::from_value::<MobRuntimeMode>(value.clone())
3664            .map(Some)
3665            .map_err(|err| {
3666                format!("runtime_mode must be \"autonomous_host\" or \"turn_driven\": {err}")
3667            }),
3668    }
3669}
3670
3671fn parse_optional_backend(params: &Value) -> Result<Option<MobBackendKind>, String> {
3672    match params.get("backend") {
3673        None | Some(Value::Null) => Ok(None),
3674        Some(value) => serde_json::from_value::<MobBackendKind>(value.clone())
3675            .map(Some)
3676            .map_err(|err| format!("backend must be \"session\" or \"external\": {err}")),
3677    }
3678}
3679
3680fn parse_optional_runtime_binding(
3681    params: &Value,
3682) -> Result<Option<meerkat_mob::RuntimeBinding>, String> {
3683    match params.get("binding") {
3684        None | Some(Value::Null) => Ok(None),
3685        Some(value) => {
3686            let wire = serde_json::from_value::<WireRuntimeBinding>(value.clone())
3687                .map_err(|err| format!("binding: {err}"))?;
3688            runtime_binding_from_wire(wire).map(Some)
3689        }
3690    }
3691}
3692
3693async fn member_entry_to_console_json(
3694    runtime: &MobRuntime,
3695    entry: &meerkat_mob::runtime::MobMemberListEntry,
3696) -> Value {
3697    let mut value = member_entry_to_json(entry);
3698    if let Some(object) = value.as_object_mut() {
3699        object.insert(
3700            "model_capabilities".to_string(),
3701            serde_json::to_value(model_capabilities_for_member_entry(
3702                runtime.handle().definition(),
3703                entry,
3704            ))
3705            .unwrap_or(Value::Null),
3706        );
3707    }
3708    value
3709}
3710
3711fn internal_error(id: Value, message: impl Into<String>) -> Value {
3712    let message = message.into();
3713    tracing::warn!(
3714        target: "mobkit::console",
3715        error = %message,
3716        "console JSON-RPC internal error"
3717    );
3718    // The real failure reason goes on the wire. It used to be logged here
3719    // and replaced with a bare "internal_error", which made every -32000
3720    // undiagnosable from the client side (meerkat-studio ask K2: their K1
3721    // retire/respawn failures cost a day because this said nothing).
3722    // `error` stays "internal_error" as the stable kind discriminator for
3723    // existing clients; `detail` carries the human-readable chain.
3724    response_value(
3725        id,
3726        None,
3727        Some(JsonRpcError {
3728            code: -32000,
3729            message: message.clone(),
3730            data: Some(json!({ "error": "internal_error", "detail": message })),
3731        }),
3732    )
3733}
3734
3735/// Render a stale-cursor failure as a JSON-RPC envelope with code
3736/// `-32010`, a typed error body the SDKs can parse into the
3737/// `MobEventsStaleError` exception, and a `data` field carrying both
3738/// cursors so callers can rewind to the current frontier.
3739fn stale_event_cursor_response(id: Value, after_cursor: u64, latest_cursor: u64) -> Value {
3740    response_value(
3741        id,
3742        None,
3743        Some(JsonRpcError {
3744            code: crate::rpc::MOB_EVENTS_STALE_CURSOR_CODE,
3745            message: format!(
3746                "stale mob event cursor: requested {after_cursor}, latest {latest_cursor}"
3747            ),
3748            data: Some(serde_json::json!({
3749                "error": "event_query_stale",
3750                "after_cursor": after_cursor,
3751                "latest_cursor": latest_cursor,
3752            })),
3753        }),
3754    )
3755}
3756
3757fn console_timeline_replay_unavailable_response(
3758    id: Value,
3759    err: ConsoleLogError,
3760    requested_cursor: Option<&ConsoleCursor>,
3761    latest_cursor: Option<ConsoleCursor>,
3762) -> Value {
3763    tracing::warn!(
3764        target: "mobkit::console",
3765        error = %err,
3766        "console timeline replay unavailable"
3767    );
3768    response_value(
3769        id,
3770        None,
3771        Some(JsonRpcError {
3772            code: crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE,
3773            message: "timeline replay unavailable".to_string(),
3774            data: Some(json!({
3775                "error": "replay_unavailable",
3776                "stream": "timeline",
3777                "requested_cursor": requested_cursor.map(ToString::to_string),
3778                "latest_cursor": latest_cursor.map(|cursor| cursor.to_string()),
3779            })),
3780        }),
3781    )
3782}
3783
3784fn parse_console_helper_options(
3785    options_val: Option<&Value>,
3786) -> Result<meerkat_mob::HelperOptions, String> {
3787    crate::rpc::mob_methods::parse_helper_options(options_val)
3788}
3789
3790fn member_is_addressable(member: &meerkat_mob::runtime::MobMemberListEntry) -> bool {
3791    member
3792        .labels
3793        .get("addressable")
3794        .map(|value: &String| !value.eq_ignore_ascii_case("false"))
3795        .unwrap_or(true)
3796}
3797
3798fn member_addressability(member: &meerkat_mob::runtime::MobMemberListEntry) -> &'static str {
3799    if member_is_addressable(member) {
3800        "addressable"
3801    } else {
3802        "internal_only"
3803    }
3804}
3805
3806fn console_identity_status_json_for_identity(
3807    identity: &str,
3808    member: &meerkat_mob::runtime::MobMemberListEntry,
3809    session_id: Option<String>,
3810    response_phase: Option<String>,
3811) -> Value {
3812    json!({
3813        "identity": identity,
3814        "state": member_status_state_string(member.status),
3815        "role": member.role.to_string(),
3816        "addressability": member_addressability(member),
3817        "display_name": member.labels.get("display_name"),
3818        "labels": member.labels,
3819        "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3820        "session_id": session_id,
3821        "generation": Value::Null,
3822        "checkpoint_version": Value::Null,
3823        "continuity_health": Value::Null,
3824        "lease_healthy": Value::Null,
3825        "lease": Value::Null,
3826        "response_phase": response_phase,
3827    })
3828}
3829
3830fn console_identity_inspect_json_for_identity(
3831    identity: &str,
3832    member: &meerkat_mob::runtime::MobMemberListEntry,
3833    session_id: Option<String>,
3834    response_phase: Option<String>,
3835) -> Value {
3836    let peers: Vec<String> = member.wired_to.iter().map(ToString::to_string).collect();
3837    json!({
3838        "identity": identity,
3839        "state": member_status_state_string(member.status),
3840        "role": member.role.to_string(),
3841        "addressability": member_addressability(member),
3842        "display_name": member.labels.get("display_name"),
3843        "labels": member.labels,
3844        "continuity_health": Value::Null,
3845        "lease_healthy": Value::Null,
3846        "lease": Value::Null,
3847        "continuity": {
3848            "generation": Value::Null,
3849            "checkpoint_version": Value::Null,
3850            "session_id": session_id,
3851            "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3852        },
3853        "topology_peers": peers,
3854        "output_preview": Value::Null,
3855        "response_phase": response_phase,
3856    })
3857}
3858
3859/// Resolve a mob member by identity plus its current bridge session id.
3860///
3861/// Returns `None` if no member with the given identity exists.
3862async fn lookup_member_with_session(
3863    handle: &MobHandle,
3864    identity: &AgentIdentity,
3865) -> Option<(meerkat_mob::runtime::MobMemberListEntry, Option<String>)> {
3866    let entries = handle.list_members_including_retiring().await;
3867    let entry = entries
3868        .into_iter()
3869        .find(|e| &e.agent_identity == identity)?;
3870    let session_id = handle
3871        .resolve_bridge_session_id_observation(identity)
3872        .await
3873        .map(|s| s.to_string());
3874    Some((entry, session_id))
3875}
3876
3877#[derive(Debug, Clone)]
3878struct ConsoleRuntimeIdentityAlias {
3879    identity: String,
3880    runtime_member_id: String,
3881    member: meerkat_mob::runtime::MobMemberListEntry,
3882    session_id: Option<String>,
3883}
3884
3885/// Exact roster snapshot used to authorize one attributed event. Its fields
3886/// are derived only after the event's runtime id and fence token match the
3887/// same live roster entry, preventing a newer incarnation of the alias from
3888/// supplying authorization attributes for an older event.
3889pub(crate) struct SseMemberAuthorizationProjection {
3890    pub(crate) identity: String,
3891    pub(crate) attributes: AgentResourceAttributes,
3892    pub(crate) visible: bool,
3893}
3894
3895/// Legacy live-only compatibility applies only to genuinely raw member ids.
3896/// A generated `rt:*` projection remains identity-owned even if target
3897/// resolution races the interval between durable deletion and member cleanup.
3898fn console_live_only_fallback_allowed(
3899    was_registered: bool,
3900    requested_identity: &str,
3901    live_alias: Option<&ConsoleRuntimeIdentityAlias>,
3902) -> bool {
3903    !was_registered
3904        && !crate::member_comms_id::is_reserved_generated_alias(requested_identity)
3905        && live_alias.is_some_and(|alias| {
3906            !crate::member_comms_id::is_reserved_generated_alias(&alias.runtime_member_id)
3907        })
3908}
3909
3910fn durable_identity_for_member(member: &meerkat_mob::runtime::MobMemberListEntry) -> String {
3911    crate::member_comms_id::durable_identity_label(&member.labels)
3912        .map(str::to_owned)
3913        // Fallback surfaces the public alias, not the comms-safe roster id.
3914        .unwrap_or_else(|| {
3915            crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned()
3916        })
3917}
3918
3919async fn lookup_member_alias_with_session(
3920    handle: &MobHandle,
3921    visibility_policy: &dyn ConsoleVisibilityPolicy,
3922    requested_identity: &str,
3923) -> Result<Option<ConsoleRuntimeIdentityAlias>, JsonRpcError> {
3924    let all_matches = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
3925    let mut visible_matches = Vec::new();
3926    for alias in &all_matches {
3927        if runtime_alias_visible_to_console(handle, visibility_policy, alias) {
3928            visible_matches.push(alias.clone());
3929        }
3930    }
3931    let member = if visible_matches.len() > 1 {
3932        return Err(ambiguous_live_identity_alias_error(
3933            requested_identity,
3934            &visible_matches
3935                .iter()
3936                .map(|alias| alias.runtime_member_id.clone())
3937                .collect::<Vec<_>>(),
3938        ));
3939    } else if let Some(alias) = visible_matches.into_iter().next() {
3940        Some(alias)
3941    } else {
3942        all_matches.into_iter().next()
3943    };
3944    Ok(member)
3945}
3946
3947async fn lookup_visible_member_alias_candidates_with_session(
3948    handle: &MobHandle,
3949    visibility_policy: &dyn ConsoleVisibilityPolicy,
3950    requested_identity: &str,
3951) -> Vec<ConsoleRuntimeIdentityAlias> {
3952    let mut visible = Vec::new();
3953    for alias in lookup_member_alias_candidates_with_session(handle, requested_identity).await {
3954        if runtime_alias_visible_to_console(handle, visibility_policy, &alias) {
3955            visible.push(alias);
3956        }
3957    }
3958    visible
3959}
3960
3961async fn lookup_member_alias_candidates_with_session(
3962    handle: &MobHandle,
3963    requested_identity: &str,
3964) -> Vec<ConsoleRuntimeIdentityAlias> {
3965    // Requested identities arrive in the public alias space; roster ids are
3966    // their comms-safe encodings (meerkat 0.7 MemberCommsName).
3967    let requested_member_id = crate::member_comms_id::mob_member_id(requested_identity);
3968    let entries = handle.list_members_including_retiring().await;
3969    let exact_matches = entries
3970        .iter()
3971        .filter(|entry| entry.agent_identity == requested_member_id)
3972        .cloned()
3973        .collect::<Vec<_>>();
3974    let label_matches = entries
3975        .iter()
3976        .filter(|entry| {
3977            crate::member_comms_id::durable_identity_label(&entry.labels)
3978                .is_some_and(|identity| identity == requested_identity)
3979        })
3980        .cloned()
3981        .collect::<Vec<_>>();
3982    let mut matches = exact_matches;
3983    matches.extend(label_matches);
3984    let mut seen_member_ids = BTreeSet::new();
3985    matches.retain(|entry| seen_member_ids.insert(entry.agent_identity.to_string()));
3986    let mut aliases = Vec::with_capacity(matches.len());
3987    for member in matches {
3988        let runtime_member_id =
3989            crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3990        let identity = durable_identity_for_member(&member);
3991        let session_id = handle
3992            .resolve_bridge_session_id_observation(&member.agent_identity)
3993            .await
3994            .map(|s| s.to_string());
3995        aliases.push(ConsoleRuntimeIdentityAlias {
3996            identity,
3997            runtime_member_id,
3998            member,
3999            session_id,
4000        });
4001    }
4002    aliases
4003}
4004
4005/// Resolve a public member alias to the console identity and evaluate the
4006/// configured visibility policy against the live roster projection.
4007///
4008/// SSE routes use this before opening a per-agent subscription and for every
4009/// attributed mob/structural envelope. `None` means the member is no longer
4010/// present in the live roster; callers retain their existing unknown-member
4011/// behavior in that case.
4012pub(crate) async fn sse_member_identity_visibility(
4013    handle: &MobHandle,
4014    visibility_policy: &dyn ConsoleVisibilityPolicy,
4015    requested_identity: &str,
4016) -> Option<(String, bool)> {
4017    let candidates = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
4018    let identity = candidates.first()?.identity.clone();
4019    let visible = candidates
4020        .iter()
4021        .any(|alias| runtime_alias_visible_to_console(handle, visibility_policy, alias));
4022    Some((identity, visible))
4023}
4024
4025/// Resolve an attributed event to the exact live roster binding that emitted
4026/// it, then snapshot both ABAC attributes and console visibility from that
4027/// entry. `None` means the authority can no longer be proven and protected
4028/// event surfaces must fail closed.
4029pub(crate) async fn sse_member_authorization_projection(
4030    handle: &MobHandle,
4031    runtime: Option<&MobRuntime>,
4032    visibility_policy: &dyn ConsoleVisibilityPolicy,
4033    requested_identity: &str,
4034    expected_runtime_id: &AgentRuntimeId,
4035    expected_fence_token: FenceToken,
4036) -> Option<SseMemberAuthorizationProjection> {
4037    let candidates = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
4038    let alias = candidates.into_iter().find(|candidate| {
4039        candidate
4040            .member
4041            .binding_atoms()
4042            .is_some_and(|(runtime_id, fence_token)| {
4043                runtime_id == *expected_runtime_id && fence_token == expected_fence_token
4044            })
4045    })?;
4046
4047    let mut labels = alias.member.labels.clone();
4048    // Roster labels are untrusted for lineage. Only the spawn registry may
4049    // restore `spawned_by` and related inheritance metadata.
4050    crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
4051    if let Some(runtime) = runtime {
4052        let registered = runtime.console_identity_labels().await;
4053        if let Some(spawn_labels) = registered.get(&alias.identity) {
4054            crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
4055        }
4056    }
4057    let visible = runtime_alias_visible_to_console(handle, visibility_policy, &alias);
4058    Some(SseMemberAuthorizationProjection {
4059        identity: alias.identity.clone(),
4060        attributes: AgentResourceAttributes {
4061            identity: alias.identity,
4062            agent_id: Some(alias.runtime_member_id),
4063            role: Some(alias.member.role.to_string()),
4064            labels,
4065        },
4066        visible,
4067    })
4068}
4069
4070async fn reject_ambiguous_projected_live_identity(
4071    handle: &MobHandle,
4072    visibility_policy: &dyn ConsoleVisibilityPolicy,
4073    alias: &ConsoleRuntimeIdentityAlias,
4074) -> Result<(), JsonRpcError> {
4075    let candidates = lookup_visible_member_alias_candidates_with_session(
4076        handle,
4077        visibility_policy,
4078        &alias.identity,
4079    )
4080    .await;
4081    if candidates.len() > 1 {
4082        return Err(ambiguous_live_identity_alias_error(
4083            &alias.identity,
4084            &candidates
4085                .iter()
4086                .map(|candidate| candidate.runtime_member_id.clone())
4087                .collect::<Vec<_>>(),
4088        ));
4089    }
4090    Ok(())
4091}
4092
4093fn ambiguous_live_identity_alias_error(
4094    requested_identity: &str,
4095    candidates: &[String],
4096) -> JsonRpcError {
4097    JsonRpcError {
4098        code: -32602,
4099        message: format!(
4100            "ambiguous live identity alias {requested_identity}: candidates [{}]",
4101            candidates.join(", ")
4102        ),
4103        data: Some(json!({
4104            "kind": "ambiguous_live_identity_alias",
4105            "identity": requested_identity,
4106            "candidates": candidates,
4107        })),
4108    }
4109}
4110
4111async fn lookup_member_runtime_alias_with_session(
4112    handle: &MobHandle,
4113    runtime_member_id: &str,
4114) -> Option<ConsoleRuntimeIdentityAlias> {
4115    let requested_member_id = crate::member_comms_id::mob_member_id(runtime_member_id);
4116    let entries = handle.list_members_including_retiring().await;
4117    let member = entries
4118        .into_iter()
4119        .find(|entry| entry.agent_identity == requested_member_id)?;
4120    let runtime_member_id =
4121        crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
4122    let identity = durable_identity_for_member(&member);
4123    let session_id = handle
4124        .resolve_bridge_session_id_observation(&member.agent_identity)
4125        .await
4126        .map(|s| s.to_string());
4127    Some(ConsoleRuntimeIdentityAlias {
4128        identity,
4129        runtime_member_id,
4130        member,
4131        session_id,
4132    })
4133}
4134
4135async fn identity_runtime_alias(
4136    identity_runtime: &crate::identity_first::IdentityRuntime,
4137    requested_identity: &str,
4138) -> Result<Option<(crate::identity_first::AgentIdentity, bool)>, String> {
4139    let requested_identity = crate::member_comms_id::runtime_alias_str(requested_identity);
4140    let requested_identity = requested_identity.as_ref();
4141    if crate::member_comms_id::is_reserved_generated_alias(requested_identity) {
4142        for status in identity_runtime.statuses().await {
4143            if status
4144                .agent_runtime_id
4145                .as_ref()
4146                .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
4147            {
4148                return Ok(Some((status.identity, false)));
4149            }
4150        }
4151        return Ok(None);
4152    }
4153    if let Ok(identity) = crate::identity_first::AgentIdentity::parse(requested_identity) {
4154        match identity_runtime.status(&identity).await {
4155            Ok(_) => return Ok(Some((identity, true))),
4156            Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
4157            Err(err) => return Err(err.to_string()),
4158        }
4159    }
4160
4161    for status in identity_runtime.statuses().await {
4162        if status
4163            .agent_runtime_id
4164            .as_ref()
4165            .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
4166        {
4167            return Ok(Some((status.identity, false)));
4168        }
4169    }
4170    Ok(None)
4171}
4172
4173async fn resolve_console_identity_control_target(
4174    handle: &MobHandle,
4175    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
4176    visibility_policy: &dyn ConsoleVisibilityPolicy,
4177    requested_identity: &str,
4178) -> Result<
4179    Option<(
4180        crate::identity_first::AgentIdentity,
4181        bool,
4182        Option<ConsoleRuntimeIdentityAlias>,
4183    )>,
4184    JsonRpcError,
4185> {
4186    if let Some(identity_runtime) = identity_runtime {
4187        match identity_runtime_alias(identity_runtime, requested_identity).await {
4188            Ok(Some((identity, exact))) => {
4189                let live = if exact {
4190                    let registered_live = match identity_runtime.status(&identity).await {
4191                        Ok(status) => match status.agent_runtime_id.as_ref() {
4192                            Some(runtime_id) => {
4193                                lookup_member_runtime_alias_with_session(
4194                                    handle,
4195                                    runtime_id.as_str(),
4196                                )
4197                                .await
4198                            }
4199                            None => None,
4200                        },
4201                        Err(_) => None,
4202                    };
4203                    if let Some(alias) = registered_live.as_ref()
4204                        && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
4205                    {
4206                        return Err(identity_hidden_by_policy_error(requested_identity));
4207                    }
4208                    if let Some(registered) = registered_live {
4209                        return Ok(Some((identity, true, Some(registered))));
4210                    }
4211                    let requested_live_candidates =
4212                        lookup_visible_member_alias_candidates_with_session(
4213                            handle,
4214                            visibility_policy,
4215                            requested_identity,
4216                        )
4217                        .await;
4218                    let requested_live = if requested_live_candidates.len() > 1 {
4219                        return Err(ambiguous_live_identity_alias_error(
4220                            requested_identity,
4221                            &requested_live_candidates
4222                                .iter()
4223                                .map(|alias| alias.runtime_member_id.clone())
4224                                .collect::<Vec<_>>(),
4225                        ));
4226                    } else {
4227                        requested_live_candidates.into_iter().next()
4228                    };
4229                    match (registered_live, requested_live) {
4230                        (Some(registered), Some(requested))
4231                            if registered.runtime_member_id == requested.runtime_member_id =>
4232                        {
4233                            Some(registered)
4234                        }
4235                        (Some(registered), None) => Some(registered),
4236                        (Some(_registered), Some(requested)) => Some(requested),
4237                        (None, requested) => requested,
4238                    }
4239                } else {
4240                    let registered_live =
4241                        lookup_member_runtime_alias_with_session(handle, requested_identity).await;
4242                    if let Some(alias) = registered_live.as_ref()
4243                        && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
4244                    {
4245                        return Err(identity_hidden_by_policy_error(requested_identity));
4246                    }
4247                    if let Some(registered) = registered_live {
4248                        return Ok(Some((identity, true, Some(registered))));
4249                    }
4250                    let durable_live_candidates =
4251                        lookup_visible_member_alias_candidates_with_session(
4252                            handle,
4253                            visibility_policy,
4254                            identity.as_str(),
4255                        )
4256                        .await;
4257                    let durable_live = if durable_live_candidates.len() > 1 {
4258                        return Err(ambiguous_live_identity_alias_error(
4259                            identity.as_str(),
4260                            &durable_live_candidates
4261                                .iter()
4262                                .map(|alias| alias.runtime_member_id.clone())
4263                                .collect::<Vec<_>>(),
4264                        ));
4265                    } else {
4266                        durable_live_candidates.into_iter().next()
4267                    };
4268                    match (registered_live, durable_live) {
4269                        (Some(registered), Some(durable))
4270                            if registered.runtime_member_id == durable.runtime_member_id =>
4271                        {
4272                            Some(registered)
4273                        }
4274                        (Some(registered), None) => Some(registered),
4275                        (Some(_registered), Some(durable)) => Some(durable),
4276                        (None, durable) => durable,
4277                    }
4278                };
4279                // The second target flag records durable ownership observed
4280                // during resolution. A later UnknownIdentity therefore means
4281                // a concurrent delete, not permission to fall back to the
4282                // captured raw member alias.
4283                return Ok(Some((identity, true, live)));
4284            }
4285            Ok(None) => {}
4286            Err(err) => {
4287                return Err(JsonRpcError {
4288                    code: -32000,
4289                    message: err,
4290                    data: None,
4291                });
4292            }
4293        }
4294    }
4295
4296    let live_alias =
4297        lookup_member_alias_with_session(handle, visibility_policy, requested_identity).await?;
4298    let Some(alias) = live_alias else {
4299        return Ok(None);
4300    };
4301    if !runtime_alias_visible_to_console(handle, visibility_policy, &alias) {
4302        return Err(identity_hidden_by_policy_error(requested_identity));
4303    }
4304    if let Some(identity_runtime) = identity_runtime
4305        && let Some(bound_status) = identity_runtime
4306            .statuses()
4307            .await
4308            .into_iter()
4309            .find(|status| {
4310                status
4311                    .agent_runtime_id
4312                    .as_ref()
4313                    .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
4314            })
4315        && bound_status.identity.as_str() != alias.identity
4316    {
4317        return Err(JsonRpcError {
4318            code: -32000,
4319            message: format!(
4320                "stale live identity alias: live console alias {} resolves to {}, but identity runtime binding belongs to {}",
4321                alias.identity,
4322                alias.runtime_member_id,
4323                bound_status.identity.as_str(),
4324            ),
4325            data: Some(json!({
4326                "kind": "stale_live_identity_alias",
4327                "identity": alias.identity,
4328                "runtime_member_id": alias.runtime_member_id,
4329                "registered_identity": bound_status.identity.as_str(),
4330            })),
4331        });
4332    }
4333    let identity = crate::identity_first::AgentIdentity::parse(&alias.identity).map_err(|err| {
4334        JsonRpcError {
4335            code: -32602,
4336            message: format!("invalid identity: {err}"),
4337            data: None,
4338        }
4339    })?;
4340    let durable_live_candidates = lookup_visible_member_alias_candidates_with_session(
4341        handle,
4342        visibility_policy,
4343        identity.as_str(),
4344    )
4345    .await;
4346    if durable_live_candidates.len() > 1 {
4347        return Err(ambiguous_live_identity_alias_error(
4348            identity.as_str(),
4349            &durable_live_candidates
4350                .iter()
4351                .map(|alias| alias.runtime_member_id.clone())
4352                .collect::<Vec<_>>(),
4353        ));
4354    }
4355    Ok(Some((identity, false, Some(alias))))
4356}
4357
4358fn live_alias_matches_status_runtime(
4359    alias: Option<&ConsoleRuntimeIdentityAlias>,
4360    status: &crate::identity_first::IdentityStatus,
4361) -> bool {
4362    let Some(alias) = alias else {
4363        return true;
4364    };
4365    let session_matches = match (
4366        status.session_id.as_ref().map(ToString::to_string),
4367        alias.session_id.as_deref(),
4368    ) {
4369        (Some(status_session), Some(live_session)) => status_session == live_session,
4370        _ => true,
4371    };
4372    status
4373        .agent_runtime_id
4374        .as_ref()
4375        .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
4376        && alias.identity == status.identity.as_str()
4377        && session_matches
4378}
4379
4380async fn stale_live_alias_json_rpc_error(
4381    operation: &str,
4382    identity_runtime: &crate::identity_first::IdentityRuntime,
4383    identity: &crate::identity_first::AgentIdentity,
4384    live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4385) -> Option<JsonRpcError> {
4386    let live_alias = live_alias?;
4387    let Ok(status) = identity_runtime.status(identity).await else {
4388        return None;
4389    };
4390    if live_alias_matches_status_runtime(Some(live_alias), &status) {
4391        return None;
4392    }
4393    let registered_runtime_member_id = status
4394        .agent_runtime_id
4395        .as_ref()
4396        .map(crate::identity_first::AgentRuntimeId::as_str);
4397    Some(JsonRpcError {
4398        code: -32000,
4399        message: format!(
4400            "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
4401            identity.as_str(),
4402            registered_runtime_member_id.unwrap_or("<none>"),
4403            live_alias.runtime_member_id
4404        ),
4405        data: Some(json!({
4406            "kind": "stale_identity_runtime_binding",
4407            "identity": identity.as_str(),
4408            "registered_runtime_member_id": registered_runtime_member_id,
4409            "live_runtime_member_id": live_alias.runtime_member_id,
4410            "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
4411            "live_session_id": live_alias.session_id,
4412        })),
4413    })
4414}
4415
4416fn identity_from_runtime_alias(alias: &str) -> Option<crate::identity_first::AgentIdentity> {
4417    let alias = crate::member_comms_id::runtime_alias_str(alias);
4418    let rest = alias.strip_prefix("rt:")?;
4419    let (identity, generation) = rest.rsplit_once(':')?;
4420    if identity.is_empty()
4421        || generation.is_empty()
4422        || !generation.chars().all(|ch| ch.is_ascii_digit())
4423    {
4424        return None;
4425    }
4426    crate::identity_first::AgentIdentity::parse(identity).ok()
4427}
4428
4429async fn stale_runtime_alias_json_rpc_error(
4430    operation: &str,
4431    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
4432    alias: &str,
4433) -> Option<JsonRpcError> {
4434    let identity_runtime = identity_runtime?;
4435    let alias = crate::member_comms_id::runtime_alias_str(alias).into_owned();
4436    let identity = identity_from_runtime_alias(&alias)?;
4437    let status = identity_runtime.status(&identity).await.ok();
4438    let registered_runtime_member_id = status
4439        .as_ref()
4440        .and_then(|status| status.agent_runtime_id.as_ref())
4441        .map(crate::identity_first::AgentRuntimeId::as_str);
4442    if registered_runtime_member_id == Some(alias.as_str()) {
4443        return None;
4444    }
4445    Some(JsonRpcError {
4446        code: -32000,
4447        message: format!(
4448            "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
4449            identity.as_str(),
4450            registered_runtime_member_id.unwrap_or("<none>"),
4451            alias
4452        ),
4453        data: Some(json!({
4454            "kind": "stale_identity_runtime_binding",
4455            "identity": identity.as_str(),
4456            "registered_runtime_member_id": registered_runtime_member_id,
4457            "live_runtime_member_id": alias,
4458            "registered_session_id": status.as_ref().and_then(|status| status.session_id.as_ref()).map(ToString::to_string),
4459            "live_session_id": Value::Null,
4460        })),
4461    })
4462}
4463
4464fn reset_requires_session_bridge_json_rpc_error() -> JsonRpcError {
4465    JsonRpcError {
4466        code: -32602,
4467        message: "reset requires an identity runtime with a session bridge".to_string(),
4468        data: Some(json!({
4469            "kind": "identity_reset_requires_session_bridge",
4470        })),
4471    }
4472}
4473
4474fn console_identity_status_json_from_record(
4475    record: &crate::console_aggregator::ConsoleIdentityRecord,
4476    response_phase: Option<String>,
4477) -> Value {
4478    json!({
4479        "identity": record.identity,
4480        "state": record.health,
4481        "role": record.labels.get("role"),
4482        "addressability": if record.addressable { "addressable" } else { "internal_only" },
4483        "display_name": record.display_name,
4484        "labels": record.labels,
4485        "agent_runtime_id": record.runtime_member_id,
4486        "session_id": record.session_id,
4487        "generation": Value::Null,
4488        "checkpoint_version": Value::Null,
4489        "continuity_health": Value::Null,
4490        "lease_healthy": Value::Null,
4491        "lease": Value::Null,
4492        "response_phase": response_phase,
4493    })
4494}
4495
4496fn console_addressability_json(
4497    addressability: crate::identity_first::AgentAddressability,
4498) -> &'static str {
4499    match addressability {
4500        crate::identity_first::AgentAddressability::Addressable => "addressable",
4501        crate::identity_first::AgentAddressability::InternalOnly => "internal_only",
4502    }
4503}
4504
4505fn console_identity_record_from_identity_status(
4506    status: &crate::identity_first::IdentityStatus,
4507) -> ConsoleIdentityRecord {
4508    let mut labels = status.labels.clone();
4509    if let Some(profile) = status.profile.as_ref() {
4510        labels
4511            .entry("role".to_string())
4512            .or_insert_with(|| profile.as_str().to_string());
4513    }
4514    let runtime_member_id = status
4515        .agent_runtime_id
4516        .as_ref()
4517        .map(crate::identity_first::AgentRuntimeId::as_str)
4518        .unwrap_or_else(|| status.identity.as_str())
4519        .to_string();
4520    let addressable = status.addressability
4521        == crate::identity_first::AgentAddressability::Addressable
4522        && matches!(
4523            status.state,
4524            crate::identity_first::IdentityLifecycleState::Active
4525                | crate::identity_first::IdentityLifecycleState::Dormant
4526                | crate::identity_first::IdentityLifecycleState::Uninitialized
4527        );
4528    let visibility = match status.state {
4529        crate::identity_first::IdentityLifecycleState::Retiring => {
4530            ConsoleVisibility::RetiredReadable
4531        }
4532        crate::identity_first::IdentityLifecycleState::Broken
4533        | crate::identity_first::IdentityLifecycleState::Suspended => {
4534            ConsoleVisibility::Unreachable
4535        }
4536        _ if addressable => ConsoleVisibility::Addressable,
4537        _ => ConsoleVisibility::Hidden,
4538    };
4539    let health = match status.state {
4540        crate::identity_first::IdentityLifecycleState::Active => "ready",
4541        crate::identity_first::IdentityLifecycleState::Dormant => "dormant",
4542        crate::identity_first::IdentityLifecycleState::Uninitialized => "uninitialized",
4543        crate::identity_first::IdentityLifecycleState::Broken => "broken",
4544        crate::identity_first::IdentityLifecycleState::Suspended => "suspended",
4545        crate::identity_first::IdentityLifecycleState::Retiring => "retired",
4546    }
4547    .to_string();
4548    ConsoleIdentityRecord {
4549        identity: status.identity.as_str().to_string(),
4550        display_name: status
4551            .display_name
4552            .as_ref()
4553            .map(crate::identity_first::DisplayName::as_str)
4554            .unwrap_or_else(|| status.identity.as_str())
4555            .to_string(),
4556        runtime_key: "identity-first".to_string(),
4557        runtime_member_id,
4558        session_id: status.session_id.as_ref().map(ToString::to_string),
4559        visibility,
4560        addressable,
4561        health,
4562        topology_peers: Vec::new(),
4563        labels,
4564    }
4565}
4566
4567fn identity_hidden_by_policy_response(response_id: Value, identity: &str) -> Value {
4568    response_value(
4569        response_id,
4570        None,
4571        Some(identity_hidden_by_policy_error(identity)),
4572    )
4573}
4574
4575fn identity_hidden_by_policy_error(identity: &str) -> JsonRpcError {
4576    JsonRpcError {
4577        code: -32001,
4578        message: format!("unknown identity: {identity}"),
4579        data: Some(json!({
4580            "kind": "identity_hidden_by_policy",
4581            "identity": identity,
4582        })),
4583    }
4584}
4585
4586fn identity_status_visible_to_console(
4587    visibility_policy: &dyn ConsoleVisibilityPolicy,
4588    status: &crate::identity_first::IdentityStatus,
4589) -> bool {
4590    visibility_policy.identity_visible(&console_identity_record_from_identity_status(status))
4591}
4592
4593fn console_member_from_runtime_alias(
4594    handle: &MobHandle,
4595    alias: &ConsoleRuntimeIdentityAlias,
4596) -> ConsoleMember {
4597    ConsoleMember {
4598        agent_identity: alias.runtime_member_id.clone(),
4599        role: alias.member.role.to_string(),
4600        state: member_status_state_string(alias.member.status),
4601        model_capabilities: model_capabilities_for_member_entry(handle.definition(), &alias.member),
4602        runtime_mode: Some(alias.member.runtime_mode.to_string()),
4603        session_id: alias.session_id.clone(),
4604        wired_to: alias
4605            .member
4606            .wired_to
4607            .iter()
4608            .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
4609            .collect(),
4610        labels: alias.member.labels.clone(),
4611        progress: None,
4612    }
4613}
4614
4615fn console_identity_record_from_runtime_alias(
4616    alias: &ConsoleRuntimeIdentityAlias,
4617) -> ConsoleIdentityRecord {
4618    let addressable = alias
4619        .member
4620        .labels
4621        .get("addressable")
4622        .map(|value| !value.eq_ignore_ascii_case("false"))
4623        .unwrap_or(true)
4624        && alias.member.status == meerkat_mob::MobMemberStatus::Active;
4625    let visibility = match alias.member.status {
4626        meerkat_mob::MobMemberStatus::Retiring | meerkat_mob::MobMemberStatus::Completed => {
4627            ConsoleVisibility::RetiredReadable
4628        }
4629        // Broken/unknown members have no live runtime binding; mirror the
4630        // identity-first projection, which marks them unreachable.
4631        meerkat_mob::MobMemberStatus::Broken | meerkat_mob::MobMemberStatus::Unknown => {
4632            ConsoleVisibility::Unreachable
4633        }
4634        _ if addressable => ConsoleVisibility::Addressable,
4635        _ => ConsoleVisibility::Hidden,
4636    };
4637    ConsoleIdentityRecord {
4638        identity: alias.identity.clone(),
4639        display_name: alias
4640            .member
4641            .labels
4642            .get("display_name")
4643            .cloned()
4644            .unwrap_or_else(|| alias.identity.clone()),
4645        runtime_key: "runtime".to_string(),
4646        runtime_member_id: alias.runtime_member_id.clone(),
4647        session_id: alias.session_id.clone(),
4648        visibility,
4649        addressable,
4650        health: match alias.member.status {
4651            meerkat_mob::MobMemberStatus::Active => "ready".to_string(),
4652            meerkat_mob::MobMemberStatus::Retiring => "retired".to_string(),
4653            // New machine statuses surface verbatim (broken/completed/unknown),
4654            // matching the identity-first health vocabulary.
4655            other => format!("{other:?}").to_ascii_lowercase(),
4656        },
4657        topology_peers: alias
4658            .member
4659            .wired_to
4660            .iter()
4661            .map(ToString::to_string)
4662            .collect(),
4663        labels: alias.member.labels.clone(),
4664    }
4665}
4666
4667fn runtime_alias_visible_to_console(
4668    handle: &MobHandle,
4669    visibility_policy: &dyn ConsoleVisibilityPolicy,
4670    alias: &ConsoleRuntimeIdentityAlias,
4671) -> bool {
4672    let member = console_member_from_runtime_alias(handle, alias);
4673    if !visibility_policy.member_visible(&member) {
4674        return false;
4675    }
4676    visibility_policy.identity_visible(&console_identity_record_from_runtime_alias(alias))
4677}
4678
4679fn console_identity_status_json_from_identity_status(
4680    status: &crate::identity_first::IdentityStatus,
4681    response_phase: Option<String>,
4682) -> Value {
4683    json!({
4684        "identity": status.identity.as_str(),
4685        "state": status.state.wire_str(),
4686        "role": status.profile.as_ref().map(ProfileName::as_str),
4687        "addressability": console_addressability_json(status.addressability),
4688        "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4689        "labels": status.labels,
4690        "agent_runtime_id": status.agent_runtime_id.as_ref().map(crate::identity_first::AgentRuntimeId::as_str),
4691        "session_id": status.session_id.as_ref().map(ToString::to_string),
4692        "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4693        "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4694        "continuity_health": status.continuity_health,
4695        "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4696        "lease": status.lease.as_ref().map(|lease| json!({
4697            "fencing_token": lease.fencing_token.get(),
4698            "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4699            "healthy": lease.healthy,
4700        })),
4701        "response_phase": response_phase,
4702    })
4703}
4704
4705fn console_identity_inspect_json_from_identity_status(
4706    status: &crate::identity_first::IdentityStatus,
4707    live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4708    response_phase: Option<String>,
4709) -> Value {
4710    let topology_peers = live_alias
4711        .map(|alias| {
4712            alias
4713                .member
4714                .wired_to
4715                .iter()
4716                .map(ToString::to_string)
4717                .map(Value::String)
4718                .collect::<Vec<_>>()
4719        })
4720        .unwrap_or_default();
4721    let session_id = status
4722        .session_id
4723        .as_ref()
4724        .map(ToString::to_string)
4725        .or_else(|| live_alias.and_then(|alias| alias.session_id.clone()));
4726    let agent_runtime_id = status
4727        .agent_runtime_id
4728        .as_ref()
4729        .map(crate::identity_first::AgentRuntimeId::as_str)
4730        .map(ToString::to_string)
4731        .or_else(|| live_alias.map(|alias| alias.runtime_member_id.clone()));
4732    json!({
4733        "identity": status.identity.as_str(),
4734        "state": status.state.wire_str(),
4735        "role": status.profile.as_ref().map(ProfileName::as_str),
4736        "addressability": console_addressability_json(status.addressability),
4737        "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4738        "labels": status.labels,
4739        "continuity_health": status.continuity_health,
4740        "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4741        "lease": status.lease.as_ref().map(|lease| json!({
4742            "fencing_token": lease.fencing_token.get(),
4743            "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4744            "healthy": lease.healthy,
4745        })),
4746        "continuity": {
4747            "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4748            "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4749            "session_id": session_id,
4750            "agent_runtime_id": agent_runtime_id,
4751        },
4752        "topology_peers": topology_peers,
4753        "output_preview": Value::Null,
4754        "response_phase": response_phase,
4755    })
4756}
4757
4758fn console_identity_inspect_json_from_record(
4759    inspection: &crate::console_aggregator::ConsoleIdentityInspection,
4760    response_phase: Option<String>,
4761) -> Value {
4762    let record = &inspection.identity;
4763    json!({
4764        "identity": record.identity,
4765        "state": record.health,
4766        "role": record.labels.get("role"),
4767        "addressability": if record.addressable { "addressable" } else { "internal_only" },
4768        "display_name": record.display_name,
4769        "labels": record.labels,
4770        "continuity_health": Value::Null,
4771        "lease_healthy": Value::Null,
4772        "lease": Value::Null,
4773        "continuity": {
4774            "generation": Value::Null,
4775            "checkpoint_version": Value::Null,
4776            "session_id": record.session_id,
4777            "agent_runtime_id": record.runtime_member_id,
4778        },
4779        "topology_peers": inspection.peers,
4780        "output_preview": Value::Null,
4781        "response_phase": response_phase,
4782    })
4783}
4784
4785fn lifecycle_archive_cleanup_completed(error: &str) -> bool {
4786    is_recoverable_lifecycle_cleanup_error(error)
4787}
4788
4789async fn respawn_console_member(
4790    handle: &MobHandle,
4791    runtime_member_id: &AgentIdentity,
4792) -> Result<Option<Value>, String> {
4793    // Best-effort repair material: a faulted lookup degrades to None (the
4794    // respawn itself surfaces real faults).
4795    let entry_before_respawn = handle.get_member(runtime_member_id).await.ok().flatten();
4796    match handle.respawn(runtime_member_id.clone(), None).await {
4797        Ok(_receipt) => Ok(None),
4798        Err(err) => {
4799            if let Some(failed_peer_ids) = topology_restore_failed_peer_ids(&err) {
4800                tracing::warn!(
4801                    member_id = %runtime_member_id,
4802                    failed_peer_count = failed_peer_ids.len(),
4803                    failed_peer_ids = ?failed_peer_ids,
4804                    "console member respawn restored member with isolated peer edges; continuing degraded respawn"
4805                );
4806                return Ok(Some(topology_restore_warning_json(&failed_peer_ids)));
4807            }
4808
4809            if lifecycle_archive_cleanup_completed(&err.to_string()) {
4810                // A faulted lookup must not read as "absent" (that would mint
4811                // a spurious replacement member); surface it instead.
4812                if handle
4813                    .get_member(runtime_member_id)
4814                    .await
4815                    .map_err(|lookup_err| lookup_err.to_string())?
4816                    .is_none()
4817                    && let Some(entry) = entry_before_respawn
4818                {
4819                    let mut spec =
4820                        SpawnMemberSpec::new(entry.role.clone(), runtime_member_id.clone());
4821                    if !entry.labels.is_empty() {
4822                        spec = spec.with_labels(entry.labels.clone());
4823                    }
4824                    handle
4825                        .ensure_member(spec)
4826                        .await
4827                        .map_err(|ensure_err| ensure_err.to_string())?;
4828                }
4829                return Ok(None);
4830            }
4831
4832            Err(err.to_string())
4833        }
4834    }
4835}
4836
4837/// Live-member fallback shared by `mobkit/respawn` and `mobkit/reset` when no
4838/// identity-first record backs the target: respawn the runtime member (fresh
4839/// session, same configuration) and report the projected member status.
4840async fn respawn_live_console_member_response(
4841    handle: &MobHandle,
4842    console_events: Option<&ConsoleEventStore>,
4843    alias: &ConsoleRuntimeIdentityAlias,
4844    lifecycle_kind: &str,
4845    action: &str,
4846    response_id: Value,
4847) -> Value {
4848    let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
4849    match Box::pin(respawn_console_member(handle, &mid)).await {
4850        Ok(topology_restore_warning) => {
4851            if let Some(store) = console_events {
4852                store
4853                    .record_lifecycle(
4854                        &alias.identity,
4855                        lifecycle_kind,
4856                        json!({ "topology_restore_warning": topology_restore_warning.clone() }),
4857                    )
4858                    .await;
4859            }
4860            let mut body = match lookup_member_with_session(handle, &mid).await {
4861                Some((entry, session_id)) => console_identity_status_json_for_identity(
4862                    &alias.identity,
4863                    &entry,
4864                    session_id,
4865                    None,
4866                ),
4867                None => json!({ "identity": alias.identity }),
4868            };
4869            if let Some(warning) = topology_restore_warning {
4870                body["topology_restore_warning"] = warning;
4871            }
4872            response_value(response_id, Some(body), None)
4873        }
4874        Err(err) => internal_error(response_id, format!("{action} failed: {err}")),
4875    }
4876}
4877
4878async fn retire_console_member(
4879    handle: &MobHandle,
4880    runtime_member_id: &AgentIdentity,
4881) -> Result<(), String> {
4882    match handle.retire(runtime_member_id.clone()).await {
4883        Ok(()) => Ok(()),
4884        Err(err) if lifecycle_archive_cleanup_completed(&err.to_string()) => Ok(()),
4885        Err(err) => Err(err.to_string()),
4886    }
4887}
4888
4889#[cfg(test)]
4890fn member_id_matches_durable_identity(member_id: &str, durable_identity: &str) -> bool {
4891    member_id == durable_identity
4892}
4893
4894fn console_runtime_alias_generation(alias: &str, durable_identity: &str) -> Option<u64> {
4895    let alias = crate::member_comms_id::runtime_alias_str(alias);
4896    let rest = alias.strip_prefix("rt:")?;
4897    let (identity, generation) = rest.rsplit_once(':')?;
4898    if identity != durable_identity {
4899        return None;
4900    }
4901    generation.parse().ok()
4902}
4903
4904async fn stale_console_member_ids_for_identity(
4905    handle: &MobHandle,
4906    visibility_policy: &dyn ConsoleVisibilityPolicy,
4907    durable_identity: &str,
4908    current_runtime_member_id: Option<&str>,
4909    include_current: bool,
4910) -> Vec<AgentIdentity> {
4911    let Some(current_generation) = current_runtime_member_id
4912        .and_then(|alias| console_runtime_alias_generation(alias, durable_identity))
4913    else {
4914        return Vec::new();
4915    };
4916    lookup_member_alias_candidates_with_session(handle, durable_identity)
4917        .await
4918        .into_iter()
4919        .filter(|alias| {
4920            runtime_alias_visible_to_console(handle, visibility_policy, alias)
4921                && console_runtime_alias_generation(
4922                    alias.runtime_member_id.as_str(),
4923                    durable_identity,
4924                )
4925                .is_some_and(|generation| {
4926                    generation < current_generation
4927                        || (include_current && generation == current_generation)
4928                })
4929        })
4930        .map(|alias| crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str()))
4931        .collect()
4932}
4933
4934/// Lifecycle-authoritative stale aliases, independent of console projection.
4935///
4936/// This is used only while the durable identity lifecycle lock is held. A
4937/// hidden generated alias is still an old concrete embodiment of the selected
4938/// durable identity and must not survive beside its current generation.
4939async fn stale_console_member_ids_for_identity_authoritative(
4940    handle: &MobHandle,
4941    durable_identity: &str,
4942    current_runtime_member_id: &str,
4943    include_current: bool,
4944) -> Vec<AgentIdentity> {
4945    let Some(current_generation) =
4946        console_runtime_alias_generation(current_runtime_member_id, durable_identity)
4947    else {
4948        return Vec::new();
4949    };
4950    lookup_member_alias_candidates_with_session(handle, durable_identity)
4951        .await
4952        .into_iter()
4953        .filter(|alias| {
4954            console_runtime_alias_generation(alias.runtime_member_id.as_str(), durable_identity)
4955                .is_some_and(|generation| {
4956                    generation < current_generation
4957                        || (include_current && generation == current_generation)
4958                })
4959        })
4960        .map(|alias| crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str()))
4961        .collect()
4962}
4963
4964async fn retire_console_member_ids(
4965    handle: &MobHandle,
4966    member_ids: Vec<AgentIdentity>,
4967) -> Result<(), String> {
4968    for member_id in member_ids {
4969        retire_console_member(handle, &member_id).await?;
4970    }
4971    Ok(())
4972}
4973
4974fn console_identity_error_response(
4975    response_id: Value,
4976    operation: &str,
4977    err: crate::identity_first::IdentityRuntimeError,
4978) -> Value {
4979    match err {
4980        crate::identity_first::IdentityRuntimeError::UnknownIdentity(identity) => {
4981            invalid_params(response_id, format!("identity not found: {identity}"))
4982        }
4983        other => internal_error(response_id, format!("{operation} failed: {other}")),
4984    }
4985}
4986
4987#[allow(clippy::too_many_arguments)]
4988async fn handle_console_aggregator_rpc(
4989    console_aggregator: Option<MobKitConsoleAggregator>,
4990    request: JsonRpcRequest,
4991    is_authenticated: bool,
4992    read_only: bool,
4993    access: Option<&AccessController>,
4994    access_view: Option<&AccessView>,
4995) -> Value {
4996    let response_id = request.id.clone().unwrap_or(Value::Null);
4997    if let Err(message) =
4998        crate::member_comms_id::validate_public_rpc_member_aliases(&request.params)
4999    {
5000        return invalid_params(response_id, message);
5001    }
5002    let can_mutate = is_authenticated && !read_only;
5003    if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
5004        return console_read_only_rpc_error(response_id);
5005    }
5006    if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
5007        return response;
5008    }
5009    if let Some(error) =
5010        console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
5011    {
5012        return response_value(response_id, None, Some(error));
5013    }
5014    match request.method.as_str() {
5015        "mobkit/capabilities" => {
5016            let mut methods = vec![
5017                "mobkit/capabilities",
5018                "mobkit/console/list_identities",
5019                "mobkit/console/inspect_identity",
5020                "mobkit/console/query_timeline",
5021            ];
5022            if can_mutate {
5023                methods.extend_from_slice(&["mobkit/retire", "mobkit/console/send"]);
5024            }
5025            if access.is_some() {
5026                methods.push("mobkit/access/status");
5027                if access_view.is_some_and(AccessView::can_administer) {
5028                    methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
5029                    if can_mutate {
5030                        methods.extend_from_slice(&[
5031                            "mobkit/access/set",
5032                            "mobkit/access/enable",
5033                            "mobkit/access/rules/upsert",
5034                            "mobkit/access/rules/delete",
5035                            "mobkit/access/groups/set",
5036                            "mobkit/access/groups/delete",
5037                        ]);
5038                    }
5039                }
5040            }
5041            response_value(
5042                response_id,
5043                Some(json!({
5044                    "methods": methods,
5045                    "authenticated": is_authenticated,
5046                    "read_only": read_only,
5047                    "runtime_capabilities": {
5048                        "can_send_messages": can_mutate,
5049                        "can_retire_members": can_mutate,
5050                        "can_spawn_members": false,
5051                    },
5052                    "features": {
5053                        "console_aggregator": console_aggregator.is_some(),
5054                        "multi_runtime_console": console_aggregator.is_some(),
5055                    }
5056                })),
5057                None,
5058            )
5059        }
5060        "mobkit/console/list_identities" => {
5061            let Some(aggregator) = &console_aggregator else {
5062                return console_aggregator_unavailable(response_id);
5063            };
5064            match Box::pin(aggregator.list_identities()).await {
5065                Ok(mut identities) => {
5066                    retain_visible_identity_records(&mut identities, access_view);
5067                    response_value(response_id, Some(json!({ "identities": identities })), None)
5068                }
5069                Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
5070            }
5071        }
5072        "mobkit/console/inspect_identity" => {
5073            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5074                return invalid_params(response_id, "identity required");
5075            };
5076            let Some(aggregator) = &console_aggregator else {
5077                return console_aggregator_unavailable(response_id);
5078            };
5079            match Box::pin(aggregator.inspect_identity(identity)).await {
5080                Ok(Some(inspection)) => response_value(
5081                    response_id,
5082                    Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
5083                    None,
5084                ),
5085                Ok(None) => response_value(
5086                    response_id,
5087                    None,
5088                    Some(JsonRpcError {
5089                        code: -32001,
5090                        message: format!("unknown identity: {identity}"),
5091                        data: None,
5092                    }),
5093                ),
5094                Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
5095            }
5096        }
5097        "mobkit/console/query_timeline" => {
5098            let query: ConsoleTimelineWindowQuery =
5099                match serde_json::from_value(request.params.clone()) {
5100                    Ok(query) => query,
5101                    Err(err) => {
5102                        return invalid_params(response_id, format!("invalid query params: {err}"));
5103                    }
5104                };
5105            let Some(aggregator) = &console_aggregator else {
5106                return console_aggregator_unavailable(response_id);
5107            };
5108            match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
5109                Ok(mut page) => {
5110                    retain_visible_timeline_frames(&mut page, access_view);
5111                    response_value(
5112                        response_id,
5113                        Some(serde_json::to_value(page).unwrap_or(Value::Null)),
5114                        None,
5115                    )
5116                }
5117                Err(err) => {
5118                    let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
5119                    console_timeline_replay_unavailable_response(
5120                        response_id,
5121                        err,
5122                        query.after.as_ref(),
5123                        latest_cursor,
5124                    )
5125                }
5126            }
5127        }
5128        "mobkit/console/send" => {
5129            let send_request: ConsoleSendRequest =
5130                match serde_json::from_value(request.params.clone()) {
5131                    Ok(request) => request,
5132                    Err(err) => {
5133                        return invalid_params(response_id, format!("invalid send params: {err}"));
5134                    }
5135                };
5136            let Some(aggregator) = &console_aggregator else {
5137                return console_aggregator_unavailable(response_id);
5138            };
5139            match Box::pin(aggregator.send(send_request)).await {
5140                Ok(accepted) => response_value(
5141                    response_id,
5142                    Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5143                    None,
5144                ),
5145                Err(err) => {
5146                    response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5147                }
5148            }
5149        }
5150        "mobkit/retire" => {
5151            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5152                return invalid_params(response_id, "identity required");
5153            };
5154            let Some(aggregator) = &console_aggregator else {
5155                return console_aggregator_unavailable(response_id);
5156            };
5157            match Box::pin(aggregator.retire_identity(identity)).await {
5158                Ok(true) => {
5159                    response_value(response_id, Some(json!({ "identity": identity })), None)
5160                }
5161                Ok(false) => response_value(
5162                    response_id,
5163                    None,
5164                    Some(JsonRpcError {
5165                        code: -32001,
5166                        message: format!("unknown identity: {identity}"),
5167                        data: None,
5168                    }),
5169                ),
5170                Err(err) => internal_error(response_id, format!("retire failed: {err}")),
5171            }
5172        }
5173        "mobkit/reset_all" => {
5174            let Some(_aggregator) = &console_aggregator else {
5175                return console_aggregator_unavailable(response_id);
5176            };
5177            response_value(
5178                response_id,
5179                None,
5180                Some(JsonRpcError {
5181                    code: -32002,
5182                    message: "reset_all is not supported on the aggregator-only RPC surface"
5183                        .to_string(),
5184                    data: Some(json!({
5185                        "kind": "unsupported_reset_all_surface",
5186                        "reason": "aggregator reset_all cannot preserve baseline identity semantics",
5187                    })),
5188                }),
5189            )
5190        }
5191        _ => response_value(
5192            response_id,
5193            None,
5194            Some(JsonRpcError {
5195                code: -32601,
5196                message: "Method not found".to_string(),
5197                data: None,
5198            }),
5199        ),
5200    }
5201}
5202
5203fn console_aggregator_unavailable(response_id: Value) -> Value {
5204    response_value(
5205        response_id,
5206        None,
5207        Some(JsonRpcError {
5208            code: -32004,
5209            message: "console aggregator unavailable".to_string(),
5210            data: None,
5211        }),
5212    )
5213}
5214
5215fn resolve_gating_approver_id(
5216    params: &Value,
5217    authenticated_principal: Option<&str>,
5218) -> Result<String, &'static str> {
5219    if let Some(principal) = authenticated_principal.and_then(|value| {
5220        let trimmed = value.trim();
5221        (!trimmed.is_empty()).then_some(trimmed)
5222    }) {
5223        return Ok(principal.to_string());
5224    }
5225    let Some(approver_id) = params.get("approver_id").and_then(Value::as_str) else {
5226        return Err("approver_id required");
5227    };
5228    let trimmed = approver_id.trim();
5229    if trimmed.is_empty() {
5230        return Err("approver_id required");
5231    }
5232    Ok(trimmed.to_string())
5233}
5234
5235#[allow(clippy::large_futures, clippy::too_many_arguments)]
5236#[cfg(test)]
5237async fn handle_console_runtime_rpc(
5238    runtime: &MobRuntime,
5239    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
5240    contact_directory: Option<&ContactDirectory>,
5241    gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
5242    console_events: Option<ConsoleEventStore>,
5243    console_aggregator: Option<MobKitConsoleAggregator>,
5244    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
5245    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
5246    mob_events: Option<MobEventsStore>,
5247    request: JsonRpcRequest,
5248    is_authenticated: bool,
5249) -> Value {
5250    handle_console_runtime_rpc_with_visibility(
5251        runtime,
5252        module_runtime,
5253        contact_directory,
5254        gateway_peer_keys,
5255        console_events,
5256        console_aggregator,
5257        identity_runtime,
5258        metadata_table,
5259        mob_events,
5260        &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
5261        request,
5262        is_authenticated,
5263        false,
5264        None,
5265        None,
5266        None,
5267        None,
5268        None,
5269        None,
5270        None,
5271    )
5272    .await
5273}
5274
5275#[allow(clippy::too_many_arguments)]
5276async fn handle_console_runtime_rpc_with_visibility(
5277    runtime: &MobRuntime,
5278    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
5279    contact_directory: Option<&ContactDirectory>,
5280    gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
5281    console_events: Option<ConsoleEventStore>,
5282    console_aggregator: Option<MobKitConsoleAggregator>,
5283    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
5284    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
5285    mob_events: Option<MobEventsStore>,
5286    visibility_policy: &dyn ConsoleVisibilityPolicy,
5287    request: JsonRpcRequest,
5288    is_authenticated: bool,
5289    read_only: bool,
5290    authenticated_principal: Option<&str>,
5291    access: Option<&AccessController>,
5292    access_view: Option<&AccessView>,
5293    memory_panel: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
5294    identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
5295    workgraph: Option<&meerkat::WorkGraphService>,
5296    topology: Option<&crate::topology_control::TopologyRuntimeHandle>,
5297) -> Value {
5298    let response_id = request.id.clone().unwrap_or(Value::Null);
5299    if let Err(message) =
5300        crate::member_comms_id::validate_public_rpc_member_aliases(&request.params)
5301    {
5302        return invalid_params(response_id, message);
5303    }
5304    let can_mutate = is_authenticated && !read_only;
5305    if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
5306        return console_read_only_rpc_error(response_id);
5307    }
5308    // Refresh the attribute cache from the live roster before any per-agent
5309    // access decision (the gate below and per-row result filtering) so
5310    // label/role rules resolve here exactly as they do on
5311    // `/console/experience`. RPC callers must not depend on having polled the
5312    // experience endpoint first.
5313    if let Some(controller) = access.filter(|controller| controller.enabled()) {
5314        prime_access_cache_from_runtime(runtime, controller).await;
5315    }
5316    if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
5317        return response;
5318    }
5319    if let Some(error) =
5320        console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
5321    {
5322        return response_value(response_id, None, Some(error));
5323    }
5324
5325    match request.method.as_str() {
5326        "mobkit/capabilities" => {
5327            let mut methods = vec![
5328                "mobkit/status",
5329                "mobkit/capabilities",
5330                "mobkit/list_members",
5331                "mobkit/get_member",
5332                "mobkit/find_members",
5333                "mobkit/member_status",
5334                "mobkit/identity/resolved_tools",
5335                "mobkit/blob/get",
5336                "mobkit/wait_ready",
5337                "mobkit/flow_status",
5338                "mobkit/list_flows",
5339                "mobkit/list_runs",
5340                "mobkit/console/list_identities",
5341                "mobkit/console/inspect_identity",
5342                "mobkit/console/query_timeline",
5343                "mobkit/mob_events/query",
5344                "mobkit/mob_events/subscribe",
5345                "mobkit/cross_mob/peer_info",
5346                "mobkit/cross_mob/directory",
5347                "mobkit/peer_pubkey",
5348                "mobkit/storage/doctor",
5349            ];
5350            if identity_runtime.is_some() {
5351                methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
5352                if let Some(identity_runtime) = &identity_runtime
5353                    && identity_runtime.agent_memory_supports_recall().await
5354                {
5355                    methods.push("mobkit/agent_memory/recall");
5356                    if can_mutate && identity_runtime.agent_memory_supports_remember().await {
5357                        methods.push("mobkit/agent_memory/remember");
5358                    }
5359                    if can_mutate && identity_runtime.agent_memory_supports_forget().await {
5360                        methods.push("mobkit/agent_memory/forget");
5361                    }
5362                    if can_mutate && identity_runtime.agent_memory_supports_update().await {
5363                        methods.push("mobkit/agent_memory/update");
5364                    }
5365                    if identity_runtime.agent_memory_supports_manifest().await {
5366                        methods.push("mobkit/agent_memory/manifest");
5367                    }
5368                }
5369                if can_mutate {
5370                    methods.push("mobkit/delete_identity");
5371                }
5372            } else if console_aggregator.is_some() {
5373                methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
5374            }
5375            if module_runtime.is_some() {
5376                methods.extend_from_slice(&[
5377                    "mobkit/routing/routes/list",
5378                    "mobkit/delivery/history",
5379                    "mobkit/gating/pending",
5380                    "mobkit/gating/audit",
5381                ]);
5382                if can_mutate {
5383                    methods.push("mobkit/gating/decide");
5384                }
5385            }
5386            if can_mutate {
5387                methods.extend_from_slice(&[
5388                    "mobkit/retire",
5389                    // Respawn/reset dispatch on every console runtime: with an
5390                    // identity-first runtime they refresh the durable record,
5391                    // and without one they fall back to a live-member respawn.
5392                    // Advertising them only under the identity runtime left
5393                    // the console buttons dead on plain runtimes even though
5394                    // the dispatcher accepted the calls.
5395                    "mobkit/respawn",
5396                    "mobkit/reset",
5397                    "mobkit/reset_all",
5398                    "mobkit/console/send",
5399                    "mobkit/blob/upload",
5400                    "mobkit/ensure_member",
5401                    "mobkit/retire_member",
5402                    "mobkit/respawn_member",
5403                    "mobkit/force_cancel_member",
5404                    "mobkit/cancel_flow",
5405                    "mobkit/collect_completed",
5406                    "mobkit/run_flow",
5407                    "mobkit/spawn_helper",
5408                    "mobkit/fork_helper",
5409                    "mobkit/attach_existing_session",
5410                    "mobkit/reconcile_edges",
5411                    "mobkit/cross_mob/wire_local",
5412                    "mobkit/cross_mob/unwire_local",
5413                ]);
5414            }
5415            if metadata_table.is_some() {
5416                methods.extend_from_slice(&["mobkit/mob_labels/get", "mobkit/run_labels/get"]);
5417                if can_mutate {
5418                    methods.extend_from_slice(&[
5419                        "mobkit/mob_labels/set",
5420                        "mobkit/mob_labels/delete",
5421                        "mobkit/run_labels/set",
5422                        "mobkit/run_labels/delete",
5423                    ]);
5424                }
5425            }
5426            if memory_panel.is_some() {
5427                // Provider-dependent supports pattern: the panel methods
5428                // exist only when the bundled sqlite store is wired. The
5429                // grant-intersection probe below additionally strips the
5430                // resource-less ones (dreams/quarantine) the caller could
5431                // never use.
5432                methods.extend_from_slice(&[
5433                    "mobkit/memory/panel/records",
5434                    "mobkit/memory/panel/record",
5435                    "mobkit/memory/panel/dreams",
5436                    "mobkit/memory/panel/dream_runs",
5437                    "mobkit/memory/panel/audit_verdicts",
5438                    "mobkit/memory/panel/overview",
5439                    "mobkit/memory/panel/proposals",
5440                    "mobkit/memory/panel/injections",
5441                    "mobkit/memory/panel/harvests",
5442                    "mobkit/memory/panel/quarantine",
5443                ]);
5444            }
5445            if workgraph.is_some() {
5446                methods.extend_from_slice(crate::rpc::workgraph_methods::WORKGRAPH_READ_METHODS);
5447                if can_mutate {
5448                    methods
5449                        .extend_from_slice(crate::rpc::workgraph_methods::WORKGRAPH_MUTATE_METHODS);
5450                    methods.extend_from_slice(
5451                        crate::rpc::workgraph_methods::WORKGRAPH_CONSOLE_MUTATE_METHODS,
5452                    );
5453                }
5454            }
5455            if access.is_some() {
5456                methods.push("mobkit/access/status");
5457                if access_view.is_some_and(AccessView::can_administer) {
5458                    methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
5459                    if can_mutate {
5460                        methods.extend_from_slice(&[
5461                            "mobkit/access/set",
5462                            "mobkit/access/enable",
5463                            "mobkit/access/rules/upsert",
5464                            "mobkit/access/rules/delete",
5465                            "mobkit/access/groups/set",
5466                            "mobkit/access/groups/delete",
5467                        ]);
5468                    }
5469                }
5470            }
5471            let topology_capabilities = topology.map(|topology| {
5472                let (topology_methods, capabilities) =
5473                    crate::rpc::topology_methods::capability_projection(
5474                        topology,
5475                        access_view,
5476                        !can_mutate,
5477                    );
5478                methods.extend(topology_methods);
5479                capabilities
5480            });
5481            // Intersect the advertised methods with the caller's grants, the
5482            // same way `/console/experience` does, so a non-admin doesn't get
5483            // a method list its panels act on only to hit `-32030`. A probe
5484            // identity reveals which mapped requirements are resource-less
5485            // (target `None` regardless of params): those are filtered by the
5486            // action grant; agent-scoped methods stay advertised and are
5487            // enforced per call.
5488            if let Some(view) = access_view.filter(|view| view.enforced()) {
5489                let probe = serde_json::json!({ "identity": "\u{0}cap-probe" });
5490                methods.retain(
5491                    |method| match console_rpc_access_requirements(method, &probe) {
5492                        Some(requirements) => requirements
5493                            .iter()
5494                            .all(|(action, target)| target.is_some() || view.allows(action)),
5495                        None => true,
5496                    },
5497                );
5498            }
5499            // Coarse capability flags, intersected with the caller's grants so
5500            // they agree with the per-agent affordances in `/console/experience`.
5501            let cap = |action: &str| -> bool {
5502                can_mutate
5503                    && access_view
5504                        .filter(|view| view.enforced())
5505                        .is_none_or(|view| view.may_perform_anywhere(action))
5506            };
5507            response_value(
5508                response_id,
5509                Some(serde_json::json!({
5510                    "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5511                    "methods": methods,
5512                    // Doctrine: consumers gate their migration on this flag —
5513                    // when true, the member RPCs re-dispatch durable targets
5514                    // through the identity authority and ensure_member stands
5515                    // up durable identities (pass plane:"worker" to opt a
5516                    // spawn onto the ephemeral mob plane).
5517                    "identity_first": identity_runtime.is_some(),
5518                    // True when a WorkGraph service is wired into this
5519                    // console runtime and the mobkit/workgraph/* group is live.
5520                    "workgraph": workgraph.is_some(),
5521                    "read_only": read_only,
5522                    // The console routes to MobRuntime directly and has no
5523                    // access to the module runtime, so loaded_modules is always [].
5524                    "loaded_modules": serde_json::json!([]),
5525                    "runtime_capabilities": {
5526                        "can_send_messages": cap(ACTION_AGENT_SEND),
5527                        "can_retire_members": cap(ACTION_AGENT_RETIRE),
5528                        "can_spawn_members": cap(ACTION_AGENT_SPAWN),
5529                    },
5530                    "topology_control": topology_capabilities,
5531                })),
5532                None,
5533            )
5534        }
5535        crate::rpc::topology_methods::TOPOLOGY_QUERY_METHOD => {
5536            let Some(topology) = topology else {
5537                return response_value(
5538                    response_id,
5539                    None,
5540                    Some(JsonRpcError {
5541                        code: -32601,
5542                        message: "Method not found".to_string(),
5543                        data: None,
5544                    }),
5545                );
5546            };
5547            serde_json::to_value(
5548                crate::rpc::topology_methods::handle_query(
5549                    topology,
5550                    response_id,
5551                    access_view,
5552                    !can_mutate,
5553                )
5554                .await,
5555            )
5556            .unwrap_or(Value::Null)
5557        }
5558        crate::rpc::topology_methods::TOPOLOGY_PLAN_METHOD => {
5559            let Some(topology) = topology else {
5560                return response_value(
5561                    response_id,
5562                    None,
5563                    Some(JsonRpcError {
5564                        code: -32601,
5565                        message: "Method not found".to_string(),
5566                        data: None,
5567                    }),
5568                );
5569            };
5570            serde_json::to_value(
5571                crate::rpc::topology_methods::handle_plan(
5572                    topology,
5573                    response_id,
5574                    &request.params,
5575                    access_view,
5576                )
5577                .await,
5578            )
5579            .unwrap_or(Value::Null)
5580        }
5581        crate::rpc::topology_methods::TOPOLOGY_APPLY_METHOD => {
5582            let Some(topology) = topology else {
5583                return response_value(
5584                    response_id,
5585                    None,
5586                    Some(JsonRpcError {
5587                        code: -32601,
5588                        message: "Method not found".to_string(),
5589                        data: None,
5590                    }),
5591                );
5592            };
5593            serde_json::to_value(
5594                crate::rpc::topology_methods::handle_apply(
5595                    topology,
5596                    response_id,
5597                    &request.params,
5598                    access_view,
5599                    authenticated_principal,
5600                )
5601                .await,
5602            )
5603            .unwrap_or(Value::Null)
5604        }
5605        crate::rpc::topology_methods::TOPOLOGY_OPERATION_METHOD => {
5606            let Some(topology) = topology else {
5607                return response_value(
5608                    response_id,
5609                    None,
5610                    Some(JsonRpcError {
5611                        code: -32601,
5612                        message: "Method not found".to_string(),
5613                        data: None,
5614                    }),
5615                );
5616            };
5617            serde_json::to_value(
5618                crate::rpc::topology_methods::handle_operation(
5619                    topology,
5620                    response_id,
5621                    &request.params,
5622                    access_view,
5623                )
5624                .await,
5625            )
5626            .unwrap_or(Value::Null)
5627        }
5628        crate::rpc::topology_methods::TOPOLOGY_AUDIT_METHOD => {
5629            let Some(topology) = topology else {
5630                return response_value(
5631                    response_id,
5632                    None,
5633                    Some(JsonRpcError {
5634                        code: -32601,
5635                        message: "Method not found".to_string(),
5636                        data: None,
5637                    }),
5638                );
5639            };
5640            serde_json::to_value(
5641                crate::rpc::topology_methods::handle_audit(
5642                    topology,
5643                    response_id,
5644                    &request.params,
5645                    access_view,
5646                )
5647                .await,
5648            )
5649            .unwrap_or(Value::Null)
5650        }
5651        "mobkit/agent_memory/remember" => {
5652            let Some(identity_runtime) = &identity_runtime else {
5653                return response_value(
5654                    response_id,
5655                    None,
5656                    Some(JsonRpcError {
5657                        code: -32601,
5658                        message: "agent memory is not configured".to_string(),
5659                        data: None,
5660                    }),
5661                );
5662            };
5663            match parse_agent_memory_remember_params(&request.params) {
5664                Ok(remember_request) => match identity_runtime
5665                    .remember_agent_memory(
5666                        &remember_request.realm,
5667                        &remember_request.identity,
5668                        remember_request.memory,
5669                    )
5670                    .await
5671                {
5672                    Ok(record) => response_value(
5673                        response_id,
5674                        Some(serde_json::to_value(record).unwrap_or(Value::Null)),
5675                        None,
5676                    ),
5677                    Err(err) => response_value(
5678                        response_id,
5679                        None,
5680                        Some(crate::rpc::agent_memory_rpc_error("write", err)),
5681                    ),
5682                },
5683                Err(err) => {
5684                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5685                }
5686            }
5687        }
5688        "mobkit/agent_memory/forget" => {
5689            let Some(identity_runtime) = &identity_runtime else {
5690                return response_value(
5691                    response_id,
5692                    None,
5693                    Some(JsonRpcError {
5694                        code: -32601,
5695                        message: "agent memory is not configured".to_string(),
5696                        data: None,
5697                    }),
5698                );
5699            };
5700            match parse_agent_memory_forget_params(&request.params) {
5701                Ok(forget_request) => match identity_runtime
5702                    .forget_agent_memory(
5703                        &forget_request.realm,
5704                        &forget_request.identity,
5705                        &forget_request.memory_id,
5706                    )
5707                    .await
5708                {
5709                    Ok(result) => response_value(
5710                        response_id,
5711                        Some(serde_json::to_value(result).unwrap_or(Value::Null)),
5712                        None,
5713                    ),
5714                    Err(err) => response_value(
5715                        response_id,
5716                        None,
5717                        Some(crate::rpc::agent_memory_rpc_error("forget", err)),
5718                    ),
5719                },
5720                Err(err) => {
5721                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5722                }
5723            }
5724        }
5725        "mobkit/agent_memory/recall" => {
5726            let Some(identity_runtime) = &identity_runtime else {
5727                return response_value(
5728                    response_id,
5729                    None,
5730                    Some(JsonRpcError {
5731                        code: -32601,
5732                        message: "agent memory is not configured".to_string(),
5733                        data: None,
5734                    }),
5735                );
5736            };
5737            match parse_agent_memory_recall_params(&request.params) {
5738                Ok(recall_request) => match identity_runtime
5739                    .recall_agent_memory(recall_request.request)
5740                    .await
5741                {
5742                    Ok(records) => {
5743                        response_value(response_id, Some(json!({ "records": records })), None)
5744                    }
5745                    Err(err) => response_value(
5746                        response_id,
5747                        None,
5748                        Some(crate::rpc::agent_memory_rpc_error("recall", err)),
5749                    ),
5750                },
5751                Err(err) => {
5752                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5753                }
5754            }
5755        }
5756        "mobkit/agent_memory/update" => {
5757            let Some(identity_runtime) = &identity_runtime else {
5758                return response_value(
5759                    response_id,
5760                    None,
5761                    Some(JsonRpcError {
5762                        code: -32601,
5763                        message: "agent memory is not configured".to_string(),
5764                        data: None,
5765                    }),
5766                );
5767            };
5768            match parse_agent_memory_update_params(&request.params) {
5769                Ok(update_request) => match identity_runtime
5770                    .update_agent_memory(
5771                        &update_request.realm,
5772                        &update_request.identity,
5773                        &update_request.memory_id,
5774                        update_request.memory,
5775                    )
5776                    .await
5777                {
5778                    Ok(new_id) => response_value(
5779                        response_id,
5780                        Some(json!({
5781                            "memory_id": new_id,
5782                            "supersedes": update_request.memory_id,
5783                        })),
5784                        None,
5785                    ),
5786                    Err(err) => response_value(
5787                        response_id,
5788                        None,
5789                        Some(crate::rpc::agent_memory_rpc_error("update", err)),
5790                    ),
5791                },
5792                Err(err) => {
5793                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5794                }
5795            }
5796        }
5797        "mobkit/agent_memory/manifest" => {
5798            let Some(identity_runtime) = &identity_runtime else {
5799                return response_value(
5800                    response_id,
5801                    None,
5802                    Some(JsonRpcError {
5803                        code: -32601,
5804                        message: "agent memory is not configured".to_string(),
5805                        data: None,
5806                    }),
5807                );
5808            };
5809            match parse_agent_memory_manifest_params(&request.params) {
5810                Ok(manifest_request) => match identity_runtime
5811                    .manifest_agent_memory(
5812                        &manifest_request.realm,
5813                        &manifest_request.identity,
5814                        manifest_request.tier,
5815                    )
5816                    .await
5817                {
5818                    Ok(records) => {
5819                        response_value(response_id, Some(json!({ "records": records })), None)
5820                    }
5821                    Err(err) => response_value(
5822                        response_id,
5823                        None,
5824                        Some(crate::rpc::agent_memory_rpc_error("manifest", err)),
5825                    ),
5826                },
5827                Err(err) => {
5828                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5829                }
5830            }
5831        }
5832        // §9.3 Memory panel reads. Read-only mode allows all of these (they
5833        // are reads); the ACL mapping lives in
5834        // `console_rpc_access_requirements` plus per-row scope filtering in
5835        // the handlers.
5836        "mobkit/memory/panel/records" => {
5837            handle_memory_panel_records(memory_panel, access_view, &request.params, response_id)
5838                .await
5839        }
5840        "mobkit/memory/panel/record" => {
5841            handle_memory_panel_record(memory_panel, access_view, &request.params, response_id)
5842                .await
5843        }
5844        "mobkit/memory/panel/quarantine" => {
5845            handle_memory_panel_quarantine(memory_panel, access_view, &request.params, response_id)
5846                .await
5847        }
5848        "mobkit/memory/panel/dreams" => {
5849            handle_memory_panel_dreams(memory_panel, &request.params, response_id).await
5850        }
5851        "mobkit/memory/panel/dream_runs" => {
5852            handle_memory_panel_dream_runs(memory_panel, &request.params, response_id).await
5853        }
5854        "mobkit/memory/panel/audit_verdicts" => {
5855            handle_memory_panel_audit_verdicts(memory_panel, &request.params, response_id).await
5856        }
5857        "mobkit/memory/panel/overview" => {
5858            handle_memory_panel_overview(memory_panel, &request.params, response_id).await
5859        }
5860        "mobkit/memory/panel/proposals" => {
5861            handle_memory_panel_proposals(memory_panel, &request.params, response_id).await
5862        }
5863        "mobkit/memory/panel/injections" => {
5864            handle_memory_panel_injections(memory_panel, &request.params, response_id).await
5865        }
5866        "mobkit/memory/panel/harvests" => {
5867            handle_memory_panel_harvests(memory_panel, &request.params, response_id).await
5868        }
5869        // Read-only state-directory diagnosis (registered as a read method:
5870        // allowed in read-only mode, admin-gated in
5871        // `console_rpc_access_requirements`).
5872        "mobkit/storage/doctor" => {
5873            match crate::rpc::storage_methods::parse_storage_doctor_params(&request.params) {
5874                Ok(Some(params)) => {
5875                    let result = crate::rpc::storage_methods::run_storage_doctor(
5876                        &params,
5877                        runtime.resolved_storage(),
5878                    )
5879                    .await;
5880                    response_value(response_id, Some(result), None)
5881                }
5882                Ok(None) => response_value(
5883                    response_id,
5884                    None,
5885                    Some(crate::rpc::storage_methods::storage_doctor_state_dir_unavailable_error()),
5886                ),
5887                Err(reason) => invalid_params(response_id, reason),
5888            }
5889        }
5890        "mobkit/status" => {
5891            let mob_state = runtime.handle().status_observation_snapshot();
5892            let mut result = serde_json::json!({
5893                "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5894                "running": matches!(mob_state, MobState::Creating | MobState::Running),
5895                // Console routes to MobRuntime directly — no module runtime available.
5896                // Return [] to keep StatusResult.loaded_modules schema-consistent.
5897                "loaded_modules": serde_json::json!([]),
5898            });
5899            // H1/H2 storage durability resolution, same object as the
5900            // unified `mobkit/status` shape.
5901            if let Some(storage) = runtime.resolved_storage() {
5902                result["storage"] = storage.status_json();
5903            }
5904            // The console's storage/doctor read method is always dispatchable
5905            // (auto-creates the storage object on runtimes without a
5906            // composition-time resolution summary).
5907            result["storage"]["doctor_available"] = serde_json::json!(true);
5908            response_value(response_id, Some(result), None)
5909        }
5910        "mobkit/console/list_identities" => {
5911            let Some(aggregator) = &console_aggregator else {
5912                return response_value(
5913                    response_id,
5914                    None,
5915                    Some(JsonRpcError {
5916                        code: -32004,
5917                        message: "console aggregator unavailable".to_string(),
5918                        data: None,
5919                    }),
5920                );
5921            };
5922            match Box::pin(aggregator.list_identities()).await {
5923                Ok(mut identities) => {
5924                    retain_visible_identity_records(&mut identities, access_view);
5925                    response_value(response_id, Some(json!({ "identities": identities })), None)
5926                }
5927                Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
5928            }
5929        }
5930        "mobkit/console/inspect_identity" => {
5931            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5932                return invalid_params(response_id, "identity required");
5933            };
5934            let Some(aggregator) = &console_aggregator else {
5935                return response_value(
5936                    response_id,
5937                    None,
5938                    Some(JsonRpcError {
5939                        code: -32004,
5940                        message: "console aggregator unavailable".to_string(),
5941                        data: None,
5942                    }),
5943                );
5944            };
5945            match Box::pin(aggregator.inspect_identity(identity)).await {
5946                Ok(Some(inspection)) => response_value(
5947                    response_id,
5948                    Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
5949                    None,
5950                ),
5951                Ok(None) => response_value(
5952                    response_id,
5953                    None,
5954                    Some(JsonRpcError {
5955                        code: -32001,
5956                        message: format!("unknown identity: {identity}"),
5957                        data: None,
5958                    }),
5959                ),
5960                Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
5961            }
5962        }
5963        "mobkit/console/query_timeline" => {
5964            let query: ConsoleTimelineWindowQuery =
5965                match serde_json::from_value(request.params.clone()) {
5966                    Ok(query) => query,
5967                    Err(err) => {
5968                        return invalid_params(response_id, format!("invalid query params: {err}"));
5969                    }
5970                };
5971            let Some(aggregator) = &console_aggregator else {
5972                return response_value(
5973                    response_id,
5974                    None,
5975                    Some(JsonRpcError {
5976                        code: -32004,
5977                        message: "console aggregator unavailable".to_string(),
5978                        data: None,
5979                    }),
5980                );
5981            };
5982            match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
5983                Ok(mut page) => {
5984                    retain_visible_timeline_frames(&mut page, access_view);
5985                    response_value(
5986                        response_id,
5987                        Some(serde_json::to_value(page).unwrap_or(Value::Null)),
5988                        None,
5989                    )
5990                }
5991                Err(err) => {
5992                    let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
5993                    console_timeline_replay_unavailable_response(
5994                        response_id,
5995                        err,
5996                        query.after.as_ref(),
5997                        latest_cursor,
5998                    )
5999                }
6000            }
6001        }
6002        "mobkit/console/send" => {
6003            let send_request: ConsoleSendRequest =
6004                match serde_json::from_value(request.params.clone()) {
6005                    Ok(request) => request,
6006                    Err(err) => {
6007                        return invalid_params(response_id, format!("invalid send params: {err}"));
6008                    }
6009                };
6010            let Some(aggregator) = &console_aggregator else {
6011                return response_value(
6012                    response_id,
6013                    None,
6014                    Some(JsonRpcError {
6015                        code: -32004,
6016                        message: "console aggregator unavailable".to_string(),
6017                        data: None,
6018                    }),
6019                );
6020            };
6021            if let Some(identity_runtime) = &identity_runtime {
6022                return match Box::pin(console_send_with_identity_first_fallback(
6023                    aggregator,
6024                    identity_runtime.clone(),
6025                    Some(runtime),
6026                    console_events.as_ref(),
6027                    send_request,
6028                ))
6029                .await
6030                {
6031                    Ok(accepted) => response_value(
6032                        response_id,
6033                        Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
6034                        None,
6035                    ),
6036                    Err(err) => {
6037                        response_value(response_id, None, Some(console_send_json_rpc_error(err)))
6038                    }
6039                };
6040            }
6041            match Box::pin(aggregator.send(send_request)).await {
6042                Ok(accepted) => response_value(
6043                    response_id,
6044                    Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
6045                    None,
6046                ),
6047                Err(err) => {
6048                    response_value(response_id, None, Some(console_send_json_rpc_error(err)))
6049                }
6050            }
6051        }
6052        "mobkit/blob/get" => {
6053            let Some(blob_id) = request
6054                .params
6055                .get("blob_id")
6056                .or_else(|| request.params.get("id"))
6057                .and_then(Value::as_str)
6058            else {
6059                return invalid_params(response_id, "blob_id required");
6060            };
6061            if !is_valid_blob_id_value(blob_id) {
6062                return invalid_params(response_id, "invalid blob_id");
6063            }
6064            let Some(store) = runtime.binary_blob_store() else {
6065                return internal_error(response_id, "binary blob store unavailable");
6066            };
6067            match store.get_bytes(&meerkat_core::BlobId::from(blob_id)).await {
6068                Ok(payload) => response_value(
6069                    response_id,
6070                    Some(serde_json::json!({
6071                        "blob_id": payload.blob_id,
6072                        "media_type": payload.media_type,
6073                        "size": payload.size,
6074                        "data": base64::engine::general_purpose::STANDARD.encode(payload.data.as_ref()),
6075                    })),
6076                    None,
6077                ),
6078                Err(meerkat_core::BlobStoreError::NotFound(_)) => response_value(
6079                    response_id,
6080                    None,
6081                    Some(JsonRpcError {
6082                        code: -32001,
6083                        message: format!("blob not found: {blob_id}"),
6084                        data: Some(json!({ "kind": "not_found", "blob_id": blob_id })),
6085                    }),
6086                ),
6087                Err(err) => internal_error(response_id, format!("blob get failed: {err}")),
6088            }
6089        }
6090        "mobkit/list_members" => {
6091            let handle = runtime.handle();
6092            let entries = handle.list_members_including_retiring().await;
6093            let mut members = Vec::with_capacity(entries.len());
6094            for entry in &entries {
6095                let alias =
6096                    crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
6097                if stale_runtime_alias_json_rpc_error(
6098                    "list_members",
6099                    identity_runtime.as_ref(),
6100                    alias.as_ref(),
6101                )
6102                .await
6103                .is_some()
6104                {
6105                    continue;
6106                }
6107                members.push(member_entry_to_console_json(runtime, entry).await);
6108            }
6109            retain_visible_member_rows(&mut members, access_view);
6110            response_value(response_id, Some(Value::Array(members)), None)
6111        }
6112        "mobkit/get_member" => {
6113            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6114                return invalid_params(response_id, "member_id required");
6115            };
6116            if let Some(error) = stale_runtime_alias_json_rpc_error(
6117                "get_member",
6118                identity_runtime.as_ref(),
6119                member_id,
6120            )
6121            .await
6122            {
6123                return response_value(response_id, None, Some(error));
6124            }
6125            let handle = runtime.handle();
6126            let identity = crate::member_comms_id::mob_member_id(member_id);
6127            let entries = handle.list_members_including_retiring().await;
6128            match entries.into_iter().find(|e| e.agent_identity == identity) {
6129                Some(entry) => response_value(
6130                    response_id,
6131                    Some(member_entry_to_console_json(runtime, &entry).await),
6132                    None,
6133                ),
6134                None => invalid_params(response_id, format!("member not found: {member_id}")),
6135            }
6136        }
6137        "mobkit/find_members" => {
6138            let Some(label_key) = request.params.get("label_key").and_then(Value::as_str) else {
6139                return invalid_params(response_id, "label_key required");
6140            };
6141            let Some(label_value) = request.params.get("label_value").and_then(Value::as_str)
6142            else {
6143                return invalid_params(response_id, "label_value required");
6144            };
6145            let handle = runtime.handle();
6146            let filter = MemberFilter {
6147                labels: std::collections::BTreeMap::from([(
6148                    label_key.to_string(),
6149                    label_value.to_string(),
6150                )]),
6151                role: None,
6152                status: None,
6153            };
6154            let entries = match handle.list_members_matching(filter).await {
6155                Ok(entries) => entries,
6156                Err(err) => {
6157                    return invalid_params(response_id, format!("member lookup failed: {err}"));
6158                }
6159            };
6160            let mut matches = Vec::with_capacity(entries.len());
6161            for entry in &entries {
6162                let alias =
6163                    crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
6164                if stale_runtime_alias_json_rpc_error(
6165                    "find_members",
6166                    identity_runtime.as_ref(),
6167                    alias.as_ref(),
6168                )
6169                .await
6170                .is_some()
6171                {
6172                    continue;
6173                }
6174                matches.push(member_entry_to_console_json(runtime, entry).await);
6175            }
6176            retain_visible_member_rows(&mut matches, access_view);
6177            response_value(response_id, Some(Value::Array(matches)), None)
6178        }
6179        "mobkit/status_identity" => {
6180            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6181                return invalid_params(response_id, "identity required");
6182            };
6183            let handle = runtime.handle();
6184            if let Some(identity_runtime) = &identity_runtime {
6185                let (parsed_identity, _was_registered, live_alias) =
6186                    match resolve_console_identity_control_target(
6187                        &handle,
6188                        Some(identity_runtime),
6189                        visibility_policy,
6190                        identity,
6191                    )
6192                    .await
6193                    {
6194                        Ok(Some(target)) => target,
6195                        Ok(None) => {
6196                            return invalid_params(
6197                                response_id,
6198                                format!("identity not found: {identity}"),
6199                            );
6200                        }
6201                        Err(err) => return response_value(response_id, None, Some(err)),
6202                    };
6203                match identity_runtime.status(&parsed_identity).await {
6204                    Ok(status) => {
6205                        if !identity_status_visible_to_console(visibility_policy, &status) {
6206                            return identity_hidden_by_policy_response(response_id, identity);
6207                        }
6208                        let phase = if let Some(store) = &console_events {
6209                            store
6210                                .response_phase_for_identity(status.identity.as_str())
6211                                .await
6212                        } else {
6213                            None
6214                        };
6215                        if let Some(error) = stale_live_alias_json_rpc_error(
6216                            "status_identity",
6217                            identity_runtime,
6218                            &parsed_identity,
6219                            live_alias.as_ref(),
6220                        )
6221                        .await
6222                        {
6223                            return response_value(response_id, None, Some(error));
6224                        }
6225                        return response_value(
6226                            response_id,
6227                            Some(console_identity_status_json_from_identity_status(
6228                                &status, phase,
6229                            )),
6230                            None,
6231                        );
6232                    }
6233                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
6234                    Err(err) => {
6235                        return console_identity_error_response(
6236                            response_id,
6237                            "status_identity",
6238                            err,
6239                        );
6240                    }
6241                }
6242            }
6243            if let Some(aggregator) = &console_aggregator {
6244                return match Box::pin(aggregator.inspect_identity(identity)).await {
6245                    Ok(Some(inspection)) => {
6246                        let phase = if let Some(store) = &console_events {
6247                            store
6248                                .response_phase_for_identity(&inspection.identity.identity)
6249                                .await
6250                        } else {
6251                            None
6252                        };
6253                        response_value(
6254                            response_id,
6255                            Some(console_identity_status_json_from_record(
6256                                &inspection.identity,
6257                                phase,
6258                            )),
6259                            None,
6260                        )
6261                    }
6262                    Ok(None) => response_value(
6263                        response_id,
6264                        None,
6265                        Some(JsonRpcError {
6266                            code: -32001,
6267                            message: format!("unknown identity: {identity}"),
6268                            data: None,
6269                        }),
6270                    ),
6271                    Err(err) => {
6272                        internal_error(response_id, format!("status_identity failed: {err}"))
6273                    }
6274                };
6275            }
6276            let live_alias = match lookup_member_alias_with_session(
6277                &handle,
6278                visibility_policy,
6279                identity,
6280            )
6281            .await
6282            {
6283                Ok(alias) => alias,
6284                Err(err) => return response_value(response_id, None, Some(err)),
6285            };
6286            let Some(alias) = live_alias else {
6287                return invalid_params(response_id, format!("identity not found: {identity}"));
6288            };
6289            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6290                return identity_hidden_by_policy_response(response_id, identity);
6291            }
6292            if let Err(err) =
6293                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6294            {
6295                return response_value(response_id, None, Some(err));
6296            }
6297            let phase = if let Some(store) = &console_events {
6298                store.response_phase_for_identity(&alias.identity).await
6299            } else {
6300                None
6301            };
6302            response_value(
6303                response_id,
6304                Some(console_identity_status_json_for_identity(
6305                    &alias.identity,
6306                    &alias.member,
6307                    alias.session_id,
6308                    phase,
6309                )),
6310                None,
6311            )
6312        }
6313        "mobkit/inspect_identity" => {
6314            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6315                return invalid_params(response_id, "identity required");
6316            };
6317            let handle = runtime.handle();
6318            if let Some(identity_runtime) = &identity_runtime {
6319                let (parsed_identity, _was_registered, live_alias) =
6320                    match resolve_console_identity_control_target(
6321                        &handle,
6322                        Some(identity_runtime),
6323                        visibility_policy,
6324                        identity,
6325                    )
6326                    .await
6327                    {
6328                        Ok(Some(target)) => target,
6329                        Ok(None) => {
6330                            return invalid_params(
6331                                response_id,
6332                                format!("identity not found: {identity}"),
6333                            );
6334                        }
6335                        Err(err) => return response_value(response_id, None, Some(err)),
6336                    };
6337                match identity_runtime.status(&parsed_identity).await {
6338                    Ok(status) => {
6339                        if !identity_status_visible_to_console(visibility_policy, &status) {
6340                            return identity_hidden_by_policy_response(response_id, identity);
6341                        }
6342                        let phase = if let Some(store) = &console_events {
6343                            store
6344                                .response_phase_for_identity(status.identity.as_str())
6345                                .await
6346                        } else {
6347                            None
6348                        };
6349                        if let Some(error) = stale_live_alias_json_rpc_error(
6350                            "inspect_identity",
6351                            identity_runtime,
6352                            &parsed_identity,
6353                            live_alias.as_ref(),
6354                        )
6355                        .await
6356                        {
6357                            return response_value(response_id, None, Some(error));
6358                        }
6359                        return response_value(
6360                            response_id,
6361                            Some(console_identity_inspect_json_from_identity_status(
6362                                &status,
6363                                live_alias.as_ref(),
6364                                phase,
6365                            )),
6366                            None,
6367                        );
6368                    }
6369                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
6370                    Err(err) => {
6371                        return console_identity_error_response(
6372                            response_id,
6373                            "inspect_identity",
6374                            err,
6375                        );
6376                    }
6377                }
6378            }
6379            if let Some(aggregator) = &console_aggregator {
6380                return match Box::pin(aggregator.inspect_identity(identity)).await {
6381                    Ok(Some(inspection)) => {
6382                        let phase = if let Some(store) = &console_events {
6383                            store
6384                                .response_phase_for_identity(&inspection.identity.identity)
6385                                .await
6386                        } else {
6387                            None
6388                        };
6389                        response_value(
6390                            response_id,
6391                            Some(console_identity_inspect_json_from_record(
6392                                &inspection,
6393                                phase,
6394                            )),
6395                            None,
6396                        )
6397                    }
6398                    Ok(None) => response_value(
6399                        response_id,
6400                        None,
6401                        Some(JsonRpcError {
6402                            code: -32001,
6403                            message: format!("unknown identity: {identity}"),
6404                            data: None,
6405                        }),
6406                    ),
6407                    Err(err) => {
6408                        internal_error(response_id, format!("inspect_identity failed: {err}"))
6409                    }
6410                };
6411            }
6412            let live_alias = match lookup_member_alias_with_session(
6413                &handle,
6414                visibility_policy,
6415                identity,
6416            )
6417            .await
6418            {
6419                Ok(alias) => alias,
6420                Err(err) => return response_value(response_id, None, Some(err)),
6421            };
6422            let Some(alias) = live_alias else {
6423                return invalid_params(response_id, format!("identity not found: {identity}"));
6424            };
6425            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6426                return identity_hidden_by_policy_response(response_id, identity);
6427            }
6428            if let Err(err) =
6429                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6430            {
6431                return response_value(response_id, None, Some(err));
6432            }
6433            let phase = if let Some(store) = &console_events {
6434                store.response_phase_for_identity(&alias.identity).await
6435            } else {
6436                None
6437            };
6438            response_value(
6439                response_id,
6440                Some(console_identity_inspect_json_for_identity(
6441                    &alias.identity,
6442                    &alias.member,
6443                    alias.session_id,
6444                    phase,
6445                )),
6446                None,
6447            )
6448        }
6449        "mobkit/retire" => {
6450            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6451                return invalid_params(response_id, "identity required");
6452            };
6453            let handle = runtime.handle();
6454            if let Some(identity_runtime) = &identity_runtime {
6455                let (parsed_identity, was_registered, live_alias) =
6456                    match resolve_console_identity_control_target(
6457                        &handle,
6458                        Some(identity_runtime),
6459                        visibility_policy,
6460                        identity,
6461                    )
6462                    .await
6463                    {
6464                        Ok(Some(target)) => target,
6465                        Ok(None) => {
6466                            return invalid_params(
6467                                response_id,
6468                                format!("identity not found: {identity}"),
6469                            );
6470                        }
6471                        Err(err) => return response_value(response_id, None, Some(err)),
6472                    };
6473                let registered_status = match identity_runtime.status(&parsed_identity).await {
6474                    Ok(status) => Some(status),
6475                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
6476                    Err(err) => {
6477                        return console_identity_error_response(response_id, "retire", err);
6478                    }
6479                };
6480                if let Some(status) = registered_status.as_ref() {
6481                    if !identity_status_visible_to_console(visibility_policy, status) {
6482                        return identity_hidden_by_policy_response(response_id, identity);
6483                    }
6484                    if let Some(error) = stale_live_alias_json_rpc_error(
6485                        "retire",
6486                        identity_runtime,
6487                        &parsed_identity,
6488                        live_alias.as_ref(),
6489                    )
6490                    .await
6491                    {
6492                        return response_value(response_id, None, Some(error));
6493                    }
6494                }
6495                let cleanup_handle = handle.clone();
6496                let cleanup_identity = parsed_identity.clone();
6497                let include_current = !identity_runtime.has_session_bridge();
6498                let expected_alias = crate::member_comms_id::is_reserved_generated_alias(identity)
6499                    .then_some(identity);
6500                let retire_result = identity_runtime
6501                    .retire_and_cleanup_live_members_tracked(
6502                        &parsed_identity,
6503                        expected_alias,
6504                        move |retired_alias| async move {
6505                            let stale_member_ids = match retired_alias.as_ref() {
6506                                Some(retired_alias) => {
6507                                    stale_console_member_ids_for_identity_authoritative(
6508                                        &cleanup_handle,
6509                                        cleanup_identity.as_str(),
6510                                        retired_alias.as_str(),
6511                                        include_current,
6512                                    )
6513                                    .await
6514                                }
6515                                None => Vec::new(),
6516                            };
6517                            retire_console_member_ids(&cleanup_handle, stale_member_ids)
6518                                .await
6519                                .err()
6520                                .map(|error| {
6521                                    json!({
6522                                        "kind": "stale_member_cleanup_failed_after_identity_retire",
6523                                        "identity": cleanup_identity.as_str(),
6524                                        "message": error,
6525                                    })
6526                                })
6527                        },
6528                    )
6529                    .await;
6530                match retire_result {
6531                    Ok((token, cleanup_warning)) => {
6532                        if let Some(store) = &console_events {
6533                            store
6534                                .record_lifecycle(
6535                                    parsed_identity.as_str(),
6536                                    "identity_retired",
6537                                    json!({
6538                                        "fencing_token": token.get(),
6539                                        "cleanup_warning": cleanup_warning.clone(),
6540                                    }),
6541                                )
6542                                .await;
6543                        }
6544                        return response_value(
6545                            response_id,
6546                            Some(json!({
6547                                "identity": parsed_identity.as_str(),
6548                                "fencing_token": token.get(),
6549                                "cleanup_warning": cleanup_warning,
6550                            })),
6551                            None,
6552                        );
6553                    }
6554                    Err(err @ crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6555                        if !console_live_only_fallback_allowed(
6556                            was_registered,
6557                            identity,
6558                            live_alias.as_ref(),
6559                        ) {
6560                            return console_identity_error_response(response_id, "retire", err);
6561                        }
6562                        if let Some(alias) = live_alias.as_ref() {
6563                            if !runtime_alias_visible_to_console(&handle, visibility_policy, alias)
6564                            {
6565                                return identity_hidden_by_policy_response(response_id, identity);
6566                            }
6567                            let mid = crate::member_comms_id::mob_member_id(
6568                                alias.runtime_member_id.as_str(),
6569                            );
6570                            return match retire_console_member(&handle, &mid).await {
6571                                Ok(()) => {
6572                                    if let Some(store) = &console_events {
6573                                        store
6574                                            .record_lifecycle(
6575                                                &alias.identity,
6576                                                "identity_retired",
6577                                                json!({}),
6578                                            )
6579                                            .await;
6580                                    }
6581                                    response_value(
6582                                        response_id,
6583                                        Some(json!({ "identity": alias.identity })),
6584                                        None,
6585                                    )
6586                                }
6587                                Err(err) => {
6588                                    internal_error(response_id, format!("retire failed: {err}"))
6589                                }
6590                            };
6591                        }
6592                    }
6593                    Err(err) => return console_identity_error_response(response_id, "retire", err),
6594                }
6595            }
6596            if let Some(aggregator) = &console_aggregator {
6597                let canonical_identity = match Box::pin(aggregator.inspect_identity(identity)).await
6598                {
6599                    Ok(Some(inspection)) => inspection.identity.identity,
6600                    Ok(None) => identity.to_string(),
6601                    Err(_) => identity.to_string(),
6602                };
6603                return match Box::pin(aggregator.retire_identity(identity)).await {
6604                    Ok(true) => {
6605                        if let Some(store) = &console_events {
6606                            store
6607                                .record_lifecycle(
6608                                    &canonical_identity,
6609                                    "identity_retired",
6610                                    json!({}),
6611                                )
6612                                .await;
6613                        }
6614                        response_value(
6615                            response_id,
6616                            Some(json!({ "identity": canonical_identity })),
6617                            None,
6618                        )
6619                    }
6620                    Ok(false) => response_value(
6621                        response_id,
6622                        None,
6623                        Some(JsonRpcError {
6624                            code: -32001,
6625                            message: format!("unknown identity: {identity}"),
6626                            data: None,
6627                        }),
6628                    ),
6629                    Err(err) => internal_error(response_id, format!("retire failed: {err}")),
6630                };
6631            }
6632            let live_alias = match lookup_member_alias_with_session(
6633                &handle,
6634                visibility_policy,
6635                identity,
6636            )
6637            .await
6638            {
6639                Ok(alias) => alias,
6640                Err(err) => return response_value(response_id, None, Some(err)),
6641            };
6642            let Some(alias) = live_alias else {
6643                return invalid_params(response_id, format!("identity not found: {identity}"));
6644            };
6645            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6646                return identity_hidden_by_policy_response(response_id, identity);
6647            }
6648            if let Err(err) =
6649                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6650            {
6651                return response_value(response_id, None, Some(err));
6652            }
6653            let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
6654            match retire_console_member(&handle, &mid).await {
6655                Ok(()) => {
6656                    if let Some(store) = &console_events {
6657                        store
6658                            .record_lifecycle(&alias.identity, "identity_retired", json!({}))
6659                            .await;
6660                    }
6661                    response_value(
6662                        response_id,
6663                        Some(json!({ "identity": alias.identity })),
6664                        None,
6665                    )
6666                }
6667                Err(err) => internal_error(response_id, format!("retire failed: {err}")),
6668            }
6669        }
6670        "mobkit/respawn" => {
6671            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6672                return invalid_params(response_id, "identity required");
6673            };
6674            let handle = runtime.handle();
6675            if let Some(identity_runtime) = &identity_runtime {
6676                let (parsed_identity, was_registered, live_alias) =
6677                    match resolve_console_identity_control_target(
6678                        &handle,
6679                        Some(identity_runtime),
6680                        visibility_policy,
6681                        identity,
6682                    )
6683                    .await
6684                    {
6685                        Ok(Some(target)) => target,
6686                        Ok(None) => {
6687                            return invalid_params(
6688                                response_id,
6689                                format!("identity not found: {identity}"),
6690                            );
6691                        }
6692                        Err(err) => return response_value(response_id, None, Some(err)),
6693                    };
6694                let registered_status = match identity_runtime.status(&parsed_identity).await {
6695                    Ok(status) => Some(status),
6696                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
6697                    Err(err) => {
6698                        return console_identity_error_response(response_id, "respawn", err);
6699                    }
6700                };
6701                if let Some(status) = registered_status.as_ref() {
6702                    if !identity_status_visible_to_console(visibility_policy, status) {
6703                        return identity_hidden_by_policy_response(response_id, identity);
6704                    }
6705                    if let Some(error) = stale_live_alias_json_rpc_error(
6706                        "respawn",
6707                        identity_runtime,
6708                        &parsed_identity,
6709                        live_alias.as_ref(),
6710                    )
6711                    .await
6712                    {
6713                        return response_value(response_id, None, Some(error));
6714                    }
6715                }
6716                let expected_alias = crate::member_comms_id::is_reserved_generated_alias(identity)
6717                    .then_some(identity);
6718                let respawn_result = identity_runtime
6719                    .respawn_identity_in_place_tracked(&parsed_identity, expected_alias)
6720                    .await;
6721                match respawn_result {
6722                    Ok(record) => {
6723                        // Durable identity respawn recovers the authoritative
6724                        // session in place, so the legacy raw-member warning
6725                        // fields remain null but present.
6726                        let live_respawn_warning: Option<Value> = None;
6727                        let cleanup_warning: Option<Value> = None;
6728                        if let Some(store) = &console_events {
6729                            store
6730                                .record_lifecycle(
6731                                    parsed_identity.as_str(),
6732                                    "identity_respawned",
6733                                    json!({
6734                                        "generation": record.generation.get(),
6735                                        "checkpoint_version": record.checkpoint_version.get(),
6736                                        "live_respawn_warning": live_respawn_warning.clone(),
6737                                        "cleanup_warning": cleanup_warning.clone(),
6738                                    }),
6739                                )
6740                                .await;
6741                        }
6742                        return response_value(
6743                            response_id,
6744                            Some(json!({
6745                                "identity": record.identity.as_str(),
6746                                "agent_runtime_id": record.agent_runtime_id.as_str(),
6747                                "session_id": record.session_id.to_string(),
6748                                "generation": record.generation.get(),
6749                                "checkpoint_version": record.checkpoint_version.get(),
6750                                "live_respawn_warning": live_respawn_warning,
6751                                "cleanup_warning": cleanup_warning,
6752                            })),
6753                            None,
6754                        );
6755                    }
6756                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6757                        if !console_live_only_fallback_allowed(
6758                            was_registered,
6759                            identity,
6760                            live_alias.as_ref(),
6761                        ) {
6762                            return invalid_params(
6763                                response_id,
6764                                format!("identity not found: {identity}"),
6765                            );
6766                        }
6767                    }
6768                    Err(err) => {
6769                        return console_identity_error_response(response_id, "respawn", err);
6770                    }
6771                }
6772            }
6773            let live_alias = match lookup_member_alias_with_session(
6774                &handle,
6775                visibility_policy,
6776                identity,
6777            )
6778            .await
6779            {
6780                Ok(alias) => alias,
6781                Err(err) => return response_value(response_id, None, Some(err)),
6782            };
6783            let Some(alias) = live_alias else {
6784                return invalid_params(response_id, format!("identity not found: {identity}"));
6785            };
6786            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6787                return identity_hidden_by_policy_response(response_id, identity);
6788            }
6789            if let Err(err) =
6790                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6791            {
6792                return response_value(response_id, None, Some(err));
6793            }
6794            Box::pin(respawn_live_console_member_response(
6795                &handle,
6796                console_events.as_ref(),
6797                &alias,
6798                "identity_respawned",
6799                "respawn",
6800                response_id,
6801            ))
6802            .await
6803        }
6804        "mobkit/reset" => {
6805            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6806                return invalid_params(response_id, "identity required");
6807            };
6808            let handle = runtime.handle();
6809            let Some(identity_runtime) = &identity_runtime else {
6810                // No identity-first runtime: degrade to the same live-member
6811                // respawn fallback the identity path applies to live-only
6812                // members (fresh session, same configuration). This keeps the
6813                // console Reset button working on plain runtimes, matching
6814                // the advertised `mobkit/reset` capability.
6815                let live_alias =
6816                    match lookup_member_alias_with_session(&handle, visibility_policy, identity)
6817                        .await
6818                    {
6819                        Ok(alias) => alias,
6820                        Err(err) => return response_value(response_id, None, Some(err)),
6821                    };
6822                let Some(alias) = live_alias else {
6823                    return invalid_params(response_id, format!("identity not found: {identity}"));
6824                };
6825                if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6826                    return identity_hidden_by_policy_response(response_id, identity);
6827                }
6828                if let Err(err) =
6829                    reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias)
6830                        .await
6831                {
6832                    return response_value(response_id, None, Some(err));
6833                }
6834                return Box::pin(respawn_live_console_member_response(
6835                    &handle,
6836                    console_events.as_ref(),
6837                    &alias,
6838                    "identity_reset",
6839                    "reset",
6840                    response_id,
6841                ))
6842                .await;
6843            };
6844            let (parsed_identity, was_registered, live_alias) =
6845                match resolve_console_identity_control_target(
6846                    &handle,
6847                    Some(identity_runtime),
6848                    visibility_policy,
6849                    identity,
6850                )
6851                .await
6852                {
6853                    Ok(Some(target)) => target,
6854                    Ok(None) => {
6855                        return invalid_params(
6856                            response_id,
6857                            format!("identity not found: {identity}"),
6858                        );
6859                    }
6860                    Err(err) => return response_value(response_id, None, Some(err)),
6861                };
6862            match identity_runtime.status(&parsed_identity).await {
6863                Ok(status) => {
6864                    if !identity_status_visible_to_console(visibility_policy, &status) {
6865                        return identity_hidden_by_policy_response(response_id, identity);
6866                    }
6867                    if let Some(error) = stale_live_alias_json_rpc_error(
6868                        "reset",
6869                        identity_runtime,
6870                        &parsed_identity,
6871                        live_alias.as_ref(),
6872                    )
6873                    .await
6874                    {
6875                        return response_value(response_id, None, Some(error));
6876                    }
6877                    if !identity_runtime.has_session_bridge() {
6878                        return response_value(
6879                            response_id,
6880                            None,
6881                            Some(reset_requires_session_bridge_json_rpc_error()),
6882                        );
6883                    }
6884                }
6885                Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6886                    if console_live_only_fallback_allowed(
6887                        was_registered,
6888                        identity,
6889                        live_alias.as_ref(),
6890                    ) && let Some(alias) = live_alias.as_ref()
6891                    {
6892                        if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6893                            return identity_hidden_by_policy_response(response_id, identity);
6894                        }
6895                        return Box::pin(respawn_live_console_member_response(
6896                            &handle,
6897                            console_events.as_ref(),
6898                            alias,
6899                            "identity_reset",
6900                            "reset",
6901                            response_id,
6902                        ))
6903                        .await;
6904                    }
6905                    return invalid_params(response_id, format!("identity not found: {identity}"));
6906                }
6907                Err(err) => return console_identity_error_response(response_id, "reset", err),
6908            }
6909            let reset_result = if crate::member_comms_id::is_reserved_generated_alias(identity) {
6910                identity_runtime
6911                    .reset_member_alias_tracked(&parsed_identity, identity)
6912                    .await
6913            } else {
6914                identity_runtime.reset_tracked(&parsed_identity).await
6915            };
6916            match reset_result {
6917                Ok(record) => {
6918                    let cleanup_warning = Some(json!({
6919                        "kind": "stale_member_cleanup_skipped_after_identity_reset",
6920                        "identity": parsed_identity.as_str(),
6921                        "agent_runtime_id": record.agent_runtime_id.as_str(),
6922                        "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
6923                    }));
6924                    if let Some(store) = &console_events {
6925                        store
6926                            .record_lifecycle(
6927                                parsed_identity.as_str(),
6928                                "identity_reset",
6929                                json!({
6930                                    "generation": record.generation.get(),
6931                                    "checkpoint_version": record.checkpoint_version.get(),
6932                                    "cleanup_warning": cleanup_warning.clone(),
6933                                }),
6934                            )
6935                            .await;
6936                    }
6937                    response_value(
6938                        response_id,
6939                        Some(json!({
6940                            "identity": record.identity.as_str(),
6941                            "agent_runtime_id": record.agent_runtime_id.as_str(),
6942                            "session_id": record.session_id.to_string(),
6943                            "generation": record.generation.get(),
6944                            "checkpoint_version": record.checkpoint_version.get(),
6945                            "cleanup_warning": cleanup_warning,
6946                        })),
6947                        None,
6948                    )
6949                }
6950                Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6951                    invalid_params(response_id, format!("identity not found: {identity}"))
6952                }
6953                Err(err) => console_identity_error_response(response_id, "reset", err),
6954            }
6955        }
6956        "mobkit/delete_identity" => {
6957            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6958                return invalid_params(response_id, "identity required");
6959            };
6960            let handle = runtime.handle();
6961            let Some(identity_runtime) = &identity_runtime else {
6962                return invalid_params(
6963                    response_id,
6964                    "identity-first runtime required for delete_identity",
6965                );
6966            };
6967            let (parsed_identity, was_registered, live_alias) =
6968                match resolve_console_identity_control_target(
6969                    &handle,
6970                    Some(identity_runtime),
6971                    visibility_policy,
6972                    identity,
6973                )
6974                .await
6975                {
6976                    Ok(Some(target)) => target,
6977                    Ok(None) => {
6978                        return invalid_params(
6979                            response_id,
6980                            format!("identity not found: {identity}"),
6981                        );
6982                    }
6983                    Err(err) => return response_value(response_id, None, Some(err)),
6984                };
6985            let registered_status = match identity_runtime.status(&parsed_identity).await {
6986                Ok(status) => status,
6987                Err(err @ crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6988                    if was_registered {
6989                        return console_identity_error_response(
6990                            response_id,
6991                            "delete_identity",
6992                            err,
6993                        );
6994                    }
6995                    if let Some(alias) = live_alias.as_ref() {
6996                        if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6997                            return identity_hidden_by_policy_response(response_id, identity);
6998                        }
6999                        return response_value(
7000                            response_id,
7001                            None,
7002                            Some(JsonRpcError {
7003                                code: -32602,
7004                                message: format!(
7005                                    "delete_identity requires durable identity: {} is live-only",
7006                                    parsed_identity.as_str()
7007                                ),
7008                                data: Some(json!({
7009                                    "kind": "live_only_identity_delete_unsupported",
7010                                    "identity": parsed_identity.as_str(),
7011                                })),
7012                            }),
7013                        );
7014                    }
7015                    return invalid_params(response_id, format!("identity not found: {identity}"));
7016                }
7017                Err(err) => {
7018                    return console_identity_error_response(response_id, "delete_identity", err);
7019                }
7020            };
7021            if !identity_status_visible_to_console(visibility_policy, &registered_status) {
7022                return identity_hidden_by_policy_response(response_id, identity);
7023            }
7024            if let Some(error) = stale_live_alias_json_rpc_error(
7025                "delete_identity",
7026                identity_runtime,
7027                &parsed_identity,
7028                live_alias.as_ref(),
7029            )
7030            .await
7031            {
7032                return response_value(response_id, None, Some(error));
7033            }
7034            let cleanup_handle = handle.clone();
7035            let cleanup_identity = parsed_identity.clone();
7036            let include_current = !identity_runtime.has_session_bridge();
7037            let expected_alias =
7038                crate::member_comms_id::is_reserved_generated_alias(identity).then_some(identity);
7039            let delete_result = identity_runtime
7040                .delete_identity_and_cleanup_live_members_tracked(
7041                    &parsed_identity,
7042                    expected_alias,
7043                    move |deleted_alias| async move {
7044                        let stale_member_ids = match deleted_alias.as_ref() {
7045                            Some(deleted_alias) => {
7046                                stale_console_member_ids_for_identity_authoritative(
7047                                    &cleanup_handle,
7048                                    cleanup_identity.as_str(),
7049                                    deleted_alias.as_str(),
7050                                    include_current,
7051                                )
7052                                .await
7053                            }
7054                            None => Vec::new(),
7055                        };
7056                        retire_console_member_ids(&cleanup_handle, stale_member_ids)
7057                            .await
7058                            .err()
7059                            .map(|error| {
7060                                json!({
7061                                    "kind": "stale_member_cleanup_failed_after_identity_delete",
7062                                    "identity": cleanup_identity.as_str(),
7063                                    "message": error,
7064                                })
7065                            })
7066                    },
7067                )
7068                .await;
7069            match delete_result {
7070                Ok(cleanup_warning) => {
7071                    if let Some(store) = &console_events {
7072                        store
7073                            .record_lifecycle(
7074                                parsed_identity.as_str(),
7075                                "identity_deleted",
7076                                json!({
7077                                    "cleanup_warning": cleanup_warning.clone(),
7078                                }),
7079                            )
7080                            .await;
7081                    }
7082                    response_value(
7083                        response_id,
7084                        Some(json!({
7085                            "identity": parsed_identity.as_str(),
7086                            "cleanup_warning": cleanup_warning,
7087                        })),
7088                        None,
7089                    )
7090                }
7091                Err(err) => console_identity_error_response(response_id, "delete_identity", err),
7092            }
7093        }
7094        "mobkit/reset_all" => {
7095            match Box::pin(reset_all_live_console_agents(
7096                runtime,
7097                console_events.as_ref(),
7098                console_aggregator.as_ref(),
7099                identity_runtime.as_ref(),
7100                visibility_policy,
7101            ))
7102            .await
7103            {
7104                Ok(body) => {
7105                    if body
7106                        .get("failed")
7107                        .and_then(Value::as_array)
7108                        .is_some_and(|failed| !failed.is_empty())
7109                    {
7110                        response_value(
7111                            response_id,
7112                            None,
7113                            Some(JsonRpcError {
7114                                code: -32000,
7115                                message: "reset_all failed for one or more identities".to_string(),
7116                                data: Some(body),
7117                            }),
7118                        )
7119                    } else {
7120                        response_value(response_id, Some(body), None)
7121                    }
7122                }
7123                Err(err) => internal_error(response_id, format!("reset_all failed: {err}")),
7124            }
7125        }
7126        "mobkit/routing/routes/list" => {
7127            let Some(module_runtime) = &module_runtime else {
7128                return response_value(
7129                    response_id,
7130                    None,
7131                    Some(JsonRpcError {
7132                        code: -32601,
7133                        message: "Method not found".to_string(),
7134                        data: None,
7135                    }),
7136                );
7137            };
7138            let routes = module_runtime.lock().await.list_runtime_routes();
7139            response_value(response_id, Some(json!({ "routes": routes })), None)
7140        }
7141        "mobkit/delivery/history" => {
7142            let Some(module_runtime) = &module_runtime else {
7143                return response_value(
7144                    response_id,
7145                    None,
7146                    Some(JsonRpcError {
7147                        code: -32601,
7148                        message: "Method not found".to_string(),
7149                        data: None,
7150                    }),
7151                );
7152            };
7153            let limit = request
7154                .params
7155                .get("limit")
7156                .and_then(Value::as_u64)
7157                .unwrap_or(50) as usize;
7158            let history = module_runtime
7159                .lock()
7160                .await
7161                .delivery_history(DeliveryHistoryRequest {
7162                    recipient: None,
7163                    sink: None,
7164                    limit,
7165                });
7166            response_value(
7167                response_id,
7168                Some(serde_json::to_value(history).unwrap_or(Value::Null)),
7169                None,
7170            )
7171        }
7172        "mobkit/gating/pending" => {
7173            let Some(module_runtime) = &module_runtime else {
7174                return response_value(
7175                    response_id,
7176                    None,
7177                    Some(JsonRpcError {
7178                        code: -32601,
7179                        message: "Method not found".to_string(),
7180                        data: None,
7181                    }),
7182                );
7183            };
7184            let pending = module_runtime.lock().await.list_gating_pending();
7185            response_value(response_id, Some(json!({ "pending": pending })), None)
7186        }
7187        "mobkit/gating/audit" => {
7188            let Some(module_runtime) = &module_runtime else {
7189                return response_value(
7190                    response_id,
7191                    None,
7192                    Some(JsonRpcError {
7193                        code: -32601,
7194                        message: "Method not found".to_string(),
7195                        data: None,
7196                    }),
7197                );
7198            };
7199            let limit = request
7200                .params
7201                .get("limit")
7202                .and_then(Value::as_u64)
7203                .unwrap_or(50) as usize;
7204            let entries = module_runtime.lock().await.gating_audit_entries(limit);
7205            response_value(response_id, Some(json!({ "entries": entries })), None)
7206        }
7207        "mobkit/gating/decide" => {
7208            let Some(module_runtime) = &module_runtime else {
7209                return response_value(
7210                    response_id,
7211                    None,
7212                    Some(JsonRpcError {
7213                        code: -32601,
7214                        message: "Method not found".to_string(),
7215                        data: None,
7216                    }),
7217                );
7218            };
7219            let Some(pending_id) = request.params.get("pending_id").and_then(Value::as_str) else {
7220                return invalid_params(response_id, "pending_id required");
7221            };
7222            let approver_id =
7223                match resolve_gating_approver_id(&request.params, authenticated_principal) {
7224                    Ok(approver_id) => approver_id,
7225                    Err(message) => return invalid_params(response_id, message),
7226                };
7227            let Some(raw_decision) = request.params.get("decision").and_then(Value::as_str) else {
7228                return invalid_params(response_id, "decision required");
7229            };
7230            let decision = match raw_decision {
7231                "approve" => GatingDecision::Approve,
7232                "reject" | "deny" => GatingDecision::Reject,
7233                "escalate" => GatingDecision::Escalate,
7234                _ => {
7235                    return invalid_params(
7236                        response_id,
7237                        format!("unsupported decision: {raw_decision}"),
7238                    );
7239                }
7240            };
7241            let reason = request
7242                .params
7243                .get("reason")
7244                .and_then(Value::as_str)
7245                .map(ToString::to_string);
7246            match module_runtime
7247                .lock()
7248                .await
7249                .decide_gating_action(GatingDecideRequest {
7250                    pending_id: pending_id.to_string(),
7251                    approver_id,
7252                    decision,
7253                    reason,
7254                }) {
7255                Ok(result) => response_value(
7256                    response_id,
7257                    Some(serde_json::to_value(result).unwrap_or(Value::Null)),
7258                    None,
7259                ),
7260                Err(err) => gating_decision_failed_error(response_id, err),
7261            }
7262        }
7263        "mobkit/ensure_member" => {
7264            let Some(role) = request.params.get("role").and_then(Value::as_str) else {
7265                return invalid_params(response_id, "role required");
7266            };
7267            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7268            else {
7269                return invalid_params(response_id, "agent_identity required");
7270            };
7271            if crate::member_comms_id::is_reserved_generated_alias(agent_identity) {
7272                return invalid_params(
7273                    response_id,
7274                    "rt:* is reserved for identity-runtime generated aliases",
7275                );
7276            }
7277            let labels = match request.params.get("labels") {
7278                None | Some(Value::Null) => std::collections::BTreeMap::new(),
7279                Some(value) => match serde_json::from_value(value.clone()) {
7280                    Ok(map) => map,
7281                    Err(err) => {
7282                        return invalid_params(response_id, format!("invalid labels: {err}"));
7283                    }
7284                },
7285            };
7286            if let Err(message) = crate::member_comms_id::validate_raw_identity_labels(&labels) {
7287                return invalid_params(response_id, message);
7288            }
7289            let context = request.params.get("context").cloned();
7290            let resume_session_id = match request.params.get("resume_session_id") {
7291                None => None,
7292                Some(Value::Null) => None,
7293                Some(v) => match v.as_str() {
7294                    Some(s) => match meerkat_core::types::SessionId::parse(s) {
7295                        Ok(sid) => Some(sid),
7296                        Err(_) => {
7297                            return invalid_params(
7298                                response_id,
7299                                format!("invalid resume_session_id: {s:?}"),
7300                            );
7301                        }
7302                    },
7303                    None => {
7304                        return invalid_params(
7305                            response_id,
7306                            "resume_session_id must be a string".to_string(),
7307                        );
7308                    }
7309                },
7310            };
7311            let additional_instructions = match request.params.get("additional_instructions") {
7312                None | Some(Value::Null) => None,
7313                Some(Value::Array(arr)) => {
7314                    let mut strs = Vec::with_capacity(arr.len());
7315                    for (i, entry) in arr.iter().enumerate() {
7316                        match entry.as_str() {
7317                            Some(s) => strs.push(s.to_string()),
7318                            None => {
7319                                return invalid_params(
7320                                    response_id,
7321                                    format!("additional_instructions[{i}] must be a string"),
7322                                );
7323                            }
7324                        }
7325                    }
7326                    if strs.is_empty() { None } else { Some(strs) }
7327                }
7328                Some(_) => {
7329                    return invalid_params(
7330                        response_id,
7331                        "additional_instructions must be an array of strings",
7332                    );
7333                }
7334            };
7335            let runtime_mode = match parse_optional_runtime_mode(&request.params) {
7336                Ok(value) => value,
7337                Err(message) => return invalid_params(response_id, message),
7338            };
7339            let backend = match parse_optional_backend(&request.params) {
7340                Ok(value) => value,
7341                Err(message) => return invalid_params(response_id, message),
7342            };
7343            let binding = match parse_optional_runtime_binding(&request.params) {
7344                Ok(value) => value,
7345                Err(message) => return invalid_params(response_id, message),
7346            };
7347            // Identity-first gateways (ask K0): ensure_member upserts the
7348            // desired-identity roster and reconciles — the durable-identity
7349            // equivalent of the mob-member spawn, with the tolerant identity
7350            // lifecycle underneath (the ask-20 retire/respawn class does not
7351            // exist on this surface). Doctrine escape hatch: plane:"worker"
7352            // pins the spawn to the ephemeral mob plane (idle-retire reaping,
7353            // no continuity record) for helper churn that must not become a
7354            // durable identity.
7355            let worker_plane = matches!(
7356                request.params.get("plane").and_then(Value::as_str),
7357                Some("worker")
7358            );
7359            if !worker_plane
7360                && let (Some(identity_runtime_ref), Some(roster)) =
7361                    (identity_runtime.as_ref(), identity_roster.as_ref())
7362            {
7363                let identity = match crate::identity_first::AgentIdentity::parse(agent_identity) {
7364                    Ok(identity) => identity,
7365                    Err(err) => {
7366                        return invalid_params(
7367                            response_id,
7368                            format!("invalid agent_identity: {err}"),
7369                        );
7370                    }
7371                };
7372                if resume_session_id.is_some() {
7373                    return invalid_params(
7374                        response_id,
7375                        "resume_session_id is not supported on an identity-first \
7376                         gateway: continuity records own session resumption",
7377                    );
7378                }
7379                if binding.is_some() {
7380                    return invalid_params(
7381                        response_id,
7382                        "external runtime bindings are not supported on an \
7383                         identity-first gateway yet",
7384                    );
7385                }
7386                let spec = crate::identity_first::DurableAgentSpec {
7387                    identity: identity.clone(),
7388                    profile: ProfileName::from(role),
7389                    addressability: crate::identity_first::AgentAddressability::Addressable,
7390                    display_name: None,
7391                    labels,
7392                    context,
7393                    additional_instructions: additional_instructions.unwrap_or_default(),
7394                    initial_message: None,
7395                    runtime_mode_override: runtime_mode,
7396                    backend,
7397                    binding: None,
7398                };
7399                roster.upsert(spec);
7400                return match crate::identity_first::restore_flow(
7401                    identity_runtime_ref,
7402                    &roster.snapshot(),
7403                    None,
7404                    None,
7405                )
7406                .await
7407                {
7408                    Ok(result) => {
7409                        // Converge declared definition wiring after every
7410                        // materialization — upstream spawn-time wiring is
7411                        // bring-up-order dependent (HomeCore, 2026-07-09).
7412                        let _ = reconcile_console_topology(runtime, topology).await;
7413                        let outcome = match result.outcomes.get(&identity) {
7414                            Some(crate::identity_first::RestoreOutcome::Created { .. }) => {
7415                                "created"
7416                            }
7417                            Some(crate::identity_first::RestoreOutcome::Resumed { .. }) => {
7418                                "resumed"
7419                            }
7420                            Some(crate::identity_first::RestoreOutcome::Dormant { .. }) => {
7421                                "dormant"
7422                            }
7423                            Some(crate::identity_first::RestoreOutcome::Broken(_)) => "broken",
7424                            None => "unchanged",
7425                        };
7426                        let state = identity_runtime_ref
7427                            .status(&identity)
7428                            .await
7429                            .map(|status| status.state.wire_str().to_string())
7430                            .unwrap_or_else(|_| "unknown".to_string());
7431                        response_value(
7432                            response_id,
7433                            Some(serde_json::json!({
7434                                "agent_identity": identity.as_str(),
7435                                "role": role,
7436                                "identity_first": true,
7437                                "outcome": outcome,
7438                                "state": state,
7439                            })),
7440                            None,
7441                        )
7442                    }
7443                    Err(err) => {
7444                        internal_error(response_id, format!("ensure_member (identity): {err}"))
7445                    }
7446                };
7447            }
7448            let raw_reservation = match crate::member_comms_id::reserve_raw_member_target(
7449                identity_runtime.as_ref(),
7450                agent_identity,
7451            )
7452            .await
7453            {
7454                Ok(reservation) => reservation,
7455                Err(message) => return invalid_params(response_id, message),
7456            };
7457            let mut spec = SpawnMemberSpec::new(
7458                ProfileName::from(role),
7459                crate::member_comms_id::mob_member_id(raw_reservation.alias()),
7460            );
7461            if let Some(runtime_mode) = runtime_mode {
7462                spec = spec.with_runtime_mode(runtime_mode);
7463            }
7464            if let Some(backend) = backend {
7465                spec = spec.with_backend(backend);
7466            }
7467            if let Some(binding) = binding {
7468                spec.binding = Some(binding);
7469            }
7470            if !labels.is_empty() {
7471                spec = spec.with_labels(labels);
7472            }
7473            if let Some(ctx) = context {
7474                spec = spec.with_context(ctx);
7475            }
7476            if let Some(sid) = resume_session_id {
7477                spec = spec.with_resume_bridge_session_id(sid);
7478            }
7479            if let Some(instructions) = additional_instructions {
7480                spec = spec.with_additional_instructions(instructions);
7481            }
7482            let handle = runtime.handle();
7483            let mid = spec.identity.clone();
7484            let ensure_result = handle.ensure_member(spec).await;
7485            drop(raw_reservation);
7486            match ensure_result {
7487                Ok(_outcome) => {
7488                    let _ = reconcile_console_topology(runtime, topology).await;
7489                    let body = match lookup_member_with_session(&handle, &mid).await {
7490                        Some((entry, _sid)) => member_entry_to_json(&entry),
7491                        None => Value::Null,
7492                    };
7493                    response_value(response_id, Some(body), None)
7494                }
7495                Err(err) => internal_error(response_id, format!("ensure_member failed: {err}")),
7496            }
7497        }
7498        "mobkit/retire_member" => {
7499            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7500                return invalid_params(response_id, "member_id required");
7501            };
7502            let member_id = crate::member_comms_id::runtime_alias_str(member_id).into_owned();
7503            if let Some(error) = stale_runtime_alias_json_rpc_error(
7504                "retire_member",
7505                identity_runtime.as_ref(),
7506                &member_id,
7507            )
7508            .await
7509            {
7510                return response_value(response_id, None, Some(error));
7511            }
7512            if let (Some(identity_runtime_ref), Some(roster)) =
7513                (identity_runtime.as_ref(), identity_roster.as_ref())
7514                && let Some(identity) = identity_runtime_ref
7515                    .identity_for_member_mutation(&member_id)
7516                    .await
7517            {
7518                return match identity_runtime_ref
7519                    .retire_member_alias_tracked(&identity, &member_id)
7520                    .await
7521                {
7522                    Ok(_token) => {
7523                        // Off the desired roster too, or the next reconcile
7524                        // (or the repair task) re-creates it.
7525                        roster.remove(&identity);
7526                        response_value(
7527                            response_id,
7528                            Some(serde_json::json!({
7529                                "accepted": true,
7530                                "identity_first": true,
7531                            })),
7532                            None,
7533                        )
7534                    }
7535                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
7536                        response_value(
7537                            response_id,
7538                            None,
7539                            Some(JsonRpcError {
7540                                code: -32001,
7541                                message: format!("unknown identity: {member_id}"),
7542                                data: None,
7543                            }),
7544                        )
7545                    }
7546                    Err(err) => {
7547                        internal_error(response_id, format!("retire_member (identity): {err}"))
7548                    }
7549                };
7550            }
7551            // Doctrine: identity-owned members retire through the identity
7552            // authority even when this console has no roster slot (builder-
7553            // constructed identity runtimes serve these arms too).
7554            if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7555                && let Some(durable) = identity_runtime_ref
7556                    .identity_for_member_mutation(&member_id)
7557                    .await
7558            {
7559                return match identity_runtime_ref
7560                    .retire_member_alias_tracked(&durable, &member_id)
7561                    .await
7562                {
7563                    Ok(_token) => response_value(
7564                        response_id,
7565                        Some(serde_json::json!({
7566                            "accepted": true,
7567                            "identity_first": true,
7568                        })),
7569                        None,
7570                    ),
7571                    Err(err) => {
7572                        internal_error(response_id, format!("retire_member (identity): {err}"))
7573                    }
7574                };
7575            }
7576            if crate::member_comms_id::is_reserved_generated_alias(&member_id) {
7577                return internal_error(
7578                    response_id,
7579                    format!(
7580                        "generated member alias requires current identity authority: {member_id}"
7581                    ),
7582                );
7583            }
7584            if let Some(aggregator) = &console_aggregator {
7585                return match Box::pin(aggregator.retire_identity(&member_id)).await {
7586                    Ok(true) => response_value(
7587                        response_id,
7588                        Some(serde_json::json!({ "accepted": true })),
7589                        None,
7590                    ),
7591                    Ok(false) => response_value(
7592                        response_id,
7593                        None,
7594                        Some(JsonRpcError {
7595                            code: -32001,
7596                            message: format!("unknown identity: {member_id}"),
7597                            data: None,
7598                        }),
7599                    ),
7600                    Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
7601                };
7602            }
7603            // Parity with the SDK dispatcher and the identity-named console
7604            // paths: a completed-disposal cleanup miss reads as retired.
7605            match retire_console_member(
7606                &runtime.handle(),
7607                &crate::member_comms_id::mob_member_id(&member_id),
7608            )
7609            .await
7610            {
7611                Ok(()) => response_value(
7612                    response_id,
7613                    Some(serde_json::json!({ "accepted": true })),
7614                    None,
7615                ),
7616                Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
7617            }
7618        }
7619        "mobkit/respawn_member" => {
7620            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7621                return invalid_params(response_id, "member_id required");
7622            };
7623            let member_id = crate::member_comms_id::runtime_alias_str(member_id).into_owned();
7624            if let Some(error) = stale_runtime_alias_json_rpc_error(
7625                "respawn_member",
7626                identity_runtime.as_ref(),
7627                &member_id,
7628            )
7629            .await
7630            {
7631                return response_value(response_id, None, Some(error));
7632            }
7633            if identity_roster.is_some()
7634                && let Some(identity_runtime_ref) = identity_runtime.as_ref()
7635                && let Some(identity) = identity_runtime_ref
7636                    .identity_for_member_mutation(&member_id)
7637                    .await
7638            {
7639                // Identity-first respawn = reset: retire the live session and
7640                // rebuild from the current roster spec under the same durable
7641                // identity (fresh session, new generation).
7642                return match identity_runtime_ref
7643                    .reset_member_alias_tracked(&identity, &member_id)
7644                    .await
7645                {
7646                    Ok(record) => response_value(
7647                        response_id,
7648                        Some(serde_json::json!({
7649                            "accepted": true,
7650                            "identity_first": true,
7651                            "session_id": record.session_id.to_string(),
7652                            "generation": record.generation.get(),
7653                        })),
7654                        None,
7655                    ),
7656                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
7657                        response_value(
7658                            response_id,
7659                            None,
7660                            Some(JsonRpcError {
7661                                code: -32001,
7662                                message: format!("unknown identity: {member_id}"),
7663                                data: None,
7664                            }),
7665                        )
7666                    }
7667                    Err(err) => {
7668                        internal_error(response_id, format!("respawn_member (identity): {err}"))
7669                    }
7670                };
7671            }
7672            if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7673                && let Some(durable) = identity_runtime_ref
7674                    .identity_for_member_mutation(&member_id)
7675                    .await
7676            {
7677                return match identity_runtime_ref
7678                    .reset_member_alias_tracked(&durable, &member_id)
7679                    .await
7680                {
7681                    Ok(record) => response_value(
7682                        response_id,
7683                        Some(serde_json::json!({
7684                            "accepted": true,
7685                            "identity_first": true,
7686                            "session_id": record.session_id.to_string(),
7687                            "generation": record.generation.get(),
7688                        })),
7689                        None,
7690                    ),
7691                    Err(err) => {
7692                        internal_error(response_id, format!("respawn_member (identity): {err}"))
7693                    }
7694                };
7695            }
7696            if crate::member_comms_id::is_reserved_generated_alias(&member_id) {
7697                return internal_error(
7698                    response_id,
7699                    format!(
7700                        "generated member alias requires current identity authority: {member_id}"
7701                    ),
7702                );
7703            }
7704            // Parity with respawn_console_member's tolerance set: topology
7705            // warnings degrade, completed-disposal cleanup misses repair.
7706            match Box::pin(respawn_console_member(
7707                &runtime.handle(),
7708                &crate::member_comms_id::mob_member_id(&member_id),
7709            ))
7710            .await
7711            {
7712                Ok(topology_restore_warning) => {
7713                    let mut body = serde_json::json!({ "accepted": true });
7714                    if let Some(warning) = topology_restore_warning {
7715                        body["topology_restore_warning"] = warning;
7716                    }
7717                    response_value(response_id, Some(body), None)
7718                }
7719                Err(err) => internal_error(response_id, format!("respawn_member failed: {err}")),
7720            }
7721        }
7722        "mobkit/reconcile_edges" => {
7723            // Previously a hardcoded noop ("console runtime routes directly
7724            // to MobRuntime") — which left declared definition wiring
7725            // unreconcilable from the console surface while the stdin
7726            // surface had the real handler (HomeCore, 2026-07-09).
7727            let report = reconcile_console_topology(runtime, topology).await;
7728            response_value(
7729                response_id,
7730                Some(serde_json::to_value(&report).unwrap_or(serde_json::Value::Null)),
7731                None,
7732            )
7733        }
7734        "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => {
7735            let query: EventQuery = if request.params.is_null() {
7736                EventQuery::default()
7737            } else {
7738                match serde_json::from_value(request.params.clone()) {
7739                    Ok(q) => q,
7740                    Err(err) => {
7741                        return invalid_params(response_id, format!("invalid query params: {err}"));
7742                    }
7743                }
7744            };
7745            let Some(store) = mob_events.as_ref() else {
7746                return response_value(
7747                    response_id,
7748                    Some(serde_json::json!({
7749                        "events": [],
7750                        "next_after_seq": Value::Null,
7751                    })),
7752                    None,
7753                );
7754            };
7755            let events_view = runtime.handle().events();
7756            // Capture latest_cursor at handshake so the SSE continuation
7757            // URL still covers the empty-snapshot case without losing
7758            // events between the JSON-RPC response and the SSE connect.
7759            let latest_at_handshake = events_view.latest_cursor().await.unwrap_or(0);
7760            let handle = runtime.handle();
7761            let result = crate::unified_runtime::mob_events::query_ledger_with_filter_selected(
7762                &events_view,
7763                store,
7764                &query,
7765                {
7766                    let handle = handle.clone();
7767                    move |event| {
7768                        let handle = handle.clone();
7769                        async move {
7770                            crate::http_sse::project_structural_envelope_for_console(
7771                                &handle,
7772                                Some(runtime),
7773                                visibility_policy,
7774                                access_view,
7775                                event,
7776                                true,
7777                            )
7778                            .await
7779                        }
7780                    }
7781                },
7782            )
7783            .await;
7784            match result {
7785                Ok(page) => {
7786                    // `mob.observe` gates the surface; agent-attributed
7787                    // ledger entries still require known `agent.view`
7788                    // attributes and the same visibility/redaction projection
7789                    // as the SSE continuation. This prevents the initial JSON
7790                    // snapshot from becoming a side door for retired members.
7791                    // Authorization happens inside the ledger scan so hidden
7792                    // rows cannot consume `limit` and starve later visible
7793                    // rows. The raw scan frontier also advances across hidden
7794                    // rows, making query pagination and subscribe handoff
7795                    // lossless without replaying them.
7796                    let events = page.items;
7797                    let resume_after_seq = Some(page.resume_after_seq);
7798                    let body = if request.method == "mobkit/mob_events/subscribe" {
7799                        let subscribe_url = crate::unified_runtime::mob_events::build_subscribe_url(
7800                            &query,
7801                            resume_after_seq,
7802                            latest_at_handshake,
7803                        );
7804                        serde_json::json!({
7805                            "stream": "mob_events",
7806                            "events": events,
7807                            "next_after_seq": resume_after_seq,
7808                            "subscribe_url": subscribe_url,
7809                            "keep_alive": {
7810                                "interval_ms": 15_000_u64,
7811                                "event": "keep_alive",
7812                            },
7813                        })
7814                    } else {
7815                        serde_json::json!({
7816                            "events": events,
7817                            "next_after_seq": resume_after_seq,
7818                        })
7819                    };
7820                    response_value(response_id, Some(body), None)
7821                }
7822                Err(crate::unified_runtime::mob_events::MobEventsQueryError::Stale {
7823                    after_cursor,
7824                    latest_cursor,
7825                }) => stale_event_cursor_response(response_id, after_cursor, latest_cursor),
7826                Err(err) => internal_error(response_id, format!("mob_events query failed: {err}")),
7827            }
7828        }
7829        // 0.5 API methods
7830        "mobkit/member_status" => {
7831            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7832                return invalid_params(response_id, "member_id required");
7833            };
7834            let member_id = crate::member_comms_id::runtime_alias_str(member_id).into_owned();
7835            if let Some(error) = stale_runtime_alias_json_rpc_error(
7836                "member_status",
7837                identity_runtime.as_ref(),
7838                &member_id,
7839            )
7840            .await
7841            {
7842                return response_value(response_id, None, Some(error));
7843            }
7844            match runtime
7845                .handle()
7846                .member_status(&crate::member_comms_id::mob_member_id(&member_id))
7847                .await
7848            {
7849                Ok(snapshot) => response_value(
7850                    response_id,
7851                    Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7852                    None,
7853                ),
7854                Err(err) => internal_error(response_id, format!("member_status failed: {err}")),
7855            }
7856        }
7857        "mobkit/identity/resolved_tools" => {
7858            let Some(identity) = request
7859                .params
7860                .get("identity")
7861                .or_else(|| request.params.get("member_id"))
7862                .and_then(Value::as_str)
7863            else {
7864                return invalid_params(response_id, "identity required");
7865            };
7866            let identity = crate::member_comms_id::runtime_alias_str(identity).into_owned();
7867            if let Some(error) = stale_runtime_alias_json_rpc_error(
7868                "identity_resolved_tools",
7869                identity_runtime.as_ref(),
7870                &identity,
7871            )
7872            .await
7873            {
7874                return response_value(response_id, None, Some(error));
7875            }
7876            if let Some(identity_runtime) = identity_runtime.as_ref()
7877                && let Ok(parsed) = crate::identity_first::AgentIdentity::parse(&identity)
7878                && let Ok(status) = identity_runtime.status(&parsed).await
7879                && let Some(session_id) = status.session_id
7880            {
7881                match resolved_tools_for_session(runtime.session_service(), &identity, session_id)
7882                    .await
7883                {
7884                    Ok(snapshot) => {
7885                        return response_value(
7886                            response_id,
7887                            Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7888                            None,
7889                        );
7890                    }
7891                    Err(err) => {
7892                        return internal_error(
7893                            response_id,
7894                            format!("resolved_tools failed: {err}"),
7895                        );
7896                    }
7897                }
7898            }
7899            match resolved_tools_for_member(&runtime.handle(), runtime.session_service(), &identity)
7900                .await
7901            {
7902                Ok(snapshot) => response_value(
7903                    response_id,
7904                    Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7905                    None,
7906                ),
7907                Err(err) => internal_error(response_id, format!("resolved_tools failed: {err}")),
7908            }
7909        }
7910        "mobkit/force_cancel_member" => {
7911            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7912                return invalid_params(response_id, "member_id required");
7913            };
7914            let member_id = crate::member_comms_id::runtime_alias_str(member_id).into_owned();
7915            if let Some(error) = stale_runtime_alias_json_rpc_error(
7916                "force_cancel_member",
7917                identity_runtime.as_ref(),
7918                &member_id,
7919            )
7920            .await
7921            {
7922                return response_value(response_id, None, Some(error));
7923            }
7924            let result = if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7925                && let Some(identity) = identity_runtime_ref
7926                    .identity_for_member_mutation(&member_id)
7927                    .await
7928            {
7929                let handle = runtime.handle();
7930                let member_id_value = crate::member_comms_id::mob_member_id(&member_id);
7931                identity_runtime_ref
7932                    .run_member_alias_operation_tracked(&identity, &member_id, move || async move {
7933                        handle
7934                            .force_cancel_member(member_id_value)
7935                            .await
7936                            .map_err(|error| error.to_string())
7937                    })
7938                    .await
7939                    .map_err(|error| error.to_string())
7940            } else if crate::member_comms_id::is_reserved_generated_alias(&member_id) {
7941                Err(format!(
7942                    "generated member alias requires current identity authority: {member_id}"
7943                ))
7944            } else {
7945                runtime
7946                    .handle()
7947                    .force_cancel_member(crate::member_comms_id::mob_member_id(&member_id))
7948                    .await
7949                    .map_err(|error| error.to_string())
7950            };
7951            match result {
7952                Ok(()) => response_value(
7953                    response_id,
7954                    Some(serde_json::json!({ "accepted": true })),
7955                    None,
7956                ),
7957                Err(err) => {
7958                    internal_error(response_id, format!("force_cancel_member failed: {err}"))
7959                }
7960            }
7961        }
7962        "mobkit/wait_ready" => {
7963            // Omit `timeout_ms` => mobkit's generous default ceiling (the SDK
7964            // contract is "wait until ready"), not meerkat-mob 0.7.9's lowered
7965            // 60s internal default that `None` would otherwise inherit.
7966            let timeout = Some(
7967                request
7968                    .params
7969                    .get("timeout_ms")
7970                    .and_then(Value::as_u64)
7971                    .map(std::time::Duration::from_millis)
7972                    .unwrap_or(crate::unified_runtime::mob_ops::DEFAULT_WAIT_READY_TIMEOUT),
7973            );
7974            match runtime.handle().wait_for_ready(timeout).await {
7975                Ok(ready) => {
7976                    let entries: Vec<Value> = ready
7977                        .into_iter()
7978                        // Per-agent visibility: a caller only sees readiness
7979                        // for agents they may `agent.view`. The cache was
7980                        // primed from the roster at dispatch.
7981                        .filter_map(|(identity, snapshot)| {
7982                            let identity = identity.to_string();
7983                            let visible = access_view
7984                                .filter(|view| view.enforced())
7985                                .is_none_or(|view| view.can_view_agent(&identity));
7986                            visible.then(|| {
7987                                serde_json::json!({
7988                                    "agent_identity": identity,
7989                                    "snapshot": serde_json::to_value(&snapshot)
7990                                        .unwrap_or(Value::Null),
7991                                })
7992                            })
7993                        })
7994                        .collect();
7995                    response_value(
7996                        response_id,
7997                        Some(serde_json::json!({
7998                            "ready": entries,
7999                            "timeout": false,
8000                        })),
8001                        None,
8002                    )
8003                }
8004                Err(err) => {
8005                    if crate::unified_runtime::mob_ops::is_ready_wait_timeout(&err) {
8006                        response_value(
8007                            response_id,
8008                            Some(serde_json::json!({
8009                                "ready": Vec::<Value>::new(),
8010                                "timeout": true,
8011                            })),
8012                            None,
8013                        )
8014                    } else {
8015                        internal_error(response_id, format!("wait_for_ready failed: {err}"))
8016                    }
8017                }
8018            }
8019        }
8020        "mobkit/collect_completed" => {
8021            let completed = runtime.handle().collect_completed().await;
8022            let entries: Vec<Value> = completed
8023                .into_iter()
8024                // Per-agent visibility: even an `agent.spawn`-gated caller only
8025                // collects completions for agents they may `agent.view`.
8026                .filter_map(|(mid, snapshot)| {
8027                    let mid = mid.to_string();
8028                    let visible = access_view
8029                        .filter(|view| view.enforced())
8030                        .is_none_or(|view| view.can_view_agent(&mid));
8031                    visible.then(|| {
8032                        serde_json::json!({
8033                            "member_id": mid,
8034                            "snapshot": serde_json::to_value(&snapshot).unwrap_or(Value::Null),
8035                        })
8036                    })
8037                })
8038                .collect();
8039            response_value(
8040                response_id,
8041                Some(serde_json::json!({ "completed": entries })),
8042                None,
8043            )
8044        }
8045        "mobkit/cancel_flow" => {
8046            let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
8047                return invalid_params(response_id, "run_id required");
8048            };
8049            let run_id: meerkat_mob::RunId = match run_id.parse() {
8050                Ok(id) => id,
8051                Err(_) => return invalid_params(response_id, "invalid run_id format"),
8052            };
8053            match runtime.handle().cancel_flow(run_id).await {
8054                Ok(()) => response_value(
8055                    response_id,
8056                    Some(serde_json::json!({ "accepted": true })),
8057                    None,
8058                ),
8059                Err(err) => internal_error(response_id, format!("cancel_flow failed: {err}")),
8060            }
8061        }
8062        "mobkit/flow_status" => {
8063            let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
8064                return invalid_params(response_id, "run_id required");
8065            };
8066            let run_id: meerkat_mob::RunId = match run_id.parse() {
8067                Ok(id) => id,
8068                Err(_) => return invalid_params(response_id, "invalid run_id format"),
8069            };
8070            match runtime.handle().flow_status(run_id).await {
8071                Ok(Some(mob_run)) => response_value(
8072                    response_id,
8073                    Some(serde_json::to_value(&mob_run).unwrap_or(Value::Null)),
8074                    None,
8075                ),
8076                Ok(None) => response_value(response_id, Some(Value::Null), None),
8077                Err(err) => internal_error(response_id, format!("flow_status failed: {err}")),
8078            }
8079        }
8080        "mobkit/list_flows" => {
8081            let flows: Vec<String> = runtime
8082                .handle()
8083                .list_flows()
8084                .into_iter()
8085                .map(|id| id.to_string())
8086                .collect();
8087            response_value(
8088                response_id,
8089                Some(serde_json::json!({ "flows": flows })),
8090                None,
8091            )
8092        }
8093        "mobkit/list_runs" => {
8094            let flow_id = request
8095                .params
8096                .get("flow_id")
8097                .and_then(Value::as_str)
8098                .filter(|value| !value.is_empty())
8099                .map(meerkat_mob::FlowId::from);
8100            match runtime.handle().list_runs(flow_id.as_ref()).await {
8101                Ok(runs) => response_value(
8102                    response_id,
8103                    Some(serde_json::json!({
8104                        "runs": serde_json::to_value(&runs).unwrap_or(Value::Null),
8105                    })),
8106                    None,
8107                ),
8108                Err(err) => internal_error(response_id, format!("list_runs failed: {err}")),
8109            }
8110        }
8111        "mobkit/run_flow" => {
8112            let Some(flow_id_str) = request.params.get("flow_id").and_then(Value::as_str) else {
8113                return invalid_params(response_id, "flow_id required");
8114            };
8115            if flow_id_str.is_empty() {
8116                return invalid_params(response_id, "flow_id required");
8117            }
8118            let flow_id = meerkat_mob::FlowId::from(flow_id_str);
8119            let flow_params = request.params.get("params").cloned().unwrap_or(Value::Null);
8120            if let Some(identity_runtime) = &identity_runtime
8121                && let Err(err) = identity_runtime.materialize_all_required_tracked().await
8122            {
8123                return internal_error(
8124                    response_id,
8125                    format!("identity-first flow materialization failed: {err}"),
8126                );
8127            }
8128            match Box::pin(runtime.handle().run_flow(flow_id, flow_params)).await {
8129                Ok(run_id) => response_value(
8130                    response_id,
8131                    Some(serde_json::json!({ "run_id": run_id.to_string() })),
8132                    None,
8133                ),
8134                Err(err) => invalid_params(response_id, format!("run_flow failed: {err}")),
8135            }
8136        }
8137        "mobkit/spawn_helper" => {
8138            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
8139            else {
8140                return invalid_params(response_id, "agent_identity required");
8141            };
8142            let Some(task) = request.params.get("task").and_then(Value::as_str) else {
8143                return invalid_params(response_id, "task required");
8144            };
8145            let options = match parse_console_helper_options(request.params.get("options")) {
8146                Ok(opts) => opts,
8147                Err(msg) => return invalid_params(response_id, msg),
8148            };
8149            let raw_reservation = match crate::member_comms_id::reserve_raw_member_target(
8150                identity_runtime.as_ref(),
8151                agent_identity,
8152            )
8153            .await
8154            {
8155                Ok(reservation) => reservation,
8156                Err(message) => return invalid_params(response_id, message),
8157            };
8158            let handle = runtime.handle();
8159            let spawn_result = Box::pin(handle.spawn_helper(
8160                crate::member_comms_id::mob_member_id(raw_reservation.alias()),
8161                task,
8162                options,
8163            ))
8164            .await;
8165            drop(raw_reservation);
8166            match spawn_result {
8167                Ok(result) => {
8168                    // Meerkat 0.6 retires the helper before `spawn_helper`
8169                    // returns, so a post-hoc `resolve_bridge_session_id`
8170                    // call would come back `None`. We drop `session_id`
8171                    // from the response rather than emit a misleading null.
8172                    response_value(
8173                        response_id,
8174                        Some(serde_json::json!({
8175                            "output": result.output,
8176                            "tokens_used": result.tokens_used,
8177                        })),
8178                        None,
8179                    )
8180                }
8181                Err(err) => internal_error(response_id, format!("spawn_helper failed: {err}")),
8182            }
8183        }
8184        "mobkit/fork_helper" => {
8185            let Some(source) = request
8186                .params
8187                .get("source_member_id")
8188                .and_then(Value::as_str)
8189            else {
8190                return invalid_params(response_id, "source_member_id required");
8191            };
8192            let source = crate::member_comms_id::runtime_alias_str(source).into_owned();
8193            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
8194            else {
8195                return invalid_params(response_id, "agent_identity required");
8196            };
8197            if let Err(message) = crate::member_comms_id::validate_raw_member_target(
8198                identity_runtime.as_ref(),
8199                agent_identity,
8200            )
8201            .await
8202            {
8203                return invalid_params(response_id, message);
8204            }
8205            let Some(task) = request.params.get("task").and_then(Value::as_str) else {
8206                return invalid_params(response_id, "task required");
8207            };
8208            let fork_context = match request.params.get("fork_context") {
8209                Some(v) if !v.is_null() => {
8210                    match serde_json::from_value::<meerkat_mob::launch::ForkContext>(v.clone()) {
8211                        Ok(ctx) => ctx,
8212                        Err(err) => {
8213                            return invalid_params(
8214                                response_id,
8215                                format!("invalid fork_context: {err}"),
8216                            );
8217                        }
8218                    }
8219                }
8220                _ => meerkat_mob::launch::ForkContext::default(),
8221            };
8222            let options = match parse_console_helper_options(request.params.get("options")) {
8223                Ok(opts) => opts,
8224                Err(msg) => return invalid_params(response_id, msg),
8225            };
8226            let handle = runtime.handle();
8227            let source_member_id = crate::member_comms_id::mob_member_id(&source);
8228            let helper_alias = agent_identity.to_string();
8229            let task = task.to_string();
8230            let identity_runtime_owned = identity_runtime.clone();
8231            let authority_target = if let Some(identity_runtime_ref) = identity_runtime.as_ref() {
8232                identity_runtime_ref
8233                    .member_alias_lifecycle_target(&source)
8234                    .await
8235            } else {
8236                Ok(None)
8237            };
8238            let fork_result = match authority_target {
8239                Err(error) => Err(error.to_string()),
8240                Ok(Some(target)) => {
8241                    crate::identity_first::IdentityRuntime::run_member_alias_targets_operation_tracked(
8242                        vec![target],
8243                        move || async move {
8244                            let raw_reservation =
8245                                crate::member_comms_id::reserve_raw_member_target(
8246                                    identity_runtime_owned.as_ref(),
8247                                    helper_alias.as_str(),
8248                                )
8249                                .await?;
8250                            let helper_member_id = crate::member_comms_id::mob_member_id(
8251                                raw_reservation.alias(),
8252                            );
8253                            let result = handle
8254                            .fork_helper(
8255                                &source_member_id,
8256                                helper_member_id,
8257                                task.as_str(),
8258                                fork_context,
8259                                options,
8260                            )
8261                            .await
8262                            .map_err(|error| error.to_string());
8263                            drop(raw_reservation);
8264                            result
8265                        },
8266                    )
8267                    .await
8268                    .map_err(|error| error.to_string())
8269                }
8270                Ok(None) if crate::member_comms_id::is_reserved_generated_alias(&source) => {
8271                    Err(format!(
8272                        "generated source alias requires current identity authority: {source}"
8273                    ))
8274                }
8275                Ok(None) => {
8276                    match crate::member_comms_id::reserve_raw_member_target(
8277                        identity_runtime_owned.as_ref(),
8278                        helper_alias.as_str(),
8279                    )
8280                    .await
8281                    {
8282                        Err(error) => Err(error),
8283                        Ok(raw_reservation) => {
8284                            let helper_member_id = crate::member_comms_id::mob_member_id(
8285                                raw_reservation.alias(),
8286                            );
8287                            let result = handle
8288                                .fork_helper(
8289                                    &source_member_id,
8290                                    helper_member_id,
8291                                    task.as_str(),
8292                                    fork_context,
8293                                    options,
8294                                )
8295                                .await
8296                                .map_err(|error| error.to_string());
8297                            drop(raw_reservation);
8298                            result
8299                        }
8300                    }
8301                }
8302            };
8303            match fork_result {
8304                Ok(result) => {
8305                    // See `spawn_helper`: meerkat 0.6 retires the forked
8306                    // helper before returning, so session_id is omitted
8307                    // rather than silently null.
8308                    response_value(
8309                        response_id,
8310                        Some(serde_json::json!({
8311                            "output": result.output,
8312                            "tokens_used": result.tokens_used,
8313                        })),
8314                        None,
8315                    )
8316                }
8317                Err(err) => internal_error(response_id, format!("fork_helper failed: {err}")),
8318            }
8319        }
8320        "mobkit/attach_existing_session" => {
8321            let Some(role) = request.params.get("role").and_then(Value::as_str) else {
8322                return invalid_params(response_id, "role required");
8323            };
8324            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
8325            else {
8326                return invalid_params(response_id, "agent_identity required");
8327            };
8328            let Some(session_id_str) = request.params.get("session_id").and_then(Value::as_str)
8329            else {
8330                return invalid_params(response_id, "session_id required");
8331            };
8332            let bridge_session_id = match meerkat_core::types::SessionId::parse(session_id_str) {
8333                Ok(s) => s,
8334                Err(_) => return invalid_params(response_id, "invalid session_id format"),
8335            };
8336            let raw_reservation = match crate::member_comms_id::reserve_raw_member_target(
8337                identity_runtime.as_ref(),
8338                agent_identity,
8339            )
8340            .await
8341            {
8342                Ok(reservation) => reservation,
8343                Err(message) => return invalid_params(response_id, message),
8344            };
8345            let mid = crate::member_comms_id::mob_member_id(raw_reservation.alias());
8346            let spec = SpawnMemberSpec::new(ProfileName::from(role), mid.clone())
8347                .with_launch_mode(MemberLaunchMode::Resume { bridge_session_id });
8348            let handle = runtime.handle();
8349            let spawn_result = Box::pin(handle.spawn_spec(spec)).await;
8350            drop(raw_reservation);
8351            match spawn_result {
8352                Ok(_) => match handle.member_status(&mid).await {
8353                    Ok(snapshot) => response_value(
8354                        response_id,
8355                        Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
8356                        None,
8357                    ),
8358                    Err(err) => internal_error(
8359                        response_id,
8360                        format!("attach_existing_session status lookup failed: {err}"),
8361                    ),
8362                },
8363                Err(err) => internal_error(
8364                    response_id,
8365                    format!("attach_existing_session failed: {err}"),
8366                ),
8367            }
8368        }
8369        "mobkit/cross_mob/wire_local" => {
8370            handle_console_wire_local(
8371                runtime,
8372                identity_runtime.as_ref(),
8373                &request.params,
8374                response_id,
8375                true,
8376            )
8377            .await
8378        }
8379        "mobkit/cross_mob/unwire_local" => {
8380            handle_console_wire_local(
8381                runtime,
8382                identity_runtime.as_ref(),
8383                &request.params,
8384                response_id,
8385                false,
8386            )
8387            .await
8388        }
8389        "mobkit/peer_pubkey" => match gateway_peer_keys {
8390            Some(keys) => response_value(
8391                response_id,
8392                Some(serde_json::json!({ "pubkey_b64": keys.pubkey_b64() })),
8393                None,
8394            ),
8395            None => response_value(
8396                response_id,
8397                None,
8398                Some(JsonRpcError {
8399                    code: -32004,
8400                    message: "gateway has no signing keypair configured".to_string(),
8401                    data: None,
8402                }),
8403            ),
8404        },
8405        "mobkit/cross_mob/peer_info" => {
8406            let member_id = request.params.get("member_id").and_then(Value::as_str);
8407            match member_id {
8408                Some(mid) if !mid.is_empty() => {
8409                    let mid = crate::member_comms_id::runtime_alias_str(mid).into_owned();
8410                    let handle = runtime.handle();
8411                    let authority_target = if let Some(identity_runtime) = identity_runtime.as_ref()
8412                    {
8413                        identity_runtime.member_alias_lifecycle_target(&mid).await
8414                    } else {
8415                        Ok(None)
8416                    };
8417                    let result = match authority_target {
8418                        Err(error) => Err(error.to_string()),
8419                        Ok(Some(target)) => {
8420                            let identity_runtime = identity_runtime.clone();
8421                            crate::identity_first::IdentityRuntime::run_member_alias_targets_operation_tracked(
8422                                vec![target],
8423                                move || async move {
8424                                    console_member_peer_info(
8425                                        handle,
8426                                        identity_runtime.as_ref(),
8427                                        true,
8428                                        mid,
8429                                    )
8430                                    .await
8431                                },
8432                            )
8433                            .await
8434                            .map_err(|error| error.to_string())
8435                        }
8436                        Ok(None) if crate::member_comms_id::is_reserved_generated_alias(&mid) => {
8437                            Err(format!(
8438                                "generated member alias requires current identity authority: {mid}"
8439                            ))
8440                        }
8441                        Ok(None) => console_member_peer_info(handle, None, false, mid).await,
8442                    };
8443                    match result {
8444                        Ok(value) => response_value(response_id, Some(value), None),
8445                        Err(error) => internal_error(
8446                            response_id,
8447                            format!("cross_mob/peer_info failed: {error}"),
8448                        ),
8449                    }
8450                }
8451                _ => invalid_params(response_id, "member_id required".to_string()),
8452            }
8453        }
8454        "mobkit/cross_mob/directory" => {
8455            let entries: Vec<Value> = contact_directory
8456                .map(|dir| {
8457                    dir.list()
8458                        .into_iter()
8459                        .filter_map(|e| serde_json::to_value(e).ok())
8460                        .collect()
8461                })
8462                .unwrap_or_default();
8463            response_value(
8464                response_id,
8465                Some(serde_json::json!({ "mobs": entries })),
8466                None,
8467            )
8468        }
8469        method
8470            if matches!(
8471                method,
8472                "mobkit/mob_labels/set"
8473                    | "mobkit/mob_labels/get"
8474                    | "mobkit/mob_labels/delete"
8475                    | "mobkit/run_labels/set"
8476                    | "mobkit/run_labels/get"
8477                    | "mobkit/run_labels/delete",
8478            ) =>
8479        {
8480            dispatch_label_method(
8481                method,
8482                metadata_table.as_deref(),
8483                runtime.handle().mob_id().as_str(),
8484                response_id,
8485                &request.params,
8486            )
8487            .await
8488        }
8489        method if crate::rpc::workgraph_methods::is_workgraph_method(method) => {
8490            // The read-only gate and the workgraph.view/manage ABAC checks
8491            // already ran above; the surface carries the authenticated
8492            // console principal, which goal/confirm promotes into the
8493            // trusted confirmation seam and break_glass_reassign records as
8494            // the audited operator. The admission always comes from the
8495            // runtime (never a parameter): whatever constructed the service,
8496            // the unified stdin surface acquires the runtime's admission, so
8497            // the console must acquire that same one.
8498            let admission = runtime.workgraph_admission();
8499            match crate::rpc::workgraph_methods::handle_workgraph_method(
8500                workgraph,
8501                &admission,
8502                crate::rpc::workgraph_methods::WorkgraphSurface::Console {
8503                    authenticated_principal,
8504                },
8505                method,
8506                &request.params,
8507            )
8508            .await
8509            {
8510                Ok(result) => response_value(response_id, Some(result), None),
8511                Err(error) => response_value(response_id, None, Some(error)),
8512            }
8513        }
8514        _ => response_value(
8515            response_id,
8516            None,
8517            Some(JsonRpcError {
8518                code: -32601,
8519                message: "Method not found".to_string(),
8520                data: None,
8521            }),
8522        ),
8523    }
8524}
8525
8526async fn reconcile_console_topology(
8527    runtime: &MobRuntime,
8528    topology: Option<&crate::topology_control::TopologyRuntimeHandle>,
8529) -> crate::unified_runtime::UnifiedRuntimeReconcileEdgesReport {
8530    match topology {
8531        Some(topology) => topology.reconcile().await,
8532        None => crate::unified_runtime::edge_reconcile::reconcile_definition_edges(runtime).await,
8533    }
8534}
8535
8536/// Dispatch the six `mobkit/{mob,run}_labels/*` RPCs against a metadata table.
8537///
8538/// Single entrypoint shared by every label method; the dispatch arms in
8539/// `handle_console_runtime_rpc` simply delegate based on the matched method
8540/// name. Mirrors the unified-runtime handlers in `rpc::mob_methods` — both
8541/// transports project the same outcomes to the same wire shape.
8542async fn dispatch_label_method(
8543    method: &str,
8544    metadata_table: Option<&RuntimeMetadataTable>,
8545    mob_id: &str,
8546    response_id: Value,
8547    params: &Value,
8548) -> Value {
8549    let Some(table) = metadata_table else {
8550        return invalid_params(
8551            response_id,
8552            "metadata table not configured for this runtime",
8553        );
8554    };
8555
8556    let scope = match method {
8557        "mobkit/mob_labels/set" | "mobkit/mob_labels/get" | "mobkit/mob_labels/delete" => {
8558            MetadataScope::Mob(mob_id.to_string())
8559        }
8560        _ => match crate::runtime::parse_run_id_param(params) {
8561            Ok(run_id) => MetadataScope::Run(mob_id.to_string(), run_id.to_string()),
8562            Err(message) => return invalid_params(response_id, message),
8563        },
8564    };
8565
8566    let outcome = match method {
8567        "mobkit/mob_labels/set" | "mobkit/run_labels/set" => {
8568            crate::runtime::dispatch_labels_set(table, scope, params).await
8569        }
8570        "mobkit/mob_labels/get" | "mobkit/run_labels/get" => {
8571            crate::runtime::dispatch_labels_get(table, scope).await
8572        }
8573        "mobkit/mob_labels/delete" | "mobkit/run_labels/delete" => {
8574            crate::runtime::dispatch_labels_delete(table, scope).await
8575        }
8576        _ => unreachable!("dispatch_label_method called with non-label method: {method}"),
8577    };
8578
8579    match outcome {
8580        crate::runtime::LabelRpcResult::Accepted => response_value(
8581            response_id,
8582            Some(serde_json::json!({"accepted": true})),
8583            None,
8584        ),
8585        crate::runtime::LabelRpcResult::Labels(labels) => response_value(
8586            response_id,
8587            Some(serde_json::json!({"labels": labels_to_json_value(&labels)})),
8588            None,
8589        ),
8590        crate::runtime::LabelRpcResult::InvalidParams(message) => {
8591            invalid_params(response_id, message)
8592        }
8593    }
8594}
8595
8596async fn console_member_peer_info(
8597    handle: meerkat_mob::MobHandle,
8598    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
8599    identity_authoritative: bool,
8600    member_alias: String,
8601) -> Result<Value, String> {
8602    let current_alias = if identity_authoritative {
8603        let identity_runtime = identity_runtime
8604            .ok_or_else(|| "durable peer target lost its IdentityRuntime authority".to_string())?;
8605        let identity = crate::identity_first::IdentityRuntime::identity_for_generated_member_alias(
8606            &member_alias,
8607        )
8608        .or_else(|| crate::identity_first::AgentIdentity::parse(&member_alias).ok())
8609        .ok_or_else(|| format!("invalid durable member alias {member_alias:?}"))?;
8610        identity_runtime
8611            .status(&identity)
8612            .await
8613            .map_err(|error| error.to_string())?
8614            .agent_runtime_id
8615            .map(|runtime_id| runtime_id.to_string())
8616            .ok_or_else(|| format!("identity {identity} has no current runtime member"))?
8617    } else {
8618        let direct = crate::member_comms_id::mob_member_id(&member_alias);
8619        if handle
8620            .get_member(&direct)
8621            .await
8622            .map_err(|error| error.to_string())?
8623            .is_some()
8624        {
8625            member_alias.clone()
8626        } else {
8627            let candidates = handle
8628                .list_members_including_retiring()
8629                .await
8630                .into_iter()
8631                .filter(|entry| {
8632                    crate::member_comms_id::durable_identity_label(&entry.labels)
8633                        .is_some_and(|identity| identity == member_alias)
8634                })
8635                .map(|entry| {
8636                    crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str())
8637                        .into_owned()
8638                })
8639                .collect::<BTreeSet<_>>();
8640            match candidates.len() {
8641                0 => member_alias.clone(),
8642                1 => candidates
8643                    .into_iter()
8644                    .next()
8645                    .ok_or_else(|| "peer-info member alias candidate disappeared".to_string())?,
8646                _ => {
8647                    return Err(format!(
8648                        "ambiguous durable member alias {member_alias}: candidates [{}]",
8649                        candidates.into_iter().collect::<Vec<_>>().join(", ")
8650                    ));
8651                }
8652            }
8653        }
8654    };
8655    let member_id = crate::member_comms_id::mob_member_id(&current_alias);
8656    let entry = handle
8657        .get_member(&member_id)
8658        .await
8659        .map_err(|error| format!("member lookup failed: {error}"))?
8660        .ok_or_else(|| format!("member {member_alias:?} not found"))?;
8661    let peer_id = entry
8662        .peer_id()
8663        .ok_or_else(|| format!("member {member_alias:?} has no comms runtime"))?;
8664    let mob_id = handle.mob_id().to_string();
8665    let comms_name = meerkat_core::MemberCommsName::new(
8666        &mob_id,
8667        entry.role.as_str(),
8668        entry.agent_identity.as_str(),
8669    )
8670    .map_err(|error| format!("invalid member comms name: {error}"))?
8671    .to_string();
8672    let address = format!("inproc://{comms_name}");
8673    Ok(serde_json::json!({
8674        "member_id": member_alias,
8675        "mob_id": mob_id,
8676        "comms_name": comms_name,
8677        "peer_id": peer_id,
8678        "address": address,
8679    }))
8680}
8681
8682/// Shared body for `mobkit/cross_mob/wire_local` and `unwire_local` over
8683/// the console transport. `wire = true` calls `MobHandle::wire`, `false`
8684/// calls `MobHandle::unwire`. Both share param parsing and response shape.
8685///
8686/// Non-inproc transports (`tcp://`, `uds://`) require a non-zero pubkey;
8687/// the caller may supply it via `remote_pubkey_b64` or rely on TOFU
8688/// flows configured at the contact-directory layer (which this handler
8689/// does not consult — it only sees the explicit params).
8690async fn handle_console_wire_local(
8691    runtime: &MobRuntime,
8692    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
8693    params: &Value,
8694    response_id: Value,
8695    wire: bool,
8696) -> Value {
8697    let local = params.get("local_member_id").and_then(Value::as_str);
8698    let comms_name = params.get("remote_comms_name").and_then(Value::as_str);
8699    let peer_id = params.get("remote_peer_id").and_then(Value::as_str);
8700    let addr = params.get("remote_address").and_then(Value::as_str);
8701
8702    let remote_pubkey = match params.get("remote_pubkey_b64") {
8703        None => None,
8704        Some(v) if v.is_null() => None,
8705        Some(v) => match v.as_str() {
8706            Some(s) if !s.is_empty() => match crate::auth::peer_keys::decode_pubkey_b64(s) {
8707                Ok(bytes) => Some(bytes),
8708                Err(err) => {
8709                    return invalid_params(response_id, format!("remote_pubkey_b64: {err}"));
8710                }
8711            },
8712            _ => None,
8713        },
8714    };
8715
8716    let (local_id, cname, pid, address) = match (local, comms_name, peer_id, addr) {
8717        (Some(l), Some(c), Some(p), Some(a))
8718            if !l.is_empty() && !c.is_empty() && !p.is_empty() && !a.is_empty() =>
8719        {
8720            (l, c, p, a)
8721        }
8722        _ => {
8723            return invalid_params(
8724                response_id,
8725                "local_member_id, remote_comms_name, remote_peer_id, and remote_address required",
8726            );
8727        }
8728    };
8729
8730    let is_inproc = address.starts_with("inproc://");
8731    let spec_result = match (is_inproc, remote_pubkey) {
8732        (true, None) => TrustedPeerDescriptor::test_only_unsigned(cname, pid, address),
8733        (true, Some(bytes)) => {
8734            TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
8735        }
8736        (false, None) => {
8737            return invalid_params(
8738                response_id,
8739                "remote_pubkey_b64 is required for non-inproc transports",
8740            );
8741        }
8742        (false, Some(bytes)) => {
8743            if bytes == [0u8; 32] {
8744                return invalid_params(
8745                    response_id,
8746                    "remote_pubkey_b64 must be non-zero for non-inproc transports",
8747                );
8748            }
8749            TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
8750        }
8751    };
8752
8753    let spec = match spec_result {
8754        Ok(spec) => spec,
8755        Err(err) => {
8756            return invalid_params(response_id, format!("invalid peer spec: {err}"));
8757        }
8758    };
8759
8760    let handle = runtime.handle();
8761    let local_alias = crate::member_comms_id::runtime_alias_str(local_id).into_owned();
8762    let authority_target = if let Some(identity_runtime) = identity_runtime {
8763        identity_runtime
8764            .member_alias_lifecycle_target(&local_alias)
8765            .await
8766    } else {
8767        Ok(None)
8768    };
8769    let result = match authority_target {
8770        Err(error) => Err(error.to_string()),
8771        Ok(Some(target)) => {
8772            let operation_alias = local_alias.clone();
8773            crate::identity_first::IdentityRuntime::run_member_alias_targets_operation_tracked(
8774                vec![target],
8775                move || async move {
8776                    let result = if wire {
8777                        handle
8778                            .wire(
8779                                crate::member_comms_id::mob_member_id(&operation_alias),
8780                                PeerTarget::External(spec),
8781                            )
8782                            .await
8783                    } else {
8784                        handle
8785                            .unwire(
8786                                crate::member_comms_id::mob_member_id(&operation_alias),
8787                                PeerTarget::External(spec),
8788                            )
8789                            .await
8790                    };
8791                    result.map_err(|err| err.to_string())
8792                },
8793            )
8794            .await
8795            .map_err(|err| err.to_string())
8796        }
8797        Ok(None) if crate::member_comms_id::is_reserved_generated_alias(&local_alias) => Err(
8798            format!("generated member alias requires current identity authority: {local_alias}"),
8799        ),
8800        Ok(None) => {
8801            let result = if wire {
8802                handle
8803                    .wire(
8804                        crate::member_comms_id::mob_member_id(&local_alias),
8805                        PeerTarget::External(spec),
8806                    )
8807                    .await
8808            } else {
8809                handle
8810                    .unwire(
8811                        crate::member_comms_id::mob_member_id(&local_alias),
8812                        PeerTarget::External(spec),
8813                    )
8814                    .await
8815            };
8816            result.map_err(|err| err.to_string())
8817        }
8818    };
8819
8820    let action = if wire { "wire_local" } else { "unwire_local" };
8821    match result {
8822        Ok(()) => response_value(
8823            response_id,
8824            Some(serde_json::json!({
8825                "accepted": true,
8826                "local_member_id": local_id,
8827                "remote_comms_name": cname,
8828            })),
8829            None,
8830        ),
8831        Err(err) => internal_error(response_id, format!("cross_mob/{action} failed: {err}")),
8832    }
8833}
8834
8835async fn build_live_snapshot(
8836    runtime: &MobRuntime,
8837    config_module_ids: &[String],
8838    console_events: Option<&ConsoleEventStore>,
8839    visibility_policy: &dyn ConsoleVisibilityPolicy,
8840    read_model: &ConsoleSnapshotReadModel,
8841) -> ConsoleLiveSnapshot {
8842    let read_model_state = Box::pin(read_model.snapshot(runtime)).await;
8843    let running = read_model_state.running.unwrap_or(true);
8844    // Hot path: clone the pre-projected members from the cached read
8845    // model. NO `handle.*` async calls happen here — the background
8846    // refresh task is the only thing that walks the mob roster, so
8847    // snapshot requests never contend with spawn/retire activity.
8848    // First request on a cold cache pays one synchronous refresh via
8849    // `snapshot(runtime).await` above; subsequent requests just clone.
8850    let mut members = read_model_state.primary_members.clone();
8851    if visibility_policy.include_implicit_delegate_members() {
8852        for group in &read_model_state.delegate_member_groups {
8853            members.extend(group.iter().cloned());
8854        }
8855    }
8856    dedupe_console_members_by_identity(&mut members);
8857    members.retain(|member| {
8858        visibility_policy.member_visible(member)
8859            && visibility_policy
8860                .identity_visible(&console_identity_record_from_console_member(member))
8861    });
8862
8863    // Use configured module IDs when available because topology and health
8864    // surfaces describe loaded modules, not live mob members.
8865    // Fall back to member IDs only for pure mob runtimes with no module config.
8866    let loaded_modules = if config_module_ids.is_empty() {
8867        let mut mods: Vec<String> = members
8868            .iter()
8869            .filter(|member| member.state != MEMBER_STATE_RETIRING)
8870            .map(|member| member.agent_identity.clone())
8871            .collect();
8872        mods.sort();
8873        mods
8874    } else {
8875        let mut mods = config_module_ids.to_vec();
8876        mods.sort();
8877        mods
8878    };
8879
8880    let agents = members
8881        .iter()
8882        .map(|member| async move {
8883            let console_identity = console_member_console_identity(member);
8884            let label = member
8885                .labels
8886                .get("display_name")
8887                .cloned()
8888                .unwrap_or_else(|| member.agent_identity.clone());
8889            let watched = member
8890                .labels
8891                .get("console_watched")
8892                .map(|value: &String| value == "true");
8893            let alert_level = member
8894                .labels
8895                .get("console_alert_level")
8896                .filter(|value: &&String| matches!(value.as_str(), "elevated" | "critical"))
8897                .cloned();
8898            let degraded = member
8899                .labels
8900                .get("console_degraded")
8901                .map(|value: &String| value == "true");
8902            let degraded_reason = member.labels.get("console_degraded_reason").cloned();
8903            let response_phase = match console_events {
8904                Some(store) => store.response_phase_for_identity(console_identity).await,
8905                None => None,
8906            };
8907            ConsoleAgentLiveSnapshot {
8908                agent_id: member.agent_identity.clone(),
8909                member_id: member.agent_identity.clone(),
8910                label,
8911                kind: "meerkat".to_string(),
8912                identity: Some(console_identity.to_string()),
8913                role: Some(member.role.clone()),
8914                state: Some(member.state.clone()),
8915                session_id: member.session_id.clone(),
8916                model_capabilities: member.model_capabilities.clone(),
8917                response_phase,
8918                watched,
8919                alert_level,
8920                degraded,
8921                degraded_reason,
8922            }
8923        })
8924        .collect::<Vec<_>>();
8925    let mut agents = join_all(agents).await;
8926    agents.sort_by(|left, right| left.label.cmp(&right.label));
8927    ConsoleLiveSnapshot::new(
8928        Some(runtime.handle().mob_id().to_string()),
8929        running,
8930        loaded_modules,
8931        agents,
8932        members,
8933        true,
8934    )
8935}
8936
8937async fn collect_console_snapshot_read_model(
8938    runtime: &MobRuntime,
8939) -> ConsoleSnapshotReadModelState {
8940    let handle = runtime.handle();
8941    let mut state = ConsoleSnapshotReadModelState {
8942        running: Some(matches!(
8943            handle.status_observation_snapshot(),
8944            MobState::Creating | MobState::Running
8945        )),
8946        ..ConsoleSnapshotReadModelState::default()
8947    };
8948    collect_console_session_index_for_handle(&handle, &mut state).await;
8949    let registered_labels = runtime.console_identity_labels().await;
8950
8951    // Snapshot + project the primary mob into the cache. Done here
8952    // under the background refresh lock so per-request
8953    // `build_live_snapshot` calls never need to enter MobHandle async
8954    // methods. The session-id index in `state` was populated above by
8955    // `collect_console_session_index_for_handle`.
8956    let (primary_members, _primary_owner_index) =
8957        project_console_members_from_handle(&handle, None, None, &state, &registered_labels).await;
8958    state.primary_members = primary_members;
8959
8960    let Some(mcp_state) = runtime.agent_mob_mcp_state() else {
8961        return state;
8962    };
8963    let primary_mob_id = handle.mob_id().to_string();
8964    let mut processed = BTreeSet::from([primary_mob_id]);
8965    let mut delegate_groups: Vec<Vec<ConsoleMember>> = Vec::new();
8966    loop {
8967        let mut progressed = false;
8968        for (mob_id, delegate_handle) in Box::pin(mcp_state.mob_handles_snapshot())
8969            .await
8970            .unwrap_or_default()
8971        {
8972            if processed.contains(mob_id.as_str()) {
8973                continue;
8974            }
8975            // Meerkat 0.7: the owner bridge binding is machine-owned state,
8976            // no longer a definition-level index.
8977            let Some(owner_authority) = delegate_handle.owner_bridge_session_lifecycle_authority()
8978            else {
8979                processed.insert(mob_id.to_string());
8980                continue;
8981            };
8982            let owner_session_id = owner_authority.bridge_session_id.to_string();
8983            let Some(host_identity) = state
8984                .session_owner_by_id
8985                .get(owner_session_id.as_str())
8986                .cloned()
8987            else {
8988                continue;
8989            };
8990            collect_console_session_index_for_handle(&delegate_handle, &mut state).await;
8991            let (delegate_members, _delegate_owner_index) = project_console_members_from_handle(
8992                &delegate_handle,
8993                Some(&host_identity),
8994                Some(mob_id.as_str()),
8995                &state,
8996                &registered_labels,
8997            )
8998            .await;
8999            delegate_groups.push(delegate_members);
9000            processed.insert(mob_id.to_string());
9001            progressed = true;
9002        }
9003        if !progressed {
9004            break;
9005        }
9006    }
9007    state.delegate_member_groups = delegate_groups;
9008    state
9009}
9010
9011async fn collect_console_session_index_for_handle(
9012    handle: &MobHandle,
9013    state: &mut ConsoleSnapshotReadModelState,
9014) {
9015    for entry in handle.list_members_observation_snapshot().await {
9016        // Session read-model keys live in the public alias space (decoded
9017        // from the comms-safe roster id, meerkat 0.7).
9018        let identity =
9019            crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
9020        let Some(session_id) = handle
9021            .resolve_bridge_session_id_observation(&entry.agent_identity)
9022            .await
9023            .map(|session_id| session_id.to_string())
9024        else {
9025            state.session_id_by_identity.remove(&identity);
9026            continue;
9027        };
9028        state
9029            .session_owner_by_id
9030            .insert(session_id.clone(), identity.clone());
9031        state.session_id_by_identity.insert(identity, session_id);
9032    }
9033}
9034
9035fn apply_console_visibility_policy(
9036    snapshot: &mut ConsoleLiveSnapshot,
9037    visibility_policy: &dyn ConsoleVisibilityPolicy,
9038) {
9039    let mut hidden = BTreeSet::new();
9040    snapshot.members.retain(|member| {
9041        let visible = visibility_policy.member_visible(member)
9042            && visibility_policy
9043                .identity_visible(&console_identity_record_from_console_member(member));
9044        if !visible {
9045            hidden.insert(member.agent_identity.clone());
9046        }
9047        visible
9048    });
9049    snapshot
9050        .agents
9051        .retain(|agent| !hidden.contains(&agent.agent_id));
9052    snapshot
9053        .loaded_modules
9054        .retain(|module_id| !hidden.contains(module_id));
9055}
9056
9057async fn reset_all_live_console_agents(
9058    runtime: &MobRuntime,
9059    console_events: Option<&ConsoleEventStore>,
9060    console_aggregator: Option<&MobKitConsoleAggregator>,
9061    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
9062    visibility_policy: &dyn ConsoleVisibilityPolicy,
9063) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
9064    let read_model = ConsoleSnapshotReadModel::default();
9065    *read_model.inner.write().await = Box::pin(collect_console_snapshot_read_model(runtime)).await;
9066    // Mark the freshly-built model primed so `build_live_snapshot` doesn't
9067    // try to re-prime; this is a one-shot read for the reset path.
9068    read_model
9069        .primed
9070        .store(true, std::sync::atomic::Ordering::Release);
9071    let snapshot = Box::pin(build_live_snapshot(
9072        runtime,
9073        &[],
9074        console_events,
9075        visibility_policy,
9076        &read_model,
9077    ))
9078    .await;
9079    let raw_snapshot = Box::pin(build_live_snapshot(
9080        runtime,
9081        &[],
9082        console_events,
9083        &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
9084        &read_model,
9085    ))
9086    .await;
9087    let identity_runtime_statuses = if let Some(identity_runtime) = identity_runtime {
9088        identity_runtime.statuses().await
9089    } else {
9090        Vec::new()
9091    };
9092    let identity_by_runtime_member_id = identity_runtime_statuses
9093        .iter()
9094        .filter_map(|status| {
9095            status
9096                .agent_runtime_id
9097                .as_ref()
9098                .map(|runtime_id| (runtime_id.as_str().to_string(), status.identity.to_string()))
9099        })
9100        .collect::<BTreeMap<_, _>>();
9101    let mut durable_identity_runtime_identities = identity_runtime_statuses
9102        .iter()
9103        .filter(|status| identity_status_visible_to_console(visibility_policy, status))
9104        .map(|status| status.identity.to_string())
9105        .collect::<BTreeSet<_>>();
9106    let mut main_identities = BTreeSet::new();
9107    let mut runtime_member_id_by_identity = BTreeMap::new();
9108    let mut runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9109    let mut session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
9110        BTreeMap::new();
9111    let mut live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
9112        BTreeMap::new();
9113    let mut visible_runtime_member_ids = BTreeSet::new();
9114    let mut duplicate_live_identities = BTreeSet::new();
9115    let mut delegate_members = BTreeSet::new();
9116    for member in snapshot.members {
9117        if member.state == MEMBER_STATE_RETIRING {
9118            continue;
9119        }
9120        if let Some(source_mob_id) = member.labels.get("source_mob_id").cloned() {
9121            delegate_members.insert((source_mob_id, member.agent_identity));
9122        } else {
9123            let identity = crate::member_comms_id::durable_identity_label(&member.labels)
9124                .map(str::to_owned)
9125                .or_else(|| {
9126                    identity_by_runtime_member_id
9127                        .get(&member.agent_identity)
9128                        .cloned()
9129                })
9130                .unwrap_or_else(|| member.agent_identity.clone());
9131            if let Some(existing) = runtime_member_id_by_identity.get(&identity)
9132                && existing != &member.agent_identity
9133            {
9134                duplicate_live_identities.insert(identity.clone());
9135            }
9136            runtime_member_ids_by_identity
9137                .entry(identity.clone())
9138                .or_default()
9139                .insert(member.agent_identity.clone());
9140            session_id_by_identity_runtime_member.insert(
9141                (identity.clone(), member.agent_identity.clone()),
9142                member.session_id.clone(),
9143            );
9144            live_alias_by_runtime_member_id.insert(
9145                member.agent_identity.clone(),
9146                (identity.clone(), member.session_id.clone()),
9147            );
9148            visible_runtime_member_ids.insert(member.agent_identity.clone());
9149            runtime_member_id_by_identity
9150                .entry(identity.clone())
9151                .or_insert(member.agent_identity);
9152            main_identities.insert(identity);
9153        }
9154    }
9155    let mut raw_runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> =
9156        BTreeMap::new();
9157    let mut raw_session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
9158        BTreeMap::new();
9159    let mut raw_live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
9160        BTreeMap::new();
9161    for member in raw_snapshot.members {
9162        if member.state == MEMBER_STATE_RETIRING || member.labels.contains_key("source_mob_id") {
9163            continue;
9164        }
9165        let identity = crate::member_comms_id::durable_identity_label(&member.labels)
9166            .map(str::to_owned)
9167            .or_else(|| {
9168                identity_by_runtime_member_id
9169                    .get(&member.agent_identity)
9170                    .cloned()
9171            })
9172            .unwrap_or_else(|| member.agent_identity.clone());
9173        raw_runtime_member_ids_by_identity
9174            .entry(identity.clone())
9175            .or_default()
9176            .insert(member.agent_identity.clone());
9177        raw_session_id_by_identity_runtime_member.insert(
9178            (identity.clone(), member.agent_identity.clone()),
9179            member.session_id.clone(),
9180        );
9181        raw_live_alias_by_runtime_member_id
9182            .insert(member.agent_identity, (identity, member.session_id));
9183    }
9184    durable_identity_runtime_identities.retain(|identity| {
9185        identity_runtime_statuses
9186            .iter()
9187            .find(|status| status.identity.as_str() == identity)
9188            .and_then(|status| status.agent_runtime_id.as_ref())
9189            .is_none_or(|runtime_id| {
9190                let runtime_id = runtime_id.as_str();
9191                !raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
9192                    || visible_runtime_member_ids.contains(runtime_id)
9193            })
9194    });
9195    let current_main_identities = main_identities.clone();
9196    let baseline_specs = runtime.baseline_member_specs().await;
9197    let baseline_identities = baseline_specs
9198        .iter()
9199        .filter(|spec| baseline_spec_visible_to_console(visibility_policy, spec))
9200        .map(|spec| spec.identity.to_string())
9201        .collect::<BTreeSet<_>>();
9202    main_identities.extend(baseline_identities.iter().cloned());
9203    main_identities.extend(durable_identity_runtime_identities.iter().cloned());
9204
9205    let mut retired_delegates = Vec::new();
9206    let mut reset_main = Vec::new();
9207    let mut retired_delegate_details = Vec::new();
9208    let mut reset_details = Vec::new();
9209    let mut failures = Vec::new();
9210    let mut warnings = Vec::new();
9211
9212    for identity in &main_identities {
9213        let parsed_identity = crate::identity_first::AgentIdentity::parse(identity).ok();
9214        let registered_status = if let (Some(identity_runtime), Some(parsed_identity)) =
9215            (identity_runtime, parsed_identity.as_ref())
9216        {
9217            identity_runtime.status(parsed_identity).await.ok()
9218        } else {
9219            None
9220        };
9221        let baseline_identity_runtime_registered = registered_status.is_some();
9222        if baseline_identities.contains(identity)
9223            && !current_main_identities.contains(identity)
9224            && !baseline_identity_runtime_registered
9225        {
9226            continue;
9227        }
9228        let registered_runtime_id = registered_status
9229            .as_ref()
9230            .and_then(|status| status.agent_runtime_id.as_ref())
9231            .map(crate::identity_first::AgentRuntimeId::as_str);
9232        let registered_visible = registered_runtime_id
9233            .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
9234        let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
9235            raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
9236                && !visible_runtime_member_ids.contains(runtime_id)
9237        });
9238        if registered_hidden {
9239            continue;
9240        }
9241        if duplicate_live_identities.contains(identity) && !registered_visible {
9242            failures.push(json!({
9243                "identity": identity,
9244                "error": "ambiguous live identity alias",
9245            }));
9246            continue;
9247        }
9248        if let Some(status) = registered_status.as_ref() {
9249            if let Some(registered_runtime_id) = registered_runtime_id
9250                && let Some((live_identity, _live_session_id)) =
9251                    raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
9252                && live_identity != identity
9253            {
9254                failures.push(json!({
9255                    "identity": identity,
9256                    "error": format!(
9257                        "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
9258                    ),
9259                    "kind": "stale_live_identity_alias",
9260                }));
9261                continue;
9262            }
9263            if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(identity) {
9264                if !registered_runtime_id
9265                    .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
9266                {
9267                    failures.push(json!({
9268                        "identity": identity,
9269                        "error": format!(
9270                            "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
9271                            registered_runtime_id.unwrap_or("<none>"),
9272                            live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
9273                        ),
9274                        "kind": "stale_live_identity_alias",
9275                    }));
9276                    continue;
9277                }
9278                if let Some(registered_runtime_id) = registered_runtime_id
9279                    && let Some(registered_session_id) =
9280                        status.session_id.as_ref().map(ToString::to_string)
9281                    && let Some(Some(live_session_id)) = raw_session_id_by_identity_runtime_member
9282                        .get(&(identity.clone(), registered_runtime_id.to_string()))
9283                    && live_session_id != &registered_session_id
9284                {
9285                    failures.push(json!({
9286                        "identity": identity,
9287                        "error": format!(
9288                            "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}"
9289                        ),
9290                        "kind": "stale_live_identity_alias",
9291                    }));
9292                    continue;
9293                }
9294            }
9295            if baseline_identities.contains(identity)
9296                && !identity_runtime
9297                    .is_some_and(|identity_runtime| identity_runtime.has_session_bridge())
9298            {
9299                failures.push(json!({
9300                    "identity": identity,
9301                    "error": "reset requires an identity runtime with a session bridge",
9302                    "kind": "identity_reset_requires_session_bridge",
9303                }));
9304            }
9305            continue;
9306        }
9307
9308        let runtime_member_id = runtime_member_id_by_identity
9309            .get(identity)
9310            .map(String::as_str)
9311            .unwrap_or(identity.as_str());
9312        if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
9313            && bound_identity != identity
9314        {
9315            failures.push(json!({
9316                "identity": identity,
9317                "error": format!(
9318                    "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
9319                ),
9320                "kind": "stale_live_identity_alias",
9321            }));
9322        }
9323    }
9324
9325    if !failures.is_empty() {
9326        return Ok(json!({
9327            "reset": reset_main,
9328            "retired_delegates": retired_delegates,
9329            "reset_details": reset_details,
9330            "retired_delegate_details": retired_delegate_details,
9331            "warnings": warnings,
9332            "failed": failures,
9333            "startup_history": Value::Null,
9334        }));
9335    }
9336
9337    if let Some(state) = runtime.agent_mob_mcp_state() {
9338        for (mob_id, identity) in delegate_members {
9339            match Box::pin(state.handle_for(&MobId::from(mob_id.as_str()))).await {
9340                Ok(handle) => {
9341                    match retire_console_member(
9342                        &handle,
9343                        &crate::member_comms_id::mob_member_id(identity.as_str()),
9344                    )
9345                    .await
9346                    {
9347                        Ok(()) => {
9348                            let detail = json!({
9349                                "identity": identity,
9350                                "mob_id": mob_id,
9351                            });
9352                            retired_delegates.push(detail.clone());
9353                            retired_delegate_details.push(detail);
9354                        }
9355                        Err(err) => failures.push(json!({
9356                            "identity": identity,
9357                            "mob_id": mob_id,
9358                            "error": err,
9359                        })),
9360                    }
9361                }
9362                Err(err) => failures.push(json!({
9363                    "identity": identity,
9364                    "mob_id": mob_id,
9365                    "error": err.to_string(),
9366                })),
9367            }
9368        }
9369    } else if let Some(aggregator) = console_aggregator {
9370        let identities = delegate_members
9371            .into_iter()
9372            .map(|(_, identity)| identity)
9373            .collect::<BTreeSet<_>>();
9374        for identity in identities {
9375            match Box::pin(aggregator.retire_identity(&identity)).await {
9376                Ok(true) => {
9377                    let detail = json!({ "identity": identity });
9378                    retired_delegates.push(detail.clone());
9379                    retired_delegate_details.push(detail);
9380                }
9381                Ok(false) => failures.push(json!({
9382                    "identity": identity,
9383                    "error": "unknown identity",
9384                })),
9385                Err(err) => failures.push(json!({
9386                    "identity": identity,
9387                    "error": err.to_string(),
9388                })),
9389            }
9390        }
9391    }
9392
9393    let handle = runtime.handle();
9394    for spec in baseline_specs {
9395        let identity = spec.identity.to_string();
9396        if !baseline_spec_visible_to_console(visibility_policy, &spec) {
9397            continue;
9398        }
9399        if current_main_identities.contains(&identity) {
9400            continue;
9401        }
9402        if let Some(identity_runtime) = identity_runtime
9403            && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9404            && identity_runtime.status(&parsed_identity).await.is_ok()
9405        {
9406            continue;
9407        }
9408        match handle.ensure_member(spec).await {
9409            Ok(_outcome) => {
9410                if let Some(store) = console_events {
9411                    store
9412                        .record_lifecycle(
9413                            &identity,
9414                            "identity_reset",
9415                            json!({ "scope": "reset_all", "restored": true }),
9416                        )
9417                        .await;
9418                }
9419                reset_main.push(identity.clone());
9420                reset_details.push(json!({ "identity": identity }));
9421            }
9422            Err(err) => failures.push(json!({
9423                "identity": identity,
9424                "error": err.to_string(),
9425            })),
9426        }
9427    }
9428    for identity in main_identities {
9429        let baseline_identity_runtime_registered = if let Some(identity_runtime) = identity_runtime
9430            && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9431        {
9432            identity_runtime.status(&parsed_identity).await.is_ok()
9433        } else {
9434            false
9435        };
9436        if baseline_identities.contains(&identity)
9437            && !current_main_identities.contains(&identity)
9438            && !baseline_identity_runtime_registered
9439        {
9440            continue;
9441        }
9442        let registered_status = if let Some(identity_runtime) = identity_runtime
9443            && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9444        {
9445            identity_runtime.status(&parsed_identity).await.ok()
9446        } else {
9447            None
9448        };
9449        let registered_runtime_id = registered_status
9450            .as_ref()
9451            .and_then(|status| status.agent_runtime_id.as_ref())
9452            .map(crate::identity_first::AgentRuntimeId::as_str);
9453        let registered_visible = registered_runtime_id
9454            .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
9455        let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
9456            raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
9457                && !visible_runtime_member_ids.contains(runtime_id)
9458        });
9459        if registered_hidden {
9460            continue;
9461        }
9462        if duplicate_live_identities.contains(&identity) && !registered_visible {
9463            failures.push(json!({
9464                "identity": identity,
9465                "error": "ambiguous live identity alias",
9466            }));
9467            continue;
9468        }
9469        if baseline_identities.contains(&identity) {
9470            if let Some(identity_runtime) = identity_runtime
9471                && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9472                && let Ok(status) = identity_runtime.status(&parsed_identity).await
9473            {
9474                let registered_runtime_id = status
9475                    .agent_runtime_id
9476                    .as_ref()
9477                    .map(crate::identity_first::AgentRuntimeId::as_str);
9478                if let Some(registered_runtime_id) = registered_runtime_id
9479                    && let Some((live_identity, _live_session_id)) =
9480                        raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
9481                    && live_identity != &identity
9482                {
9483                    failures.push(json!({
9484                        "identity": identity,
9485                        "error": format!(
9486                            "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
9487                        ),
9488                        "kind": "stale_live_identity_alias",
9489                    }));
9490                    continue;
9491                }
9492                if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
9493                    if !registered_runtime_id
9494                        .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
9495                    {
9496                        failures.push(json!({
9497                            "identity": identity,
9498                            "error": format!(
9499                                "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
9500                                registered_runtime_id.unwrap_or("<none>"),
9501                                live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
9502                            ),
9503                            "kind": "stale_live_identity_alias",
9504                        }));
9505                        continue;
9506                    }
9507                    if let Some(registered_runtime_id) = registered_runtime_id
9508                        && let Some(registered_session_id) =
9509                            status.session_id.as_ref().map(ToString::to_string)
9510                        && let Some(Some(live_session_id)) =
9511                            raw_session_id_by_identity_runtime_member
9512                                .get(&(identity.clone(), registered_runtime_id.to_string()))
9513                        && live_session_id != &registered_session_id
9514                    {
9515                        failures.push(json!({
9516                            "identity": identity,
9517                            "error": format!(
9518                                "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}"
9519                            ),
9520                            "kind": "stale_live_identity_alias",
9521                        }));
9522                        continue;
9523                    }
9524                }
9525                if !identity_runtime.has_session_bridge() {
9526                    failures.push(json!({
9527                        "identity": identity,
9528                        "error": "reset requires an identity runtime with a session bridge",
9529                        "kind": "identity_reset_requires_session_bridge",
9530                    }));
9531                    continue;
9532                }
9533                match identity_runtime.reset_tracked(&parsed_identity).await {
9534                    Ok(record) => {
9535                        let cleanup_warning = json!({
9536                            "identity": identity,
9537                            "kind": "stale_member_cleanup_skipped_after_identity_reset",
9538                            "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
9539                        });
9540                        warnings.push(cleanup_warning);
9541                        reset_details.push(json!({
9542                            "identity": identity,
9543                            "cleanup_warning": warnings.last().cloned(),
9544                        }));
9545                        reset_main.push(identity);
9546                        if let Some(store) = console_events {
9547                            store
9548                                .record_lifecycle(
9549                                    parsed_identity.as_str(),
9550                                    "identity_reset",
9551                                    json!({
9552                                        "scope": "reset_all",
9553                                        "generation": record.generation.get(),
9554                                        "checkpoint_version": record.checkpoint_version.get(),
9555                                        "cleanup_warning": warnings.last().cloned(),
9556                                    }),
9557                                )
9558                                .await;
9559                        }
9560                    }
9561                    Err(err) => failures.push(json!({
9562                        "identity": identity,
9563                        "error": err.to_string(),
9564                    })),
9565                }
9566                continue;
9567            }
9568            let runtime_member_id = runtime_member_id_by_identity
9569                .get(&identity)
9570                .map(String::as_str)
9571                .unwrap_or(identity.as_str());
9572            if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
9573                && bound_identity != &identity
9574            {
9575                failures.push(json!({
9576                    "identity": identity,
9577                    "error": format!(
9578                        "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
9579                    ),
9580                    "kind": "stale_live_identity_alias",
9581                }));
9582                continue;
9583            }
9584            match Box::pin(respawn_console_member(
9585                &handle,
9586                &crate::member_comms_id::mob_member_id(runtime_member_id),
9587            ))
9588            .await
9589            {
9590                Ok(topology_restore_warning) => {
9591                    if let Some(store) = console_events {
9592                        store
9593                            .record_lifecycle(
9594                                &identity,
9595                                "identity_reset",
9596                                json!({
9597                                    "scope": "reset_all",
9598                                    "topology_restore_warning": topology_restore_warning.clone(),
9599                                }),
9600                            )
9601                            .await;
9602                    }
9603                    reset_main.push(identity.clone());
9604                    let mut detail = json!({ "identity": identity });
9605                    if let Some(warning) = topology_restore_warning {
9606                        detail["topology_restore_warning"] = warning;
9607                    }
9608                    reset_details.push(detail);
9609                }
9610                Err(err) => failures.push(json!({
9611                    "identity": identity,
9612                    "error": err,
9613                })),
9614            }
9615        } else {
9616            if let Some(identity_runtime) = identity_runtime
9617                && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9618                && let Ok(registered_status) = identity_runtime.status(&parsed_identity).await
9619            {
9620                let registered_runtime_id = registered_status
9621                    .agent_runtime_id
9622                    .as_ref()
9623                    .map(crate::identity_first::AgentRuntimeId::as_str);
9624                let registered_visible = registered_runtime_id
9625                    .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
9626                if duplicate_live_identities.contains(&identity) && !registered_visible {
9627                    failures.push(json!({
9628                        "identity": identity,
9629                        "error": "ambiguous live identity alias",
9630                    }));
9631                    continue;
9632                }
9633                if let Some(registered_runtime_id) = registered_runtime_id
9634                    && let Some((live_identity, _live_session_id)) =
9635                        raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
9636                    && live_identity != &identity
9637                {
9638                    failures.push(json!({
9639                        "identity": identity,
9640                        "error": format!(
9641                            "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
9642                        ),
9643                        "kind": "stale_live_identity_alias",
9644                    }));
9645                    continue;
9646                }
9647                if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
9648                    if !registered_runtime_id
9649                        .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
9650                    {
9651                        failures.push(json!({
9652                            "identity": identity,
9653                            "error": format!(
9654                                "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
9655                                registered_runtime_id.unwrap_or("<none>"),
9656                                live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
9657                            ),
9658                            "kind": "stale_live_identity_alias",
9659                        }));
9660                        continue;
9661                    }
9662                    if let Some(registered_runtime_id) = registered_runtime_id
9663                        && let Some(registered_session_id) = registered_status
9664                            .session_id
9665                            .as_ref()
9666                            .map(ToString::to_string)
9667                        && let Some(Some(live_session_id)) =
9668                            raw_session_id_by_identity_runtime_member
9669                                .get(&(identity.clone(), registered_runtime_id.to_string()))
9670                        && live_session_id != &registered_session_id
9671                    {
9672                        failures.push(json!({
9673                            "identity": identity,
9674                            "error": format!(
9675                                "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}"
9676                            ),
9677                            "kind": "stale_live_identity_alias",
9678                        }));
9679                        continue;
9680                    }
9681                }
9682                let stale_member_ids = stale_console_member_ids_for_identity(
9683                    &handle,
9684                    visibility_policy,
9685                    parsed_identity.as_str(),
9686                    registered_status
9687                        .agent_runtime_id
9688                        .as_ref()
9689                        .map(crate::identity_first::AgentRuntimeId::as_str),
9690                    !identity_runtime.has_session_bridge(),
9691                )
9692                .await;
9693                match identity_runtime.retire_tracked(&parsed_identity).await {
9694                    Ok(token) => match retire_console_member_ids(&handle, stale_member_ids).await {
9695                        Ok(()) => {
9696                            retired_delegate_details.push(json!({ "identity": identity }));
9697                            retired_delegates.push(json!({ "identity": identity }));
9698                            if let Some(store) = console_events {
9699                                store
9700                                    .record_lifecycle(
9701                                        parsed_identity.as_str(),
9702                                        "identity_retired",
9703                                        json!({
9704                                            "scope": "reset_all",
9705                                            "dynamic": true,
9706                                            "fencing_token": token.get(),
9707                                        }),
9708                                    )
9709                                    .await;
9710                            }
9711                        }
9712                        Err(err) => {
9713                            warnings.push(json!({
9714                                "identity": identity,
9715                                "kind": "stale_member_cleanup_failed_after_identity_retire",
9716                                "message": err,
9717                            }));
9718                            retired_delegate_details.push(json!({
9719                                "identity": identity,
9720                                "cleanup_warning": warnings.last().cloned(),
9721                            }));
9722                            retired_delegates.push(json!({ "identity": identity }));
9723                            if let Some(store) = console_events {
9724                                store
9725                                    .record_lifecycle(
9726                                        parsed_identity.as_str(),
9727                                        "identity_retired",
9728                                        json!({
9729                                            "scope": "reset_all",
9730                                            "dynamic": true,
9731                                            "fencing_token": token.get(),
9732                                            "cleanup_warning": warnings.last().cloned(),
9733                                        }),
9734                                    )
9735                                    .await;
9736                            }
9737                        }
9738                    },
9739                    Err(err) => failures.push(json!({
9740                        "identity": identity,
9741                        "error": err.to_string(),
9742                    })),
9743                }
9744                continue;
9745            }
9746            let runtime_member_id = runtime_member_id_by_identity
9747                .get(&identity)
9748                .map(String::as_str)
9749                .unwrap_or(identity.as_str());
9750            if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
9751                && bound_identity != &identity
9752            {
9753                failures.push(json!({
9754                    "identity": identity,
9755                    "error": format!(
9756                        "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
9757                    ),
9758                    "kind": "stale_live_identity_alias",
9759                }));
9760                continue;
9761            }
9762            match retire_console_member(
9763                &handle,
9764                &crate::member_comms_id::mob_member_id(runtime_member_id),
9765            )
9766            .await
9767            {
9768                Ok(()) => {
9769                    if let Some(store) = console_events {
9770                        store
9771                            .record_lifecycle(
9772                                &identity,
9773                                "identity_retired",
9774                                json!({ "scope": "reset_all", "dynamic": true }),
9775                            )
9776                            .await;
9777                    }
9778                    retired_delegates.push(json!({ "identity": identity }));
9779                    retired_delegate_details.push(json!({ "identity": identity }));
9780                }
9781                Err(err) => failures.push(json!({
9782                    "identity": identity,
9783                    "error": err,
9784                })),
9785            }
9786        }
9787    }
9788
9789    let startup_history = if failures.is_empty() {
9790        if let Some(aggregator) = console_aggregator {
9791            Box::pin(wait_for_reset_startup_history(
9792                aggregator,
9793                reset_main.iter().cloned().collect::<BTreeSet<_>>(),
9794                Duration::from_secs(10),
9795            ))
9796            .await
9797            .unwrap_or_else(|err| json!({ "error": err.to_string() }))
9798        } else {
9799            Value::Null
9800        }
9801    } else {
9802        Value::Null
9803    };
9804
9805    Ok(json!({
9806        "reset": reset_main,
9807        "retired_delegates": retired_delegates,
9808        "reset_details": reset_details,
9809        "retired_delegate_details": retired_delegate_details,
9810        "warnings": warnings,
9811        "failed": failures,
9812        "startup_history": startup_history,
9813    }))
9814}
9815
9816async fn wait_for_reset_startup_history(
9817    aggregator: &MobKitConsoleAggregator,
9818    identities: BTreeSet<String>,
9819    timeout: Duration,
9820) -> ConsoleLogResult<Value> {
9821    if identities.is_empty() {
9822        return Ok(json!({
9823            "timeout": false,
9824            "ready": Vec::<String>::new(),
9825            "pending": Vec::<String>::new(),
9826        }));
9827    }
9828
9829    let deadline = Instant::now() + timeout;
9830    let mut pending = identities;
9831    let mut ready = BTreeSet::new();
9832    while !pending.is_empty() {
9833        for identity in pending.clone() {
9834            let page = Box::pin(aggregator.query_timeline(ConsoleTimelineQuery {
9835                identity: Some(identity.clone()),
9836                limit: 1000,
9837                ..ConsoleTimelineQuery::default()
9838            }))
9839            .await?;
9840            let startup_completed = page.frames.iter().any(|frame| {
9841                matches!(
9842                    frame.kind.as_str(),
9843                    "interaction_complete" | "turn_completed"
9844                )
9845            });
9846            if startup_completed {
9847                pending.remove(&identity);
9848                ready.insert(identity);
9849            }
9850        }
9851
9852        if pending.is_empty() {
9853            break;
9854        }
9855        if Instant::now() >= deadline {
9856            return Ok(json!({
9857                "timeout": true,
9858                "ready": ready.into_iter().collect::<Vec<_>>(),
9859                "pending": pending.into_iter().collect::<Vec<_>>(),
9860            }));
9861        }
9862        tokio::time::sleep(Duration::from_millis(250)).await;
9863    }
9864
9865    Ok(json!({
9866        "timeout": false,
9867        "ready": ready.into_iter().collect::<Vec<_>>(),
9868        "pending": Vec::<String>::new(),
9869    }))
9870}
9871
9872fn dedupe_console_members_by_identity(members: &mut Vec<ConsoleMember>) {
9873    let mut seen_member_ids = BTreeSet::new();
9874    members.retain(|member| seen_member_ids.insert(member.agent_identity.clone()));
9875}
9876
9877fn console_member_console_identity(member: &ConsoleMember) -> &str {
9878    crate::member_comms_id::durable_identity_label(&member.labels)
9879        .unwrap_or(member.agent_identity.as_str())
9880}
9881
9882fn console_identity_record_from_console_member(member: &ConsoleMember) -> ConsoleIdentityRecord {
9883    let identity = console_member_console_identity(member).to_string();
9884    let addressable = member
9885        .labels
9886        .get("addressable")
9887        .map(|value| !value.eq_ignore_ascii_case("false"))
9888        .unwrap_or(true)
9889        && member.state == MEMBER_STATE_ACTIVE;
9890    let visibility = if member.state == MEMBER_STATE_RETIRING {
9891        ConsoleVisibility::RetiredReadable
9892    } else if addressable {
9893        ConsoleVisibility::Addressable
9894    } else {
9895        ConsoleVisibility::Hidden
9896    };
9897    ConsoleIdentityRecord {
9898        identity: identity.clone(),
9899        display_name: member
9900            .labels
9901            .get("display_name")
9902            .cloned()
9903            .unwrap_or(identity),
9904        runtime_key: "runtime".to_string(),
9905        runtime_member_id: member.agent_identity.clone(),
9906        session_id: member.session_id.clone(),
9907        visibility,
9908        addressable,
9909        health: member.state.clone(),
9910        topology_peers: member.wired_to.clone(),
9911        labels: member.labels.clone(),
9912    }
9913}
9914
9915fn baseline_spec_visible_to_console(
9916    visibility_policy: &dyn ConsoleVisibilityPolicy,
9917    spec: &SpawnMemberSpec,
9918) -> bool {
9919    let mut labels = spec.labels.clone().unwrap_or_default();
9920    labels
9921        .entry("role".to_string())
9922        .or_insert_with(|| spec.role_name.to_string());
9923    let record = ConsoleIdentityRecord {
9924        identity: spec.identity.to_string(),
9925        display_name: spec.identity.to_string(),
9926        runtime_key: "baseline".to_string(),
9927        runtime_member_id: spec.identity.to_string(),
9928        session_id: None,
9929        visibility: ConsoleVisibility::Addressable,
9930        addressable: true,
9931        health: "baseline".to_string(),
9932        topology_peers: Vec::new(),
9933        labels,
9934    };
9935    let member = ConsoleMember {
9936        agent_identity: spec.identity.to_string(),
9937        role: spec.role_name.to_string(),
9938        state: MEMBER_STATE_ACTIVE.to_string(),
9939        model_capabilities: ConsoleModelCapabilities::default(),
9940        runtime_mode: spec
9941            .runtime_mode
9942            .as_ref()
9943            .map(std::string::ToString::to_string),
9944        session_id: None,
9945        wired_to: Vec::new(),
9946        labels: record.labels.clone(),
9947        progress: None,
9948    };
9949    visibility_policy.member_visible(&member) && visibility_policy.identity_visible(&record)
9950}
9951
9952/// Prime the access-control attribute cache from the live primary-mob roster
9953/// so label/role rules resolve on surfaces that carry only an identity string
9954/// (SSE event streams, timeline frames, `mob_events`). Without this, those
9955/// surfaces evaluate role/label rules with attributes unknown and can fail
9956/// open for "broad allow + label-keyed deny" policies — the cache must not be
9957/// gated on a prior `/console/experience` call. Additive (it does not evict),
9958/// so it never clears delegate-member attributes primed by the experience
9959/// projection; the experience path owns wholesale rebuild + eviction.
9960///
9961/// Console metadata registered by agent-tool spawns (`spawned_by` lineage,
9962/// spawn labels) fills label gaps the roster does not carry, and identities
9963/// known only to the spawn registry (e.g. members of agent-created sub-mobs)
9964/// are primed registry-only so lineage rules resolve for them too.
9965pub(crate) async fn prime_access_cache_from_runtime(
9966    runtime: &MobRuntime,
9967    access: &AccessController,
9968) {
9969    if !access.enabled() {
9970        return;
9971    }
9972    let registered = runtime.console_identity_labels().await;
9973    prime_access_cache_from_handle_with_registry(&runtime.handle(), access, &registered).await;
9974}
9975
9976pub(crate) async fn prime_access_cache_from_handle(handle: &MobHandle, access: &AccessController) {
9977    if !access.enabled() {
9978        return;
9979    }
9980    prime_access_cache_from_handle_with_registry(handle, access, &BTreeMap::new()).await;
9981}
9982
9983async fn prime_access_cache_from_handle_with_registry(
9984    handle: &MobHandle,
9985    access: &AccessController,
9986    registered: &BTreeMap<String, BTreeMap<String, String>>,
9987) {
9988    let mut registry_only: BTreeMap<&String, &BTreeMap<String, String>> =
9989        registered.iter().collect();
9990    for entry in handle.list_all_members().await {
9991        // Roster ids are comms-safe encodings (meerkat 0.7); the console/ABAC
9992        // attribute space uses the public alias.
9993        let member_identity =
9994            crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
9995        // Console identity may be overridden by a label, mirroring
9996        // `console_member_console_identity`.
9997        let console_identity = crate::member_comms_id::durable_identity_label(&entry.labels)
9998            .map_or_else(|| member_identity.clone(), ToString::to_string);
9999        let mut labels = entry.labels.clone();
10000        // Roster labels are caller-controlled; only the spawn registry may
10001        // assert lineage, or a spoofed `spawned_by` could mint inherited
10002        // visibility (or hide a member behind a denied parent).
10003        crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
10004        if let Some(spawn_labels) = registry_only.remove(&console_identity) {
10005            crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
10006        }
10007        access.record_agent_attributes(AgentResourceAttributes {
10008            identity: console_identity,
10009            agent_id: Some(member_identity),
10010            role: Some(entry.role.to_string()),
10011            labels,
10012        });
10013    }
10014    for (identity, spawn_labels) in registry_only {
10015        access.record_agent_attributes(AgentResourceAttributes {
10016            identity: identity.clone(),
10017            agent_id: None,
10018            role: spawn_labels.get("role").cloned(),
10019            labels: spawn_labels.clone(),
10020        });
10021    }
10022}
10023
10024async fn project_console_members_from_handle(
10025    handle: &MobHandle,
10026    host_identity: Option<&str>,
10027    source_mob_id: Option<&str>,
10028    read_model: &ConsoleSnapshotReadModelState,
10029    registered_labels: &BTreeMap<String, BTreeMap<String, String>>,
10030) -> (Vec<ConsoleMember>, BTreeMap<String, String>) {
10031    // Meerkat 0.7: lifecycle status is machine-projected onto
10032    // `MobMemberListEntry`; the structural `list_all_members` roster no longer
10033    // carries it, so the console projection reads the operational list.
10034    let entries = handle.list_members_including_retiring().await;
10035    let mut members = Vec::with_capacity(entries.len());
10036    let mut session_owner_by_id = BTreeMap::new();
10037    for entry in &entries {
10038        // Roster ids are comms-safe encodings (meerkat 0.7 MemberCommsName);
10039        // console members surface the public alias.
10040        let identity =
10041            crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
10042        let session_id = read_model.session_id_by_identity.get(&identity).cloned();
10043        if let Some(session_id) = session_id.as_ref() {
10044            session_owner_by_id.insert(session_id.clone(), identity.clone());
10045        }
10046        let model_capabilities =
10047            model_capabilities_for_role(handle.definition(), entry.role.as_str());
10048        let mut labels = entry.labels.clone();
10049        // Spawn-time console metadata (group/display labels, spawned_by
10050        // lineage) fills gaps the roster does not carry; roster labels win —
10051        // except lineage, which only the spawn registry may assert (this
10052        // projection feeds the experience-path ABAC attribute rebuild).
10053        crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
10054        let console_identity_key = crate::member_comms_id::durable_identity_label(&entry.labels)
10055            .unwrap_or(identity.as_str());
10056        if let Some(spawn_labels) = registered_labels.get(console_identity_key) {
10057            crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
10058        }
10059        if let Some(host_identity) = host_identity {
10060            labels
10061                .entry("delegate_host_identity".to_string())
10062                .or_insert_with(|| host_identity.to_string());
10063            labels
10064                .entry("group".to_string())
10065                .or_insert_with(|| "Coordinators".to_string());
10066        }
10067        if let Some(source_mob_id) = source_mob_id {
10068            labels
10069                .entry("source_mob_id".to_string())
10070                .or_insert_with(|| source_mob_id.to_string());
10071        }
10072        let mut wired_to: Vec<String> = entry
10073            .wired_to
10074            .iter()
10075            .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
10076            .collect();
10077        if let Some(host_identity) = host_identity
10078            && !wired_to.iter().any(|peer| peer == host_identity)
10079        {
10080            wired_to.push(host_identity.to_string());
10081        }
10082        members.push(ConsoleMember {
10083            agent_identity: identity,
10084            role: entry.role.to_string(),
10085            state: member_status_state_string(entry.status),
10086            model_capabilities,
10087            runtime_mode: Some(entry.runtime_mode.to_string()),
10088            session_id,
10089            wired_to,
10090            labels,
10091            progress: None,
10092        });
10093    }
10094    attach_member_progress(handle, &entries, &mut members).await;
10095    (members, session_owner_by_id)
10096}
10097
10098/// Console progress projection cap: `member_status` is an actor-mailbox
10099/// roundtrip per member, and the experience endpoint refreshes every ~15s
10100/// (plus SSE-triggered refetches). Small durable rosters (HomeCore: 16) get
10101/// liveness for free; whole-mob fan-out at OB3 scale (hundreds of members)
10102/// would compete with real work on the mob actor mailbox, so large rosters
10103/// skip the projection unless the operator raises the cap.
10104/// `MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP` overrides; `0` disables entirely.
10105const CONSOLE_PROGRESS_MEMBER_CAP: usize = 64;
10106
10107fn console_progress_member_cap() -> usize {
10108    std::env::var("MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP")
10109        .ok()
10110        .and_then(|value| value.trim().parse::<usize>().ok())
10111        .unwrap_or(CONSOLE_PROGRESS_MEMBER_CAP)
10112}
10113
10114/// Attach the machine-owned liveness projection (meerkat 0.7.29+, ask 14) to
10115/// non-final console members. Best-effort: a failed or absent snapshot leaves
10116/// `progress: None` — the console renders nothing rather than a lie.
10117async fn attach_member_progress(
10118    handle: &MobHandle,
10119    entries: &[meerkat_mob::runtime::MobMemberListEntry],
10120    members: &mut [ConsoleMember],
10121) {
10122    let cap = console_progress_member_cap();
10123    if entries.len() > cap {
10124        tracing::debug!(
10125            member_count = entries.len(),
10126            cap,
10127            "skipping console progress projection: roster exceeds \
10128             MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP"
10129        );
10130        return;
10131    }
10132    for (entry, member) in entries.iter().zip(members.iter_mut()) {
10133        if entry.is_final {
10134            continue;
10135        }
10136        let Ok(snapshot) = handle.member_status(&entry.agent_identity).await else {
10137            continue;
10138        };
10139        member.progress = snapshot
10140            .progress
10141            .as_ref()
10142            .and_then(|progress| serde_json::to_value(progress).ok());
10143    }
10144}
10145
10146async fn build_aggregator_live_snapshot(
10147    aggregator: &MobKitConsoleAggregator,
10148    config_module_ids: &[String],
10149) -> Result<ConsoleLiveSnapshot, Box<dyn std::error::Error + Send + Sync>> {
10150    let identities = Box::pin(aggregator.list_identities()).await?;
10151    let mut members = Vec::with_capacity(identities.len());
10152    for identity in &identities {
10153        let mut labels = identity.labels.clone();
10154        labels
10155            .entry("display_name".to_string())
10156            .or_insert_with(|| identity.display_name.clone());
10157        labels
10158            .entry("addressable".to_string())
10159            .or_insert_with(|| identity.addressable.to_string());
10160        // Keep /console/experience on the cached identity read model. Live
10161        // peer inspection walks the actor-backed mob/member path, and a stuck
10162        // turn must not make the console shell fail to load.
10163        let wired_to = identity.topology_peers.clone();
10164        members.push(ConsoleMember {
10165            agent_identity: identity.identity.clone(),
10166            role: labels
10167                .get("role")
10168                .cloned()
10169                .unwrap_or_else(|| "identity".to_string()),
10170            state: identity.health.clone(),
10171            model_capabilities: ConsoleModelCapabilities::default(),
10172            runtime_mode: Some("console_aggregator".to_string()),
10173            session_id: identity.session_id.clone(),
10174            wired_to,
10175            labels,
10176            progress: None,
10177        });
10178    }
10179    members.sort_by(|left, right| left.agent_identity.cmp(&right.agent_identity));
10180    let agents = members
10181        .iter()
10182        .map(|member| ConsoleAgentLiveSnapshot {
10183            agent_id: member.agent_identity.clone(),
10184            member_id: member.agent_identity.clone(),
10185            label: member
10186                .labels
10187                .get("display_name")
10188                .cloned()
10189                .unwrap_or_else(|| member.agent_identity.clone()),
10190            kind: "meerkat".to_string(),
10191            identity: Some(member.agent_identity.clone()),
10192            role: Some(member.role.clone()),
10193            state: Some(member.state.clone()),
10194            session_id: member.session_id.clone(),
10195            model_capabilities: member.model_capabilities.clone(),
10196            response_phase: None,
10197            watched: None,
10198            alert_level: None,
10199            degraded: None,
10200            degraded_reason: None,
10201        })
10202        .collect::<Vec<_>>();
10203    let loaded_modules = if config_module_ids.is_empty() {
10204        members
10205            .iter()
10206            .map(|member| member.agent_identity.clone())
10207            .collect()
10208    } else {
10209        config_module_ids.to_vec()
10210    };
10211    Ok(ConsoleLiveSnapshot::new(
10212        Some("console-aggregator".to_string()),
10213        true,
10214        loaded_modules,
10215        agents,
10216        members,
10217        true,
10218    ))
10219}
10220
10221pub async fn console_frontend_index_handler() -> impl IntoResponse {
10222    (
10223        [
10224            (header::CONTENT_TYPE, "text/html; charset=utf-8"),
10225            (header::CACHE_CONTROL, "no-store"),
10226        ],
10227        CONSOLE_FRONTEND_INDEX_HTML,
10228    )
10229}
10230
10231pub async fn console_frontend_app_js_handler() -> impl IntoResponse {
10232    (
10233        [
10234            (
10235                header::CONTENT_TYPE,
10236                "application/javascript; charset=utf-8",
10237            ),
10238            (header::CACHE_CONTROL, "no-store"),
10239        ],
10240        CONSOLE_FRONTEND_APP_JS,
10241    )
10242}
10243
10244pub async fn console_frontend_app_css_handler() -> impl IntoResponse {
10245    (
10246        [
10247            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
10248            (header::CACHE_CONTROL, "no-store"),
10249        ],
10250        CONSOLE_FRONTEND_APP_CSS,
10251    )
10252}
10253
10254#[cfg(test)]
10255#[allow(clippy::expect_used, clippy::large_futures)]
10256mod tests {
10257    use super::ConsoleTimelineHttpQuery;
10258    use super::{
10259        ConsoleSnapshotReadModel, ConsoleSnapshotReadModelState, MAX_MULTIPART_BODY_BYTES,
10260        MAX_MULTIPART_IMAGE_BYTES, MultipartImageUpload, apply_console_visibility_policy,
10261        build_aggregator_live_snapshot, collect_console_snapshot_read_model,
10262        console_runtime_alias_generation, console_send_identity_first,
10263        console_send_with_identity_first_fallback, console_timeline_replay_unavailable_response,
10264        cursor_is_after, dedupe_console_members_by_identity, externalize_image_upload_placeholders,
10265        externalize_single_image_upload, handle_console_aggregator_rpc, handle_console_runtime_rpc,
10266        handle_console_runtime_rpc_with_visibility, member_id_matches_durable_identity,
10267        project_console_members_from_handle, query_timeline_snapshot, timeline_query_from_http,
10268    };
10269    use crate::access::{
10270        ACTION_AGENT_MEMORY_DELETE, ACTION_AGENT_MEMORY_READ, ACTION_AGENT_MEMORY_WRITE,
10271        ACTION_AGENT_SEND, ACTION_AGENT_VIEW, AccessController,
10272    };
10273    use crate::blob_store::{BinaryBlobStore, ObjectStoreBlobStore};
10274    use crate::console_aggregator::{
10275        AllowAllConsoleVisibilityPolicy, ConsoleIdentityRecord,
10276        HideImplicitDelegateMembersConsoleVisibilityPolicy,
10277    };
10278    use crate::console_aggregator::{
10279        ConsoleCursor, ConsoleFrameSource, ConsoleFrameSourceKind, ConsoleFrameStatus,
10280        ConsoleTimelineQuery, ConsoleTimelineWindowQuery, ConsoleVisibilityPolicy,
10281        MobKitConsoleAggregator, NewConsoleFrame,
10282    };
10283    use crate::identity_first::contracts::{ContinuityStore, LeaseProvider};
10284    use crate::identity_first::{
10285        AgentAddressability, AgentBuildDraft, AgentIdentity, AgentMemoryConfig,
10286        AgentMemoryRuntimeInjector, AgentRuntimeId, BridgeError, CheckpointVersion,
10287        ContinuityGeneration, ContinuityRecord, DurabilityPolicy, DurableAgentSpec, FencingToken,
10288        IdentityLifecycleState, IdentityRuntime, IdentityRuntimeConfig, LeaseAcquireResult,
10289        LeaseGrant, LocalContinuityStore, LocalLeaseProvider, ManagedPeerEdge,
10290        MarkdownAgentMemoryStore, ResumeSessionOutcome, SessionBridge, SessionSnapshot,
10291    };
10292    use crate::mob_handle_runtime::{MobRuntime, model_capabilities_for_role};
10293    use crate::rpc::{JSONRPC_VERSION, JsonRpcRequest};
10294    use crate::runtime::{ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember};
10295    use crate::unified_runtime::ConsoleEventStore;
10296    use crate::{MobBootstrapOptions, MobBootstrapSpec};
10297    use bytes::Bytes;
10298    use meerkat::{AgentFactory, Config, build_ephemeral_service};
10299    use meerkat_client::TestClient;
10300    use meerkat_core::types::HandlingMode;
10301    use meerkat_mob::ProfileName;
10302    use meerkat_mob::{MobDefinition, MobStorage, SpawnMemberSpec};
10303    use serde_json::{Value, json};
10304    use std::collections::BTreeMap;
10305    use std::sync::atomic::{AtomicUsize, Ordering};
10306    use std::sync::{Arc, Mutex};
10307    use std::time::Duration;
10308
10309    struct BlockingIdentityBridge {
10310        deliver_calls: Arc<AtomicUsize>,
10311    }
10312
10313    struct RecordingIdentityBridge {
10314        session_id: meerkat_core::types::SessionId,
10315        handling_modes: Arc<Mutex<Vec<HandlingMode>>>,
10316    }
10317
10318    #[async_trait::async_trait]
10319    impl SessionBridge for BlockingIdentityBridge {
10320        async fn create_session(
10321            &self,
10322            _identity: &AgentIdentity,
10323            _runtime_id: &AgentRuntimeId,
10324            _spec: &DurableAgentSpec,
10325            _draft: &AgentBuildDraft,
10326            session_id: &meerkat_core::types::SessionId,
10327        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
10328            Ok(session_id.clone())
10329        }
10330
10331        async fn resume_session(
10332            &self,
10333            _identity: &AgentIdentity,
10334            _runtime_id: &AgentRuntimeId,
10335            _spec: &DurableAgentSpec,
10336            _draft: &AgentBuildDraft,
10337            session_id: &meerkat_core::types::SessionId,
10338            _snapshot: &SessionSnapshot,
10339        ) -> Result<ResumeSessionOutcome, BridgeError> {
10340            Ok(ResumeSessionOutcome::Resumed {
10341                session_id: session_id.clone(),
10342            })
10343        }
10344
10345        async fn deliver_admitted(
10346            &self,
10347            _runtime_id: &AgentRuntimeId,
10348            _delivery: crate::identity_first::BridgeDelivery,
10349        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
10350            self.deliver_calls.fetch_add(1, Ordering::SeqCst);
10351            std::future::pending().await
10352        }
10353
10354        async fn checkpoint_session(
10355            &self,
10356            _runtime_id: &AgentRuntimeId,
10357            _session_id: &meerkat_core::types::SessionId,
10358        ) -> Result<SessionSnapshot, BridgeError> {
10359            Err(BridgeError::Mob("checkpoint not used in test".to_string()))
10360        }
10361
10362        async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
10363            Ok(())
10364        }
10365    }
10366
10367    #[async_trait::async_trait]
10368    impl SessionBridge for RecordingIdentityBridge {
10369        async fn create_session(
10370            &self,
10371            _identity: &AgentIdentity,
10372            _runtime_id: &AgentRuntimeId,
10373            _spec: &DurableAgentSpec,
10374            _draft: &AgentBuildDraft,
10375            session_id: &meerkat_core::types::SessionId,
10376        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
10377            Ok(session_id.clone())
10378        }
10379
10380        async fn resume_session(
10381            &self,
10382            _identity: &AgentIdentity,
10383            _runtime_id: &AgentRuntimeId,
10384            _spec: &DurableAgentSpec,
10385            _draft: &AgentBuildDraft,
10386            session_id: &meerkat_core::types::SessionId,
10387            _snapshot: &SessionSnapshot,
10388        ) -> Result<ResumeSessionOutcome, BridgeError> {
10389            Ok(ResumeSessionOutcome::Resumed {
10390                session_id: session_id.clone(),
10391            })
10392        }
10393
10394        async fn deliver_admitted(
10395            &self,
10396            _runtime_id: &AgentRuntimeId,
10397            delivery: crate::identity_first::BridgeDelivery,
10398        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
10399            self.handling_modes
10400                .lock()
10401                .map_err(|_| BridgeError::Mob("handling modes mutex poisoned".to_string()))?
10402                .push(delivery.handling_mode);
10403            Ok(self.session_id.clone())
10404        }
10405
10406        async fn checkpoint_session(
10407            &self,
10408            _runtime_id: &AgentRuntimeId,
10409            _session_id: &meerkat_core::types::SessionId,
10410        ) -> Result<SessionSnapshot, BridgeError> {
10411            Err(BridgeError::Mob("checkpoint not used in test".to_string()))
10412        }
10413
10414        async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
10415            Ok(())
10416        }
10417    }
10418
10419    async fn build_empty_console_test_runtime(
10420        mob_id: &str,
10421    ) -> Result<(tempfile::TempDir, MobRuntime), Box<dyn std::error::Error + Send + Sync>> {
10422        let temp_dir = tempfile::tempdir()?;
10423        let session_path = temp_dir.path().join("sessions");
10424        std::fs::create_dir_all(&session_path)?;
10425        let factory = AgentFactory::new(&session_path).comms(true);
10426        let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
10427        let definition = MobDefinition::from_toml(&format!(
10428            r#"
10429[mob]
10430id = "{mob_id}"
10431
10432[profiles.worker]
10433model = "gpt-5.5"
10434external_addressable = true
10435
10436[profiles.worker.tools]
10437comms = true
10438"#
10439        ))?;
10440        let runtime = MobRuntime::bootstrap(
10441            MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
10442                .with_options(MobBootstrapOptions {
10443                    allow_ephemeral_sessions: true,
10444                    notify_orchestrator_on_resume: true,
10445                    default_llm_client: Some(Arc::new(TestClient::default())),
10446                }),
10447        )
10448        .await?;
10449        Ok((temp_dir, runtime))
10450    }
10451
10452    fn rpc_request(method: &str) -> JsonRpcRequest {
10453        JsonRpcRequest {
10454            jsonrpc: JSONRPC_VERSION.to_string(),
10455            id: Some(json!(1)),
10456            method: method.to_string(),
10457            params: json!({}),
10458        }
10459    }
10460
10461    fn rpc_request_with_params(method: &str, params: Value) -> JsonRpcRequest {
10462        JsonRpcRequest {
10463            jsonrpc: JSONRPC_VERSION.to_string(),
10464            id: Some(json!(1)),
10465            method: method.to_string(),
10466            params,
10467        }
10468    }
10469
10470    #[tokio::test]
10471    async fn console_storage_doctor_is_a_read_method_and_status_advertises_it()
10472    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10473        let (_temp_dir, runtime) =
10474            build_empty_console_test_runtime("console-storage-doctor").await?;
10475
10476        let doctor_rpc = |params: Value| {
10477            Box::pin(handle_console_runtime_rpc(
10478                &runtime,
10479                None,
10480                None,
10481                None,
10482                None,
10483                None,
10484                None,
10485                None,
10486                None,
10487                rpc_request_with_params("mobkit/storage/doctor", params),
10488                true,
10489            ))
10490        };
10491
10492        // The status payload advertises the doctor affordance.
10493        let status = Box::pin(handle_console_runtime_rpc(
10494            &runtime,
10495            None,
10496            None,
10497            None,
10498            None,
10499            None,
10500            None,
10501            None,
10502            None,
10503            rpc_request_with_params("mobkit/status", json!({})),
10504            true,
10505        ))
10506        .await;
10507        assert_eq!(
10508            status["result"]["storage"]["doctor_available"],
10509            json!(true),
10510            "{status:#?}"
10511        );
10512
10513        // Advertised on the console capabilities list.
10514        let caps = Box::pin(handle_console_runtime_rpc(
10515            &runtime,
10516            None,
10517            None,
10518            None,
10519            None,
10520            None,
10521            None,
10522            None,
10523            None,
10524            rpc_request_with_params("mobkit/capabilities", json!({})),
10525            true,
10526        ))
10527        .await;
10528        let methods = caps["result"]["methods"].as_array().expect("methods");
10529        assert!(
10530            methods
10531                .iter()
10532                .any(|m| m.as_str() == Some("mobkit/storage/doctor")),
10533            "{methods:?}"
10534        );
10535
10536        // Missing state_dir → the typed capability error.
10537        let missing = doctor_rpc(json!({})).await;
10538        assert_eq!(missing["error"]["code"], json!(-32004), "{missing:#?}");
10539
10540        // A fixture directory with a filename twin diagnoses over the wire.
10541        let fixture = tempfile::tempdir()?;
10542        std::fs::write(fixture.path().join("sessions.db"), b"")?;
10543        std::fs::write(fixture.path().join("sessions.sqlite"), b"")?;
10544        let resp = doctor_rpc(json!({ "state_dir": fixture.path() })).await;
10545        let findings = resp["result"]["diagnosis"]["findings"]
10546            .as_array()
10547            .expect("findings array");
10548        assert!(
10549            findings
10550                .iter()
10551                .any(|f| f["code"] == "file-name-twins" && f["severity"] == "error"),
10552            "{findings:#?}"
10553        );
10554        Ok(())
10555    }
10556
10557    #[tokio::test]
10558    async fn read_only_aggregator_capabilities_omit_mutating_methods() {
10559        let response = handle_console_aggregator_rpc(
10560            None,
10561            rpc_request("mobkit/capabilities"),
10562            true,
10563            true,
10564            None,
10565            None,
10566        )
10567        .await;
10568
10569        let methods = response["result"]["methods"]
10570            .as_array()
10571            .expect("capabilities methods");
10572        assert!(
10573            methods.iter().all(|method| method != "mobkit/console/send"),
10574            "read-only capabilities must omit send: {methods:#?}"
10575        );
10576        assert_eq!(response["result"]["read_only"], json!(true));
10577        assert_eq!(
10578            response["result"]["runtime_capabilities"]["can_send_messages"],
10579            json!(false)
10580        );
10581    }
10582
10583    #[tokio::test]
10584    async fn read_only_aggregator_denies_direct_mutating_rpc() {
10585        let response = handle_console_aggregator_rpc(
10586            None,
10587            rpc_request_with_params(
10588                "mobkit/console/send",
10589                json!({
10590                    "identity": "worker",
10591                    "content": "hello",
10592                    "origin": "test",
10593                    "idempotency_key": "read-only-send",
10594                }),
10595            ),
10596            true,
10597            true,
10598            None,
10599            None,
10600        )
10601        .await;
10602
10603        assert_eq!(response["result"], Value::Null);
10604        assert_eq!(response["error"]["code"], json!(-32010));
10605        assert_eq!(response["error"]["data"]["kind"], json!("read_only"));
10606    }
10607
10608    #[test]
10609    fn read_only_mutating_methods_include_state_draining_collect_completed() {
10610        assert!(super::is_console_mutating_rpc_method(
10611            "mobkit/collect_completed"
10612        ));
10613    }
10614
10615    #[test]
10616    fn authenticated_gating_approver_comes_from_console_principal() {
10617        let forged_params = json!({ "approver_id": "forged-browser-value" });
10618
10619        assert_eq!(
10620            super::resolve_gating_approver_id(&forged_params, Some("admin@example.com"),),
10621            Ok("admin@example.com".to_string()),
10622        );
10623    }
10624
10625    #[test]
10626    fn unauthenticated_local_gating_approver_requires_request_param() {
10627        assert_eq!(
10628            super::resolve_gating_approver_id(&json!({ "approver_id": " local-operator " }), None,),
10629            Ok("local-operator".to_string()),
10630        );
10631        assert_eq!(
10632            super::resolve_gating_approver_id(&json!({}), None),
10633            Err("approver_id required"),
10634        );
10635    }
10636
10637    /// DECISION (2026-07-06, meerkat-studio ask K2): console JSON-RPC internal
10638    /// errors DO disclose the failure reason. Every caller that reaches these
10639    /// handlers is an operator by construction — an auth-gated console 401s
10640    /// unauthenticated callers before dispatch, and an open console is a
10641    /// trusted-local deployment choice — while the previous redaction made
10642    /// every -32000 undiagnosable from the client (the K1 retire/respawn
10643    /// failures cost the reporter a day). The `error` field stays a stable
10644    /// kind discriminator; `console_send_public_message` keeps ITS redaction
10645    /// because that surface can reflect into agent-visible space.
10646    #[test]
10647    fn internal_error_carries_detail_and_stable_kind() {
10648        let response = super::internal_error(json!(7), "retire failed: backend says no");
10649
10650        assert_eq!(response["error"]["code"], json!(-32000));
10651        assert_eq!(
10652            response["error"]["message"],
10653            json!("retire failed: backend says no")
10654        );
10655        assert_eq!(response["error"]["data"]["error"], json!("internal_error"));
10656        assert_eq!(
10657            response["error"]["data"]["detail"],
10658            json!("retire failed: backend says no")
10659        );
10660    }
10661
10662    #[test]
10663    fn console_send_public_message_hides_dispatch_details() {
10664        let message = super::console_send_public_message(
10665            &crate::console_aggregator::ConsoleSendError::Dispatch(
10666                "secret backend DSN".to_string(),
10667            ),
10668        );
10669
10670        assert_eq!(message, "console send failed");
10671        assert!(!message.contains("secret backend DSN"));
10672    }
10673
10674    #[test]
10675    fn console_send_json_rpc_error_hides_backend_details() {
10676        let response = super::console_send_rpc_error(
10677            json!(7),
10678            crate::console_aggregator::ConsoleSendError::Dispatch("secret backend DSN".to_string()),
10679        );
10680
10681        assert_eq!(response["error"]["code"], json!(-32000));
10682        assert_eq!(response["error"]["message"], json!("console send failed"));
10683        assert!(!response.to_string().contains("secret backend DSN"));
10684    }
10685
10686    #[test]
10687    fn console_timeline_replay_error_hides_backend_details() {
10688        let response = super::console_timeline_replay_unavailable_response(
10689            json!(7),
10690            Box::new(std::io::Error::other("secret backend DSN")),
10691            None,
10692            None,
10693        );
10694
10695        assert_eq!(
10696            response["error"]["code"],
10697            json!(crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE)
10698        );
10699        assert_eq!(
10700            response["error"]["message"],
10701            json!("timeline replay unavailable")
10702        );
10703        assert!(!response.to_string().contains("secret backend DSN"));
10704    }
10705
10706    #[test]
10707    fn gating_decision_error_hides_backend_details() {
10708        let response = super::gating_decision_failed_error(json!(7), "secret backend DSN");
10709
10710        assert_eq!(response["error"]["code"], json!(-32602));
10711        assert_eq!(
10712            response["error"]["message"],
10713            json!("gating decision failed")
10714        );
10715        assert!(!response.to_string().contains("secret backend DSN"));
10716    }
10717
10718    #[tokio::test]
10719    async fn console_runtime_identity_controls_resolve_durable_member_aliases()
10720    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10721        let (_temp_dir, runtime) =
10722            build_empty_console_test_runtime("console-identity-control-alias").await?;
10723        let mut labels = BTreeMap::new();
10724        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10725        labels.insert("display_name".to_string(), "Review Agent".to_string());
10726        runtime
10727            .handle()
10728            .spawn_spec(
10729                SpawnMemberSpec::from_wire(
10730                    "worker".to_string(),
10731                    // meerkat 0.7: roster ids are comms-safe encodings of the
10732                    // public alias (MemberCommsName rejects ":").
10733                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10734                    Some("You are Review Agent.".into()),
10735                    None,
10736                    None,
10737                )
10738                .with_labels(labels),
10739            )
10740            .await?;
10741
10742        let durable_status = Box::pin(handle_console_runtime_rpc(
10743            &runtime,
10744            None,
10745            None,
10746            None,
10747            None,
10748            None,
10749            None,
10750            None,
10751            None,
10752            rpc_request_with_params(
10753                "mobkit/status_identity",
10754                json!({ "identity": "review:singleton" }),
10755            ),
10756            true,
10757        ))
10758        .await;
10759        assert_eq!(durable_status["error"], Value::Null);
10760        assert_eq!(
10761            durable_status["result"]["identity"],
10762            json!("review:singleton")
10763        );
10764        assert_eq!(
10765            durable_status["result"]["agent_runtime_id"],
10766            json!("rt:review:singleton:0")
10767        );
10768
10769        let runtime_id_status = Box::pin(handle_console_runtime_rpc(
10770            &runtime,
10771            None,
10772            None,
10773            None,
10774            None,
10775            None,
10776            None,
10777            None,
10778            None,
10779            rpc_request_with_params(
10780                "mobkit/status_identity",
10781                json!({ "identity": "rt:review:singleton:0" }),
10782            ),
10783            true,
10784        ))
10785        .await;
10786        assert_eq!(runtime_id_status["error"], Value::Null);
10787        assert_eq!(
10788            runtime_id_status["result"]["identity"],
10789            json!("review:singleton")
10790        );
10791
10792        let runtime_id_inspect = Box::pin(handle_console_runtime_rpc(
10793            &runtime,
10794            None,
10795            None,
10796            None,
10797            None,
10798            None,
10799            None,
10800            None,
10801            None,
10802            rpc_request_with_params(
10803                "mobkit/inspect_identity",
10804                json!({ "identity": "rt:review:singleton:0" }),
10805            ),
10806            true,
10807        ))
10808        .await;
10809        assert_eq!(runtime_id_inspect["error"], Value::Null);
10810        assert_eq!(
10811            runtime_id_inspect["result"]["identity"],
10812            json!("review:singleton")
10813        );
10814
10815        let respawn = Box::pin(handle_console_runtime_rpc(
10816            &runtime,
10817            None,
10818            None,
10819            None,
10820            Some(ConsoleEventStore::new()),
10821            None,
10822            None,
10823            None,
10824            None,
10825            rpc_request_with_params("mobkit/respawn", json!({ "identity": "review:singleton" })),
10826            true,
10827        ))
10828        .await;
10829        assert_eq!(respawn["error"], Value::Null);
10830        assert_eq!(respawn["result"]["identity"], json!("review:singleton"));
10831        assert_eq!(
10832            respawn["result"]["agent_runtime_id"],
10833            json!("rt:review:singleton:0")
10834        );
10835
10836        // Without an identity-first runtime, reset degrades to the live
10837        // member respawn fallback instead of erroring; the console Reset
10838        // button stays functional on plain runtimes.
10839        let reset_without_identity_runtime = Box::pin(handle_console_runtime_rpc(
10840            &runtime,
10841            None,
10842            None,
10843            None,
10844            Some(ConsoleEventStore::new()),
10845            None,
10846            None,
10847            None,
10848            None,
10849            rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
10850            true,
10851        ))
10852        .await;
10853        assert_eq!(
10854            reset_without_identity_runtime["error"],
10855            Value::Null,
10856            "{reset_without_identity_runtime:#?}"
10857        );
10858        assert_eq!(
10859            reset_without_identity_runtime["result"]["identity"],
10860            json!("review:singleton")
10861        );
10862        assert_eq!(
10863            reset_without_identity_runtime["result"]["agent_runtime_id"],
10864            json!("rt:review:singleton:0")
10865        );
10866
10867        let reset_unknown_identity = Box::pin(handle_console_runtime_rpc(
10868            &runtime,
10869            None,
10870            None,
10871            None,
10872            Some(ConsoleEventStore::new()),
10873            None,
10874            None,
10875            None,
10876            None,
10877            rpc_request_with_params("mobkit/reset", json!({ "identity": "missing:member" })),
10878            true,
10879        ))
10880        .await;
10881        assert_ne!(reset_unknown_identity["error"], Value::Null);
10882        assert!(
10883            reset_unknown_identity["error"]["message"]
10884                .as_str()
10885                .unwrap_or_default()
10886                .contains("identity not found")
10887        );
10888
10889        let _ = runtime.handle().stop().await;
10890        Ok(())
10891    }
10892
10893    #[tokio::test]
10894    async fn console_runtime_identity_controls_reject_ambiguous_live_label_aliases()
10895    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10896        let (_temp_dir, runtime) =
10897            build_empty_console_test_runtime("console-identity-ambiguous-live-alias").await?;
10898        for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
10899            let mut labels = BTreeMap::new();
10900            labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10901            runtime
10902                .handle()
10903                .spawn_spec(
10904                    SpawnMemberSpec::from_wire(
10905                        "worker".to_string(),
10906                        // meerkat 0.7: roster ids are comms-safe encodings of
10907                        // the public alias (MemberCommsName rejects ":").
10908                        crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
10909                        Some("You are a duplicate Review Agent.".into()),
10910                        None,
10911                        None,
10912                    )
10913                    .with_labels(labels),
10914                )
10915                .await?;
10916        }
10917
10918        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10919            for method in [
10920                "mobkit/status_identity",
10921                "mobkit/inspect_identity",
10922                "mobkit/retire",
10923                "mobkit/respawn",
10924                "mobkit/reset",
10925            ] {
10926                let response = Box::pin(handle_console_runtime_rpc(
10927                    &runtime,
10928                    None,
10929                    None,
10930                    None,
10931                    Some(ConsoleEventStore::new()),
10932                    None,
10933                    None,
10934                    None,
10935                    None,
10936                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
10937                    true,
10938                ))
10939                .await;
10940                assert_ne!(
10941                    response["error"],
10942                    Value::Null,
10943                    "{method} must reject ambiguous live alias for {requested_identity}"
10944                );
10945                assert_eq!(
10946                    response["error"]["data"]["kind"],
10947                    json!("ambiguous_live_identity_alias"),
10948                    "unexpected response for {method}/{requested_identity}: {response:#?}"
10949                );
10950            }
10951        }
10952
10953        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10954            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10955            lease_provider: Arc::new(LocalLeaseProvider::new()),
10956            runtime_instance_id: "console-identity-ambiguous-live-alias".to_string(),
10957            has_runtime_store: true,
10958            durability_policy: DurabilityPolicy::SyncWriteThrough,
10959            bridge: None,
10960            default_timeout: None,
10961        }));
10962        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10963            for method in ["mobkit/reset", "mobkit/delete_identity"] {
10964                let response = Box::pin(handle_console_runtime_rpc(
10965                    &runtime,
10966                    None,
10967                    None,
10968                    None,
10969                    Some(ConsoleEventStore::new()),
10970                    None,
10971                    Some(identity_runtime.clone()),
10972                    None,
10973                    None,
10974                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
10975                    true,
10976                ))
10977                .await;
10978                assert_ne!(
10979                    response["error"],
10980                    Value::Null,
10981                    "{method} must reject ambiguous live alias for {requested_identity}"
10982                );
10983                assert_eq!(
10984                    response["error"]["data"]["kind"],
10985                    json!("ambiguous_live_identity_alias"),
10986                    "unexpected response for {method}/{requested_identity}: {response:#?}"
10987                );
10988            }
10989        }
10990
10991        let _ = runtime.handle().stop().await;
10992        Ok(())
10993    }
10994
10995    #[tokio::test]
10996    async fn console_runtime_durable_identity_prefers_registered_live_over_duplicate_labels()
10997    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10998        let (_temp_dir, runtime) =
10999            build_empty_console_test_runtime("console-durable-wins-duplicate-live-labels").await?;
11000        for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
11001            let mut labels = BTreeMap::new();
11002            labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11003            runtime
11004                .handle()
11005                .spawn_spec(
11006                    SpawnMemberSpec::from_wire(
11007                        "worker".to_string(),
11008                        // meerkat 0.7: roster ids are comms-safe encodings of
11009                        // the public alias (MemberCommsName rejects ":").
11010                        crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
11011                        Some("You are a Review Agent candidate.".into()),
11012                        None,
11013                        None,
11014                    )
11015                    .with_labels(labels),
11016                )
11017                .await?;
11018        }
11019
11020        let store = Arc::new(LocalContinuityStore::in_memory()?);
11021        let lease_provider = Arc::new(LocalLeaseProvider::new());
11022        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11023            continuity_store: store.clone(),
11024            lease_provider: lease_provider.clone(),
11025            runtime_instance_id: "test-runtime".to_string(),
11026            has_runtime_store: true,
11027            durability_policy: DurabilityPolicy::SyncWriteThrough,
11028            bridge: None,
11029            default_timeout: None,
11030        }));
11031        let identity = AgentIdentity::parse("review:singleton")?;
11032        let registered_session_id = runtime
11033            .handle()
11034            .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
11035                "rt:review:singleton:0",
11036            ))
11037            .await
11038            .unwrap_or_else(meerkat_core::types::SessionId::new);
11039        let record = ContinuityRecord {
11040            identity: identity.clone(),
11041            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11042            session_id: registered_session_id,
11043            generation: ContinuityGeneration::new(0),
11044            checkpoint_version: CheckpointVersion::new(0),
11045        };
11046        let grants = lease_provider
11047            .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
11048            .await?;
11049        let grant = match grants.get(&identity).cloned() {
11050            Some(LeaseAcquireResult::Acquired(grant)) => grant,
11051            other => return Err(format!("expected acquired lease, got {other:?}").into()),
11052        };
11053        store
11054            .upsert_continuity_record(&record, grant.fencing_token)
11055            .await?;
11056        identity_runtime
11057            .register(
11058                DurableAgentSpec {
11059                    identity: identity.clone(),
11060                    profile: ProfileName::from("worker"),
11061                    addressability: AgentAddressability::Addressable,
11062                    display_name: None,
11063                    labels: BTreeMap::new(),
11064                    context: None,
11065                    additional_instructions: Vec::new(),
11066                    initial_message: None,
11067                    runtime_mode_override: None,
11068                    backend: None,
11069                    binding: None,
11070                },
11071                IdentityLifecycleState::Active,
11072                Some(record),
11073                Some(grant),
11074            )
11075            .await;
11076
11077        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
11078            for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
11079                let response = Box::pin(handle_console_runtime_rpc(
11080                    &runtime,
11081                    None,
11082                    None,
11083                    None,
11084                    Some(ConsoleEventStore::new()),
11085                    None,
11086                    Some(identity_runtime.clone()),
11087                    None,
11088                    None,
11089                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
11090                    true,
11091                ))
11092                .await;
11093                assert_eq!(
11094                    response["error"],
11095                    Value::Null,
11096                    "{method} must use durable registered live binding despite duplicate labels for {requested_identity}: {response:#?}"
11097                );
11098            }
11099        }
11100        let reset_all_response = Box::pin(handle_console_runtime_rpc(
11101            &runtime,
11102            None,
11103            None,
11104            None,
11105            Some(ConsoleEventStore::new()),
11106            None,
11107            Some(identity_runtime.clone()),
11108            None,
11109            None,
11110            rpc_request("mobkit/reset_all"),
11111            true,
11112        ))
11113        .await;
11114        assert_eq!(
11115            reset_all_response["error"],
11116            Value::Null,
11117            "reset_all must also prefer the durable registered live binding despite duplicate labels: {reset_all_response:#?}"
11118        );
11119        assert!(
11120            reset_all_response["result"]["failed"]
11121                .as_array()
11122                .is_some_and(Vec::is_empty),
11123            "reset_all should not report duplicate-label failure for durable registered binding: {reset_all_response:#?}"
11124        );
11125
11126        let _ = runtime.handle().stop().await;
11127        Ok(())
11128    }
11129
11130    #[tokio::test]
11131    async fn console_runtime_identity_controls_reject_wrong_projected_live_only_alias()
11132    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11133        let (_temp_dir, runtime) =
11134            build_empty_console_test_runtime("console-identity-wrong-projected-live-only").await?;
11135
11136        let mut labels = BTreeMap::new();
11137        labels.insert("agent_identity".to_string(), "other:singleton".to_string());
11138        runtime
11139            .handle()
11140            .spawn_spec(
11141                SpawnMemberSpec::from_wire(
11142                    "worker".to_string(),
11143                    // meerkat 0.7: roster ids are comms-safe encodings of the
11144                    // public alias (MemberCommsName rejects ":").
11145                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11146                    Some("You are a wrong-projected Review Agent.".into()),
11147                    None,
11148                    None,
11149                )
11150                .with_labels(labels),
11151            )
11152            .await?;
11153
11154        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11155            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11156            lease_provider: Arc::new(LocalLeaseProvider::new()),
11157            runtime_instance_id: "console-identity-wrong-projected-live-only".to_string(),
11158            has_runtime_store: true,
11159            durability_policy: DurabilityPolicy::SyncWriteThrough,
11160            bridge: None,
11161            default_timeout: None,
11162        }));
11163        let identity = AgentIdentity::parse("review:singleton")?;
11164        let record = ContinuityRecord {
11165            identity: identity.clone(),
11166            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11167            session_id: meerkat_core::types::SessionId::new(),
11168            generation: ContinuityGeneration::new(0),
11169            checkpoint_version: CheckpointVersion::new(0),
11170        };
11171        identity_runtime
11172            .register(
11173                DurableAgentSpec {
11174                    identity: identity.clone(),
11175                    profile: ProfileName::from("worker"),
11176                    addressability: AgentAddressability::Addressable,
11177                    display_name: None,
11178                    labels: BTreeMap::new(),
11179                    context: None,
11180                    additional_instructions: Vec::new(),
11181                    initial_message: None,
11182                    runtime_mode_override: None,
11183                    backend: None,
11184                    binding: None,
11185                },
11186                IdentityLifecycleState::Active,
11187                Some(record),
11188                Some(LeaseGrant {
11189                    identity,
11190                    fencing_token: FencingToken::new(1),
11191                    ttl: Duration::from_mins(1),
11192                }),
11193            )
11194            .await;
11195
11196        for method in [
11197            "mobkit/status_identity",
11198            "mobkit/inspect_identity",
11199            "mobkit/retire",
11200        ] {
11201            let response = Box::pin(handle_console_runtime_rpc(
11202                &runtime,
11203                None,
11204                None,
11205                None,
11206                Some(ConsoleEventStore::new()),
11207                None,
11208                Some(identity_runtime.clone()),
11209                None,
11210                None,
11211                rpc_request_with_params(method, json!({ "identity": "other:singleton" })),
11212                true,
11213            ))
11214            .await;
11215            assert_ne!(
11216                response["error"],
11217                Value::Null,
11218                "{method} must reject wrong-projected live-only alias"
11219            );
11220            assert_eq!(
11221                response["error"]["data"]["kind"],
11222                json!("stale_live_identity_alias"),
11223                "unexpected response for {method}: {response:#?}"
11224            );
11225        }
11226        assert!(
11227            runtime
11228                .handle()
11229                .get_member(&crate::member_comms_id::mob_member_id(
11230                    "rt:review:singleton:0"
11231                ))
11232                .await
11233                .ok()
11234                .flatten()
11235                .is_some(),
11236            "wrong-projected durable runtime member must not be retired through projected alias"
11237        );
11238
11239        let _ = runtime.handle().stop().await;
11240        Ok(())
11241    }
11242
11243    #[derive(Debug)]
11244    struct HideIdentityPolicy(&'static str);
11245
11246    impl ConsoleVisibilityPolicy for HideIdentityPolicy {
11247        fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
11248            record.identity != self.0
11249        }
11250    }
11251
11252    #[derive(Debug)]
11253    struct HideMemberPolicy(&'static str);
11254
11255    impl ConsoleVisibilityPolicy for HideMemberPolicy {
11256        fn member_visible(&self, member: &ConsoleMember) -> bool {
11257            member.agent_identity != self.0
11258                && member
11259                    .labels
11260                    .get("agent_identity")
11261                    .is_none_or(|identity| identity != self.0)
11262        }
11263
11264        fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
11265            record.runtime_member_id != self.0
11266        }
11267    }
11268
11269    #[derive(Debug)]
11270    struct HideOnlyMemberPolicy(&'static str);
11271
11272    impl ConsoleVisibilityPolicy for HideOnlyMemberPolicy {
11273        fn member_visible(&self, member: &ConsoleMember) -> bool {
11274            member.agent_identity != self.0
11275        }
11276    }
11277
11278    #[tokio::test]
11279    async fn console_runtime_identity_controls_respect_visibility_policy()
11280    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11281        let (_temp_dir, runtime) =
11282            build_empty_console_test_runtime("console-identity-hidden-controls").await?;
11283        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11284            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11285            lease_provider: Arc::new(LocalLeaseProvider::new()),
11286            runtime_instance_id: "console-identity-hidden-controls".to_string(),
11287            has_runtime_store: true,
11288            durability_policy: DurabilityPolicy::SyncWriteThrough,
11289            bridge: None,
11290            default_timeout: None,
11291        }));
11292        let identity = AgentIdentity::parse("review:singleton")?;
11293        let record = ContinuityRecord {
11294            identity: identity.clone(),
11295            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11296            session_id: meerkat_core::types::SessionId::new(),
11297            generation: ContinuityGeneration::new(0),
11298            checkpoint_version: CheckpointVersion::new(0),
11299        };
11300        identity_runtime
11301            .register(
11302                DurableAgentSpec {
11303                    identity: identity.clone(),
11304                    profile: ProfileName::from("worker"),
11305                    addressability: AgentAddressability::Addressable,
11306                    display_name: None,
11307                    labels: BTreeMap::new(),
11308                    context: None,
11309                    additional_instructions: Vec::new(),
11310                    initial_message: None,
11311                    runtime_mode_override: None,
11312                    backend: None,
11313                    binding: None,
11314                },
11315                IdentityLifecycleState::Active,
11316                Some(record),
11317                Some(LeaseGrant {
11318                    identity: identity.clone(),
11319                    fencing_token: FencingToken::new(7),
11320                    ttl: Duration::from_mins(1),
11321                }),
11322            )
11323            .await;
11324
11325        for method in [
11326            "mobkit/status_identity",
11327            "mobkit/inspect_identity",
11328            "mobkit/retire",
11329            "mobkit/respawn",
11330            "mobkit/reset",
11331            "mobkit/delete_identity",
11332        ] {
11333            let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11334                &runtime,
11335                None,
11336                None,
11337                None,
11338                Some(ConsoleEventStore::new()),
11339                None,
11340                Some(identity_runtime.clone()),
11341                None,
11342                None,
11343                &HideIdentityPolicy("review:singleton"),
11344                rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
11345                true,
11346                false,
11347                None,
11348                None,
11349                None,
11350                None,
11351                None,
11352                None,
11353                None,
11354            ))
11355            .await;
11356            assert_ne!(
11357                response["error"],
11358                Value::Null,
11359                "{method} must reject hidden durable identity"
11360            );
11361            assert_eq!(
11362                response["error"]["data"]["kind"],
11363                json!("identity_hidden_by_policy"),
11364                "unexpected hidden response for {method}: {response:#?}"
11365            );
11366        }
11367        identity_runtime
11368            .status(&AgentIdentity::parse("review:singleton")?)
11369            .await
11370            .expect("hidden control RPCs must not mutate the durable identity");
11371
11372        let _ = runtime.handle().stop().await;
11373        Ok(())
11374    }
11375
11376    #[tokio::test]
11377    async fn console_runtime_durable_identity_controls_reject_hidden_bound_member()
11378    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11379        let (_temp_dir, runtime) =
11380            build_empty_console_test_runtime("console-durable-hidden-bound-member").await?;
11381        runtime
11382            .handle()
11383            .spawn_spec(SpawnMemberSpec::from_wire(
11384                "worker".to_string(),
11385                // meerkat 0.7: roster ids are comms-safe encodings of the
11386                // public alias (MemberCommsName rejects ":").
11387                crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11388                Some("You are the live Review Agent.".into()),
11389                None,
11390                None,
11391            ))
11392            .await?;
11393
11394        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11395            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11396            lease_provider: Arc::new(LocalLeaseProvider::new()),
11397            runtime_instance_id: "console-durable-hidden-bound-member".to_string(),
11398            has_runtime_store: true,
11399            durability_policy: DurabilityPolicy::SyncWriteThrough,
11400            bridge: None,
11401            default_timeout: None,
11402        }));
11403        let identity = AgentIdentity::parse("review:singleton")?;
11404        let record = ContinuityRecord {
11405            identity: identity.clone(),
11406            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11407            session_id: meerkat_core::types::SessionId::new(),
11408            generation: ContinuityGeneration::new(0),
11409            checkpoint_version: CheckpointVersion::new(0),
11410        };
11411        identity_runtime
11412            .register(
11413                DurableAgentSpec {
11414                    identity: identity.clone(),
11415                    profile: ProfileName::from("worker"),
11416                    addressability: AgentAddressability::Addressable,
11417                    display_name: None,
11418                    labels: BTreeMap::new(),
11419                    context: None,
11420                    additional_instructions: Vec::new(),
11421                    initial_message: None,
11422                    runtime_mode_override: None,
11423                    backend: None,
11424                    binding: None,
11425                },
11426                IdentityLifecycleState::Active,
11427                Some(record),
11428                Some(LeaseGrant {
11429                    identity: identity.clone(),
11430                    fencing_token: FencingToken::new(7),
11431                    ttl: Duration::from_mins(1),
11432                }),
11433            )
11434            .await;
11435
11436        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
11437            for method in [
11438                "mobkit/status_identity",
11439                "mobkit/inspect_identity",
11440                "mobkit/retire",
11441                "mobkit/respawn",
11442                "mobkit/reset",
11443                "mobkit/delete_identity",
11444            ] {
11445                let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11446                    &runtime,
11447                    None,
11448                    None,
11449                    None,
11450                    Some(ConsoleEventStore::new()),
11451                    None,
11452                    Some(identity_runtime.clone()),
11453                    None,
11454                    None,
11455                    &HideOnlyMemberPolicy("rt:review:singleton:0"),
11456                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
11457                    true,
11458                    false,
11459                    None,
11460                    None,
11461                    None,
11462                    None,
11463                    None,
11464                    None,
11465                    None,
11466                ))
11467                .await;
11468                assert_eq!(
11469                    response["error"]["data"]["kind"],
11470                    json!("identity_hidden_by_policy"),
11471                    "durable {method} must reject hidden bound live member for {requested_identity}: {response:#?}"
11472                );
11473            }
11474        }
11475
11476        let _ = runtime.handle().stop().await;
11477        Ok(())
11478    }
11479
11480    #[tokio::test]
11481    async fn console_runtime_live_only_identity_controls_respect_visibility_policy()
11482    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11483        let (_temp_dir, runtime) =
11484            build_empty_console_test_runtime("console-live-only-hidden-controls").await?;
11485
11486        let mut labels = BTreeMap::new();
11487        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11488        runtime
11489            .handle()
11490            .spawn_spec(
11491                SpawnMemberSpec::from_wire(
11492                    "worker".to_string(),
11493                    // meerkat 0.7: roster ids are comms-safe encodings of the
11494                    // public alias (MemberCommsName rejects ":").
11495                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11496                    Some("You are the live Review Agent.".into()),
11497                    None,
11498                    None,
11499                )
11500                .with_labels(labels),
11501            )
11502            .await?;
11503
11504        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11505            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11506            lease_provider: Arc::new(LocalLeaseProvider::new()),
11507            runtime_instance_id: "console-live-only-hidden-controls".to_string(),
11508            has_runtime_store: true,
11509            durability_policy: DurabilityPolicy::SyncWriteThrough,
11510            bridge: None,
11511            default_timeout: None,
11512        }));
11513
11514        for method in [
11515            "mobkit/status_identity",
11516            "mobkit/inspect_identity",
11517            "mobkit/retire",
11518            "mobkit/respawn",
11519            "mobkit/reset",
11520            "mobkit/delete_identity",
11521        ] {
11522            let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11523                &runtime,
11524                None,
11525                None,
11526                None,
11527                Some(ConsoleEventStore::new()),
11528                None,
11529                Some(identity_runtime.clone()),
11530                None,
11531                None,
11532                &HideMemberPolicy("rt:review:singleton:0"),
11533                rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
11534                true,
11535                false,
11536                None,
11537                None,
11538                None,
11539                None,
11540                None,
11541                None,
11542                None,
11543            ))
11544            .await;
11545            assert_ne!(
11546                response["error"],
11547                Value::Null,
11548                "{method} must reject hidden live-only identity"
11549            );
11550            assert_eq!(
11551                response["error"]["data"]["kind"],
11552                json!("identity_hidden_by_policy"),
11553                "unexpected hidden live-only response for {method}: {response:#?}"
11554            );
11555        }
11556        assert!(
11557            runtime
11558                .handle()
11559                .get_member(&crate::member_comms_id::mob_member_id(
11560                    "rt:review:singleton:0"
11561                ))
11562                .await
11563                .ok()
11564                .flatten()
11565                .is_some(),
11566            "hidden live-only controls must not mutate the live member"
11567        );
11568
11569        let _ = runtime.handle().stop().await;
11570        Ok(())
11571    }
11572
11573    #[tokio::test]
11574    async fn console_runtime_reset_generated_alias_without_registration_fails_closed()
11575    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11576        let (_temp_dir, runtime) =
11577            build_empty_console_test_runtime("console-reset-live-only-no-bridge").await?;
11578
11579        let mut labels = BTreeMap::new();
11580        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11581        runtime
11582            .handle()
11583            .spawn_spec(
11584                SpawnMemberSpec::from_wire(
11585                    "worker".to_string(),
11586                    // meerkat 0.7: roster ids are comms-safe encodings of the
11587                    // public alias (MemberCommsName rejects ":").
11588                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11589                    Some("You are the live Review Agent.".into()),
11590                    None,
11591                    None,
11592                )
11593                .with_labels(labels),
11594            )
11595            .await?;
11596
11597        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11598            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11599            lease_provider: Arc::new(LocalLeaseProvider::new()),
11600            runtime_instance_id: "console-reset-live-only-no-bridge".to_string(),
11601            has_runtime_store: true,
11602            durability_policy: DurabilityPolicy::SyncWriteThrough,
11603            bridge: None,
11604            default_timeout: None,
11605        }));
11606
11607        let member_id = crate::member_comms_id::mob_member_id("rt:review:singleton:0");
11608        let session_before = runtime
11609            .handle()
11610            .resolve_bridge_session_id_observation(&member_id)
11611            .await;
11612        let response = Box::pin(handle_console_runtime_rpc(
11613            &runtime,
11614            None,
11615            None,
11616            None,
11617            Some(ConsoleEventStore::new()),
11618            None,
11619            Some(identity_runtime),
11620            None,
11621            None,
11622            rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
11623            true,
11624        ))
11625        .await;
11626        assert_ne!(
11627            response["error"],
11628            Value::Null,
11629            "a generated alias must not become raw fallback after registration disappears: {response:#?}"
11630        );
11631        assert_eq!(
11632            runtime
11633                .handle()
11634                .resolve_bridge_session_id_observation(&member_id)
11635                .await,
11636            session_before,
11637            "failing closed must not respawn the orphaned generated member"
11638        );
11639
11640        let _ = runtime.handle().stop().await;
11641        Ok(())
11642    }
11643
11644    #[tokio::test]
11645    async fn console_runtime_reset_bare_live_member_without_session_bridge_uses_fallback()
11646    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11647        let (_temp_dir, runtime) =
11648            build_empty_console_test_runtime("console-reset-bare-live-no-bridge").await?;
11649
11650        let mut labels = BTreeMap::new();
11651        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11652        runtime
11653            .handle()
11654            .spawn_spec(
11655                SpawnMemberSpec::from_wire(
11656                    "worker".to_string(),
11657                    "legacy-review-singleton".to_string(),
11658                    Some("You are the live Review Agent.".into()),
11659                    None,
11660                    None,
11661                )
11662                .with_labels(labels),
11663            )
11664            .await?;
11665
11666        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11667            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11668            lease_provider: Arc::new(LocalLeaseProvider::new()),
11669            runtime_instance_id: "console-reset-bare-live-no-bridge".to_string(),
11670            has_runtime_store: true,
11671            durability_policy: DurabilityPolicy::SyncWriteThrough,
11672            bridge: None,
11673            default_timeout: None,
11674        }));
11675
11676        let response = Box::pin(handle_console_runtime_rpc(
11677            &runtime,
11678            None,
11679            None,
11680            None,
11681            Some(ConsoleEventStore::new()),
11682            None,
11683            Some(identity_runtime),
11684            None,
11685            None,
11686            rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
11687            true,
11688        ))
11689        .await;
11690        assert_eq!(
11691            response["error"],
11692            Value::Null,
11693            "genuine bare live-only reset should retain compatibility fallback: {response:#?}"
11694        );
11695        assert_eq!(response["result"]["identity"], json!("review:singleton"));
11696
11697        let _ = runtime.handle().stop().await;
11698        Ok(())
11699    }
11700
11701    #[tokio::test]
11702    async fn reset_all_rejects_registered_runtime_projected_under_wrong_identity()
11703    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11704        let (_temp_dir, runtime) =
11705            build_empty_console_test_runtime("console-reset-all-stale-projection").await?;
11706
11707        let mut labels = BTreeMap::new();
11708        labels.insert("agent_identity".to_string(), "other:singleton".to_string());
11709        runtime
11710            .handle()
11711            .spawn_spec(
11712                SpawnMemberSpec::from_wire(
11713                    "worker".to_string(),
11714                    // meerkat 0.7: roster ids are comms-safe encodings of the
11715                    // public alias (MemberCommsName rejects ":").
11716                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11717                    Some("You are a mislabeled Review Agent.".into()),
11718                    None,
11719                    None,
11720                )
11721                .with_labels(labels),
11722            )
11723            .await?;
11724
11725        let store = Arc::new(LocalContinuityStore::in_memory()?);
11726        let lease_provider = Arc::new(LocalLeaseProvider::new());
11727        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11728            continuity_store: store.clone(),
11729            lease_provider: lease_provider.clone(),
11730            runtime_instance_id: "test-runtime".to_string(),
11731            has_runtime_store: true,
11732            durability_policy: DurabilityPolicy::SyncWriteThrough,
11733            bridge: None,
11734            default_timeout: None,
11735        }));
11736        let identity = AgentIdentity::parse("review:singleton")?;
11737        let record = ContinuityRecord {
11738            identity: identity.clone(),
11739            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11740            session_id: meerkat_core::types::SessionId::new(),
11741            generation: ContinuityGeneration::new(0),
11742            checkpoint_version: CheckpointVersion::new(0),
11743        };
11744        let grants = lease_provider
11745            .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
11746            .await?;
11747        let grant = match grants.get(&identity).cloned() {
11748            Some(LeaseAcquireResult::Acquired(grant)) => grant,
11749            other => return Err(format!("expected acquired lease, got {other:?}").into()),
11750        };
11751        store
11752            .upsert_continuity_record(&record, grant.fencing_token)
11753            .await?;
11754        identity_runtime
11755            .register(
11756                DurableAgentSpec {
11757                    identity: identity.clone(),
11758                    profile: ProfileName::from("worker"),
11759                    addressability: AgentAddressability::Addressable,
11760                    display_name: None,
11761                    labels: BTreeMap::new(),
11762                    context: None,
11763                    additional_instructions: Vec::new(),
11764                    initial_message: None,
11765                    runtime_mode_override: None,
11766                    backend: None,
11767                    binding: None,
11768                },
11769                IdentityLifecycleState::Active,
11770                Some(record),
11771                Some(grant),
11772            )
11773            .await;
11774
11775        let response = Box::pin(handle_console_runtime_rpc(
11776            &runtime,
11777            None,
11778            None,
11779            None,
11780            Some(ConsoleEventStore::new()),
11781            None,
11782            Some(identity_runtime),
11783            None,
11784            None,
11785            rpc_request("mobkit/reset_all"),
11786            true,
11787        ))
11788        .await;
11789        assert_ne!(response["error"], Value::Null);
11790        let failed = response["error"]["data"]["failed"]
11791            .as_array()
11792            .expect("reset_all should report failed identities");
11793        let stale_failure = failed
11794            .iter()
11795            .find(|failure| failure["identity"] == json!("review:singleton"))
11796            .expect("review identity should fail stale alias validation");
11797        assert_eq!(
11798            stale_failure["kind"],
11799            json!("stale_live_identity_alias"),
11800            "unexpected reset_all response: {response:#?}"
11801        );
11802        assert!(
11803            stale_failure["error"]
11804                .as_str()
11805                .unwrap_or_default()
11806                .contains("projects identity other:singleton"),
11807            "unexpected stale failure: {stale_failure:#?}"
11808        );
11809        let retired = response["error"]["data"]["retired_delegates"]
11810            .as_array()
11811            .expect("reset_all should return retired delegates");
11812        assert!(
11813            !retired
11814                .iter()
11815                .any(|entry| entry["identity"] == json!("other:singleton")),
11816            "wrong-projected live alias must not be destructively retired before stale validation; response: {response:#?}"
11817        );
11818        assert!(
11819            runtime
11820                .handle()
11821                .get_member(&crate::member_comms_id::mob_member_id(
11822                    "rt:review:singleton:0"
11823                ))
11824                .await
11825                .ok()
11826                .flatten()
11827                .is_some(),
11828            "wrong-projected durable runtime member must remain present after reset_all rejection"
11829        );
11830
11831        let _ = runtime.handle().stop().await;
11832        Ok(())
11833    }
11834
11835    #[tokio::test]
11836    async fn reset_all_respects_console_visibility_policy_for_live_members()
11837    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11838        let (_temp_dir, runtime) =
11839            build_empty_console_test_runtime("console-reset-all-hidden-live").await?;
11840
11841        runtime
11842            .handle()
11843            .spawn_spec(
11844                SpawnMemberSpec::from_wire(
11845                    "worker".to_string(),
11846                    // meerkat 0.7: roster ids are comms-safe encodings of the
11847                    // public alias (MemberCommsName rejects ":").
11848                    crate::member_comms_id::mob_member_id_str("hidden:singleton").into_owned(),
11849                    Some("You are hidden from console lifecycle controls.".into()),
11850                    None,
11851                    None,
11852                )
11853                .with_labels(BTreeMap::from([(
11854                    "agent_identity".to_string(),
11855                    "hidden:singleton".to_string(),
11856                )])),
11857            )
11858            .await?;
11859
11860        let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11861            &runtime,
11862            None,
11863            None,
11864            None,
11865            Some(ConsoleEventStore::new()),
11866            None,
11867            None,
11868            None,
11869            None,
11870            &HideMemberPolicy("hidden:singleton"),
11871            rpc_request("mobkit/reset_all"),
11872            true,
11873            false,
11874            None,
11875            None,
11876            None,
11877            None,
11878            None,
11879            None,
11880            None,
11881        ))
11882        .await;
11883        assert_eq!(
11884            response["error"],
11885            Value::Null,
11886            "hidden live member should be outside reset_all target set: {response:#?}"
11887        );
11888        assert!(
11889            runtime
11890                .handle()
11891                .get_member(&crate::member_comms_id::mob_member_id("hidden:singleton"))
11892                .await
11893                .ok()
11894                .flatten()
11895                .is_some(),
11896            "reset_all must not retire hidden live members"
11897        );
11898
11899        let _ = runtime.handle().stop().await;
11900        Ok(())
11901    }
11902
11903    #[tokio::test]
11904    async fn reset_all_skips_durable_identity_with_hidden_bound_member()
11905    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11906        let (_temp_dir, runtime) =
11907            build_empty_console_test_runtime("console-reset-all-hidden-durable-bound").await?;
11908        runtime
11909            .handle()
11910            .spawn_spec(SpawnMemberSpec::from_wire(
11911                "worker".to_string(),
11912                // meerkat 0.7: roster ids are comms-safe encodings of the
11913                // public alias (MemberCommsName rejects ":").
11914                crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11915                Some("You are the hidden Review Agent.".into()),
11916                None,
11917                None,
11918            ))
11919            .await?;
11920
11921        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11922            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11923            lease_provider: Arc::new(LocalLeaseProvider::new()),
11924            runtime_instance_id: "console-reset-all-hidden-durable-bound".to_string(),
11925            has_runtime_store: true,
11926            durability_policy: DurabilityPolicy::SyncWriteThrough,
11927            bridge: None,
11928            default_timeout: None,
11929        }));
11930        let identity = AgentIdentity::parse("review:singleton")?;
11931        identity_runtime
11932            .register(
11933                DurableAgentSpec {
11934                    identity: identity.clone(),
11935                    profile: ProfileName::from("worker"),
11936                    addressability: AgentAddressability::Addressable,
11937                    display_name: None,
11938                    labels: BTreeMap::new(),
11939                    context: None,
11940                    additional_instructions: Vec::new(),
11941                    initial_message: None,
11942                    runtime_mode_override: None,
11943                    backend: None,
11944                    binding: None,
11945                },
11946                IdentityLifecycleState::Active,
11947                Some(ContinuityRecord {
11948                    identity: identity.clone(),
11949                    agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11950                    session_id: meerkat_core::types::SessionId::new(),
11951                    generation: ContinuityGeneration::new(0),
11952                    checkpoint_version: CheckpointVersion::new(0),
11953                }),
11954                Some(LeaseGrant {
11955                    identity: identity.clone(),
11956                    fencing_token: FencingToken::new(9),
11957                    ttl: Duration::from_mins(1),
11958                }),
11959            )
11960            .await;
11961
11962        let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11963            &runtime,
11964            None,
11965            None,
11966            None,
11967            Some(ConsoleEventStore::new()),
11968            None,
11969            Some(identity_runtime.clone()),
11970            None,
11971            None,
11972            &HideOnlyMemberPolicy("rt:review:singleton:0"),
11973            rpc_request("mobkit/reset_all"),
11974            true,
11975            false,
11976            None,
11977            None,
11978            None,
11979            None,
11980            None,
11981            None,
11982            None,
11983        ))
11984        .await;
11985        assert_eq!(
11986            response["error"],
11987            Value::Null,
11988            "hidden durable bound member should be outside reset_all target set: {response:#?}"
11989        );
11990        assert_eq!(
11991            identity_runtime.status(&identity).await?.state,
11992            IdentityLifecycleState::Active
11993        );
11994
11995        let _ = runtime.handle().stop().await;
11996        Ok(())
11997    }
11998
11999    #[tokio::test]
12000    async fn identity_lifecycle_cleanup_skips_hidden_projected_duplicates()
12001    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12002        let (_temp_dir, runtime) =
12003            build_empty_console_test_runtime("console-hidden-stale-duplicate-cleanup").await?;
12004        for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
12005            runtime
12006                .handle()
12007                .spawn_spec(
12008                    SpawnMemberSpec::from_wire(
12009                        "worker".to_string(),
12010                        // meerkat 0.7: roster ids are comms-safe encodings of
12011                        // the public alias (MemberCommsName rejects ":").
12012                        crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
12013                        Some("You are a Review Agent candidate.".into()),
12014                        None,
12015                        None,
12016                    )
12017                    .with_labels(BTreeMap::from([(
12018                        "agent_identity".to_string(),
12019                        "review:singleton".to_string(),
12020                    )])),
12021                )
12022                .await?;
12023        }
12024
12025        let store = Arc::new(LocalContinuityStore::in_memory()?);
12026        let lease_provider = Arc::new(LocalLeaseProvider::new());
12027        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12028            continuity_store: store.clone(),
12029            lease_provider: lease_provider.clone(),
12030            runtime_instance_id: "console-hidden-stale-duplicate-cleanup".to_string(),
12031            has_runtime_store: true,
12032            durability_policy: DurabilityPolicy::SyncWriteThrough,
12033            bridge: None,
12034            default_timeout: None,
12035        }));
12036        let identity = AgentIdentity::parse("review:singleton")?;
12037        let record = ContinuityRecord {
12038            identity: identity.clone(),
12039            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
12040            session_id: runtime
12041                .handle()
12042                .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
12043                    "rt:review:singleton:0",
12044                ))
12045                .await
12046                .unwrap_or_else(meerkat_core::types::SessionId::new),
12047            generation: ContinuityGeneration::new(0),
12048            checkpoint_version: CheckpointVersion::new(0),
12049        };
12050        let grants = lease_provider
12051            .acquire_leases(
12052                std::slice::from_ref(&identity),
12053                "console-hidden-stale-duplicate-cleanup",
12054            )
12055            .await?;
12056        let grant = match grants.get(&identity).cloned() {
12057            Some(LeaseAcquireResult::Acquired(grant)) => grant,
12058            other => return Err(format!("expected acquired lease, got {other:?}").into()),
12059        };
12060        store
12061            .upsert_continuity_record(&record, grant.fencing_token)
12062            .await?;
12063        identity_runtime
12064            .register(
12065                DurableAgentSpec {
12066                    identity: identity.clone(),
12067                    profile: ProfileName::from("worker"),
12068                    addressability: AgentAddressability::Addressable,
12069                    display_name: None,
12070                    labels: BTreeMap::new(),
12071                    context: None,
12072                    additional_instructions: Vec::new(),
12073                    initial_message: None,
12074                    runtime_mode_override: None,
12075                    backend: None,
12076                    binding: None,
12077                },
12078                IdentityLifecycleState::Active,
12079                Some(record),
12080                Some(grant),
12081            )
12082            .await;
12083
12084        let response = Box::pin(handle_console_runtime_rpc_with_visibility(
12085            &runtime,
12086            None,
12087            None,
12088            None,
12089            Some(ConsoleEventStore::new()),
12090            None,
12091            Some(identity_runtime),
12092            None,
12093            None,
12094            &HideOnlyMemberPolicy("rt:review:singleton:1"),
12095            rpc_request_with_params("mobkit/retire", json!({ "identity": "review:singleton" })),
12096            true,
12097            false,
12098            None,
12099            None,
12100            None,
12101            None,
12102            None,
12103            None,
12104            None,
12105        ))
12106        .await;
12107        assert_eq!(
12108            response["error"],
12109            Value::Null,
12110            "visible durable retire should succeed without touching hidden duplicate: {response:#?}"
12111        );
12112        assert!(
12113            runtime
12114                .handle()
12115                .get_member(&crate::member_comms_id::mob_member_id(
12116                    "rt:review:singleton:1"
12117                ))
12118                .await
12119                .ok()
12120                .flatten()
12121                .is_some(),
12122            "post-mutation stale cleanup must not retire member-hidden projected duplicates"
12123        );
12124
12125        let _ = runtime.handle().stop().await;
12126        Ok(())
12127    }
12128
12129    #[tokio::test]
12130    async fn console_runtime_capabilities_advertise_identity_controls_when_identity_runtime_exists()
12131    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12132        let (_temp_dir, runtime) =
12133            build_empty_console_test_runtime("console-identity-capabilities").await?;
12134        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12135            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12136            lease_provider: Arc::new(LocalLeaseProvider::new()),
12137            runtime_instance_id: "console-identity-capabilities".to_string(),
12138            has_runtime_store: true,
12139            durability_policy: DurabilityPolicy::SyncWriteThrough,
12140            bridge: None,
12141            default_timeout: None,
12142        }));
12143
12144        let response = Box::pin(handle_console_runtime_rpc(
12145            &runtime,
12146            None,
12147            None,
12148            None,
12149            None,
12150            None,
12151            Some(identity_runtime.clone()),
12152            None,
12153            None,
12154            rpc_request("mobkit/capabilities"),
12155            true,
12156        ))
12157        .await;
12158
12159        assert_eq!(response["error"], Value::Null, "{response:#?}");
12160        let methods = response["result"]["methods"]
12161            .as_array()
12162            .ok_or("capabilities methods should be an array")?;
12163        for method in [
12164            "mobkit/status_identity",
12165            "mobkit/inspect_identity",
12166            "mobkit/respawn",
12167            "mobkit/reset",
12168            "mobkit/delete_identity",
12169        ] {
12170            assert!(
12171                methods.iter().any(|candidate| candidate == method),
12172                "identity runtime capabilities should advertise {method}: {methods:#?}"
12173            );
12174        }
12175        for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
12176            assert!(
12177                !methods.iter().any(|candidate| candidate == method),
12178                "console runtime capabilities must not advertise flow-editor method {method}: {methods:#?}"
12179            );
12180        }
12181
12182        let mobpack_response = Box::pin(handle_console_runtime_rpc(
12183            &runtime,
12184            None,
12185            None,
12186            None,
12187            None,
12188            None,
12189            Some(identity_runtime),
12190            None,
12191            None,
12192            rpc_request("mobkit/mobpacks/schema"),
12193            true,
12194        ))
12195        .await;
12196        assert_eq!(
12197            mobpack_response["error"]["code"],
12198            json!(-32601),
12199            "console runtime RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
12200        );
12201
12202        let _ = runtime.handle().stop().await;
12203        Ok(())
12204    }
12205
12206    #[tokio::test]
12207    async fn console_runtime_capabilities_advertise_agent_memory_with_read_only_gating()
12208    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12209        let (_temp_dir, runtime) =
12210            build_empty_console_test_runtime("console-agent-memory-capabilities").await?;
12211        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12212            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12213            lease_provider: Arc::new(LocalLeaseProvider::new()),
12214            runtime_instance_id: "console-agent-memory-capabilities".to_string(),
12215            has_runtime_store: true,
12216            durability_policy: DurabilityPolicy::SyncWriteThrough,
12217            bridge: None,
12218            default_timeout: None,
12219        }));
12220        let memory_dir = tempfile::tempdir()?;
12221        let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
12222        identity_runtime
12223            .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
12224                memory_store,
12225                AgentMemoryConfig::default(),
12226            )))
12227            .await;
12228
12229        let response = Box::pin(handle_console_runtime_rpc(
12230            &runtime,
12231            None,
12232            None,
12233            None,
12234            None,
12235            None,
12236            Some(identity_runtime.clone()),
12237            None,
12238            None,
12239            rpc_request("mobkit/capabilities"),
12240            true,
12241        ))
12242        .await;
12243        assert_eq!(response["error"], Value::Null, "{response:#?}");
12244        let methods = response["result"]["methods"]
12245            .as_array()
12246            .ok_or("capabilities methods should be an array")?;
12247        for method in [
12248            "mobkit/agent_memory/recall",
12249            "mobkit/agent_memory/remember",
12250            "mobkit/agent_memory/forget",
12251        ] {
12252            assert!(
12253                methods.iter().any(|candidate| candidate == method),
12254                "authenticated capabilities should advertise {method}: {methods:#?}"
12255            );
12256        }
12257
12258        let read_only_response = Box::pin(handle_console_runtime_rpc(
12259            &runtime,
12260            None,
12261            None,
12262            None,
12263            None,
12264            None,
12265            Some(identity_runtime),
12266            None,
12267            None,
12268            rpc_request("mobkit/capabilities"),
12269            false,
12270        ))
12271        .await;
12272        assert_eq!(
12273            read_only_response["error"],
12274            Value::Null,
12275            "{read_only_response:#?}"
12276        );
12277        let read_only_methods = read_only_response["result"]["methods"]
12278            .as_array()
12279            .ok_or("read-only capabilities methods should be an array")?;
12280        assert!(
12281            read_only_methods
12282                .iter()
12283                .any(|candidate| candidate == "mobkit/agent_memory/recall"),
12284            "read-only capabilities should keep recall: {read_only_methods:#?}"
12285        );
12286        for method in ["mobkit/agent_memory/remember", "mobkit/agent_memory/forget"] {
12287            assert!(
12288                !read_only_methods
12289                    .iter()
12290                    .any(|candidate| candidate == method),
12291                "read-only capabilities must omit {method}: {read_only_methods:#?}"
12292            );
12293        }
12294
12295        let _ = runtime.handle().stop().await;
12296        Ok(())
12297    }
12298
12299    #[tokio::test]
12300    async fn console_runtime_agent_memory_methods_round_trip()
12301    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12302        let (_temp_dir, runtime) =
12303            build_empty_console_test_runtime("console-agent-memory-round-trip").await?;
12304        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12305            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12306            lease_provider: Arc::new(LocalLeaseProvider::new()),
12307            runtime_instance_id: "console-agent-memory-round-trip".to_string(),
12308            has_runtime_store: true,
12309            durability_policy: DurabilityPolicy::SyncWriteThrough,
12310            bridge: None,
12311            default_timeout: None,
12312        }));
12313        let identity = AgentIdentity::parse("identity:memory-console")?;
12314        identity_runtime
12315            .register(
12316                DurableAgentSpec {
12317                    identity: identity.clone(),
12318                    profile: ProfileName::from("default"),
12319                    addressability: AgentAddressability::Addressable,
12320                    display_name: None,
12321                    labels: BTreeMap::new(),
12322                    context: None,
12323                    additional_instructions: Vec::new(),
12324                    initial_message: None,
12325                    runtime_mode_override: None,
12326                    backend: None,
12327                    binding: None,
12328                },
12329                IdentityLifecycleState::Active,
12330                None,
12331                None,
12332            )
12333            .await;
12334        let memory_dir = tempfile::tempdir()?;
12335        let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
12336        identity_runtime
12337            .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
12338                memory_store,
12339                AgentMemoryConfig::default(),
12340            )))
12341            .await;
12342
12343        let remember = Box::pin(handle_console_runtime_rpc(
12344            &runtime,
12345            None,
12346            None,
12347            None,
12348            None,
12349            None,
12350            Some(identity_runtime.clone()),
12351            None,
12352            None,
12353            rpc_request_with_params(
12354                "mobkit/agent_memory/remember",
12355                json!({
12356                    "identity": "identity:memory-console",
12357                    "title": "Console memory token",
12358                    "body": "The console memory token is CONSOLE-MEM-17.",
12359                    "tags": ["console", "memory"]
12360                }),
12361            ),
12362            true,
12363        ))
12364        .await;
12365        assert_eq!(remember["error"], Value::Null, "{remember:#?}");
12366        let memory_id = remember["result"]["memory_id"]
12367            .as_str()
12368            .ok_or("remember result should include memory_id")?
12369            .to_string();
12370
12371        let recall = Box::pin(handle_console_runtime_rpc(
12372            &runtime,
12373            None,
12374            None,
12375            None,
12376            None,
12377            None,
12378            Some(identity_runtime.clone()),
12379            None,
12380            None,
12381            rpc_request_with_params(
12382                "mobkit/agent_memory/recall",
12383                json!({
12384                    "identity": "identity:memory-console",
12385                    "selection": "contextual",
12386                    "query_text": "Where is CONSOLE-MEM-17?",
12387                    "query_terms": ["CONSOLE-MEM-17"]
12388                }),
12389            ),
12390            false,
12391        ))
12392        .await;
12393        assert_eq!(recall["error"], Value::Null, "{recall:#?}");
12394        assert_eq!(
12395            recall["result"]["records"]
12396                .as_array()
12397                .map(std::vec::Vec::len),
12398            Some(1)
12399        );
12400        assert_eq!(
12401            recall["result"]["records"][0]["memory_id"],
12402            json!(memory_id)
12403        );
12404
12405        let read_only_remember = Box::pin(handle_console_runtime_rpc(
12406            &runtime,
12407            None,
12408            None,
12409            None,
12410            None,
12411            None,
12412            Some(identity_runtime.clone()),
12413            None,
12414            None,
12415            rpc_request_with_params(
12416                "mobkit/agent_memory/remember",
12417                json!({
12418                    "identity": "identity:memory-console",
12419                    "title": "Denied",
12420                    "body": "Denied"
12421                }),
12422            ),
12423            false,
12424        ))
12425        .await;
12426        assert_eq!(
12427            read_only_remember["error"]["data"]["kind"],
12428            json!("read_only"),
12429            "{read_only_remember:#?}"
12430        );
12431
12432        let access_controller = AccessController::new(crate::access::AccessControlConfig {
12433            enabled: true,
12434            admins: vec!["admin@example.test".to_string()],
12435            ..crate::access::AccessControlConfig::default()
12436        })?;
12437        let denied_view = access_controller.view_for_subject(Some("viewer@example.test"));
12438        let denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
12439            &runtime,
12440            None,
12441            None,
12442            None,
12443            None,
12444            None,
12445            Some(identity_runtime.clone()),
12446            None,
12447            None,
12448            &AllowAllConsoleVisibilityPolicy,
12449            rpc_request_with_params(
12450                "mobkit/agent_memory/recall",
12451                json!({
12452                    "identity": "identity:memory-console",
12453                    "selection": "always"
12454                }),
12455            ),
12456            true,
12457            false,
12458            None,
12459            Some(&access_controller),
12460            Some(&denied_view),
12461            None,
12462            None,
12463            None,
12464            None,
12465        ))
12466        .await;
12467        assert_eq!(
12468            denied_recall["error"]["data"]["kind"],
12469            json!("access_denied"),
12470            "{denied_recall:#?}"
12471        );
12472        // §10.3: recall requires agent.memory.read AND agent.view; the read
12473        // action is checked (and reported) first.
12474        assert_eq!(
12475            denied_recall["error"]["data"]["action"],
12476            json!(ACTION_AGENT_MEMORY_READ),
12477            "{denied_recall:#?}"
12478        );
12479
12480        let send_only_controller = AccessController::new(crate::access::AccessControlConfig {
12481            enabled: true,
12482            admins: vec!["admin@example.test".to_string()],
12483            rules: vec![crate::access::AccessRule {
12484                id: "send-only-memory-console".to_string(),
12485                subjects: vec!["sender@example.test".to_string()],
12486                actions: vec![ACTION_AGENT_SEND.to_string()],
12487                agents: vec!["identity:memory-console".to_string()],
12488                ..crate::access::AccessRule::default()
12489            }],
12490            ..crate::access::AccessControlConfig::default()
12491        })?;
12492        let send_only_view = send_only_controller.view_for_subject(Some("sender@example.test"));
12493        let send_only_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
12494            &runtime,
12495            None,
12496            None,
12497            None,
12498            None,
12499            None,
12500            Some(identity_runtime.clone()),
12501            None,
12502            None,
12503            &AllowAllConsoleVisibilityPolicy,
12504            rpc_request_with_params(
12505                "mobkit/agent_memory/remember",
12506                json!({
12507                    "identity": "identity:memory-console",
12508                    "title": "Send-only denied",
12509                    "body": "Send-only users must not persist durable memory."
12510                }),
12511            ),
12512            true,
12513            false,
12514            None,
12515            Some(&send_only_controller),
12516            Some(&send_only_view),
12517            None,
12518            None,
12519            None,
12520            None,
12521        ))
12522        .await;
12523        assert_eq!(
12524            send_only_remember["error"]["data"]["kind"],
12525            json!("access_denied"),
12526            "{send_only_remember:#?}"
12527        );
12528        assert_eq!(
12529            send_only_remember["error"]["data"]["action"],
12530            json!(ACTION_AGENT_MEMORY_WRITE),
12531            "{send_only_remember:#?}"
12532        );
12533
12534        let send_only_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
12535            &runtime,
12536            None,
12537            None,
12538            None,
12539            None,
12540            None,
12541            Some(identity_runtime.clone()),
12542            None,
12543            None,
12544            &AllowAllConsoleVisibilityPolicy,
12545            rpc_request_with_params(
12546                "mobkit/agent_memory/forget",
12547                json!({
12548                    "identity": "identity:memory-console",
12549                    "memory_id": memory_id.clone()
12550                }),
12551            ),
12552            true,
12553            false,
12554            None,
12555            Some(&send_only_controller),
12556            Some(&send_only_view),
12557            None,
12558            None,
12559            None,
12560            None,
12561        ))
12562        .await;
12563        assert_eq!(
12564            send_only_forget["error"]["data"]["kind"],
12565            json!("access_denied"),
12566            "{send_only_forget:#?}"
12567        );
12568        assert_eq!(
12569            send_only_forget["error"]["data"]["action"],
12570            json!(ACTION_AGENT_MEMORY_DELETE),
12571            "{send_only_forget:#?}"
12572        );
12573
12574        let wildcard_allow_exact_deny_controller =
12575            AccessController::new(crate::access::AccessControlConfig {
12576                enabled: true,
12577                admins: vec!["admin@example.test".to_string()],
12578                rules: vec![
12579                    crate::access::AccessRule {
12580                        id: "deny-canonical-memory-console".to_string(),
12581                        effect: crate::access::AccessEffect::Deny,
12582                        subjects: vec!["wildcard@example.test".to_string()],
12583                        actions: vec![
12584                            ACTION_AGENT_VIEW.to_string(),
12585                            ACTION_AGENT_MEMORY_WRITE.to_string(),
12586                            ACTION_AGENT_MEMORY_DELETE.to_string(),
12587                        ],
12588                        agents: vec!["identity:memory-console".to_string()],
12589                        ..crate::access::AccessRule::default()
12590                    },
12591                    crate::access::AccessRule {
12592                        id: "allow-wildcard-memory-console".to_string(),
12593                        subjects: vec!["wildcard@example.test".to_string()],
12594                        actions: vec![
12595                            ACTION_AGENT_VIEW.to_string(),
12596                            ACTION_AGENT_MEMORY_WRITE.to_string(),
12597                            ACTION_AGENT_MEMORY_DELETE.to_string(),
12598                        ],
12599                        agents: vec!["*".to_string()],
12600                        ..crate::access::AccessRule::default()
12601                    },
12602                ],
12603                ..crate::access::AccessControlConfig::default()
12604            })?;
12605        let wildcard_allow_exact_deny_view =
12606            wildcard_allow_exact_deny_controller.view_for_subject(Some("wildcard@example.test"));
12607
12608        let whitespace_denied_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
12609            &runtime,
12610            None,
12611            None,
12612            None,
12613            None,
12614            None,
12615            Some(identity_runtime.clone()),
12616            None,
12617            None,
12618            &AllowAllConsoleVisibilityPolicy,
12619            rpc_request_with_params(
12620                "mobkit/agent_memory/remember",
12621                json!({
12622                    "identity": " identity:memory-console ",
12623                    "title": "Whitespace denied",
12624                    "body": "Whitespace-wrapped identities must be authorized canonically."
12625                }),
12626            ),
12627            true,
12628            false,
12629            None,
12630            Some(&wildcard_allow_exact_deny_controller),
12631            Some(&wildcard_allow_exact_deny_view),
12632            None,
12633            None,
12634            None,
12635            None,
12636        ))
12637        .await;
12638        assert_eq!(
12639            whitespace_denied_remember["error"]["data"]["kind"],
12640            json!("access_denied"),
12641            "{whitespace_denied_remember:#?}"
12642        );
12643        assert_eq!(
12644            whitespace_denied_remember["error"]["data"]["action"],
12645            json!(ACTION_AGENT_MEMORY_WRITE),
12646            "{whitespace_denied_remember:#?}"
12647        );
12648
12649        let whitespace_denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
12650            &runtime,
12651            None,
12652            None,
12653            None,
12654            None,
12655            None,
12656            Some(identity_runtime.clone()),
12657            None,
12658            None,
12659            &AllowAllConsoleVisibilityPolicy,
12660            rpc_request_with_params(
12661                "mobkit/agent_memory/recall",
12662                json!({
12663                    "identity": " identity:memory-console ",
12664                    "selection": "always"
12665                }),
12666            ),
12667            true,
12668            false,
12669            None,
12670            Some(&wildcard_allow_exact_deny_controller),
12671            Some(&wildcard_allow_exact_deny_view),
12672            None,
12673            None,
12674            None,
12675            None,
12676        ))
12677        .await;
12678        assert_eq!(
12679            whitespace_denied_recall["error"]["data"]["kind"],
12680            json!("access_denied"),
12681            "{whitespace_denied_recall:#?}"
12682        );
12683        // The exact-identity deny matches agent.memory.read through its
12684        // `agent.*` pattern; read is checked first (§10.3 mapping).
12685        assert_eq!(
12686            whitespace_denied_recall["error"]["data"]["action"],
12687            json!(ACTION_AGENT_MEMORY_READ),
12688            "{whitespace_denied_recall:#?}"
12689        );
12690
12691        let whitespace_denied_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
12692            &runtime,
12693            None,
12694            None,
12695            None,
12696            None,
12697            None,
12698            Some(identity_runtime.clone()),
12699            None,
12700            None,
12701            &AllowAllConsoleVisibilityPolicy,
12702            rpc_request_with_params(
12703                "mobkit/agent_memory/forget",
12704                json!({
12705                    "identity": " identity:memory-console ",
12706                    "memory_id": memory_id.clone()
12707                }),
12708            ),
12709            true,
12710            false,
12711            None,
12712            Some(&wildcard_allow_exact_deny_controller),
12713            Some(&wildcard_allow_exact_deny_view),
12714            None,
12715            None,
12716            None,
12717            None,
12718        ))
12719        .await;
12720        assert_eq!(
12721            whitespace_denied_forget["error"]["data"]["kind"],
12722            json!("access_denied"),
12723            "{whitespace_denied_forget:#?}"
12724        );
12725        assert_eq!(
12726            whitespace_denied_forget["error"]["data"]["action"],
12727            json!(ACTION_AGENT_MEMORY_DELETE),
12728            "{whitespace_denied_forget:#?}"
12729        );
12730
12731        let forget = Box::pin(handle_console_runtime_rpc(
12732            &runtime,
12733            None,
12734            None,
12735            None,
12736            None,
12737            None,
12738            Some(identity_runtime.clone()),
12739            None,
12740            None,
12741            rpc_request_with_params(
12742                "mobkit/agent_memory/forget",
12743                json!({
12744                    "identity": "identity:memory-console",
12745                    "memory_id": memory_id
12746                }),
12747            ),
12748            true,
12749        ))
12750        .await;
12751        assert_eq!(forget["error"], Value::Null, "{forget:#?}");
12752        assert_eq!(forget["result"]["deleted"], json!(true));
12753
12754        let after_forget = Box::pin(handle_console_runtime_rpc(
12755            &runtime,
12756            None,
12757            None,
12758            None,
12759            None,
12760            None,
12761            Some(identity_runtime),
12762            None,
12763            None,
12764            rpc_request_with_params(
12765                "mobkit/agent_memory/recall",
12766                json!({
12767                    "identity": "identity:memory-console",
12768                    "selection": "always"
12769                }),
12770            ),
12771            false,
12772        ))
12773        .await;
12774        assert_eq!(after_forget["error"], Value::Null, "{after_forget:#?}");
12775        assert_eq!(
12776            after_forget["result"]["records"]
12777                .as_array()
12778                .map(std::vec::Vec::len),
12779            Some(0)
12780        );
12781
12782        let _ = runtime.handle().stop().await;
12783        Ok(())
12784    }
12785
12786    #[tokio::test]
12787    async fn console_runtime_capabilities_advertise_respawn_and_reset_without_identity_runtime()
12788    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12789        let (_temp_dir, runtime) =
12790            build_empty_console_test_runtime("console-plain-lifecycle-capabilities").await?;
12791
12792        // Mutating caller, no identity runtime: respawn/reset dispatch via the
12793        // live-member fallback, so they must be advertised (the console gates
12794        // its Respawn/Reset buttons on this list).
12795        let response = Box::pin(handle_console_runtime_rpc(
12796            &runtime,
12797            None,
12798            None,
12799            None,
12800            None,
12801            None,
12802            None,
12803            None,
12804            None,
12805            rpc_request("mobkit/capabilities"),
12806            true,
12807        ))
12808        .await;
12809        assert_eq!(response["error"], Value::Null, "{response:#?}");
12810        let methods = response["result"]["methods"]
12811            .as_array()
12812            .ok_or("capabilities methods should be an array")?;
12813        for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
12814            assert!(
12815                methods.iter().any(|candidate| candidate == method),
12816                "plain runtime capabilities should advertise {method}: {methods:#?}"
12817            );
12818        }
12819        assert!(
12820            !methods
12821                .iter()
12822                .any(|candidate| candidate == "mobkit/delete_identity"),
12823            "delete_identity requires an identity runtime: {methods:#?}"
12824        );
12825
12826        // Unauthenticated callers keep the read-only projection: no lifecycle
12827        // mutations advertised.
12828        let read_only_response = Box::pin(handle_console_runtime_rpc(
12829            &runtime,
12830            None,
12831            None,
12832            None,
12833            None,
12834            None,
12835            None,
12836            None,
12837            None,
12838            rpc_request("mobkit/capabilities"),
12839            false,
12840        ))
12841        .await;
12842        assert_eq!(
12843            read_only_response["error"],
12844            Value::Null,
12845            "{read_only_response:#?}"
12846        );
12847        let read_only_methods = read_only_response["result"]["methods"]
12848            .as_array()
12849            .ok_or("capabilities methods should be an array")?;
12850        for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
12851            assert!(
12852                !read_only_methods
12853                    .iter()
12854                    .any(|candidate| candidate == method),
12855                "unauthenticated capabilities must omit {method}: {read_only_methods:#?}"
12856            );
12857        }
12858
12859        let _ = runtime.handle().stop().await;
12860        Ok(())
12861    }
12862
12863    #[tokio::test]
12864    async fn console_aggregator_rpc_does_not_expose_flow_editor_authoring_methods() {
12865        let response = Box::pin(handle_console_aggregator_rpc(
12866            None,
12867            rpc_request("mobkit/capabilities"),
12868            true,
12869            false,
12870            None,
12871            None,
12872        ))
12873        .await;
12874
12875        assert_eq!(response["error"], Value::Null, "{response:#?}");
12876        let methods = response["result"]["methods"]
12877            .as_array()
12878            .expect("capabilities methods should be an array");
12879        for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
12880            assert!(
12881                !methods.iter().any(|candidate| candidate == method),
12882                "console aggregator capabilities must not advertise flow-editor method {method}: {methods:#?}"
12883            );
12884        }
12885
12886        let mobpack_response = Box::pin(handle_console_aggregator_rpc(
12887            None,
12888            rpc_request("mobkit/mobpacks/schema"),
12889            true,
12890            false,
12891            None,
12892            None,
12893        ))
12894        .await;
12895        assert_eq!(
12896            mobpack_response["error"]["code"],
12897            json!(-32601),
12898            "console aggregator RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
12899        );
12900    }
12901
12902    #[tokio::test]
12903    async fn console_runtime_identity_reads_reject_stale_runtime_aliases()
12904    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12905        let (_temp_dir, runtime) =
12906            build_empty_console_test_runtime("console-identity-stale-read-alias").await?;
12907        let mut labels = BTreeMap::new();
12908        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
12909        runtime
12910            .handle()
12911            .spawn_spec(
12912                SpawnMemberSpec::from_wire(
12913                    "worker".to_string(),
12914                    // meerkat 0.7: roster ids are comms-safe encodings of the
12915                    // public alias (MemberCommsName rejects ":").
12916                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
12917                    Some("You are the stale Review Agent.".into()),
12918                    None,
12919                    None,
12920                )
12921                .with_labels(labels),
12922            )
12923            .await?;
12924
12925        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12926            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12927            lease_provider: Arc::new(LocalLeaseProvider::new()),
12928            runtime_instance_id: "console-identity-stale-read-alias".to_string(),
12929            has_runtime_store: true,
12930            durability_policy: DurabilityPolicy::SyncWriteThrough,
12931            bridge: None,
12932            default_timeout: None,
12933        }));
12934        let identity = AgentIdentity::parse("review:singleton")?;
12935        let record = ContinuityRecord {
12936            identity: identity.clone(),
12937            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:1")?,
12938            session_id: meerkat_core::types::SessionId::new(),
12939            generation: ContinuityGeneration::new(1),
12940            checkpoint_version: CheckpointVersion::new(0),
12941        };
12942        identity_runtime
12943            .register(
12944                DurableAgentSpec {
12945                    identity: identity.clone(),
12946                    profile: ProfileName::from("worker"),
12947                    addressability: AgentAddressability::Addressable,
12948                    display_name: None,
12949                    labels: BTreeMap::new(),
12950                    context: None,
12951                    additional_instructions: Vec::new(),
12952                    initial_message: None,
12953                    runtime_mode_override: None,
12954                    backend: None,
12955                    binding: None,
12956                },
12957                IdentityLifecycleState::Active,
12958                Some(record),
12959                Some(LeaseGrant {
12960                    identity,
12961                    fencing_token: FencingToken::new(7),
12962                    ttl: Duration::from_mins(1),
12963                }),
12964            )
12965            .await;
12966
12967        for requested_identity in ["rt:review:singleton:0", "review:singleton"] {
12968            for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
12969                let response = Box::pin(handle_console_runtime_rpc(
12970                    &runtime,
12971                    None,
12972                    None,
12973                    None,
12974                    None,
12975                    None,
12976                    Some(identity_runtime.clone()),
12977                    None,
12978                    None,
12979                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
12980                    true,
12981                ))
12982                .await;
12983                assert_ne!(
12984                    response["error"],
12985                    Value::Null,
12986                    "{method} must reject stale alias for {requested_identity}"
12987                );
12988                let message = response["error"]["message"].as_str().unwrap_or_default();
12989                assert!(
12990                    message.contains(
12991                        "identity runtime binding for review:singleton points at rt:review:singleton:1"
12992                    ),
12993                    "unexpected stale-alias message for {method}/{requested_identity}: {message}"
12994                );
12995                assert_eq!(
12996                    response["error"]["data"]["kind"],
12997                    json!("stale_identity_runtime_binding")
12998                );
12999                assert_eq!(
13000                    response["error"]["data"]["registered_runtime_member_id"],
13001                    json!("rt:review:singleton:1")
13002                );
13003                assert_eq!(
13004                    response["error"]["data"]["live_runtime_member_id"],
13005                    json!("rt:review:singleton:0")
13006                );
13007            }
13008        }
13009
13010        let list_response = Box::pin(handle_console_runtime_rpc(
13011            &runtime,
13012            None,
13013            None,
13014            None,
13015            None,
13016            None,
13017            Some(identity_runtime.clone()),
13018            None,
13019            None,
13020            rpc_request("mobkit/list_members"),
13021            true,
13022        ))
13023        .await;
13024        let listed = list_response["result"].as_array().expect("list members");
13025        assert!(
13026            listed
13027                .iter()
13028                .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
13029            "list_members must filter stale runtime aliases: {list_response:#?}"
13030        );
13031
13032        let find_response = Box::pin(handle_console_runtime_rpc(
13033            &runtime,
13034            None,
13035            None,
13036            None,
13037            None,
13038            None,
13039            Some(identity_runtime.clone()),
13040            None,
13041            None,
13042            rpc_request_with_params(
13043                "mobkit/find_members",
13044                json!({ "label_key": "agent_identity", "label_value": "review:singleton" }),
13045            ),
13046            true,
13047        ))
13048        .await;
13049        let found = find_response["result"].as_array().expect("find members");
13050        assert!(
13051            found
13052                .iter()
13053                .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
13054            "find_members must filter stale runtime aliases: {find_response:#?}"
13055        );
13056
13057        for method in [
13058            "mobkit/get_member",
13059            "mobkit/member_status",
13060            "mobkit/identity/resolved_tools",
13061            "mobkit/retire_member",
13062            "mobkit/respawn_member",
13063            "mobkit/force_cancel_member",
13064        ] {
13065            let param_name = if method == "mobkit/identity/resolved_tools" {
13066                "identity"
13067            } else {
13068                "member_id"
13069            };
13070            let response = Box::pin(handle_console_runtime_rpc(
13071                &runtime,
13072                None,
13073                None,
13074                None,
13075                None,
13076                None,
13077                Some(identity_runtime.clone()),
13078                None,
13079                None,
13080                rpc_request_with_params(method, json!({ param_name: "rt:review:singleton:0" })),
13081                true,
13082            ))
13083            .await;
13084            assert_ne!(
13085                response["error"],
13086                Value::Null,
13087                "{method} must reject stale runtime alias"
13088            );
13089            assert_eq!(
13090                response["error"]["data"]["kind"],
13091                json!("stale_identity_runtime_binding")
13092            );
13093            assert_eq!(
13094                response["error"]["data"]["live_runtime_member_id"],
13095                json!("rt:review:singleton:0")
13096            );
13097        }
13098
13099        let (_temp_dir_without_stale, runtime_without_stale) =
13100            build_empty_console_test_runtime("console-identity-no-live-stale-alias").await?;
13101
13102        for method in [
13103            "mobkit/status_identity",
13104            "mobkit/inspect_identity",
13105            "mobkit/retire",
13106            "mobkit/respawn",
13107            "mobkit/reset",
13108        ] {
13109            let response = Box::pin(handle_console_runtime_rpc(
13110                &runtime_without_stale,
13111                None,
13112                None,
13113                None,
13114                Some(ConsoleEventStore::new()),
13115                None,
13116                Some(identity_runtime.clone()),
13117                None,
13118                None,
13119                rpc_request_with_params(method, json!({ "identity": "rt:review:singleton:0" })),
13120                true,
13121            ))
13122            .await;
13123            assert_ne!(
13124                response["error"],
13125                Value::Null,
13126                "{method} must reject stale synthetic runtime alias"
13127            );
13128            assert!(
13129                response["error"]["message"]
13130                    .as_str()
13131                    .unwrap_or_default()
13132                    .contains("identity not found: rt:review:singleton:0"),
13133                "unexpected no-live stale-alias response for {method}: {response:#?}"
13134            );
13135        }
13136        let _ = runtime_without_stale.handle().stop().await;
13137
13138        let _ = runtime.handle().stop().await;
13139        Ok(())
13140    }
13141
13142    #[tokio::test]
13143    async fn console_peer_info_uses_current_identity_generation_when_stale_row_remains()
13144    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13145        let (_temp_dir, runtime) =
13146            build_empty_console_test_runtime("console-current-generation-peer-info").await?;
13147        let durable_identity = "review:peer-info-current";
13148        let stale_alias = "rt:review:peer-info-current:0";
13149        let current_alias = "rt:review:peer-info-current:1";
13150        for runtime_alias in [stale_alias, current_alias] {
13151            runtime
13152                .handle()
13153                .spawn_spec(
13154                    SpawnMemberSpec::from_wire(
13155                        "worker".to_string(),
13156                        crate::member_comms_id::mob_member_id_str(runtime_alias).into_owned(),
13157                        None,
13158                        None,
13159                        None,
13160                    )
13161                    .with_labels(BTreeMap::from([(
13162                        "agent_identity".to_string(),
13163                        durable_identity.to_string(),
13164                    )])),
13165                )
13166                .await?;
13167        }
13168
13169        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13170            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13171            lease_provider: Arc::new(LocalLeaseProvider::new()),
13172            runtime_instance_id: "console-current-generation-peer-info".to_string(),
13173            has_runtime_store: true,
13174            durability_policy: DurabilityPolicy::SyncWriteThrough,
13175            bridge: None,
13176            default_timeout: None,
13177        }));
13178        let identity = AgentIdentity::parse(durable_identity)?;
13179        identity_runtime
13180            .register(
13181                DurableAgentSpec {
13182                    identity: identity.clone(),
13183                    profile: ProfileName::from("worker"),
13184                    addressability: AgentAddressability::Addressable,
13185                    display_name: None,
13186                    labels: BTreeMap::new(),
13187                    context: None,
13188                    additional_instructions: Vec::new(),
13189                    initial_message: None,
13190                    runtime_mode_override: None,
13191                    backend: None,
13192                    binding: None,
13193                },
13194                IdentityLifecycleState::Active,
13195                Some(ContinuityRecord {
13196                    identity,
13197                    agent_runtime_id: AgentRuntimeId::parse(current_alias)?,
13198                    session_id: meerkat_core::types::SessionId::new(),
13199                    generation: ContinuityGeneration::new(1),
13200                    checkpoint_version: CheckpointVersion::new(0),
13201                }),
13202                None,
13203            )
13204            .await;
13205
13206        let response = Box::pin(handle_console_runtime_rpc(
13207            &runtime,
13208            None,
13209            None,
13210            None,
13211            None,
13212            None,
13213            Some(identity_runtime),
13214            None,
13215            None,
13216            rpc_request_with_params(
13217                "mobkit/cross_mob/peer_info",
13218                json!({"member_id": durable_identity}),
13219            ),
13220            true,
13221        ))
13222        .await;
13223        assert_eq!(response["error"], Value::Null, "{response:#?}");
13224        let comms_name = response["result"]["comms_name"]
13225            .as_str()
13226            .ok_or("peer info must return comms_name")?;
13227        assert!(
13228            comms_name.ends_with(crate::member_comms_id::mob_member_id_str(current_alias).as_ref()),
13229            "console peer info selected the stale generation: {response:#?}"
13230        );
13231
13232        let ambiguous = Box::pin(handle_console_runtime_rpc(
13233            &runtime,
13234            None,
13235            None,
13236            None,
13237            None,
13238            None,
13239            None,
13240            None,
13241            None,
13242            rpc_request_with_params(
13243                "mobkit/cross_mob/peer_info",
13244                json!({"member_id": durable_identity}),
13245            ),
13246            true,
13247        ))
13248        .await;
13249        assert!(
13250            ambiguous["error"]["message"]
13251                .as_str()
13252                .is_some_and(|message| message.contains("ambiguous durable member alias")),
13253            "classic fallback must fail closed when both reset generations remain: {ambiguous:#?}"
13254        );
13255
13256        let _ = runtime.handle().stop().await;
13257        Ok(())
13258    }
13259
13260    #[tokio::test]
13261    async fn aggregator_live_snapshot_projects_identity_first_topology_peers()
13262    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13263        let (_temp_dir, mob_runtime) =
13264            build_empty_console_test_runtime("identity-topology-snapshot-test").await?;
13265        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13266            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13267            lease_provider: Arc::new(LocalLeaseProvider::new()),
13268            runtime_instance_id: "console-topology-snapshot-test".to_string(),
13269            has_runtime_store: true,
13270            durability_policy: DurabilityPolicy::SyncWriteThrough,
13271            bridge: None,
13272            default_timeout: None,
13273        }));
13274
13275        for name in ["agent:alpha", "agent:beta"] {
13276            let identity = AgentIdentity::parse(name)?;
13277            let record = ContinuityRecord {
13278                identity: identity.clone(),
13279                agent_runtime_id: AgentRuntimeId::parse(&format!("rt:{name}:0"))?,
13280                session_id: meerkat_core::types::SessionId::new(),
13281                generation: ContinuityGeneration::new(0),
13282                checkpoint_version: CheckpointVersion::new(0),
13283            };
13284            identity_runtime
13285                .register(
13286                    DurableAgentSpec {
13287                        identity: identity.clone(),
13288                        profile: ProfileName::from("default"),
13289                        addressability: AgentAddressability::Addressable,
13290                        display_name: None,
13291                        labels: BTreeMap::new(),
13292                        context: None,
13293                        additional_instructions: Vec::new(),
13294                        initial_message: None,
13295                        runtime_mode_override: None,
13296                        backend: None,
13297                        binding: None,
13298                    },
13299                    IdentityLifecycleState::Active,
13300                    Some(record),
13301                    Some(LeaseGrant {
13302                        identity,
13303                        fencing_token: FencingToken::new(7),
13304                        ttl: Duration::from_mins(1),
13305                    }),
13306                )
13307                .await;
13308        }
13309        identity_runtime
13310            .set_desired_peer_edges(vec![ManagedPeerEdge::new(
13311                AgentIdentity::parse("agent:alpha")?,
13312                AgentIdentity::parse("agent:beta")?,
13313            )?])
13314            .await;
13315
13316        let aggregator = MobKitConsoleAggregator::in_memory();
13317        aggregator.register_runtime_handles_with_policy(
13318            "identity-first",
13319            "",
13320            mob_runtime.clone(),
13321            Some(identity_runtime),
13322            ConsoleEventStore::new(),
13323            Arc::new(AllowAllConsoleVisibilityPolicy),
13324        );
13325
13326        let snapshot = build_aggregator_live_snapshot(&aggregator, &[]).await?;
13327        let alpha = snapshot
13328            .members
13329            .iter()
13330            .find(|member| member.agent_identity == "agent:alpha")
13331            .ok_or("agent:alpha missing from live snapshot")?;
13332        assert_eq!(alpha.wired_to, vec!["agent:beta".to_string()]);
13333
13334        let _ = mob_runtime.handle().stop().await;
13335        Ok(())
13336    }
13337
13338    #[tokio::test]
13339    async fn identity_first_console_send_reserves_timeline_and_uses_identity_runtime()
13340    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13341        let (_temp_dir, mob_runtime) =
13342            build_empty_console_test_runtime("identity-send-runtime-key-test").await?;
13343        let identity = AgentIdentity::parse("agent:console")?;
13344        let record = ContinuityRecord {
13345            identity: identity.clone(),
13346            agent_runtime_id: AgentRuntimeId::parse("rt:agent:console:0")?,
13347            session_id: meerkat_core::types::SessionId::new(),
13348            generation: ContinuityGeneration::new(0),
13349            checkpoint_version: CheckpointVersion::new(0),
13350        };
13351        let runtime = IdentityRuntime::new(IdentityRuntimeConfig {
13352            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13353            lease_provider: Arc::new(LocalLeaseProvider::new()),
13354            runtime_instance_id: "console-test".to_string(),
13355            has_runtime_store: true,
13356            durability_policy: DurabilityPolicy::SyncWriteThrough,
13357            bridge: None,
13358            default_timeout: None,
13359        });
13360        runtime
13361            .register(
13362                DurableAgentSpec {
13363                    identity: identity.clone(),
13364                    profile: ProfileName::from("default"),
13365                    addressability: AgentAddressability::Addressable,
13366                    display_name: None,
13367                    labels: BTreeMap::new(),
13368                    context: None,
13369                    additional_instructions: Vec::new(),
13370                    initial_message: None,
13371                    runtime_mode_override: None,
13372                    backend: None,
13373                    binding: None,
13374                },
13375                IdentityLifecycleState::Active,
13376                Some(record.clone()),
13377                Some(LeaseGrant {
13378                    identity: identity.clone(),
13379                    fencing_token: FencingToken::new(7),
13380                    ttl: Duration::from_mins(1),
13381                }),
13382            )
13383            .await;
13384
13385        let aggregator = MobKitConsoleAggregator::in_memory();
13386        let events = ConsoleEventStore::new();
13387        let runtime = Arc::new(runtime);
13388        aggregator.register_runtime_handles_with_policy(
13389            "default",
13390            "",
13391            mob_runtime.clone(),
13392            Some(runtime.clone()),
13393            events.clone(),
13394            Arc::new(AllowAllConsoleVisibilityPolicy),
13395        );
13396        let accepted = console_send_identity_first(
13397            &aggregator,
13398            runtime.clone(),
13399            Some(&mob_runtime),
13400            Some(&events),
13401            crate::console_aggregator::ConsoleSendRequest {
13402                identity: identity.as_str().to_string(),
13403                content: serde_json::to_value(meerkat_core::ContentInput::Text(
13404                    "hello".to_string(),
13405                ))?,
13406                origin: "test".to_string(),
13407                idempotency_key: "idem-1".to_string(),
13408                handling_mode: None,
13409            },
13410        )
13411        .await?;
13412
13413        assert_eq!(accepted.identity, identity.as_str());
13414        assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
13415        assert_eq!(accepted.session_id, Some(record.session_id.to_string()));
13416
13417        let page = aggregator
13418            .query_timeline(ConsoleTimelineQuery {
13419                identity: Some(identity.as_str().to_string()),
13420                ..ConsoleTimelineQuery::default()
13421            })
13422            .await?;
13423        assert_eq!(page.frames.len(), 1);
13424        assert_eq!(page.frames[0].runtime_key, "default");
13425        assert_eq!(page.frames[0].status, ConsoleFrameStatus::Accepted);
13426        assert_eq!(
13427            page.frames[0].session_id,
13428            Some(record.session_id.to_string())
13429        );
13430        let _ = mob_runtime.handle().stop().await;
13431        Ok(())
13432    }
13433
13434    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13435    async fn identity_first_console_send_falls_back_to_member_only_spawned_worker()
13436    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13437        let (_temp_dir, mob_runtime) =
13438            build_empty_console_test_runtime("identity-send-member-only-test").await?;
13439        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13440            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13441            lease_provider: Arc::new(LocalLeaseProvider::new()),
13442            runtime_instance_id: "console-member-only-send-test".to_string(),
13443            has_runtime_store: true,
13444            durability_policy: DurabilityPolicy::SyncWriteThrough,
13445            bridge: None,
13446            default_timeout: None,
13447        }));
13448
13449        let aggregator = MobKitConsoleAggregator::in_memory();
13450        let events = ConsoleEventStore::new();
13451        aggregator.register_runtime_handles_with_policy(
13452            "identity-first",
13453            "",
13454            mob_runtime.clone(),
13455            Some(identity_runtime.clone()),
13456            events.clone(),
13457            Arc::new(AllowAllConsoleVisibilityPolicy),
13458        );
13459
13460        mob_runtime
13461            .handle()
13462            .spawn_spec(SpawnMemberSpec::from_wire(
13463                "worker".to_string(),
13464                // meerkat 0.7: roster ids are comms-safe encodings of the
13465                // public alias (MemberCommsName rejects ":").
13466                crate::member_comms_id::mob_member_id_str("agent:member-only").into_owned(),
13467                Some("You are a member-only spawned worker.".into()),
13468                None,
13469                None,
13470            ))
13471            .await?;
13472
13473        let accepted = console_send_with_identity_first_fallback(
13474            &aggregator,
13475            identity_runtime,
13476            Some(&mob_runtime),
13477            Some(&events),
13478            crate::console_aggregator::ConsoleSendRequest {
13479                identity: "agent:member-only".to_string(),
13480                content: serde_json::to_value(meerkat_core::ContentInput::Text(
13481                    "hello spawned worker".to_string(),
13482                ))?,
13483                origin: "test".to_string(),
13484                idempotency_key: "member-only-idem-1".to_string(),
13485                handling_mode: None,
13486            },
13487        )
13488        .await?;
13489
13490        assert_eq!(accepted.identity, "agent:member-only");
13491        assert!(accepted.session_id.is_some());
13492
13493        let page = aggregator
13494            .query_timeline(ConsoleTimelineQuery {
13495                identity: Some("agent:member-only".to_string()),
13496                ..ConsoleTimelineQuery::default()
13497            })
13498            .await?;
13499        assert!(
13500            page.frames.iter().any(|frame| frame.kind == "user_input"),
13501            "fallback send should persist a user input frame for the member-only worker: {page:#?}"
13502        );
13503
13504        let _ = mob_runtime.handle().stop().await;
13505        Ok(())
13506    }
13507
13508    #[tokio::test]
13509    async fn identity_first_console_send_returns_before_bridge_delivery_completes()
13510    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13511        let identity = AgentIdentity::parse("agent:slow-console")?;
13512        let record = ContinuityRecord {
13513            identity: identity.clone(),
13514            agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-console:0")?,
13515            session_id: meerkat_core::types::SessionId::new(),
13516            generation: ContinuityGeneration::new(0),
13517            checkpoint_version: CheckpointVersion::new(0),
13518        };
13519        let deliver_calls = Arc::new(AtomicUsize::new(0));
13520        let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13521            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13522            lease_provider: Arc::new(LocalLeaseProvider::new()),
13523            runtime_instance_id: "console-slow-send-test".to_string(),
13524            has_runtime_store: true,
13525            durability_policy: DurabilityPolicy::SyncWriteThrough,
13526            bridge: Some(Arc::new(BlockingIdentityBridge {
13527                deliver_calls: deliver_calls.clone(),
13528            })),
13529            default_timeout: None,
13530        }));
13531        runtime
13532            .register(
13533                DurableAgentSpec {
13534                    identity: identity.clone(),
13535                    profile: ProfileName::from("default"),
13536                    addressability: AgentAddressability::Addressable,
13537                    display_name: None,
13538                    labels: BTreeMap::new(),
13539                    context: None,
13540                    additional_instructions: Vec::new(),
13541                    initial_message: None,
13542                    runtime_mode_override: None,
13543                    backend: None,
13544                    binding: None,
13545                },
13546                IdentityLifecycleState::Active,
13547                Some(record.clone()),
13548                Some(LeaseGrant {
13549                    identity: identity.clone(),
13550                    fencing_token: FencingToken::new(7),
13551                    ttl: Duration::from_mins(1),
13552                }),
13553            )
13554            .await;
13555
13556        let aggregator = MobKitConsoleAggregator::in_memory();
13557        let accepted = match tokio::time::timeout(
13558            Duration::from_millis(100),
13559            console_send_identity_first(
13560                &aggregator,
13561                runtime,
13562                None,
13563                None,
13564                crate::console_aggregator::ConsoleSendRequest {
13565                    identity: identity.as_str().to_string(),
13566                    content: serde_json::to_value(meerkat_core::ContentInput::Text(
13567                        "hello slow bridge".to_string(),
13568                    ))?,
13569                    origin: "test".to_string(),
13570                    idempotency_key: "idem-slow-bridge".to_string(),
13571                    handling_mode: None,
13572                },
13573            ),
13574        )
13575        .await
13576        {
13577            Ok(Ok(accepted)) => accepted,
13578            Ok(Err(err)) => return Err(format!("send should be accepted: {err}").into()),
13579            Err(err) => {
13580                return Err(
13581                    format!("console send should not wait for bridge delivery: {err}").into(),
13582                );
13583            }
13584        };
13585
13586        assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
13587        if tokio::time::timeout(Duration::from_millis(100), async {
13588            while deliver_calls.load(Ordering::SeqCst) == 0 {
13589                tokio::time::sleep(Duration::from_millis(5)).await;
13590            }
13591        })
13592        .await
13593        .is_err()
13594        {
13595            return Err("delivery should be spawned in the background".into());
13596        }
13597        Ok(())
13598    }
13599
13600    #[tokio::test]
13601    async fn identity_first_console_steer_waits_for_bridge_delivery()
13602    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13603        let identity = AgentIdentity::parse("agent:slow-steer-console")?;
13604        let record = ContinuityRecord {
13605            identity: identity.clone(),
13606            agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-steer-console:0")?,
13607            session_id: meerkat_core::types::SessionId::new(),
13608            generation: ContinuityGeneration::new(0),
13609            checkpoint_version: CheckpointVersion::new(0),
13610        };
13611        let deliver_calls = Arc::new(AtomicUsize::new(0));
13612        let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13613            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13614            lease_provider: Arc::new(LocalLeaseProvider::new()),
13615            runtime_instance_id: "console-slow-steer-send-test".to_string(),
13616            has_runtime_store: true,
13617            durability_policy: DurabilityPolicy::SyncWriteThrough,
13618            bridge: Some(Arc::new(BlockingIdentityBridge {
13619                deliver_calls: deliver_calls.clone(),
13620            })),
13621            default_timeout: None,
13622        }));
13623        runtime
13624            .register(
13625                DurableAgentSpec {
13626                    identity: identity.clone(),
13627                    profile: ProfileName::from("default"),
13628                    addressability: AgentAddressability::Addressable,
13629                    display_name: None,
13630                    labels: BTreeMap::new(),
13631                    context: None,
13632                    additional_instructions: Vec::new(),
13633                    initial_message: None,
13634                    runtime_mode_override: None,
13635                    backend: None,
13636                    binding: None,
13637                },
13638                IdentityLifecycleState::Active,
13639                Some(record),
13640                Some(LeaseGrant {
13641                    identity: identity.clone(),
13642                    fencing_token: FencingToken::new(7),
13643                    ttl: Duration::from_mins(1),
13644                }),
13645            )
13646            .await;
13647
13648        let aggregator = MobKitConsoleAggregator::in_memory();
13649        let result = tokio::time::timeout(
13650            Duration::from_millis(100),
13651            console_send_identity_first(
13652                &aggregator,
13653                runtime,
13654                None,
13655                None,
13656                crate::console_aggregator::ConsoleSendRequest {
13657                    identity: identity.as_str().to_string(),
13658                    content: serde_json::to_value(meerkat_core::ContentInput::Text(
13659                        "hello slow steer bridge".to_string(),
13660                    ))?,
13661                    origin: "test".to_string(),
13662                    idempotency_key: "idem-slow-steer-bridge".to_string(),
13663                    handling_mode: Some("steer".to_string()),
13664                },
13665            ),
13666        )
13667        .await;
13668
13669        if result.is_ok() {
13670            return Err("steer send must wait for bridge delivery admission".into());
13671        }
13672        assert_eq!(
13673            deliver_calls.load(Ordering::SeqCst),
13674            1,
13675            "steer delivery should have reached the bridge before the console response waits"
13676        );
13677        Ok(())
13678    }
13679
13680    #[tokio::test]
13681    async fn identity_first_console_send_forwards_handling_mode()
13682    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13683        let identity = AgentIdentity::parse("agent:mode-console")?;
13684        let record = ContinuityRecord {
13685            identity: identity.clone(),
13686            agent_runtime_id: AgentRuntimeId::parse("rt:agent:mode-console:0")?,
13687            session_id: meerkat_core::types::SessionId::new(),
13688            generation: ContinuityGeneration::new(0),
13689            checkpoint_version: CheckpointVersion::new(0),
13690        };
13691        let handling_modes = Arc::new(Mutex::new(Vec::new()));
13692        let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13693            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13694            lease_provider: Arc::new(LocalLeaseProvider::new()),
13695            runtime_instance_id: "console-mode-send-test".to_string(),
13696            has_runtime_store: true,
13697            durability_policy: DurabilityPolicy::SyncWriteThrough,
13698            bridge: Some(Arc::new(RecordingIdentityBridge {
13699                session_id: record.session_id.clone(),
13700                handling_modes: handling_modes.clone(),
13701            })),
13702            default_timeout: None,
13703        }));
13704        runtime
13705            .register(
13706                DurableAgentSpec {
13707                    identity: identity.clone(),
13708                    profile: ProfileName::from("default"),
13709                    addressability: AgentAddressability::Addressable,
13710                    display_name: None,
13711                    labels: BTreeMap::new(),
13712                    context: None,
13713                    additional_instructions: Vec::new(),
13714                    initial_message: None,
13715                    runtime_mode_override: None,
13716                    backend: None,
13717                    binding: None,
13718                },
13719                IdentityLifecycleState::Active,
13720                Some(record),
13721                Some(LeaseGrant {
13722                    identity: identity.clone(),
13723                    fencing_token: FencingToken::new(7),
13724                    ttl: Duration::from_mins(1),
13725                }),
13726            )
13727            .await;
13728
13729        let aggregator = MobKitConsoleAggregator::in_memory();
13730        let accepted = console_send_identity_first(
13731            &aggregator,
13732            runtime,
13733            None,
13734            None,
13735            crate::console_aggregator::ConsoleSendRequest {
13736                identity: identity.as_str().to_string(),
13737                content: serde_json::to_value(meerkat_core::ContentInput::Text(
13738                    "hello steer bridge".to_string(),
13739                ))?,
13740                origin: "test".to_string(),
13741                idempotency_key: "idem-steer-bridge".to_string(),
13742                handling_mode: Some("steer".to_string()),
13743            },
13744        )
13745        .await?;
13746
13747        if tokio::time::timeout(Duration::from_millis(100), async {
13748            loop {
13749                if handling_modes
13750                    .lock()
13751                    .map(|modes| modes.contains(&HandlingMode::Steer))
13752                    .unwrap_or(false)
13753                {
13754                    break;
13755                }
13756                tokio::time::sleep(Duration::from_millis(5)).await;
13757            }
13758        })
13759        .await
13760        .is_err()
13761        {
13762            return Err("identity-first console send should forward steer mode".into());
13763        }
13764
13765        let terminal_frame = tokio::time::timeout(Duration::from_millis(500), async {
13766            loop {
13767                let page = aggregator
13768                    .query_timeline(ConsoleTimelineQuery {
13769                        identity: Some(identity.as_str().to_string()),
13770                        ..ConsoleTimelineQuery::default()
13771                    })
13772                    .await
13773                    .map_err(|err| format!("query timeline: {err}"))?;
13774                if page.frames.iter().any(|frame| {
13775                    frame.kind == "interaction_complete"
13776                        && frame.interaction_id.as_deref() == Some(accepted.interaction_id.as_str())
13777                        && frame.payload.get("reason").and_then(Value::as_str)
13778                            == Some("steer_delivered")
13779                }) {
13780                    return Ok::<(), String>(());
13781                }
13782                tokio::time::sleep(Duration::from_millis(5)).await;
13783            }
13784        })
13785        .await;
13786        match terminal_frame {
13787            Ok(Ok(())) => {}
13788            Ok(Err(err)) => return Err(err.into()),
13789            Err(_) => {
13790                return Err(
13791                    "identity-first steer send should terminalize its console interaction".into(),
13792                );
13793            }
13794        }
13795        Ok(())
13796    }
13797
13798    #[test]
13799    fn multipart_body_limit_covers_configured_image_limit() {
13800        const _: () = assert!(MAX_MULTIPART_BODY_BYTES > MAX_MULTIPART_IMAGE_BYTES);
13801        const _: () = assert!(MAX_MULTIPART_BODY_BYTES > 2 * 1024 * 1024);
13802    }
13803
13804    /// Cold-cache contract: a `prime_now` waiter that arrives while
13805    /// another task holds `refresh_lock` must park on the lock and
13806    /// resume after that task releases it. No race-prone signaling
13807    /// involved — the lock acquisition itself IS the signal that the
13808    /// in-flight refresh has finished and `primed` is true.
13809    ///
13810    /// Test shape: hold `refresh_lock` from the test thread (no real
13811    /// refresh task), spawn a `prime_now`-style waiter, then set
13812    /// `primed` + drop the lock. The waiter must observe `primed`
13813    /// after acquiring the lock and return without redoing the
13814    /// refresh (we'd otherwise deadlock since we don't supply a
13815    /// real `MobRuntime`).
13816    #[tokio::test]
13817    async fn cold_cache_waiter_resumes_when_refresh_lock_drops()
13818    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13819        use std::sync::atomic::Ordering;
13820        use tokio::time::Duration;
13821
13822        let model = ConsoleSnapshotReadModel::default();
13823        let guard = model
13824            .refresh_lock
13825            .clone()
13826            .try_lock_owned()
13827            .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
13828
13829        let model_for_waiter = model.clone();
13830        let waiter = tokio::spawn(async move {
13831            // Inlined `prime_now` shape (skips the runtime call,
13832            // since the test will set `primed` before this acquires).
13833            if model_for_waiter
13834                .primed
13835                .load(std::sync::atomic::Ordering::Acquire)
13836            {
13837                return;
13838            }
13839            let _wait_guard = model_for_waiter.refresh_lock.clone().lock_owned().await;
13840            // After acquiring, `primed` must be true (the "refresher"
13841            // — i.e., the test thread — set it before releasing).
13842            assert!(
13843                model_for_waiter
13844                    .primed
13845                    .load(std::sync::atomic::Ordering::Acquire),
13846                "waiter acquired lock but primed is still false"
13847            );
13848        });
13849
13850        // Give the waiter time to reach `lock_owned().await`.
13851        tokio::time::sleep(Duration::from_millis(20)).await;
13852
13853        // Set primed, then release the lock. The waiter parked on
13854        // `lock_owned()` should acquire it immediately.
13855        model.primed.store(true, Ordering::Release);
13856        drop(guard);
13857
13858        let result = tokio::time::timeout(Duration::from_secs(1), waiter).await;
13859        assert!(
13860            result.is_ok(),
13861            "waiter should resume once the refresh lock drops"
13862        );
13863        Ok(())
13864    }
13865
13866    /// Companion: when `primed` is already set, `snapshot()` returns
13867    /// without touching the refresh lock at all. Guards against an
13868    /// over-eager `prime_now` that would deadlock during normal
13869    /// (hot-cache) traffic.
13870    #[tokio::test]
13871    async fn snapshot_skips_refresh_lock_when_already_primed()
13872    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13873        use std::sync::atomic::Ordering;
13874        use tokio::time::Duration;
13875
13876        let model = ConsoleSnapshotReadModel::default();
13877        model.primed.store(true, Ordering::Release);
13878        // Pre-acquire the refresh lock to prove it isn't touched.
13879        let _guard = model
13880            .refresh_lock
13881            .clone()
13882            .try_lock_owned()
13883            .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
13884
13885        // `snapshot()` calls `prime_now` only on cold cache; with
13886        // primed=true the lock-await branch must not be reached.
13887        // We test the contract via direct inspection: if `prime_now`
13888        // accidentally tried `lock_owned().await` here, this would
13889        // hang. The timeout below is the deadlock guard.
13890        let snap_fast_path = async {
13891            assert!(
13892                model.primed.load(Ordering::Acquire),
13893                "primed precondition for hot-cache path"
13894            );
13895        };
13896        let result = tokio::time::timeout(Duration::from_millis(100), snap_fast_path).await;
13897        assert!(result.is_ok(), "hot-cache snapshot path should not block");
13898        Ok(())
13899    }
13900
13901    #[tokio::test]
13902    async fn console_aggregator_reset_all_rpc_rejects_destructive_retire_all_semantics()
13903    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13904        let (_temp_dir, runtime) =
13905            build_empty_console_test_runtime("console-reset-fresh-identity-cache").await?;
13906        let aggregator = MobKitConsoleAggregator::in_memory();
13907        aggregator.register_runtime_handles_with_policy(
13908            "runtime-reset",
13909            "reset",
13910            runtime.clone(),
13911            None,
13912            ConsoleEventStore::new(),
13913            Arc::new(AllowAllConsoleVisibilityPolicy),
13914        );
13915        let primed_empty = aggregator.list_identities().await?;
13916        assert!(
13917            primed_empty.is_empty(),
13918            "test precondition: identity cache should be primed empty before late spawn"
13919        );
13920
13921        runtime
13922            .handle()
13923            .spawn_spec(SpawnMemberSpec::from_wire(
13924                "worker".to_string(),
13925                "agent-reset".to_string(),
13926                Some("You are agent-reset.".into()),
13927                None,
13928                None,
13929            ))
13930            .await?;
13931
13932        let response = Box::pin(handle_console_aggregator_rpc(
13933            Some(aggregator),
13934            rpc_request("mobkit/reset_all"),
13935            true,
13936            false,
13937            None,
13938            None,
13939        ))
13940        .await;
13941
13942        assert_eq!(response["result"], Value::Null);
13943        assert_eq!(
13944            response["error"]["data"]["kind"],
13945            json!("unsupported_reset_all_surface")
13946        );
13947        assert!(
13948            runtime
13949                .handle()
13950                .get_member(&meerkat_mob::ids::AgentIdentity::from("agent-reset"))
13951                .await
13952                .ok()
13953                .flatten()
13954                .is_some(),
13955            "aggregator reset_all must not retire live members while reporting unsupported"
13956        );
13957        let _ = runtime.handle().stop().await;
13958        Ok(())
13959    }
13960
13961    #[test]
13962    fn timeline_stream_cursor_filter_uses_numeric_console_sequence() {
13963        assert!(cursor_is_after(
13964            &ConsoleCursor::from("console:10"),
13965            &ConsoleCursor::from("console:9")
13966        ));
13967        assert!(!cursor_is_after(
13968            &ConsoleCursor::from("console:9"),
13969            &ConsoleCursor::from("console:10")
13970        ));
13971    }
13972
13973    #[test]
13974    fn console_live_snapshot_dedupes_repeated_delegate_identities() {
13975        let mut members = vec![
13976            ConsoleMember {
13977                agent_identity: "incident-commander".to_string(),
13978                role: "commander".to_string(),
13979                state: "active".to_string(),
13980                model_capabilities: Default::default(),
13981                runtime_mode: None,
13982                session_id: None,
13983                wired_to: Vec::new(),
13984                labels: BTreeMap::new(),
13985                progress: None,
13986            },
13987            ConsoleMember {
13988                agent_identity: "qa-child".to_string(),
13989                role: "delegate".to_string(),
13990                state: "active".to_string(),
13991                model_capabilities: Default::default(),
13992                runtime_mode: None,
13993                session_id: Some("first".to_string()),
13994                wired_to: vec!["qa-parent".to_string()],
13995                labels: BTreeMap::from([(
13996                    "delegate_host_identity".to_string(),
13997                    "qa-parent".to_string(),
13998                )]),
13999                progress: None,
14000            },
14001            ConsoleMember {
14002                agent_identity: "qa-child".to_string(),
14003                role: "delegate".to_string(),
14004                state: "active".to_string(),
14005                model_capabilities: Default::default(),
14006                runtime_mode: None,
14007                session_id: Some("second".to_string()),
14008                wired_to: vec!["qa-parent".to_string()],
14009                labels: BTreeMap::from([(
14010                    "delegate_host_identity".to_string(),
14011                    "qa-parent".to_string(),
14012                )]),
14013                progress: None,
14014            },
14015        ];
14016
14017        dedupe_console_members_by_identity(&mut members);
14018
14019        assert_eq!(
14020            members
14021                .iter()
14022                .map(|member| member.agent_identity.as_str())
14023                .collect::<Vec<_>>(),
14024            vec!["incident-commander", "qa-child"]
14025        );
14026        assert_eq!(members[1].session_id.as_deref(), Some("first"));
14027    }
14028
14029    #[test]
14030    fn console_visibility_policy_hides_implicit_delegate_members_from_snapshot() {
14031        let mut snapshot = ConsoleLiveSnapshot::new(
14032            Some("runtime".to_string()),
14033            true,
14034            vec!["incident-commander".to_string(), "qa-child".to_string()],
14035            vec![
14036                ConsoleAgentLiveSnapshot {
14037                    agent_id: "incident-commander".to_string(),
14038                    member_id: "incident-commander".to_string(),
14039                    label: "Incident Commander".to_string(),
14040                    kind: "meerkat".to_string(),
14041                    identity: Some("incident-commander".to_string()),
14042                    role: Some("commander".to_string()),
14043                    state: Some("active".to_string()),
14044                    session_id: None,
14045                    model_capabilities: Default::default(),
14046                    response_phase: None,
14047                    watched: None,
14048                    alert_level: None,
14049                    degraded: None,
14050                    degraded_reason: None,
14051                },
14052                ConsoleAgentLiveSnapshot {
14053                    agent_id: "qa-child".to_string(),
14054                    member_id: "qa-child".to_string(),
14055                    label: "QA Child".to_string(),
14056                    kind: "meerkat".to_string(),
14057                    identity: Some("qa-child".to_string()),
14058                    role: Some("delegate".to_string()),
14059                    state: Some("active".to_string()),
14060                    session_id: Some("delegate-session".to_string()),
14061                    model_capabilities: Default::default(),
14062                    response_phase: None,
14063                    watched: None,
14064                    alert_level: None,
14065                    degraded: None,
14066                    degraded_reason: None,
14067                },
14068            ],
14069            vec![
14070                ConsoleMember {
14071                    agent_identity: "incident-commander".to_string(),
14072                    role: "commander".to_string(),
14073                    state: "active".to_string(),
14074                    model_capabilities: Default::default(),
14075                    runtime_mode: None,
14076                    session_id: None,
14077                    wired_to: Vec::new(),
14078                    labels: BTreeMap::new(),
14079                    progress: None,
14080                },
14081                ConsoleMember {
14082                    agent_identity: "qa-child".to_string(),
14083                    role: "delegate".to_string(),
14084                    state: "active".to_string(),
14085                    model_capabilities: Default::default(),
14086                    runtime_mode: None,
14087                    session_id: Some("delegate-session".to_string()),
14088                    wired_to: vec!["qa-parent".to_string()],
14089                    labels: BTreeMap::from([(
14090                        "source_mob_id".to_string(),
14091                        "implicit-qa-mob".to_string(),
14092                    )]),
14093                    progress: None,
14094                },
14095            ],
14096            true,
14097        );
14098
14099        apply_console_visibility_policy(
14100            &mut snapshot,
14101            &HideImplicitDelegateMembersConsoleVisibilityPolicy,
14102        );
14103
14104        assert_eq!(
14105            snapshot
14106                .members
14107                .iter()
14108                .map(|member| member.agent_identity.as_str())
14109                .collect::<Vec<_>>(),
14110            vec!["incident-commander"]
14111        );
14112        assert_eq!(
14113            snapshot
14114                .agents
14115                .iter()
14116                .map(|agent| agent.agent_id.as_str())
14117                .collect::<Vec<_>>(),
14118            vec!["incident-commander"]
14119        );
14120        assert_eq!(snapshot.loaded_modules, vec!["incident-commander"]);
14121    }
14122
14123    #[tokio::test]
14124    async fn live_snapshot_member_projection_uses_roster_profile_capabilities()
14125    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14126        let temp_dir = tempfile::tempdir()?;
14127        let session_path = temp_dir.path().join("sessions");
14128        std::fs::create_dir_all(&session_path)?;
14129        let factory = AgentFactory::new(&session_path).comms(true);
14130        let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
14131        let definition = MobDefinition::from_toml(
14132            r#"
14133[mob]
14134id = "console-snapshot-test"
14135
14136[profiles.worker]
14137model = "gpt-5.5"
14138
14139[profiles.worker.tools]
14140comms = true
14141"#,
14142        )?;
14143        let expected = model_capabilities_for_role(&definition, "worker");
14144        let runtime = MobRuntime::bootstrap(
14145            MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
14146                .with_options(MobBootstrapOptions {
14147                    allow_ephemeral_sessions: true,
14148                    notify_orchestrator_on_resume: true,
14149                    default_llm_client: Some(Arc::new(TestClient::default())),
14150                }),
14151        )
14152        .await?;
14153        runtime
14154            .handle()
14155            .spawn_spec(SpawnMemberSpec::from_wire(
14156                "worker".to_string(),
14157                // meerkat 0.7: roster ids are comms-safe encodings of the
14158                // public alias (MemberCommsName rejects ":").
14159                crate::member_comms_id::mob_member_id_str("worker:one").into_owned(),
14160                Some("You are worker one.".into()),
14161                None,
14162                None,
14163            ))
14164            .await?;
14165
14166        let empty_read_model = ConsoleSnapshotReadModelState::default();
14167        let (members, session_owner_by_id) = project_console_members_from_handle(
14168            &runtime.handle(),
14169            None,
14170            None,
14171            &empty_read_model,
14172            &BTreeMap::new(),
14173        )
14174        .await;
14175
14176        assert_eq!(members.len(), 1);
14177        assert_eq!(members[0].model_capabilities, expected);
14178        assert_eq!(members[0].session_id, None);
14179        assert!(session_owner_by_id.is_empty());
14180
14181        let refreshed_read_model = collect_console_snapshot_read_model(&runtime).await;
14182        let (members, session_owner_by_id) = project_console_members_from_handle(
14183            &runtime.handle(),
14184            None,
14185            None,
14186            &refreshed_read_model,
14187            &BTreeMap::new(),
14188        )
14189        .await;
14190        assert_eq!(
14191            members[0].session_id.as_ref(),
14192            session_owner_by_id.keys().next()
14193        );
14194
14195        // Materialized cache: the refresh should have populated
14196        // `primary_members` with exactly the same shape that the
14197        // synchronous projection produces. `build_live_snapshot` reads
14198        // straight from this slot — never calls `handle.list_all_members`
14199        // — so this assertion is the cache's contract.
14200        assert_eq!(
14201            refreshed_read_model.primary_members.len(),
14202            members.len(),
14203            "primary_members cache should hold the same members as live projection"
14204        );
14205        assert_eq!(
14206            refreshed_read_model.primary_members[0].agent_identity,
14207            members[0].agent_identity
14208        );
14209        assert_eq!(
14210            refreshed_read_model.primary_members[0].session_id,
14211            members[0].session_id
14212        );
14213        Ok(())
14214    }
14215
14216    #[tokio::test]
14217    async fn fresh_timeline_snapshot_reads_tail_without_full_log_replay()
14218    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14219        let aggregator = MobKitConsoleAggregator::in_memory();
14220        for idx in 0..250_000 {
14221            aggregator
14222                .store()
14223                .append_if_absent(NewConsoleFrame {
14224                    id: None,
14225                    dedupe_key: format!("event-{idx}"),
14226                    timestamp_ms: idx,
14227                    runtime_key: "runtime-a".to_string(),
14228                    identity: "agent-a".to_string(),
14229                    conversation_id: Some("agent-a".to_string()),
14230                    session_id: None,
14231                    kind: "text_delta".to_string(),
14232                    status: ConsoleFrameStatus::Completed,
14233                    payload: json!({ "delta": idx }),
14234                    source: ConsoleFrameSource {
14235                        kind: ConsoleFrameSourceKind::ConsoleEvent,
14236                        source_cursor: None,
14237                    },
14238                    source_event_id: Some(format!("event-{idx}")),
14239                    interaction_id: None,
14240                    turn_id: None,
14241                    run_id: None,
14242                    parent_frame_id: None,
14243                    caused_by_frame_id: None,
14244                })
14245                .await?;
14246        }
14247
14248        let (frames, cursor) = query_timeline_snapshot(
14249            &aggregator,
14250            ConsoleTimelineWindowQuery {
14251                identity: Some("agent-a".to_string()),
14252                after: None,
14253                limit: 200,
14254                ..ConsoleTimelineWindowQuery::default()
14255            },
14256        )
14257        .await?;
14258
14259        assert!(!frames.is_empty());
14260        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(250_000));
14261        assert_eq!(
14262            frames.last().and_then(|frame| frame.cursor.seq()),
14263            Some(250_000)
14264        );
14265        Ok(())
14266    }
14267
14268    #[tokio::test]
14269    async fn fresh_timeline_snapshot_keeps_sparse_identity_frames()
14270    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14271        let aggregator = MobKitConsoleAggregator::in_memory();
14272        aggregator
14273            .store()
14274            .append_if_absent(NewConsoleFrame {
14275                id: None,
14276                dedupe_key: "sparse-event".to_string(),
14277                timestamp_ms: 1,
14278                runtime_key: "runtime-a".to_string(),
14279                identity: "sparse-agent".to_string(),
14280                conversation_id: Some("sparse-agent".to_string()),
14281                session_id: None,
14282                kind: "text_complete".to_string(),
14283                status: ConsoleFrameStatus::Completed,
14284                payload: json!({ "text": "still visible" }),
14285                source: ConsoleFrameSource {
14286                    kind: ConsoleFrameSourceKind::ConsoleEvent,
14287                    source_cursor: None,
14288                },
14289                source_event_id: Some("sparse-event".to_string()),
14290                interaction_id: None,
14291                turn_id: None,
14292                run_id: None,
14293                parent_frame_id: None,
14294                caused_by_frame_id: None,
14295            })
14296            .await?;
14297        for idx in 0..25_000 {
14298            aggregator
14299                .store()
14300                .append_if_absent(NewConsoleFrame {
14301                    id: None,
14302                    dedupe_key: format!("other-event-{idx}"),
14303                    timestamp_ms: idx + 2,
14304                    runtime_key: "runtime-a".to_string(),
14305                    identity: "busy-agent".to_string(),
14306                    conversation_id: Some("busy-agent".to_string()),
14307                    session_id: None,
14308                    kind: "text_delta".to_string(),
14309                    status: ConsoleFrameStatus::Completed,
14310                    payload: json!({ "delta": idx }),
14311                    source: ConsoleFrameSource {
14312                        kind: ConsoleFrameSourceKind::ConsoleEvent,
14313                        source_cursor: None,
14314                    },
14315                    source_event_id: Some(format!("other-event-{idx}")),
14316                    interaction_id: None,
14317                    turn_id: None,
14318                    run_id: None,
14319                    parent_frame_id: None,
14320                    caused_by_frame_id: None,
14321                })
14322                .await?;
14323        }
14324
14325        let (frames, cursor) = query_timeline_snapshot(
14326            &aggregator,
14327            ConsoleTimelineWindowQuery {
14328                identity: Some("sparse-agent".to_string()),
14329                after: None,
14330                limit: 200,
14331                ..ConsoleTimelineWindowQuery::default()
14332            },
14333        )
14334        .await?;
14335
14336        assert_eq!(frames.len(), 1);
14337        assert_eq!(frames[0].identity, "sparse-agent");
14338        assert_eq!(frames[0].payload["text"], json!("still visible"));
14339        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1));
14340        Ok(())
14341    }
14342
14343    #[tokio::test]
14344    async fn fresh_identity_snapshot_keeps_user_input_anchor_before_noisy_tail()
14345    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14346        let aggregator = MobKitConsoleAggregator::in_memory();
14347        aggregator
14348            .store()
14349            .append_if_absent(NewConsoleFrame {
14350                id: None,
14351                dedupe_key: "worker-kickoff".to_string(),
14352                timestamp_ms: 1,
14353                runtime_key: "runtime-a".to_string(),
14354                identity: "review-worker-a".to_string(),
14355                conversation_id: Some("review-worker-a".to_string()),
14356                session_id: None,
14357                kind: "user_input".to_string(),
14358                status: ConsoleFrameStatus::Delivered,
14359                payload: json!({
14360                    "content": [
14361                        {
14362                            "type": "text",
14363                            "text": "Console chat smoke: review this initiative"
14364                        }
14365                    ]
14366                }),
14367                source: ConsoleFrameSource {
14368                    kind: ConsoleFrameSourceKind::Synthetic,
14369                    source_cursor: None,
14370                },
14371                source_event_id: Some("worker-kickoff".to_string()),
14372                interaction_id: Some("kickoff-1".to_string()),
14373                turn_id: None,
14374                run_id: None,
14375                parent_frame_id: None,
14376                caused_by_frame_id: None,
14377            })
14378            .await?;
14379        for idx in 0..1_500 {
14380            aggregator
14381                .store()
14382                .append_if_absent(NewConsoleFrame {
14383                    id: None,
14384                    dedupe_key: format!("worker-delta-{idx}"),
14385                    timestamp_ms: idx + 2,
14386                    runtime_key: "runtime-a".to_string(),
14387                    identity: "review-worker-a".to_string(),
14388                    conversation_id: Some("review-worker-a".to_string()),
14389                    session_id: None,
14390                    kind: "reasoning_delta".to_string(),
14391                    status: ConsoleFrameStatus::Delivered,
14392                    payload: json!({ "delta": idx }),
14393                    source: ConsoleFrameSource {
14394                        kind: ConsoleFrameSourceKind::ConsoleEvent,
14395                        source_cursor: None,
14396                    },
14397                    source_event_id: Some(format!("worker-delta-{idx}")),
14398                    interaction_id: Some("kickoff-1".to_string()),
14399                    turn_id: None,
14400                    run_id: None,
14401                    parent_frame_id: None,
14402                    caused_by_frame_id: None,
14403                })
14404                .await?;
14405        }
14406
14407        let (frames, cursor) = query_timeline_snapshot(
14408            &aggregator,
14409            ConsoleTimelineWindowQuery {
14410                identity: Some("review-worker-a".to_string()),
14411                after: None,
14412                limit: 200,
14413                ..ConsoleTimelineWindowQuery::default()
14414            },
14415        )
14416        .await?;
14417
14418        assert!(
14419            frames.iter().any(|frame| {
14420                frame.kind == "user_input"
14421                    && frame.payload.to_string().contains("Console chat smoke")
14422            }),
14423            "identity chat snapshot must keep the worker kickoff prompt before a noisy tail: {frames:#?}",
14424        );
14425        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1_501));
14426        assert_eq!(
14427            frames.last().and_then(|frame| frame.cursor.seq()),
14428            Some(1_501)
14429        );
14430        Ok(())
14431    }
14432
14433    #[tokio::test]
14434    async fn timeline_snapshot_drains_since_backlog_across_store_pages()
14435    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14436        let aggregator = MobKitConsoleAggregator::in_memory();
14437        for idx in 0..2_500 {
14438            aggregator
14439                .store()
14440                .append_if_absent(NewConsoleFrame {
14441                    id: None,
14442                    dedupe_key: format!("clamp-event-{idx}"),
14443                    timestamp_ms: idx,
14444                    runtime_key: "runtime-a".to_string(),
14445                    identity: "agent-a".to_string(),
14446                    conversation_id: Some("agent-a".to_string()),
14447                    session_id: None,
14448                    kind: "text_delta".to_string(),
14449                    status: ConsoleFrameStatus::Completed,
14450                    payload: json!({ "delta": idx }),
14451                    source: ConsoleFrameSource {
14452                        kind: ConsoleFrameSourceKind::ConsoleEvent,
14453                        source_cursor: None,
14454                    },
14455                    source_event_id: Some(format!("clamp-event-{idx}")),
14456                    interaction_id: None,
14457                    turn_id: None,
14458                    run_id: None,
14459                    parent_frame_id: None,
14460                    caused_by_frame_id: None,
14461                })
14462                .await?;
14463        }
14464
14465        let (frames, cursor) = query_timeline_snapshot(
14466            &aggregator,
14467            ConsoleTimelineWindowQuery {
14468                identity: Some("agent-a".to_string()),
14469                after: Some(ConsoleCursor::from("console:100")),
14470                limit: 5_000,
14471                ..ConsoleTimelineWindowQuery::default()
14472            },
14473        )
14474        .await?;
14475
14476        assert_eq!(frames.len(), 2_400);
14477        assert_eq!(
14478            frames.first().and_then(|frame| frame.cursor.seq()),
14479            Some(101)
14480        );
14481        assert_eq!(
14482            frames.last().and_then(|frame| frame.cursor.seq()),
14483            Some(2_500)
14484        );
14485        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(2_500));
14486        Ok(())
14487    }
14488
14489    #[tokio::test]
14490    async fn timeline_snapshot_drains_since_backlog_beyond_old_page_budget()
14491    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14492        let aggregator = MobKitConsoleAggregator::in_memory();
14493        for idx in 1..=150 {
14494            aggregator
14495                .store()
14496                .append_if_absent(NewConsoleFrame {
14497                    id: None,
14498                    dedupe_key: format!("deep-backlog-event-{idx}"),
14499                    timestamp_ms: idx,
14500                    runtime_key: "runtime-a".to_string(),
14501                    identity: "agent-a".to_string(),
14502                    conversation_id: Some("agent-a".to_string()),
14503                    session_id: None,
14504                    kind: "text_delta".to_string(),
14505                    status: ConsoleFrameStatus::Completed,
14506                    payload: json!({ "delta": idx }),
14507                    source: ConsoleFrameSource {
14508                        kind: ConsoleFrameSourceKind::ConsoleEvent,
14509                        source_cursor: None,
14510                    },
14511                    source_event_id: Some(format!("deep-backlog-event-{idx}")),
14512                    interaction_id: None,
14513                    turn_id: None,
14514                    run_id: None,
14515                    parent_frame_id: None,
14516                    caused_by_frame_id: None,
14517                })
14518                .await?;
14519        }
14520
14521        let (frames, cursor) = query_timeline_snapshot(
14522            &aggregator,
14523            ConsoleTimelineWindowQuery {
14524                identity: Some("agent-a".to_string()),
14525                after: Some(ConsoleCursor::from_seq(1)),
14526                limit: 1,
14527                ..ConsoleTimelineWindowQuery::default()
14528            },
14529        )
14530        .await?;
14531
14532        assert_eq!(frames.len(), 149);
14533        assert_eq!(frames.first().and_then(|frame| frame.cursor.seq()), Some(2));
14534        assert_eq!(
14535            frames.last().and_then(|frame| frame.cursor.seq()),
14536            Some(150)
14537        );
14538        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(150));
14539        Ok(())
14540    }
14541
14542    #[tokio::test]
14543    async fn timeline_snapshot_rejects_after_cursor_beyond_store_frontier()
14544    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14545        let aggregator = MobKitConsoleAggregator::in_memory();
14546        aggregator
14547            .store()
14548            .append_if_absent(NewConsoleFrame {
14549                id: None,
14550                dedupe_key: "stale-frontier-event".to_string(),
14551                timestamp_ms: 1,
14552                runtime_key: "runtime-a".to_string(),
14553                identity: "agent-a".to_string(),
14554                conversation_id: Some("agent-a".to_string()),
14555                session_id: None,
14556                kind: "text_delta".to_string(),
14557                status: ConsoleFrameStatus::Completed,
14558                payload: json!({ "delta": 1 }),
14559                source: ConsoleFrameSource {
14560                    kind: ConsoleFrameSourceKind::ConsoleEvent,
14561                    source_cursor: None,
14562                },
14563                source_event_id: Some("stale-frontier-event".to_string()),
14564                interaction_id: None,
14565                turn_id: None,
14566                run_id: None,
14567                parent_frame_id: None,
14568                caused_by_frame_id: None,
14569            })
14570            .await?;
14571
14572        let err = match query_timeline_snapshot(
14573            &aggregator,
14574            ConsoleTimelineWindowQuery {
14575                after: Some(ConsoleCursor::from("console:99")),
14576                limit: 200,
14577                ..ConsoleTimelineWindowQuery::default()
14578            },
14579        )
14580        .await
14581        {
14582            Ok(_) => {
14583                return Err(
14584                    std::io::Error::other("future cursor must be replay-unavailable").into(),
14585                );
14586            }
14587            Err(err) => err,
14588        };
14589
14590        assert!(
14591            err.to_string()
14592                .contains("beyond the current store frontier"),
14593            "unexpected error: {err}"
14594        );
14595        Ok(())
14596    }
14597
14598    #[tokio::test]
14599    async fn timeline_snapshot_rejects_after_cursor_beyond_empty_store_frontier()
14600    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14601        let aggregator = MobKitConsoleAggregator::in_memory();
14602
14603        let err = match query_timeline_snapshot(
14604            &aggregator,
14605            ConsoleTimelineWindowQuery {
14606                after: Some(ConsoleCursor::from("console:99")),
14607                limit: 200,
14608                ..ConsoleTimelineWindowQuery::default()
14609            },
14610        )
14611        .await
14612        {
14613            Ok(_) => {
14614                return Err(std::io::Error::other("empty store future cursor must fail").into());
14615            }
14616            Err(err) => err,
14617        };
14618
14619        assert!(
14620            err.to_string()
14621                .contains("beyond the current store frontier"),
14622            "unexpected error: {err}"
14623        );
14624        Ok(())
14625    }
14626
14627    #[test]
14628    fn timeline_query_prefers_last_event_id_over_url_after_cursor() {
14629        let query = timeline_query_from_http(
14630            ConsoleTimelineHttpQuery {
14631                identity: None,
14632                conversation_id: None,
14633                after: Some("console:100".to_string()),
14634                before: None,
14635                mode: None,
14636                limit: None,
14637            },
14638            Some("console:150".to_string()),
14639        );
14640
14641        assert_eq!(query.after.as_ref().and_then(ConsoleCursor::seq), Some(150));
14642    }
14643
14644    #[test]
14645    fn console_timeline_replay_unavailable_rpc_uses_dedicated_error_code() {
14646        let response = console_timeline_replay_unavailable_response(
14647            json!("rid"),
14648            std::io::Error::other("timeline replay cursor is beyond the current store frontier")
14649                .into(),
14650            Some(&ConsoleCursor::from_seq(100)),
14651            Some(ConsoleCursor::from_seq(42)),
14652        );
14653
14654        assert_eq!(response["error"]["code"], json!(-32013));
14655        assert_eq!(
14656            response["error"]["data"],
14657            json!({
14658                "error": "replay_unavailable",
14659                "stream": "timeline",
14660                "requested_cursor": "console:100",
14661                "latest_cursor": "console:42",
14662            })
14663        );
14664    }
14665
14666    #[tokio::test]
14667    async fn multipart_blob_upload_stores_one_file() -> Result<(), Box<dyn std::error::Error>> {
14668        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14669        let mut files = BTreeMap::new();
14670        files.insert(
14671            "upload-1".to_string(),
14672            MultipartImageUpload {
14673                media_type: "image/png".to_string(),
14674                bytes: Bytes::from_static(b"png-data"),
14675            },
14676        );
14677        let result = externalize_single_image_upload(
14678            &json!({
14679                "upload": {
14680                    "type": "image_upload",
14681                    "upload_id": "upload-1",
14682                    "media_type": "image/png"
14683                }
14684            }),
14685            files,
14686            store.clone(),
14687        )
14688        .await
14689        .map_err(std::io::Error::other)?;
14690
14691        assert_eq!(result["media_type"], json!("image/png"));
14692        assert_eq!(result["size"], json!(8));
14693        let Some(blob_id) = result["blob_id"].as_str() else {
14694            return Err(std::io::Error::other("blob id").into());
14695        };
14696        let payload = store
14697            .get_bytes(&meerkat_core::BlobId::from(blob_id))
14698            .await?;
14699        assert_eq!(payload.data.as_ref(), b"png-data");
14700        Ok(())
14701    }
14702
14703    #[tokio::test]
14704    async fn multipart_blob_upload_accepts_part_name_alias()
14705    -> Result<(), Box<dyn std::error::Error>> {
14706        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14707        let mut files = BTreeMap::new();
14708        files.insert(
14709            "image-field".to_string(),
14710            MultipartImageUpload {
14711                media_type: "image/png".to_string(),
14712                bytes: Bytes::from_static(b"png-data"),
14713            },
14714        );
14715        let result = externalize_single_image_upload(
14716            &json!({
14717                "upload": {
14718                    "type": "image_upload",
14719                    "part_name": "image-field",
14720                    "media_type": "image/png"
14721                }
14722            }),
14723            files,
14724            store,
14725        )
14726        .await
14727        .map_err(std::io::Error::other)?;
14728
14729        assert_eq!(result["media_type"], json!("image/png"));
14730        assert!(
14731            result["blob_id"]
14732                .as_str()
14733                .is_some_and(|value| value.starts_with("sha256:"))
14734        );
14735        Ok(())
14736    }
14737
14738    #[tokio::test]
14739    async fn multipart_blob_upload_rejects_media_mismatch() -> Result<(), Box<dyn std::error::Error>>
14740    {
14741        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14742        let mut files = BTreeMap::new();
14743        files.insert(
14744            "upload-1".to_string(),
14745            MultipartImageUpload {
14746                media_type: "image/jpeg".to_string(),
14747                bytes: Bytes::from_static(b"jpeg-data"),
14748            },
14749        );
14750        let err = match externalize_single_image_upload(
14751            &json!({
14752                "upload": {
14753                    "type": "image_upload",
14754                    "upload_id": "upload-1",
14755                    "media_type": "image/png"
14756                }
14757            }),
14758            files,
14759            store,
14760        )
14761        .await
14762        {
14763            Ok(_) => return Err(std::io::Error::other("media mismatch").into()),
14764            Err(err) => err,
14765        };
14766        assert!(
14767            err.contains("media type mismatch"),
14768            "unexpected error: {err}"
14769        );
14770        Ok(())
14771    }
14772
14773    #[tokio::test]
14774    async fn multipart_blob_upload_rejects_extra_file() -> Result<(), Box<dyn std::error::Error>> {
14775        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14776        let mut files = BTreeMap::new();
14777        for id in ["upload-1", "upload-2"] {
14778            files.insert(
14779                id.to_string(),
14780                MultipartImageUpload {
14781                    media_type: "image/png".to_string(),
14782                    bytes: Bytes::from_static(b"png"),
14783                },
14784            );
14785        }
14786        let err = match externalize_single_image_upload(
14787            &json!({
14788                "upload": {
14789                    "type": "image_upload",
14790                    "upload_id": "upload-1",
14791                    "media_type": "image/png"
14792                }
14793            }),
14794            files,
14795            store,
14796        )
14797        .await
14798        {
14799            Ok(_) => return Err(std::io::Error::other("one file only").into()),
14800            Err(err) => err,
14801        };
14802        assert!(
14803            err.contains("exactly one file part"),
14804            "unexpected error: {err}"
14805        );
14806        Ok(())
14807    }
14808
14809    #[tokio::test]
14810    async fn multipart_send_replaces_placeholders_and_removes_shadow_message()
14811    -> Result<(), Box<dyn std::error::Error>> {
14812        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14813        let mut files = BTreeMap::new();
14814        files.insert(
14815            "upload-1".to_string(),
14816            MultipartImageUpload {
14817                media_type: "image/webp".to_string(),
14818                bytes: Bytes::from_static(b"webp-data"),
14819            },
14820        );
14821        let mut params = json!({
14822            "member_id": "artist",
14823            "message": "stale shadow text",
14824            "content": [
14825                { "type": "text", "text": "describe" },
14826                {
14827                    "type": "image_upload",
14828                    "upload_id": "upload-1",
14829                    "media_type": "image/webp"
14830                }
14831            ]
14832        });
14833        externalize_image_upload_placeholders(&mut params, files, store)
14834            .await
14835            .map_err(std::io::Error::other)?;
14836
14837        assert!(params.get("message").is_none());
14838        assert_eq!(params["content"][1]["type"], json!("image"));
14839        assert_eq!(params["content"][1]["source"], json!("blob"));
14840        assert_eq!(params["content"][1]["media_type"], json!("image/webp"));
14841        assert!(
14842            params["content"][1]["blob_id"]
14843                .as_str()
14844                .is_some_and(|value| value.starts_with("sha256:"))
14845        );
14846        Ok(())
14847    }
14848
14849    #[tokio::test]
14850    async fn multipart_send_accepts_part_name_placeholder() -> Result<(), Box<dyn std::error::Error>>
14851    {
14852        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14853        let mut files = BTreeMap::new();
14854        files.insert(
14855            "image-field".to_string(),
14856            MultipartImageUpload {
14857                media_type: "image/png".to_string(),
14858                bytes: Bytes::from_static(b"png-data"),
14859            },
14860        );
14861        let mut params = json!({
14862            "member_id": "analyst",
14863            "content": [
14864                { "type": "text", "text": "describe" },
14865                {
14866                    "type": "image_upload",
14867                    "part_name": "image-field",
14868                    "media_type": "image/png"
14869                }
14870            ]
14871        });
14872
14873        externalize_image_upload_placeholders(&mut params, files, store)
14874            .await
14875            .map_err(std::io::Error::other)?;
14876
14877        assert_eq!(params["content"][1]["type"], json!("image"));
14878        assert_eq!(params["content"][1]["source"], json!("blob"));
14879        assert_eq!(params["content"][1]["media_type"], json!("image/png"));
14880        Ok(())
14881    }
14882
14883    #[tokio::test]
14884    async fn multipart_send_rejects_placeholder_without_file()
14885    -> Result<(), Box<dyn std::error::Error>> {
14886        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14887        let mut params = json!({
14888            "content": [{
14889                "type": "image_upload",
14890                "upload_id": "missing",
14891                "media_type": "image/png"
14892            }]
14893        });
14894        let err = match externalize_image_upload_placeholders(&mut params, BTreeMap::new(), store)
14895            .await
14896        {
14897            Ok(()) => return Err(std::io::Error::other("missing file").into()),
14898            Err(err) => err,
14899        };
14900        assert!(err.contains("missing file part"), "unexpected error: {err}");
14901        Ok(())
14902    }
14903
14904    #[test]
14905    fn generated_runtime_ids_do_not_match_sibling_colon_identities() {
14906        assert!(!member_id_matches_durable_identity(
14907            "rt:review:singleton:0",
14908            "review:singleton"
14909        ));
14910        assert!(!member_id_matches_durable_identity(
14911            "review:singleton:gen1",
14912            "review:singleton"
14913        ));
14914        assert!(!member_id_matches_durable_identity(
14915            "review:singleton:1",
14916            "review:singleton"
14917        ));
14918        assert!(!member_id_matches_durable_identity(
14919            "rt:review:singleton:qa:0",
14920            "review:singleton"
14921        ));
14922        assert!(!member_id_matches_durable_identity(
14923            "review:singleton:qa",
14924            "review:singleton"
14925        ));
14926        assert_eq!(
14927            console_runtime_alias_generation("rt:review:singleton:7", "review:singleton"),
14928            Some(7)
14929        );
14930        assert_eq!(
14931            console_runtime_alias_generation("rt:review:singleton:8", "review:other"),
14932            None
14933        );
14934    }
14935}