Skip to main content

meerkat_mobkit/
http_console.rs

1//! HTTP routes for the admin console REST API.
2
3use async_stream::stream;
4use axum::extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State};
5use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
6use axum::response::sse::{Event, KeepAlive, Sse};
7use axum::response::{IntoResponse, Redirect};
8use axum::routing::{get, post};
9use axum::{Json, Router};
10use base64::Engine;
11use futures::StreamExt;
12use futures::future::join_all;
13use meerkat_contracts::WireRuntimeBinding;
14use meerkat_core::ContentInput;
15use meerkat_core::comms::TrustedPeerDescriptor;
16use meerkat_core::event::agent_event_type;
17use meerkat_mob::MobState;
18use meerkat_mob::ids::{AgentIdentity, MobId};
19use meerkat_mob::launch::MemberLaunchMode;
20use meerkat_mob::runtime::reconcile::MemberFilter;
21use meerkat_mob::{
22    MobBackendKind, MobHandle, MobRuntimeMode, PeerTarget, ProfileName, SpawnMemberSpec,
23};
24
25use crate::mob_handle_runtime::{
26    is_recoverable_lifecycle_cleanup_error, member_entry_to_json,
27    model_capabilities_for_member_entry, model_capabilities_for_role, resolved_tools_for_member,
28    resolved_tools_for_session, topology_restore_failed_peer_ids, topology_restore_warning_json,
29};
30use serde_json::{Value, json};
31use std::collections::{BTreeMap, BTreeSet};
32use std::convert::Infallible;
33use std::sync::Arc;
34use std::time::{Duration, Instant};
35
36use crate::access::{
37    ACCESS_ACTIONS, ACTION_AGENT_MEMORY_DELETE, ACTION_AGENT_MEMORY_READ,
38    ACTION_AGENT_MEMORY_WRITE, ACTION_AGENT_RESET, ACTION_AGENT_RESPAWN, ACTION_AGENT_RETIRE,
39    ACTION_AGENT_SEND, ACTION_AGENT_SPAWN, ACTION_AGENT_VIEW, ACTION_GATING_DECIDE,
40    ACTION_GATING_VIEW, ACTION_MEMORY_QUARANTINE_REVIEW, ACTION_MOB_MEMORY_READ,
41    ACTION_MOB_OBSERVE, ACTION_OPERATOR_MEMORY_READ, ACTION_RUNTIME_ADMIN, 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 bundled-store handle for the console Memory panel's
100    /// read-only `mobkit/memory/panel/*` RPCs (§9.3). `None` (markdown
101    /// store, no memory configured) leaves those methods unadvertised.
102    pub(crate) memory_panel: Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>,
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}
126
127#[derive(Debug, Clone)]
128struct ConsoleHttpAuthContext {
129    principal: Option<String>,
130    /// Per-request access snapshot when an [`AccessController`] is wired.
131    access_view: Option<AccessView>,
132}
133
134#[derive(Clone, Default)]
135pub(crate) struct ConsoleSnapshotReadModel {
136    inner: Arc<tokio::sync::RwLock<ConsoleSnapshotReadModelState>>,
137    /// Mutex held by whichever task is currently running a refresh.
138    /// Background refreshes (from `refresh_soon`) skip when the lock
139    /// is contended; cold-cache request waiters acquire it via
140    /// `lock_owned().await`, which is the actual "the in-flight
141    /// refresh has finished" signal. See `prime_now`.
142    refresh_lock: Arc<tokio::sync::Mutex<()>>,
143    /// `true` once at least one refresh has populated `inner` with
144    /// real data. Snapshot reads gate on this so a cold cache never
145    /// returns an empty member list to the first request.
146    primed: Arc<std::sync::atomic::AtomicBool>,
147}
148
149#[derive(Clone, Default)]
150struct ConsoleSnapshotReadModelState {
151    running: Option<bool>,
152    session_id_by_identity: BTreeMap<String, String>,
153    session_owner_by_id: BTreeMap<String, String>,
154    /// Pre-projected primary-mob console members. The background refresh
155    /// populates this from `handle.list_all_members()` + projection; the
156    /// snapshot hot path just clones from here so it never touches
157    /// `MobHandle` async methods.
158    primary_members: Vec<ConsoleMember>,
159    /// Pre-projected delegate-mob member groups, one Vec per delegate mob,
160    /// each already carrying its host_identity / source_mob_id label
161    /// fixups. The snapshot hot path extends `members` with these instead
162    /// of walking delegate handles per-request.
163    delegate_member_groups: Vec<Vec<ConsoleMember>>,
164}
165
166impl ConsoleSnapshotReadModel {
167    /// Returns the current cached snapshot. On a cold cache (no
168    /// refresh has completed yet) the request thread drives the
169    /// first refresh inline — or, if a background refresh task
170    /// holds the lock, waits for it to finish before reading.
171    /// Either way, snapshot endpoints never see an empty member
172    /// list before the read model has been populated.
173    async fn snapshot(&self, runtime: &MobRuntime) -> ConsoleSnapshotReadModelState {
174        if !self.primed.load(std::sync::atomic::Ordering::Acquire) {
175            Box::pin(self.prime_now(runtime)).await;
176        }
177        self.inner.read().await.clone()
178    }
179
180    /// Cold-cache priming. Acquires `refresh_lock` via the awaiting
181    /// (FIFO) path:
182    ///
183    /// - If no refresh task currently holds the lock, we acquire
184    ///   it immediately and run the refresh inline. Subsequent
185    ///   waiters that come in while we're running will queue
186    ///   behind us in the same lock.
187    /// - If a refresh task (spawned by `refresh_soon`) holds the
188    ///   lock, our `lock_owned().await` parks until the task
189    ///   drops the guard. By construction, the task only drops
190    ///   the guard *after* writing `inner` and setting `primed`.
191    ///   So when we acquire the lock, the cache is already
192    ///   populated and the second `primed` check returns early
193    ///   without redoing the work.
194    ///
195    /// No `Notify` is involved, so there's no lost-wake race to
196    /// reason about: the lock release is the signal, and `tokio`'s
197    /// Mutex enforces FIFO acquisition fairness so a `try_lock`
198    /// caller can't barge past a queued `lock_owned` waiter.
199    async fn prime_now(&self, runtime: &MobRuntime) {
200        if self.primed.load(std::sync::atomic::Ordering::Acquire) {
201            return;
202        }
203        let _guard = self.refresh_lock.clone().lock_owned().await;
204        if self.primed.load(std::sync::atomic::Ordering::Acquire) {
205            return;
206        }
207        let refreshed = Box::pin(collect_console_snapshot_read_model(runtime)).await;
208        *self.inner.write().await = refreshed;
209        self.primed
210            .store(true, std::sync::atomic::Ordering::Release);
211        // _guard drops here, releasing the lock and waking the next
212        // queued cold-cache waiter (if any). They'll see `primed`
213        // true after acquiring and return early.
214    }
215
216    /// Fire-and-forget background refresh. If a refresh is already
217    /// in flight (lock contended) we skip — the in-flight one is
218    /// enough. The request hot path doesn't call this; it goes
219    /// through `prime_now` on cold cache so it always gets a
220    /// populated snapshot. `refresh_soon` exists to keep a hot
221    /// cache fresh over time without blocking response requests.
222    fn refresh_soon(&self, runtime: MobRuntime) {
223        let Ok(runtime_handle) = tokio::runtime::Handle::try_current() else {
224            return;
225        };
226        let Ok(guard) = self.refresh_lock.clone().try_lock_owned() else {
227            return;
228        };
229        let inner = Arc::clone(&self.inner);
230        let primed = Arc::clone(&self.primed);
231        runtime_handle.spawn(async move {
232            let _guard = guard;
233            let refreshed = Box::pin(collect_console_snapshot_read_model(&runtime)).await;
234            *inner.write().await = refreshed;
235            primed.store(true, std::sync::atomic::Ordering::Release);
236            // _guard drops; cold-cache waiters parked on
237            // `lock_owned().await` in `prime_now` wake here and
238            // observe `primed = true` after acquiring.
239        });
240    }
241}
242
243const CONSOLE_FRONTEND_INDEX_HTML: &str = include_str!("../console-dist/index.html");
244const CONSOLE_FRONTEND_APP_JS: &str = include_str!("../console-dist/console-app.js");
245const CONSOLE_FRONTEND_APP_CSS: &str = include_str!("../console-dist/console-app.css");
246const MAX_MULTIPART_IMAGE_BYTES: usize = 25 * 1024 * 1024;
247const MAX_MULTIPART_IMAGES: usize = 4;
248const MAX_MULTIPART_BODY_BYTES: usize =
249    (MAX_MULTIPART_IMAGE_BYTES * MAX_MULTIPART_IMAGES) + 1024 * 1024;
250
251pub fn console_json_router(decisions: RuntimeDecisionState) -> Router {
252    console_json_router_with_state(ConsoleJsonState {
253        decisions,
254        runtime: None,
255        module_runtime: None,
256        contact_directory: None,
257        event_log: None,
258        gateway_peer_keys: None,
259        identity_runtime: None,
260        console_events: None,
261        console_aggregator: None,
262        mob_events: None,
263        metadata_table: None,
264        visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
265        snapshot_read_model: ConsoleSnapshotReadModel::default(),
266        access: None,
267        memory_panel: None,
268        operator_resolver: None,
269        identity_roster: None,
270        workgraph: None,
271        topology: None,
272    })
273}
274
275pub fn console_json_router_with_aggregator(
276    decisions: RuntimeDecisionState,
277    console_aggregator: MobKitConsoleAggregator,
278) -> Router {
279    console_json_router_with_aggregator_and_access(decisions, console_aggregator, None)
280}
281
282pub fn console_json_router_with_aggregator_and_access(
283    decisions: RuntimeDecisionState,
284    console_aggregator: MobKitConsoleAggregator,
285    access: Option<AccessController>,
286) -> Router {
287    console_json_router_with_state(ConsoleJsonState {
288        decisions,
289        runtime: None,
290        module_runtime: None,
291        contact_directory: None,
292        event_log: None,
293        gateway_peer_keys: None,
294        identity_runtime: None,
295        console_events: None,
296        console_aggregator: Some(console_aggregator),
297        mob_events: None,
298        metadata_table: None,
299        visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
300        snapshot_read_model: ConsoleSnapshotReadModel::default(),
301        access,
302        memory_panel: None,
303        operator_resolver: None,
304        identity_roster: None,
305        workgraph: None,
306        topology: None,
307    })
308}
309
310pub fn console_json_router_with_runtime(
311    decisions: RuntimeDecisionState,
312    runtime: MobRuntime,
313    contact_directory: Option<ContactDirectory>,
314    event_log: Option<std::sync::Arc<dyn EventLogStore>>,
315) -> Router {
316    console_json_router_with_runtime_and_events(
317        decisions,
318        runtime,
319        None,
320        contact_directory,
321        event_log,
322        None,
323        None,
324        None,
325        None,
326        None,
327        None,
328    )
329}
330
331#[allow(clippy::too_many_arguments)]
332pub(crate) fn console_json_router_with_runtime_and_events(
333    decisions: RuntimeDecisionState,
334    runtime: MobRuntime,
335    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
336    contact_directory: Option<ContactDirectory>,
337    event_log: Option<std::sync::Arc<dyn EventLogStore>>,
338    gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
339    console_events: Option<ConsoleEventStore>,
340    console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
341    mob_events: Option<MobEventsStore>,
342    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
343    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
344) -> Router {
345    console_json_router_with_runtime_events_and_policy(
346        decisions,
347        runtime,
348        module_runtime,
349        contact_directory,
350        event_log,
351        gateway_peer_keys,
352        console_events,
353        console_log_store,
354        mob_events,
355        metadata_table,
356        identity_runtime,
357        Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
358        None,
359        None,
360        None,
361        None,
362        None,
363        None,
364    )
365}
366
367#[allow(clippy::too_many_arguments)]
368pub(crate) fn console_json_router_with_runtime_events_and_policy(
369    decisions: RuntimeDecisionState,
370    runtime: MobRuntime,
371    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
372    contact_directory: Option<ContactDirectory>,
373    event_log: Option<std::sync::Arc<dyn EventLogStore>>,
374    gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
375    console_events: Option<ConsoleEventStore>,
376    console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
377    mob_events: Option<MobEventsStore>,
378    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
379    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
380    visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
381    access: Option<AccessController>,
382    memory_panel: Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>,
383    operator_resolver: Option<Arc<crate::memory::coordinator::ConsolePrincipalOperatorResolver>>,
384    identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
385    workgraph: Option<meerkat::WorkGraphService>,
386    topology: Option<crate::topology_control::TopologyRuntimeHandle>,
387) -> Router {
388    let console_aggregator = console_events.clone().map(|events| {
389        if let Some(store) = console_log_store {
390            let aggregator = MobKitConsoleAggregator::new(store);
391            aggregator.register_runtime_handles_with_policy(
392                "default",
393                "",
394                runtime.clone(),
395                identity_runtime.clone(),
396                events,
397                visibility_policy.clone(),
398            );
399            aggregator
400        } else {
401            let aggregator = MobKitConsoleAggregator::in_memory();
402            aggregator.register_runtime_handles_with_policy(
403                "default",
404                "",
405                runtime.clone(),
406                identity_runtime.clone(),
407                events,
408                visibility_policy.clone(),
409            );
410            aggregator
411        }
412    });
413    let snapshot_read_model = ConsoleSnapshotReadModel::default();
414    snapshot_read_model.refresh_soon(runtime.clone());
415    // Fall back to the mob runtime's bootstrap-time service so routers built
416    // through the thinner constructors still expose workgraph.
417    let workgraph = workgraph.or_else(|| runtime.workgraph_service());
418    console_json_router_with_state(ConsoleJsonState {
419        decisions,
420        runtime: Some(runtime),
421        module_runtime,
422        contact_directory,
423        event_log,
424        gateway_peer_keys,
425        identity_runtime,
426        console_events,
427        console_aggregator,
428        mob_events,
429        metadata_table,
430        visibility_policy,
431        snapshot_read_model,
432        access,
433        memory_panel,
434        operator_resolver,
435        identity_roster,
436        workgraph,
437        topology,
438    })
439}
440
441pub fn console_frontend_router() -> Router {
442    Router::new()
443        .route("/", get(|| async { Redirect::temporary("/console") }))
444        .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
445        .route("/console", get(console_frontend_index_handler))
446        .route("/console/", get(console_frontend_index_handler))
447        .route(
448            "/console/assets/console-app.js",
449            get(console_frontend_app_js_handler),
450        )
451        .route(
452            "/console/assets/console-app.css",
453            get(console_frontend_app_css_handler),
454        )
455}
456
457fn console_json_router_with_state(state: ConsoleJsonState) -> Router {
458    let router = Router::new()
459        .route("/console/experience", get(console_json_handler))
460        .route("/console/modules", get(console_json_handler))
461        .route("/console/identities", get(console_identities_handler))
462        .route("/console/timeline", get(console_timeline_handler))
463        .route(
464            "/console/timeline/stream",
465            get(console_timeline_stream_handler),
466        )
467        .route(
468            "/console/identity/{identity}/stream",
469            get(console_identity_timeline_stream_handler),
470        )
471        .route("/console/send", post(console_send_handler))
472        .route("/console/rpc", post(console_rpc_handler))
473        .route(
474            "/console/rpc/multipart",
475            post(console_rpc_multipart_handler)
476                .layer(DefaultBodyLimit::max(MAX_MULTIPART_BODY_BYTES)),
477        )
478        .route("/blobs/{blob_id}", get(blob_get_handler));
479    router.with_state(state)
480}
481
482pub async fn console_json_handler(
483    State(state): State<ConsoleJsonState>,
484    headers: HeaderMap,
485    uri: Uri,
486) -> impl IntoResponse {
487    let mut path = uri
488        .path_and_query()
489        .map(|path_and_query| path_and_query.as_str().to_string())
490        .unwrap_or_else(|| uri.path().to_string());
491
492    // If the request carries a Bearer token and the URL doesn't already have
493    // an auth_token query param, inject it so the console-ingress auth
494    // resolver can validate it through the existing query-param path.
495    //
496    // JWT tokens use base64url characters (A-Za-z0-9_-.) plus optional '='
497    // padding. We percent-encode the bearer when injecting so opaque
498    // bearer tokens containing `&`, `=`, `+`, `%`, etc. (legal under
499    // RFC 6750 §2.1) survive the round trip — pre-fix, an `&` made
500    // injection skip and authentication fail. Substring detection of
501    // an existing `auth_token=` is now key-aware via form_urlencoded
502    // so `xauth_token=` doesn't masquerade as the real key.
503    let already_has_token = path
504        .split_once('?')
505        .map(|(_, q)| form_urlencoded::parse(q.as_bytes()).any(|(key, _)| key == "auth_token"))
506        .unwrap_or(false);
507    if !already_has_token
508        && let Some(bearer) = headers
509            .get(header::AUTHORIZATION)
510            .and_then(|v| v.to_str().ok())
511            .and_then(extract_bearer_token_from_header)
512    {
513        let encoded: String = form_urlencoded::byte_serialize(bearer.as_bytes()).collect();
514        let sep = if path.contains('?') { '&' } else { '?' };
515        path = format!("{path}{sep}auth_token={encoded}");
516    }
517
518    let config_module_ids: Vec<String> = state
519        .decisions
520        .modules
521        .iter()
522        .map(|m| m.id.clone())
523        .collect();
524    let live_snapshot = match &state.runtime {
525        Some(runtime) => {
526            state.snapshot_read_model.refresh_soon(runtime.clone());
527            Some(
528                Box::pin(build_live_snapshot(
529                    runtime,
530                    &config_module_ids,
531                    state.console_events.as_ref(),
532                    state.visibility_policy.as_ref(),
533                    &state.snapshot_read_model,
534                ))
535                .await,
536            )
537        }
538        None => match &state.console_aggregator {
539            Some(aggregator) => Box::pin(build_aggregator_live_snapshot(
540                aggregator,
541                &config_module_ids,
542            ))
543            .await
544            .ok(),
545            None => None,
546        },
547    }
548    .map(|mut snapshot| {
549        apply_console_visibility_policy(&mut snapshot, state.visibility_policy.as_ref());
550        snapshot
551    });
552
553    let response = handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph(
554        &state.decisions,
555        &ConsoleRestJsonRequest {
556            method: "GET".to_string(),
557            path,
558            auth: None,
559        },
560        live_snapshot.as_ref(),
561        state.access.as_ref(),
562        state.memory_panel.is_some(),
563        state.workgraph.is_some(),
564    );
565    let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
566    (status, Json::<Value>(response.body))
567}
568
569pub async fn console_rpc_handler(
570    State(state): State<ConsoleJsonState>,
571    headers: HeaderMap,
572    uri: Uri,
573    Json(request): Json<Value>,
574) -> impl IntoResponse {
575    // Parse the request early so we can check the method for auth gating.
576    let parsed_request = match serde_json::from_value::<JsonRpcRequest>(request) {
577        Ok(req) => req,
578        Err(_) => {
579            return (
580                StatusCode::OK,
581                Json::<Value>(serde_json::json!({
582                    "jsonrpc": JSONRPC_VERSION,
583                    "id": Value::Null,
584                    "error": { "code": -32600, "message": "Invalid Request" }
585                })),
586            );
587        }
588    };
589
590    // Auth enforcement:
591    // - When require_app_auth is true: validate bearer token (OIDC + allowlist)
592    // - When require_app_auth is false: allow requests without a token.
593    // - When console.read_only is true: deny mutating methods even for
594    //   otherwise authorized callers.
595    let auth_context = match console_request_auth_context(&state, &headers, &uri) {
596        Some(context) => context,
597        None => {
598            return (
599                StatusCode::UNAUTHORIZED,
600                Json::<Value>(serde_json::json!({
601                    "jsonrpc": JSONRPC_VERSION,
602                    "id": parsed_request.id.unwrap_or(Value::Null),
603                    "error": {
604                        "code": -32600,
605                        "message": "unauthorized: console rpc requires a valid auth token",
606                    }
607                })),
608            );
609        }
610    };
611    // By this point the request is always authorized:
612    // - require_app_auth=true: an invalid token already returned 401 above.
613    // - require_app_auth=false: all methods are permitted unconditionally.
614    // Mutating methods may still be blocked by console.read_only.
615    let is_authenticated = true;
616    let read_only = state.decisions.console.read_only;
617    let Some(runtime) = &state.runtime else {
618        let response_value = Box::pin(handle_console_aggregator_rpc(
619            state.console_aggregator.clone(),
620            parsed_request,
621            is_authenticated,
622            read_only,
623            state.access.as_ref(),
624            auth_context.access_view.as_ref(),
625        ))
626        .await;
627        return (StatusCode::OK, Json::<Value>(response_value));
628    };
629
630    let response_value = Box::pin(handle_console_runtime_rpc_with_visibility(
631        runtime,
632        state.module_runtime.clone(),
633        state.contact_directory.as_ref(),
634        state.gateway_peer_keys.as_ref(),
635        state.console_events.clone(),
636        state.console_aggregator.clone(),
637        state.identity_runtime.clone(),
638        state.metadata_table.clone(),
639        state.mob_events.clone(),
640        state.visibility_policy.as_ref(),
641        parsed_request,
642        is_authenticated,
643        read_only,
644        auth_context.principal.as_deref(),
645        state.access.as_ref(),
646        auth_context.access_view.as_ref(),
647        state.memory_panel.as_ref(),
648        state.identity_roster.clone(),
649        state.workgraph.as_ref(),
650        state.topology.as_ref(),
651    ))
652    .await;
653    (StatusCode::OK, Json::<Value>(response_value))
654}
655
656#[derive(Debug, serde::Deserialize)]
657struct ConsoleTimelineHttpQuery {
658    #[serde(default)]
659    identity: Option<String>,
660    #[serde(default)]
661    conversation_id: Option<String>,
662    #[serde(default)]
663    after: Option<String>,
664    #[serde(default)]
665    before: Option<String>,
666    #[serde(default)]
667    mode: Option<ConsoleTimelineMode>,
668    #[serde(default)]
669    limit: Option<usize>,
670}
671
672async fn console_identities_handler(
673    State(state): State<ConsoleJsonState>,
674    headers: HeaderMap,
675    uri: Uri,
676) -> impl IntoResponse {
677    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
678        return console_json_error(
679            StatusCode::UNAUTHORIZED,
680            "unauthorized",
681            "console identities require a valid auth token",
682        );
683    };
684    let Some(aggregator) = &state.console_aggregator else {
685        return console_json_error(
686            StatusCode::NOT_FOUND,
687            "unavailable",
688            "console aggregator unavailable",
689        );
690    };
691    let aggregator = aggregator.clone();
692    match Box::pin(aggregator.list_identities()).await {
693        Ok(mut identities) => {
694            retain_visible_identity_records(&mut identities, auth_context.access_view.as_ref());
695            (
696                StatusCode::OK,
697                Json::<Value>(json!({ "identities": identities })),
698            )
699                .into_response()
700        }
701        Err(err) => {
702            tracing::warn!(target: "mobkit::console", error = %err, "console identities request failed");
703            console_json_error(
704                StatusCode::INTERNAL_SERVER_ERROR,
705                "internal_error",
706                "console identities unavailable",
707            )
708        }
709    }
710}
711
712async fn console_timeline_handler(
713    State(state): State<ConsoleJsonState>,
714    headers: HeaderMap,
715    uri: Uri,
716    Query(query): Query<ConsoleTimelineHttpQuery>,
717) -> impl IntoResponse {
718    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
719        return console_json_error(
720            StatusCode::UNAUTHORIZED,
721            "unauthorized",
722            "console timeline requires a valid auth token",
723        );
724    };
725    let Some(aggregator) = &state.console_aggregator else {
726        return console_json_error(
727            StatusCode::NOT_FOUND,
728            "unavailable",
729            "console aggregator unavailable",
730        );
731    };
732    // Prime the attribute cache from the live roster so label/role rules
733    // resolve when filtering frames by `agent.view` — the timeline REST
734    // surface must not depend on a prior `/console/experience` call.
735    if let Some(runtime) = &state.runtime
736        && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
737    {
738        prime_access_cache_from_runtime(runtime, controller).await;
739    }
740    let timeline_query = timeline_query_from_http(query, None);
741    match Box::pin(aggregator.query_timeline_windowed(timeline_query)).await {
742        Ok(mut page) => {
743            if let Some(view) = auth_context.access_view.as_ref() {
744                page.frames
745                    .retain(|frame| view.can_view_agent(frame.identity.as_str()));
746            }
747            (
748                StatusCode::OK,
749                Json::<Value>(
750                    serde_json::to_value(page).unwrap_or_else(|_| json!({ "frames": [] })),
751                ),
752            )
753                .into_response()
754        }
755        Err(err) => {
756            console_json_error(StatusCode::CONFLICT, "replay_unavailable", &err.to_string())
757        }
758    }
759}
760
761async fn console_send_handler(
762    State(state): State<ConsoleJsonState>,
763    headers: HeaderMap,
764    uri: Uri,
765    Json(request): Json<ConsoleSendRequest>,
766) -> impl IntoResponse {
767    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
768        return console_json_error(
769            StatusCode::UNAUTHORIZED,
770            "unauthorized",
771            "console send requires a valid auth token",
772        );
773    };
774    if state.decisions.console.read_only {
775        return console_json_error(StatusCode::FORBIDDEN, "read_only", "console is read-only");
776    }
777    // Prime the attribute cache from the live roster BEFORE the `agent.send`
778    // decision: role/label-scoped rules resolve through the cache, and on a
779    // cold cache the decision degrades to a bare-identity resource — a deny
780    // rule scoped by `roles`/`match_labels` would not match, so a scripted
781    // caller hitting `/console/send` as its first request could reach a
782    // member the rule was meant to exclude (fail-closed requires the
783    // attributes to be present when the rule is evaluated).
784    if let Some(runtime) = &state.runtime
785        && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
786    {
787        prime_access_cache_from_runtime(runtime, controller).await;
788    }
789    if let Some(view) = auth_context.access_view.as_ref()
790        && view.enforced()
791        && !view.allows_agent(ACTION_AGENT_SEND, request.identity.as_str())
792    {
793        return console_json_error(
794            StatusCode::FORBIDDEN,
795            "access_denied",
796            "you are not allowed to send to this agent",
797        );
798    }
799    // §16 Q1 provisional operator keying: an authenticated principal
800    // addressing this identity IS the active operator for its turns.
801    // Unauthenticated consoles (no subject) note nothing — operator-scope
802    // recall stays inert without a real principal.
803    if let Some(resolver) = state.operator_resolver.as_ref()
804        && let Some(subject) = auth_context
805            .access_view
806            .as_ref()
807            .and_then(|view| view.subject())
808    {
809        resolver.note_interaction(request.identity.as_str(), subject);
810    }
811    let Some(aggregator) = &state.console_aggregator else {
812        return console_json_error(
813            StatusCode::NOT_FOUND,
814            "unavailable",
815            "console aggregator unavailable",
816        );
817    };
818    if let Some(identity_runtime) = &state.identity_runtime {
819        return match Box::pin(console_send_with_identity_first_fallback(
820            aggregator,
821            identity_runtime.clone(),
822            state.runtime.as_ref(),
823            state.console_events.as_ref(),
824            request,
825        ))
826        .await
827        {
828            Ok(accepted) => (
829                StatusCode::OK,
830                Json::<Value>(
831                    serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
832                ),
833            )
834                .into_response(),
835            Err(err) => console_send_error_response(err),
836        };
837    }
838    match Box::pin(aggregator.send(request)).await {
839        Ok(accepted) => (
840            StatusCode::OK,
841            Json::<Value>(
842                serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
843            ),
844        )
845            .into_response(),
846        Err(err) => console_send_error_response(err),
847    }
848}
849
850async fn console_send_with_identity_first_fallback(
851    aggregator: &MobKitConsoleAggregator,
852    identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
853    runtime: Option<&MobRuntime>,
854    console_events: Option<&ConsoleEventStore>,
855    request: ConsoleSendRequest,
856) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
857    let member_send_request = request.clone();
858    match Box::pin(console_send_identity_first(
859        aggregator,
860        identity_runtime,
861        runtime,
862        console_events,
863        request,
864    ))
865    .await
866    {
867        Err(ConsoleSendError::UnknownIdentity(_)) => {
868            Box::pin(aggregator.send(member_send_request)).await
869        }
870        result => result,
871    }
872}
873
874async fn console_send_identity_first(
875    aggregator: &MobKitConsoleAggregator,
876    identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
877    runtime: Option<&MobRuntime>,
878    console_events: Option<&ConsoleEventStore>,
879    mut request: ConsoleSendRequest,
880) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
881    let requested_identity = request.identity.clone();
882    let parsed_identity = crate::identity_first::AgentIdentity::parse(request.identity.as_str())
883        .map_err(|err| ConsoleSendError::InvalidRequest(format!("invalid identity: {err}")))?;
884    let content: ContentInput = serde_json::from_value(request.content.clone())
885        .map_err(|err| ConsoleSendError::InvalidContent(err.to_string()))?;
886    if let ContentInput::Text(text) = &content
887        && text.trim().is_empty()
888    {
889        return Err(ConsoleSendError::InvalidContent(
890            "content must be non-empty".to_string(),
891        ));
892    }
893    if let ContentInput::Blocks(blocks) = &content
894        && blocks.is_empty()
895    {
896        return Err(ConsoleSendError::InvalidContent(
897            "content blocks must be non-empty".to_string(),
898        ));
899    }
900    let handling_mode = parse_identity_first_handling_mode(request.handling_mode.as_deref())?;
901
902    let (identity, status) = match identity_runtime.status(&parsed_identity).await {
903        Ok(status) => (parsed_identity, status),
904        Err(original_err) => {
905            let Some(canonical_identity) = Box::pin(resolve_console_send_identity_alias(
906                aggregator,
907                &requested_identity,
908            ))
909            .await
910            else {
911                return Err(identity_runtime_error_to_console_send_error(
912                    requested_identity.as_str(),
913                    original_err,
914                ));
915            };
916            let identity = crate::identity_first::AgentIdentity::parse(canonical_identity.as_str())
917                .map_err(|err| {
918                    ConsoleSendError::InvalidRequest(format!("invalid aliased identity: {err}"))
919                })?;
920            let status = identity_runtime.status(&identity).await.map_err(|_| {
921                identity_runtime_error_to_console_send_error(
922                    requested_identity.as_str(),
923                    original_err,
924                )
925            })?;
926            request.identity = canonical_identity;
927            (identity, status)
928        }
929    };
930    let session_id = status
931        .session_id
932        .as_ref()
933        .map(std::string::ToString::to_string);
934    let runtime_member_id = status
935        .agent_runtime_id
936        .as_ref()
937        .map(|id| id.as_str().to_string());
938    let accepted = Box::pin(
939        aggregator.reserve_identity_first_interaction(request.clone(), session_id.as_deref()),
940    )
941    .await?;
942
943    if let Some(events) = console_events {
944        events
945            .reserve_interaction_value(
946                identity.as_str(),
947                runtime_member_id.as_deref(),
948                &accepted.interaction_id,
949                &request.origin,
950                request.content.clone(),
951            )
952            .await
953            .map_err(ConsoleSendError::State)?;
954    }
955
956    if let (Some(runtime), Some(events), Some(runtime_member_id)) = (
957        runtime,
958        console_events.cloned(),
959        runtime_member_id.as_deref(),
960    ) {
961        start_identity_first_console_live_projection(
962            runtime,
963            events,
964            identity.as_str(),
965            runtime_member_id,
966        )
967        .await;
968    }
969
970    if handling_mode == meerkat_core::types::HandlingMode::Steer {
971        match identity_runtime
972            .send_with_mode_and_interaction(
973                &identity,
974                &content,
975                handling_mode,
976                Some(accepted.interaction_id.as_str()),
977            )
978            .await
979        {
980            Ok(_) => {
981                if let Err(err) = aggregator
982                    .mark_steer_interaction_delivered(
983                        &accepted.input_frame_id,
984                        &accepted.interaction_id,
985                    )
986                    .await
987                {
988                    tracing::warn!(
989                        identity = %identity,
990                        error = %err,
991                        "console identity-first steer was admitted but delivery status projection failed"
992                    );
993                }
994            }
995            Err(err) => {
996                let _ = aggregator
997                    .mark_interaction_delivery_failed(&accepted.input_frame_id)
998                    .await;
999                if let Some(events) = console_events {
1000                    events
1001                        .record_lifecycle(
1002                            identity.as_str(),
1003                            "interaction_failed",
1004                            json!({
1005                                "interaction_id": accepted.interaction_id,
1006                                "origin": request.origin,
1007                                "error": err.to_string(),
1008                            }),
1009                        )
1010                        .await;
1011                }
1012                tracing::warn!(
1013                    identity = %identity,
1014                    error = %err,
1015                    "console identity-first steer was accepted but delivery failed"
1016                );
1017                return Err(identity_runtime_error_to_console_send_error(
1018                    identity.as_str(),
1019                    err,
1020                ));
1021            }
1022        }
1023        return Ok(accepted);
1024    }
1025
1026    let dispatch_aggregator = aggregator.clone();
1027    let dispatch_events = console_events.cloned();
1028    let dispatch_identity = identity.clone();
1029    let dispatch_content = content.clone();
1030    let dispatch_origin = request.origin.clone();
1031    let dispatch_accepted = accepted.clone();
1032    tokio::spawn(async move {
1033        match identity_runtime
1034            .send_with_mode_and_interaction(
1035                &dispatch_identity,
1036                &dispatch_content,
1037                handling_mode,
1038                Some(dispatch_accepted.interaction_id.as_str()),
1039            )
1040            .await
1041        {
1042            Ok(_) => {
1043                if let Err(err) = dispatch_aggregator
1044                    .mark_interaction_delivered(&dispatch_accepted.input_frame_id)
1045                    .await
1046                {
1047                    tracing::warn!(
1048                        identity = %dispatch_identity,
1049                        error = %err,
1050                        "console identity-first send was accepted but delivery status projection failed"
1051                    );
1052                }
1053            }
1054            Err(err) => {
1055                let _ = dispatch_aggregator
1056                    .mark_interaction_delivery_failed(&dispatch_accepted.input_frame_id)
1057                    .await;
1058                if let Some(events) = dispatch_events {
1059                    events
1060                        .record_lifecycle(
1061                            dispatch_identity.as_str(),
1062                            "interaction_failed",
1063                            json!({
1064                                "interaction_id": dispatch_accepted.interaction_id,
1065                                "origin": dispatch_origin,
1066                                "error": err.to_string(),
1067                            }),
1068                        )
1069                        .await;
1070                }
1071                tracing::warn!(
1072                    identity = %dispatch_identity,
1073                    error = %err,
1074                    "console identity-first send was accepted but delivery failed"
1075                );
1076            }
1077        }
1078    });
1079    Ok(accepted)
1080}
1081
1082async fn start_identity_first_console_live_projection(
1083    runtime: &MobRuntime,
1084    console_events: ConsoleEventStore,
1085    identity: &str,
1086    runtime_member_id: &str,
1087) {
1088    let identity = identity.to_string();
1089    let runtime_member_id = runtime_member_id.to_string();
1090    let mut stream = match runtime
1091        .handle()
1092        .subscribe_agent_events(&crate::member_comms_id::mob_member_id(
1093            runtime_member_id.as_str(),
1094        ))
1095        .await
1096    {
1097        Ok(stream) => stream,
1098        Err(err) => {
1099            tracing::warn!(
1100                identity = %identity,
1101                runtime_member_id = %runtime_member_id,
1102                error = %err,
1103                "console identity-first live projection could not subscribe to agent events"
1104            );
1105            return;
1106        }
1107    };
1108
1109    tokio::spawn(async move {
1110        while let Some(envelope) = stream.next().await {
1111            let event_type = agent_event_type(&envelope.payload).to_string();
1112            let unified = EventEnvelope {
1113                event_id: format!("evt-agent-{}", envelope.event_id),
1114                source: "agent".to_string(),
1115                timestamp_ms: envelope.timestamp_ms,
1116                event: UnifiedEvent::Agent {
1117                    agent_id: runtime_member_id.clone(),
1118                    event_type: event_type.clone(),
1119                    // Console wire shape, not the raw 0.7 event: keeps the
1120                    // derived `result` text (and `tool_call_id` mirror) that
1121                    // the embedded console's adapters and image-frame
1122                    // projection consume.
1123                    payload: Some(crate::mob_handle_runtime::console_agent_event_payload(
1124                        &envelope.payload,
1125                    )),
1126                },
1127            };
1128            console_events.project_unified_event(&unified).await;
1129            if matches!(event_type.as_str(), "run_completed" | "run_failed") {
1130                break;
1131            }
1132        }
1133    });
1134}
1135
1136fn parse_identity_first_handling_mode(
1137    value: Option<&str>,
1138) -> Result<meerkat_core::types::HandlingMode, ConsoleSendError> {
1139    match value.unwrap_or("queue") {
1140        "queue" => Ok(meerkat_core::types::HandlingMode::Queue),
1141        "steer" => Ok(meerkat_core::types::HandlingMode::Steer),
1142        other => Err(ConsoleSendError::InvalidHandlingMode(other.to_string())),
1143    }
1144}
1145
1146async fn resolve_console_send_identity_alias(
1147    aggregator: &MobKitConsoleAggregator,
1148    requested_identity: &str,
1149) -> Option<String> {
1150    let identities = Box::pin(aggregator.list_identities()).await.ok()?;
1151    identities
1152        .into_iter()
1153        .find(|record| {
1154            record.identity == requested_identity || record.runtime_member_id == requested_identity
1155        })
1156        .map(|record| record.identity)
1157}
1158
1159fn identity_runtime_error_to_console_send_error(
1160    identity: &str,
1161    err: crate::identity_first::IdentityRuntimeError,
1162) -> ConsoleSendError {
1163    match err {
1164        crate::identity_first::IdentityRuntimeError::UnknownIdentity(_) => {
1165            ConsoleSendError::UnknownIdentity(identity.to_string())
1166        }
1167        crate::identity_first::IdentityRuntimeError::NotAddressable(_) => {
1168            ConsoleSendError::NotAddressable(identity.to_string())
1169        }
1170        crate::identity_first::IdentityRuntimeError::InvalidState { .. } => {
1171            ConsoleSendError::Retired(identity.to_string())
1172        }
1173        other => ConsoleSendError::Dispatch(other.to_string()),
1174    }
1175}
1176
1177async fn console_timeline_stream_handler(
1178    State(state): State<ConsoleJsonState>,
1179    headers: HeaderMap,
1180    uri: Uri,
1181    Query(query): Query<ConsoleTimelineHttpQuery>,
1182) -> impl IntoResponse {
1183    let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
1184        return console_json_error(
1185            StatusCode::UNAUTHORIZED,
1186            "unauthorized",
1187            "console timeline stream requires a valid auth token",
1188        );
1189    };
1190    let access_view = auth_context.access_view.filter(AccessView::enforced);
1191    // Prime the attribute cache from the live roster before any per-frame
1192    // `agent.view` filter so label/role rules resolve here exactly as on the
1193    // windowed timeline handler and the RPC/SSE seams — the stream must not
1194    // depend on a prior `/console/experience` call. Primed once at open; an
1195    // agent spawned mid-stream is filtered against the open-time roster, which
1196    // matches the windowed handler's contract.
1197    if access_view.is_some()
1198        && let Some(runtime) = &state.runtime
1199        && let Some(controller) = state
1200            .access
1201            .as_ref()
1202            .filter(|controller| controller.enabled())
1203    {
1204        prime_access_cache_from_runtime(runtime, controller).await;
1205    }
1206    let Some(aggregator) = &state.console_aggregator else {
1207        return console_json_error(
1208            StatusCode::NOT_FOUND,
1209            "unavailable",
1210            "console aggregator unavailable",
1211        );
1212    };
1213    let aggregator = aggregator.clone();
1214    let last_event_id = headers
1215        .get("last-event-id")
1216        .and_then(|value| value.to_str().ok())
1217        .map(str::trim)
1218        .filter(|value| !value.is_empty())
1219        .map(ToString::to_string);
1220    let timeline_query = timeline_query_from_http(query, last_event_id);
1221    let mut rx = aggregator.subscribe();
1222    let (snapshot_frames, snapshot_cursor) =
1223        match Box::pin(query_timeline_snapshot(&aggregator, timeline_query.clone())).await {
1224            Ok(snapshot) => snapshot,
1225            Err(_) => {
1226                let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
1227                let requested_cursor = timeline_query
1228                    .after
1229                    .as_ref()
1230                    .map(ToString::to_string)
1231                    .unwrap_or_default();
1232                return (
1233                    StatusCode::CONFLICT,
1234                    Json::<Value>(
1235                        serde_json::to_value(ConsoleReplayUnavailable {
1236                            error: "replay_unavailable".to_string(),
1237                            requested_cursor,
1238                            latest_cursor,
1239                        })
1240                        .unwrap_or_else(|_| json!({ "error": "replay_unavailable" })),
1241                    ),
1242                )
1243                    .into_response();
1244            }
1245        };
1246    let identity = timeline_query.identity.clone();
1247    let conversation_id = timeline_query.conversation_id.clone();
1248    let snapshot_after = timeline_query.after.clone();
1249    let stream = stream! {
1250        if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotStarted { after: snapshot_after }) {
1251            yield Ok::<Event, Infallible>(event);
1252        }
1253        let mut latest_cursor = snapshot_cursor;
1254        for frame in snapshot_frames {
1255            latest_cursor = Some(frame.cursor.clone());
1256            if access_view.as_ref().is_some_and(|view| !view.can_view_agent(frame.identity.as_str())) {
1257                continue;
1258            }
1259            if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::ConsoleFrame { frame }) {
1260                yield Ok::<Event, Infallible>(event);
1261            }
1262        }
1263        if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotComplete { cursor: latest_cursor.clone() }) {
1264            yield Ok::<Event, Infallible>(event);
1265        }
1266        loop {
1267            match rx.recv().await {
1268                Ok(event) if timeline_event_matches(&event, identity.as_deref(), conversation_id.as_deref()) => {
1269                    if !Box::pin(
1270                        aggregator
1271                            .timeline_event_visible_for_subscriber(&event, identity.as_deref()),
1272                    )
1273                    .await
1274                    {
1275                        continue;
1276                    }
1277                    if let Some(view) = access_view.as_ref()
1278                        && let Some(frame_identity) = timeline_event_identity(&event)
1279                        && !view.can_view_agent(frame_identity)
1280                    {
1281                        continue;
1282                    }
1283                    if let Some(event_cursor) = timeline_event_cursor(&event)
1284                        && let Some(current_cursor) = latest_cursor.as_ref()
1285                        && !cursor_is_after(event_cursor, current_cursor)
1286                    {
1287                        continue;
1288                    }
1289                    if let Some(sse) = sse_event_from_timeline_event(&event) {
1290                        if let Some(event_cursor) = timeline_event_cursor(&event) {
1291                            latest_cursor = Some(event_cursor.clone());
1292                        }
1293                        yield Ok::<Event, Infallible>(sse);
1294                    }
1295                }
1296                Ok(_) => {}
1297                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
1298                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1299            }
1300        }
1301    };
1302    Sse::new(stream)
1303        .keep_alive(
1304            KeepAlive::new()
1305                .interval(DEFAULT_KEEP_ALIVE_INTERVAL)
1306                .text(KEEP_ALIVE_TEXT),
1307        )
1308        .into_response()
1309}
1310
1311async fn console_identity_timeline_stream_handler(
1312    State(state): State<ConsoleJsonState>,
1313    headers: HeaderMap,
1314    uri: Uri,
1315    AxumPath(identity): AxumPath<String>,
1316    Query(mut query): Query<ConsoleTimelineHttpQuery>,
1317) -> impl IntoResponse {
1318    query.identity = Some(identity);
1319    Box::pin(console_timeline_stream_handler(
1320        State(state),
1321        headers,
1322        uri,
1323        Query(query),
1324    ))
1325    .await
1326    .into_response()
1327}
1328
1329fn timeline_query_from_http(
1330    query: ConsoleTimelineHttpQuery,
1331    fallback_after: Option<String>,
1332) -> ConsoleTimelineWindowQuery {
1333    let after = fallback_after.or(query.after).map(ConsoleCursor::from);
1334    let before = query.before.map(ConsoleCursor::from);
1335    ConsoleTimelineWindowQuery {
1336        identity: query
1337            .identity
1338            .map(|value| value.trim().to_string())
1339            .filter(|value| !value.is_empty()),
1340        conversation_id: query
1341            .conversation_id
1342            .map(|value| value.trim().to_string())
1343            .filter(|value| !value.is_empty()),
1344        after,
1345        before,
1346        mode: query.mode.unwrap_or_default(),
1347        limit: query.limit.unwrap_or(200),
1348    }
1349}
1350
1351async fn query_timeline_snapshot(
1352    aggregator: &MobKitConsoleAggregator,
1353    mut query: ConsoleTimelineWindowQuery,
1354) -> ConsoleLogResult<(Vec<ConsoleFrame>, Option<ConsoleCursor>)> {
1355    const DEFAULT_SNAPSHOT_LIMIT: usize = 200;
1356    query.limit = if query.limit == 0 {
1357        DEFAULT_SNAPSHOT_LIMIT
1358    } else {
1359        query.limit
1360    };
1361    if query.after.is_none() && query.mode == ConsoleTimelineMode::Since {
1362        query.mode = ConsoleTimelineMode::Recent;
1363    }
1364    let mode = query.mode;
1365    match mode {
1366        ConsoleTimelineMode::Recent => {
1367            let page = Box::pin(aggregator.query_timeline_windowed(query)).await?;
1368            Ok((page.frames, page.latest_cursor.or(page.next_cursor)))
1369        }
1370        ConsoleTimelineMode::Since => {
1371            if let (Some(after), Some(latest)) =
1372                (query.after.as_ref(), aggregator.latest_cursor().await?)
1373                && let (Some(after_seq), Some(latest_seq)) = (after.seq(), latest.seq())
1374                && after_seq > latest_seq
1375            {
1376                return Err(std::io::Error::other(
1377                    "timeline replay cursor is beyond the current store frontier",
1378                )
1379                .into());
1380            }
1381            let mut frames = Vec::new();
1382            let mut cursor = query.after.clone();
1383            let mut latest_cursor = None;
1384            loop {
1385                let page = Box::pin(aggregator.query_timeline_windowed(query.clone())).await?;
1386                latest_cursor = page.latest_cursor.clone().or(latest_cursor);
1387                if !page.frames.is_empty() {
1388                    cursor = page
1389                        .next_cursor
1390                        .clone()
1391                        .or_else(|| page.frames.last().map(|frame| frame.cursor.clone()));
1392                    frames.extend(page.frames);
1393                } else if page.next_cursor.is_some() {
1394                    cursor = page.next_cursor.clone();
1395                }
1396                if page.exhausted || page.next_cursor.is_none() {
1397                    return Ok((frames, cursor.or(latest_cursor)));
1398                }
1399                if page.next_cursor == query.after {
1400                    return Err(
1401                        std::io::Error::other("timeline replay made no cursor progress").into(),
1402                    );
1403                }
1404                query.after = page.next_cursor;
1405            }
1406        }
1407    }
1408}
1409
1410fn console_json_error(status: StatusCode, error: &str, message: &str) -> axum::response::Response {
1411    (
1412        status,
1413        Json::<Value>(json!({
1414            "error": error,
1415            "message": message,
1416        })),
1417    )
1418        .into_response()
1419}
1420
1421fn is_console_mutating_rpc_method(method: &str) -> bool {
1422    if crate::rpc::topology_methods::is_topology_mutating_method(method) {
1423        return true;
1424    }
1425    if crate::rpc::workgraph_methods::is_workgraph_mutating_method(method) {
1426        return true;
1427    }
1428    matches!(
1429        method,
1430        "mobkit/retire"
1431            | "mobkit/reset_all"
1432            | "mobkit/console/send"
1433            | "mobkit/blob/upload"
1434            | "mobkit/ensure_member"
1435            | "mobkit/retire_member"
1436            | "mobkit/respawn_member"
1437            | "mobkit/force_cancel_member"
1438            | "mobkit/cancel_flow"
1439            | "mobkit/collect_completed"
1440            | "mobkit/run_flow"
1441            | "mobkit/spawn_helper"
1442            | "mobkit/fork_helper"
1443            | "mobkit/attach_existing_session"
1444            | "mobkit/reconcile_edges"
1445            | "mobkit/cross_mob/wire_local"
1446            | "mobkit/cross_mob/unwire_local"
1447            | "mobkit/respawn"
1448            | "mobkit/reset"
1449            | "mobkit/delete_identity"
1450            | "mobkit/agent_memory/remember"
1451            | "mobkit/agent_memory/update"
1452            | "mobkit/agent_memory/forget"
1453            | "mobkit/gating/decide"
1454            | "mobkit/mob_labels/set"
1455            | "mobkit/mob_labels/delete"
1456            | "mobkit/run_labels/set"
1457            | "mobkit/run_labels/delete"
1458            | "mobkit/access/set"
1459            | "mobkit/access/enable"
1460            | "mobkit/access/rules/upsert"
1461            | "mobkit/access/rules/delete"
1462            | "mobkit/access/groups/set"
1463            | "mobkit/access/groups/delete"
1464    )
1465}
1466
1467fn console_read_only_rpc_error(response_id: Value) -> Value {
1468    response_value(
1469        response_id,
1470        None,
1471        Some(JsonRpcError {
1472            code: -32010,
1473            message: "console is read-only".to_string(),
1474            data: Some(json!({ "kind": "read_only" })),
1475        }),
1476    )
1477}
1478
1479fn console_send_error_response(err: ConsoleSendError) -> axum::response::Response {
1480    let (status, code) = match &err {
1481        ConsoleSendError::UnknownIdentity(_) => (StatusCode::NOT_FOUND, "unknown_identity"),
1482        ConsoleSendError::AmbiguousIdentity { .. } => {
1483            (StatusCode::CONFLICT, "ambiguous_live_identity_alias")
1484        }
1485        ConsoleSendError::NotAddressable(_) => (StatusCode::CONFLICT, "not_addressable"),
1486        ConsoleSendError::Retired(_) => (StatusCode::CONFLICT, "retired"),
1487        ConsoleSendError::InvalidContent(_)
1488        | ConsoleSendError::InvalidHandlingMode(_)
1489        | ConsoleSendError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
1490        ConsoleSendError::IdempotencyConflict(_) => (StatusCode::CONFLICT, "idempotency_conflict"),
1491        ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1492            (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
1493        }
1494    };
1495    console_json_error(status, code, &console_send_public_message(&err))
1496}
1497
1498fn console_send_rpc_code(err: &ConsoleSendError) -> i64 {
1499    match err {
1500        ConsoleSendError::UnknownIdentity(_) => -32001,
1501        ConsoleSendError::AmbiguousIdentity { .. } => -32602,
1502        ConsoleSendError::NotAddressable(_) => -32002,
1503        ConsoleSendError::InvalidContent(_)
1504        | ConsoleSendError::InvalidHandlingMode(_)
1505        | ConsoleSendError::InvalidRequest(_) => -32602,
1506        ConsoleSendError::IdempotencyConflict(_) => -32009,
1507        ConsoleSendError::Retired(_) => -32004,
1508        ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1509            -32000
1510        }
1511    }
1512}
1513
1514fn console_send_rpc_error(response_id: Value, err: ConsoleSendError) -> Value {
1515    response_value(response_id, None, Some(console_send_json_rpc_error(err)))
1516}
1517
1518fn console_send_json_rpc_error(err: ConsoleSendError) -> JsonRpcError {
1519    let code = console_send_rpc_code(&err);
1520    JsonRpcError {
1521        code,
1522        message: console_send_public_message(&err),
1523        data: None,
1524    }
1525}
1526
1527fn console_send_public_message(err: &ConsoleSendError) -> String {
1528    match err {
1529        ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1530            tracing::warn!(target: "mobkit::console", error = %err, "console send internal error");
1531            "console send failed".to_string()
1532        }
1533        _ => err.to_string(),
1534    }
1535}
1536
1537fn timeline_event_matches(
1538    event: &ConsoleTimelineEvent,
1539    identity: Option<&str>,
1540    conversation_id: Option<&str>,
1541) -> bool {
1542    let frame = match event {
1543        ConsoleTimelineEvent::ConsoleFrame { frame }
1544        | ConsoleTimelineEvent::FrameUpdated { frame } => frame,
1545        ConsoleTimelineEvent::SnapshotStarted { .. }
1546        | ConsoleTimelineEvent::SnapshotComplete { .. }
1547        | ConsoleTimelineEvent::ReplayUnavailable { .. } => return true,
1548    };
1549    if identity.is_some_and(|value| frame.identity != value) {
1550        return false;
1551    }
1552    if conversation_id.is_some_and(|value| frame.conversation_id.as_deref() != Some(value)) {
1553        return false;
1554    }
1555    true
1556}
1557
1558fn timeline_event_cursor(event: &ConsoleTimelineEvent) -> Option<&ConsoleCursor> {
1559    match event {
1560        ConsoleTimelineEvent::ConsoleFrame { frame }
1561        | ConsoleTimelineEvent::FrameUpdated { frame } => Some(&frame.cursor),
1562        ConsoleTimelineEvent::SnapshotStarted { .. }
1563        | ConsoleTimelineEvent::SnapshotComplete { .. }
1564        | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1565    }
1566}
1567
1568fn timeline_event_identity(event: &ConsoleTimelineEvent) -> Option<&str> {
1569    match event {
1570        ConsoleTimelineEvent::ConsoleFrame { frame }
1571        | ConsoleTimelineEvent::FrameUpdated { frame } => Some(frame.identity.as_str()),
1572        ConsoleTimelineEvent::SnapshotStarted { .. }
1573        | ConsoleTimelineEvent::SnapshotComplete { .. }
1574        | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1575    }
1576}
1577
1578// ---------------------------------------------------------------------------
1579// ABAC enforcement for console surfaces
1580// ---------------------------------------------------------------------------
1581
1582pub(crate) const ACCESS_DENIED_RPC_CODE: i64 = -32030;
1583
1584fn retain_visible_timeline_frames(page: &mut ConsoleTimelineWindowPage, view: Option<&AccessView>) {
1585    let Some(view) = view.filter(|view| view.enforced()) else {
1586        return;
1587    };
1588    page.frames
1589        .retain(|frame| view.can_view_agent(frame.identity.as_str()));
1590}
1591
1592/// Filter serialized member rows (`mobkit/list_members`, `find_members`)
1593/// down to the agents the caller may view. Field names cover both the
1594/// meerkat roster entry shape and the console projection shape.
1595fn retain_visible_member_rows(rows: &mut Vec<Value>, view: Option<&AccessView>) {
1596    let Some(view) = view.filter(|view| view.enforced()) else {
1597        return;
1598    };
1599    rows.retain(|row| {
1600        let labels: Option<std::collections::BTreeMap<String, String>> = row
1601            .get("labels")
1602            .and_then(|value| serde_json::from_value(value.clone()).ok());
1603        let agent_id = row
1604            .get("agent_identity")
1605            .or_else(|| row.get("member_id"))
1606            .or_else(|| row.get("agent_id"))
1607            .and_then(Value::as_str);
1608        let identity = labels
1609            .as_ref()
1610            .and_then(|labels| labels.get("agent_identity"))
1611            .map(String::as_str)
1612            .or_else(|| row.get("identity").and_then(Value::as_str))
1613            .or(agent_id);
1614        let role = row
1615            .get("role")
1616            .or_else(|| row.get("profile"))
1617            .and_then(Value::as_str);
1618        view.decide(
1619            ACTION_AGENT_VIEW,
1620            &AccessResource {
1621                identity,
1622                agent_id,
1623                role,
1624                labels: labels.as_ref(),
1625            },
1626        )
1627        .is_allow()
1628    });
1629}
1630
1631fn retain_visible_identity_records(
1632    identities: &mut Vec<ConsoleIdentityRecord>,
1633    view: Option<&AccessView>,
1634) {
1635    let Some(view) = view.filter(|view| view.enforced()) else {
1636        return;
1637    };
1638    identities.retain(|record| {
1639        view.decide(
1640            ACTION_AGENT_VIEW,
1641            &AccessResource {
1642                identity: Some(record.identity.as_str()),
1643                agent_id: Some(record.runtime_member_id.as_str()),
1644                role: None,
1645                labels: Some(&record.labels),
1646            },
1647        )
1648        .is_allow()
1649    });
1650}
1651
1652/// Maps a console RPC method to the access actions it requires, each with
1653/// the targeted agent (when the check is agent-scoped). Every listed action
1654/// must be allowed (logical AND). Methods not listed are either pure read
1655/// surfaces whose *results* are filtered per caller, or non-sensitive
1656/// metadata.
1657fn console_rpc_access_requirements(
1658    method: &str,
1659    params: &Value,
1660) -> Option<Vec<(&'static str, Option<String>)>> {
1661    let identity = normalized_console_rpc_string_param(params, "identity");
1662    let target = identity
1663        .clone()
1664        .or_else(|| normalized_console_rpc_string_param(params, "member_id"))
1665        .or_else(|| normalized_console_rpc_string_param(params, "agent_id"));
1666    let one = |action: &'static str, target: Option<String>| Some(vec![(action, target)]);
1667    match method {
1668        "mobkit/console/send" => one(ACTION_AGENT_SEND, identity),
1669        "mobkit/agent_memory/remember" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1670        // Update is a supersede — a write within the record's lineage.
1671        "mobkit/agent_memory/update" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1672        "mobkit/agent_memory/forget" => one(ACTION_AGENT_MEMORY_DELETE, identity),
1673        // §10.3 migration: memory reads moved from bare `agent.view` to the
1674        // per-scope read action, and the console keeps `agent.view` as a
1675        // prerequisite — both are required. Pre-migration configs are
1676        // covered by `normalize_access_config_for_memory_actions`.
1677        "mobkit/agent_memory/recall" | "mobkit/agent_memory/manifest" => Some(vec![
1678            (ACTION_AGENT_MEMORY_READ, identity.clone()),
1679            (ACTION_AGENT_VIEW, identity),
1680        ]),
1681        // Memory panel reads (§9.3). Identity-keyed listings gate up front
1682        // like recall; mob-scope listings need the mob read action;
1683        // unscoped listings carry no entry gate — every returned row is
1684        // filtered per caller scope in the handler
1685        // (`panel_record_visible`). The record-detail method is likewise
1686        // enforced post-load, where the record's scope is known.
1687        "mobkit/memory/panel/records" => match memory_panel_scope_param(params) {
1688            Some(scope) if scope == "mob" => one(ACTION_MOB_MEMORY_READ, None),
1689            Some(scope) if scope == "operator" => one(ACTION_OPERATOR_MEMORY_READ, None),
1690            _ if identity.is_some() => Some(vec![
1691                (ACTION_AGENT_MEMORY_READ, identity.clone()),
1692                (ACTION_AGENT_VIEW, identity),
1693            ]),
1694            _ => None,
1695        },
1696        // Dream history is realm-level steward activity spanning scopes; it
1697        // requires the unscoped read grant (a rule with no resource
1698        // selector), same as realm-scope record reads.
1699        "mobkit/memory/panel/dreams" => one(ACTION_AGENT_MEMORY_READ, None),
1700        // Durable dream verdict sheets + the usage-audit review queue: same
1701        // realm-level read posture as dream history.
1702        "mobkit/memory/panel/dream_runs" => one(ACTION_AGENT_MEMORY_READ, None),
1703        "mobkit/memory/panel/audit_verdicts" => one(ACTION_AGENT_MEMORY_READ, None),
1704        // Console Phase 2 cheap reads (proposal §7): store overview,
1705        // pipeline inputs, and the injection ledger — same realm-level read
1706        // posture. The health snapshot is deliberately NOT here yet: the
1707        // decided distinct runtime affordance lands with its own design.
1708        "mobkit/memory/panel/overview" => one(ACTION_AGENT_MEMORY_READ, None),
1709        "mobkit/memory/panel/proposals" => one(ACTION_AGENT_MEMORY_READ, None),
1710        "mobkit/memory/panel/injections" => one(ACTION_AGENT_MEMORY_READ, None),
1711        "mobkit/memory/panel/harvests" => one(ACTION_AGENT_MEMORY_READ, None),
1712        "mobkit/memory/panel/quarantine" => one(ACTION_MEMORY_QUARANTINE_REVIEW, None),
1713        // `mob.memory.propose` gates future propose surfaces and
1714        // `mob.memory.commit` is reserved for a future direct-commit RPC —
1715        // steward promotions ride the existing gating flow (gating.decide),
1716        // so neither maps to a method yet.
1717        "mobkit/retire"
1718        | "mobkit/retire_member"
1719        | "mobkit/force_cancel_member"
1720        | "mobkit/delete_identity" => one(ACTION_AGENT_RETIRE, target),
1721        "mobkit/respawn" | "mobkit/respawn_member" => one(ACTION_AGENT_RESPAWN, target),
1722        "mobkit/reset" | "mobkit/reset_all" => one(ACTION_AGENT_RESET, target),
1723        "mobkit/ensure_member"
1724        | "mobkit/spawn_helper"
1725        | "mobkit/fork_helper"
1726        | "mobkit/attach_existing_session"
1727        | "mobkit/run_flow"
1728        | "mobkit/cancel_flow"
1729        | "mobkit/collect_completed" => one(ACTION_AGENT_SPAWN, target),
1730        // Flow state reads expose run records (including spawned member
1731        // identities), so they sit in the same tier as running flows.
1732        "mobkit/flow_status" | "mobkit/list_flows" | "mobkit/list_runs" => {
1733            one(ACTION_AGENT_SPAWN, None)
1734        }
1735        "mobkit/gating/decide" => one(ACTION_GATING_DECIDE, None),
1736        "mobkit/gating/pending" | "mobkit/gating/audit" => one(ACTION_GATING_VIEW, None),
1737        "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => one(ACTION_MOB_OBSERVE, None),
1738        "mobkit/reconcile_edges"
1739        | "mobkit/cross_mob/wire_local"
1740        | "mobkit/cross_mob/unwire_local"
1741        | "mobkit/mob_labels/set"
1742        | "mobkit/mob_labels/delete"
1743        | "mobkit/run_labels/set"
1744        | "mobkit/run_labels/delete"
1745        // Plumbing reads: routing tables, delivery records, cross-mob
1746        // contacts, and label tables enumerate agent identities without
1747        // passing through per-agent visibility filtering, so they require
1748        // the same grant as the mutations that shape them.
1749        | "mobkit/routing/routes/list"
1750        | "mobkit/delivery/history"
1751        | "mobkit/cross_mob/directory"
1752        | "mobkit/cross_mob/peer_info"
1753        | "mobkit/mob_labels/get"
1754        | "mobkit/run_labels/get" => one(ACTION_RUNTIME_ADMIN, None),
1755        "mobkit/get_member"
1756        | "mobkit/member_status"
1757        | "mobkit/identity/resolved_tools"
1758        | "mobkit/inspect_identity"
1759        | "mobkit/status_identity"
1760        | "mobkit/console/inspect_identity" => one(ACTION_AGENT_VIEW, target),
1761        // WorkGraph spans the whole mob, so both actions are resource-less:
1762        // reads need the view grant, every mutation the manage grant.
1763        method if crate::rpc::workgraph_methods::is_workgraph_mutating_method(method) => {
1764            one(ACTION_WORKGRAPH_MANAGE, None)
1765        }
1766        method if crate::rpc::workgraph_methods::is_workgraph_read_method(method) => {
1767            one(ACTION_WORKGRAPH_VIEW, None)
1768        }
1769        _ => None,
1770    }
1771}
1772
1773fn memory_panel_scope_param(params: &Value) -> Option<String> {
1774    normalized_console_rpc_string_param(params, "scope").filter(|scope| !scope.is_empty())
1775}
1776
1777fn normalized_console_rpc_string_param(params: &Value, key: &str) -> Option<String> {
1778    params
1779        .get(key)
1780        .and_then(Value::as_str)
1781        .map(str::trim)
1782        .map(str::to_string)
1783}
1784
1785/// Returns the denial error when the caller's view forbids the method.
1786/// Every mapped requirement must pass; the first failing one is reported.
1787fn console_rpc_access_violation(
1788    view: Option<&AccessView>,
1789    method: &str,
1790    params: &Value,
1791) -> Option<JsonRpcError> {
1792    let view = view.filter(|view| view.enforced())?;
1793    let requirements = console_rpc_access_requirements(method, params)?;
1794    for (action, target) in requirements {
1795        let allowed = match target {
1796            Some(ref identity) => view.allows_agent(action, identity),
1797            None => view.allows(action),
1798        };
1799        if !allowed {
1800            return Some(JsonRpcError {
1801                code: ACCESS_DENIED_RPC_CODE,
1802                message: format!("access denied: {action}"),
1803                data: Some(json!({
1804                    "kind": "access_denied",
1805                    "action": action,
1806                    "resource": target,
1807                })),
1808            });
1809        }
1810    }
1811    None
1812}
1813
1814fn access_denied_rpc_error(response_id: Value, message: impl Into<String>) -> Value {
1815    response_value(
1816        response_id,
1817        None,
1818        Some(JsonRpcError {
1819            code: ACCESS_DENIED_RPC_CODE,
1820            message: message.into(),
1821            data: Some(json!({ "kind": "access_denied" })),
1822        }),
1823    )
1824}
1825
1826fn access_unavailable_rpc_error(response_id: Value) -> Value {
1827    response_value(
1828        response_id,
1829        None,
1830        Some(JsonRpcError {
1831            code: -32004,
1832            message: "access control is not configured on this runtime".to_string(),
1833            data: Some(json!({ "kind": "access_unavailable" })),
1834        }),
1835    )
1836}
1837
1838fn access_config_rpc_error(response_id: Value, err: crate::access::AccessConfigError) -> Value {
1839    response_value(
1840        response_id,
1841        None,
1842        Some(JsonRpcError {
1843            code: -32602,
1844            message: err.to_string(),
1845            data: Some(json!({ "kind": "invalid_access_config" })),
1846        }),
1847    )
1848}
1849
1850// ---------------------------------------------------------------------------
1851// Console Memory panel (§9.3): read-only `mobkit/memory/panel/*` RPCs
1852// ---------------------------------------------------------------------------
1853
1854const MEMORY_PANEL_DEFAULT_LIMIT: usize = 50;
1855const MEMORY_PANEL_MAX_LIMIT: usize = 200;
1856const MEMORY_PANEL_CHAIN_MAX: usize = 32;
1857const MEMORY_PANEL_INJECTIONS_MAX: usize = 50;
1858const MEMORY_PANEL_DREAMS_DEFAULT: usize = 20;
1859const MEMORY_PANEL_DREAMS_MAX: usize = 100;
1860
1861fn memory_panel_unavailable(response_id: Value) -> Value {
1862    response_value(
1863        response_id,
1864        None,
1865        Some(JsonRpcError {
1866            code: -32601,
1867            message: "memory panel is not configured".to_string(),
1868            data: None,
1869        }),
1870    )
1871}
1872
1873fn memory_panel_store_error(
1874    response_id: Value,
1875    err: crate::identity_first::agent_memory::AgentMemoryError,
1876) -> Value {
1877    response_value(
1878        response_id,
1879        None,
1880        Some(crate::rpc::agent_memory_rpc_error("panel", err)),
1881    )
1882}
1883
1884fn memory_panel_limit_param(params: &Value, default: usize, max: usize) -> usize {
1885    params
1886        .get("limit")
1887        .and_then(Value::as_u64)
1888        .map(|limit| (limit as usize).clamp(1, max))
1889        .unwrap_or(default)
1890}
1891
1892fn encode_memory_panel_cursor(cursor: &(u64, String)) -> String {
1893    format!("{}:{}", cursor.0, cursor.1)
1894}
1895
1896fn parse_memory_panel_cursor(raw: &str) -> Option<(u64, String)> {
1897    let (ms, id) = raw.split_once(':')?;
1898    let ms = ms.parse::<u64>().ok()?;
1899    (!id.is_empty()).then(|| (ms, id.to_string()))
1900}
1901
1902/// The read action guarding a record of this scope on the panel (§10.3).
1903/// Realm-scope reads ride an *unscoped* `agent.memory.read` grant (a rule
1904/// with no resource selector); operator scope (live since P4's provisional
1905/// keying) requires its own explicit `operator.memory.read` grant.
1906fn memory_panel_scope_action(scope: &crate::memory::records::MemoryScope) -> &'static str {
1907    match scope {
1908        crate::memory::records::MemoryScope::Identity { .. } => ACTION_AGENT_MEMORY_READ,
1909        crate::memory::records::MemoryScope::Mob { .. } => ACTION_MOB_MEMORY_READ,
1910        crate::memory::records::MemoryScope::Operator { .. } => ACTION_OPERATOR_MEMORY_READ,
1911        crate::memory::records::MemoryScope::Realm { .. } => ACTION_AGENT_MEMORY_READ,
1912    }
1913}
1914
1915/// Per-row visibility for panel reads. Identity-scope rows require the
1916/// per-scope read action AND `agent.view` on the identity (§10.3: the
1917/// console keeps view as a prerequisite); mob rows require
1918/// `mob.memory.read`; operator rows require the explicit
1919/// `operator.memory.read` grant (cross-mob personal facts — an unscoped
1920/// `agent.memory.read` deliberately does NOT cover them); realm rows
1921/// require the unscoped read grant. Quarantined rows are additionally
1922/// reviewer-only — their bodies are exactly the content the quarantine
1923/// gate exists for.
1924fn memory_panel_record_visible(
1925    view: Option<&AccessView>,
1926    record: &crate::memory::records::MemoryRecord,
1927) -> bool {
1928    let Some(view) = view.filter(|view| view.enforced()) else {
1929        return true;
1930    };
1931    let scope_allowed = match &record.scope {
1932        crate::memory::records::MemoryScope::Identity { identity, .. } => {
1933            view.allows_agent(ACTION_AGENT_MEMORY_READ, identity)
1934                && view.allows_agent(ACTION_AGENT_VIEW, identity)
1935        }
1936        crate::memory::records::MemoryScope::Mob { .. } => view.allows(ACTION_MOB_MEMORY_READ),
1937        crate::memory::records::MemoryScope::Operator { .. } => {
1938            view.allows(ACTION_OPERATOR_MEMORY_READ)
1939        }
1940        crate::memory::records::MemoryScope::Realm { .. } => view.allows(ACTION_AGENT_MEMORY_READ),
1941    };
1942    if !scope_allowed {
1943        return false;
1944    }
1945    if matches!(
1946        record.status,
1947        crate::memory::records::RecordStatus::Quarantined { .. }
1948    ) {
1949        return view.allows(ACTION_MEMORY_QUARANTINE_REVIEW);
1950    }
1951    true
1952}
1953
1954/// Per-row visibility for pending-promotion queue rows, mirroring
1955/// [`memory_panel_record_visible`] on the promotion's *target* scope:
1956/// promotions carry the scope key and steward rationale, so they leak the
1957/// same cross-scope metadata as record rows. `memory.quarantine.review` is
1958/// the queue's entry gate but is deliberately not sufficient per row.
1959/// Unknown scope kinds are denied.
1960fn memory_panel_promotion_visible(
1961    view: Option<&AccessView>,
1962    scope_kind: &str,
1963    scope_key: &str,
1964) -> bool {
1965    let Some(view) = view.filter(|view| view.enforced()) else {
1966        return true;
1967    };
1968    match scope_kind {
1969        "identity" => {
1970            view.allows_agent(ACTION_AGENT_MEMORY_READ, scope_key)
1971                && view.allows_agent(ACTION_AGENT_VIEW, scope_key)
1972        }
1973        "mob" => view.allows(ACTION_MOB_MEMORY_READ),
1974        "operator" => view.allows(ACTION_OPERATOR_MEMORY_READ),
1975        "realm" => view.allows(ACTION_AGENT_MEMORY_READ),
1976        _ => false,
1977    }
1978}
1979
1980/// Serialize a record for the panel. List rows are body-free (`body_bytes`
1981/// stands in); only the record-detail surface carries the body.
1982fn memory_panel_record_json(
1983    record: &crate::memory::records::MemoryRecord,
1984    include_body: bool,
1985) -> Value {
1986    let mut value = serde_json::to_value(record).unwrap_or(Value::Null);
1987    if !include_body && let Some(object) = value.as_object_mut() {
1988        object.remove("body");
1989        object.insert("body_bytes".to_string(), json!(record.body.len()));
1990    }
1991    value
1992}
1993
1994/// Resolve the realms a panel read spans: the explicit `realm` param, or
1995/// every realm with a store file.
1996async fn memory_panel_realms(
1997    store: &crate::memory::sqlite_store::SqliteAgentMemoryStore,
1998    params: &Value,
1999) -> Result<Vec<String>, crate::identity_first::agent_memory::AgentMemoryError> {
2000    match normalized_console_rpc_string_param(params, "realm").filter(|realm| !realm.is_empty()) {
2001        Some(realm) => Ok(vec![realm]),
2002        None => store.panel_realms().await,
2003    }
2004}
2005
2006async fn handle_memory_panel_records(
2007    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2008    view: Option<&AccessView>,
2009    params: &Value,
2010    response_id: Value,
2011) -> Value {
2012    let Some(store) = store else {
2013        return memory_panel_unavailable(response_id);
2014    };
2015    let scope_kind = memory_panel_scope_param(params);
2016    if let Some(kind) = &scope_kind
2017        && !matches!(kind.as_str(), "identity" | "mob" | "operator" | "realm")
2018    {
2019        return invalid_params(
2020            response_id,
2021            format!("Invalid params: unknown scope '{kind}'"),
2022        );
2023    }
2024    let identity = normalized_console_rpc_string_param(params, "identity")
2025        .filter(|identity| !identity.is_empty());
2026    let scope_kind = scope_kind.or_else(|| identity.as_ref().map(|_| "identity".to_string()));
2027    let scope_key = normalized_console_rpc_string_param(params, "scope_key")
2028        .filter(|key| !key.is_empty())
2029        .or(identity);
2030    let status =
2031        normalized_console_rpc_string_param(params, "status").filter(|status| !status.is_empty());
2032    if let Some(status) = &status
2033        && !matches!(
2034            status.as_str(),
2035            "active" | "quarantined" | "superseded" | "tombstoned"
2036        )
2037    {
2038        return invalid_params(
2039            response_id,
2040            format!("Invalid params: unknown status '{status}'"),
2041        );
2042    }
2043    let limit =
2044        memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2045    let cursor = normalized_console_rpc_string_param(params, "cursor")
2046        .filter(|cursor| !cursor.is_empty())
2047        .map(|raw| parse_memory_panel_cursor(&raw));
2048    let cursor = match cursor {
2049        Some(None) => {
2050            return invalid_params(response_id, "Invalid params: malformed cursor".to_string());
2051        }
2052        Some(Some(cursor)) => Some(cursor),
2053        None => None,
2054    };
2055    let realms = match memory_panel_realms(store, params).await {
2056        Ok(realms) => realms,
2057        Err(err) => return memory_panel_store_error(response_id, err),
2058    };
2059    // Keyset continuation is per-realm: honored only when the query names
2060    // one realm. Multi-realm listings merge one bounded page per realm.
2061    let single_realm = realms.len() == 1;
2062    let mut records = Vec::new();
2063    let mut next_cursor = None;
2064    for realm in &realms {
2065        let page = match store
2066            .records_page(
2067                realm,
2068                scope_kind.as_deref(),
2069                scope_key.as_deref(),
2070                status.as_deref(),
2071                limit,
2072                if single_realm { cursor.clone() } else { None },
2073            )
2074            .await
2075        {
2076            Ok(page) => page,
2077            Err(err) => return memory_panel_store_error(response_id, err),
2078        };
2079        if single_realm {
2080            next_cursor = page.next_cursor.as_ref().map(encode_memory_panel_cursor);
2081        }
2082        records.extend(page.records);
2083    }
2084    records.retain(|record| memory_panel_record_visible(view, record));
2085    records.sort_by(|a, b| {
2086        b.updated_at_ms
2087            .cmp(&a.updated_at_ms)
2088            .then_with(|| b.id.cmp(&a.id))
2089    });
2090    records.truncate(limit);
2091    let rows: Vec<Value> = records
2092        .iter()
2093        .map(|record| memory_panel_record_json(record, false))
2094        .collect();
2095    response_value(
2096        response_id,
2097        Some(json!({
2098            "records": rows,
2099            "next_cursor": next_cursor,
2100            "realms": realms,
2101        })),
2102        None,
2103    )
2104}
2105
2106async fn handle_memory_panel_record(
2107    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2108    view: Option<&AccessView>,
2109    params: &Value,
2110    response_id: Value,
2111) -> Value {
2112    let Some(store) = store else {
2113        return memory_panel_unavailable(response_id);
2114    };
2115    let Some(memory_id) = normalized_console_rpc_string_param(params, "memory_id")
2116        .filter(|memory_id| !memory_id.is_empty())
2117    else {
2118        return invalid_params(
2119            response_id,
2120            "Invalid params: memory_id is required".to_string(),
2121        );
2122    };
2123    let realms = match memory_panel_realms(store, params).await {
2124        Ok(realms) => realms,
2125        Err(err) => return memory_panel_store_error(response_id, err),
2126    };
2127    let mut found = None;
2128    for realm in &realms {
2129        match store.record_by_id(realm, &memory_id).await {
2130            Ok(Some(record)) => {
2131                found = Some((realm.clone(), record));
2132                break;
2133            }
2134            Ok(None) => {}
2135            Err(err) => return memory_panel_store_error(response_id, err),
2136        }
2137    }
2138    let Some((realm, record)) = found else {
2139        return response_value(
2140            response_id,
2141            None,
2142            Some(JsonRpcError {
2143                code: -32001,
2144                message: format!("unknown memory record '{memory_id}'"),
2145                data: None,
2146            }),
2147        );
2148    };
2149    // Scope is only known post-load, so the entry gate lives here rather
2150    // than in the requirements table.
2151    if !memory_panel_record_visible(view, &record) {
2152        let action = if matches!(
2153            record.status,
2154            crate::memory::records::RecordStatus::Quarantined { .. }
2155        ) && view
2156            .is_some_and(|view| view.enforced() && !view.allows(ACTION_MEMORY_QUARANTINE_REVIEW))
2157        {
2158            ACTION_MEMORY_QUARANTINE_REVIEW
2159        } else {
2160            memory_panel_scope_action(&record.scope)
2161        };
2162        return response_value(
2163            response_id,
2164            None,
2165            Some(JsonRpcError {
2166                code: ACCESS_DENIED_RPC_CODE,
2167                message: format!("access denied: {action}"),
2168                data: Some(json!({
2169                    "kind": "access_denied",
2170                    "action": action,
2171                    "resource": record.id,
2172                })),
2173            }),
2174        );
2175    }
2176    let chain = match store
2177        .supersede_chain(&realm, &memory_id, MEMORY_PANEL_CHAIN_MAX)
2178        .await
2179    {
2180        Ok(chain) => chain,
2181        Err(err) => return memory_panel_store_error(response_id, err),
2182    };
2183    let chain_rows: Vec<Value> = chain
2184        .iter()
2185        .filter(|link| memory_panel_record_visible(view, link))
2186        .map(|link| memory_panel_record_json(link, false))
2187        .collect();
2188    let injections = match store
2189        .injection_log_for_record(&realm, &memory_id, MEMORY_PANEL_INJECTIONS_MAX)
2190        .await
2191    {
2192        Ok(entries) => entries,
2193        Err(err) => return memory_panel_store_error(response_id, err),
2194    };
2195    response_value(
2196        response_id,
2197        Some(json!({
2198            "realm": realm,
2199            "record": memory_panel_record_json(&record, true),
2200            "chain": chain_rows,
2201            "injections": injections,
2202        })),
2203        None,
2204    )
2205}
2206
2207async fn handle_memory_panel_quarantine(
2208    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2209    view: Option<&AccessView>,
2210    params: &Value,
2211    response_id: Value,
2212) -> Value {
2213    let Some(store) = store else {
2214        return memory_panel_unavailable(response_id);
2215    };
2216    let limit =
2217        memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2218    let realms = match memory_panel_realms(store, params).await {
2219        Ok(realms) => realms,
2220        Err(err) => return memory_panel_store_error(response_id, err),
2221    };
2222    let mut records = Vec::new();
2223    let mut pending = Vec::new();
2224    for realm in &realms {
2225        match store.quarantined_records(realm, limit).await {
2226            Ok(rows) => records.extend(rows),
2227            Err(err) => return memory_panel_store_error(response_id, err),
2228        }
2229        match store.pending_promotions(realm).await {
2230            Ok(rows) => pending.extend(
2231                rows.into_iter()
2232                    .filter(|promotion| {
2233                        memory_panel_promotion_visible(
2234                            view,
2235                            &promotion.scope_kind,
2236                            &promotion.scope_key,
2237                        )
2238                    })
2239                    .map(|promotion| {
2240                        // stage_token is a commit capability — never surfaced.
2241                        json!({
2242                            "realm": realm,
2243                            "pending_id": promotion.pending_id,
2244                            "record_id": promotion.record_id,
2245                            "scope_kind": promotion.scope_kind,
2246                            "scope_key": promotion.scope_key,
2247                            "rationale": promotion.rationale,
2248                            "status": promotion.status,
2249                            "created_at_ms": promotion.created_at_ms,
2250                        })
2251                    }),
2252            ),
2253            Err(err) => return memory_panel_store_error(response_id, err),
2254        }
2255    }
2256    // The reviewer entry gate is necessary but not sufficient: each queue row
2257    // still needs the caller's per-scope read grant, and hidden rows must not
2258    // consume the page budget.
2259    records.retain(|record| memory_panel_record_visible(view, record));
2260    records.sort_by_key(|record| std::cmp::Reverse(record.created_at_ms));
2261    records.truncate(limit);
2262    // Queue rows stay body-free even for reviewers: verdicts are decided on
2263    // the record detail surface, which renders the body with provenance.
2264    let rows: Vec<Value> = records
2265        .iter()
2266        .map(|record| memory_panel_record_json(record, false))
2267        .collect();
2268    response_value(
2269        response_id,
2270        Some(json!({
2271            "records": rows,
2272            "pending_promotions": pending,
2273            "realms": realms,
2274        })),
2275        None,
2276    )
2277}
2278
2279async fn handle_memory_panel_dreams(
2280    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2281    params: &Value,
2282    response_id: Value,
2283) -> Value {
2284    let Some(store) = store else {
2285        return memory_panel_unavailable(response_id);
2286    };
2287    let limit =
2288        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2289    let realms = match memory_panel_realms(store, params).await {
2290        Ok(realms) => realms,
2291        Err(err) => return memory_panel_store_error(response_id, err),
2292    };
2293    let mut runs = Vec::new();
2294    for realm in &realms {
2295        match store.dream_history(realm, limit).await {
2296            Ok(history) => runs.extend(history.into_iter().map(|run| (realm.clone(), run))),
2297            Err(err) => return memory_panel_store_error(response_id, err),
2298        }
2299    }
2300    runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.last_op_at_ms));
2301    runs.truncate(limit);
2302    let rows: Vec<Value> = runs
2303        .iter()
2304        .map(|(realm, run)| {
2305            json!({
2306                "realm": realm,
2307                "run_id": run.run_id,
2308                "first_op_at_ms": run.first_op_at_ms,
2309                "last_op_at_ms": run.last_op_at_ms,
2310                "ops": run.ops,
2311                "op_kinds": run.op_kinds,
2312                "quarantined_ops": run.quarantined_ops,
2313                "memory_ids": run.memory_ids,
2314                "rationales": run.rationales,
2315            })
2316        })
2317        .collect();
2318    response_value(
2319        response_id,
2320        Some(json!({ "runs": rows, "realms": realms })),
2321        None,
2322    )
2323}
2324
2325/// Durable dream verdict sheets (`dream_runs` table): phases, verdict
2326/// counters, skips, and partition label per run — survives restarts, unlike
2327/// the audit-trail reconstruction served by `panel/dreams`.
2328async fn handle_memory_panel_dream_runs(
2329    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2330    params: &Value,
2331    response_id: Value,
2332) -> Value {
2333    let Some(store) = store else {
2334        return memory_panel_unavailable(response_id);
2335    };
2336    let limit =
2337        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2338    let realms = match memory_panel_realms(store, params).await {
2339        Ok(realms) => realms,
2340        Err(err) => return memory_panel_store_error(response_id, err),
2341    };
2342    let mut runs = Vec::new();
2343    for realm in &realms {
2344        match store.dream_runs(realm, limit).await {
2345            Ok(rows) => runs.extend(rows.into_iter().map(|run| (realm.clone(), run))),
2346            Err(err) => return memory_panel_store_error(response_id, err),
2347        }
2348    }
2349    runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.completed_at_ms));
2350    runs.truncate(limit);
2351    let rows: Vec<Value> = runs
2352        .iter()
2353        .map(|(realm, run)| {
2354            let detail: Value =
2355                serde_json::from_str(&run.detail).unwrap_or_else(|_| json!(run.detail));
2356            json!({
2357                "realm": realm,
2358                "run_id": run.run_id,
2359                "partition": run.partition_label,
2360                "started_at_ms": run.started_at_ms,
2361                "completed_at_ms": run.completed_at_ms,
2362                "ops_committed": run.ops_committed,
2363                "detail": detail,
2364            })
2365        })
2366        .collect();
2367    response_value(
2368        response_id,
2369        Some(json!({ "runs": rows, "realms": realms })),
2370        None,
2371    )
2372}
2373
2374/// The open usage-audit review queue: dead-weight verdicts awaiting operator
2375/// action ("memories you might want to correct").
2376async fn handle_memory_panel_audit_verdicts(
2377    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2378    params: &Value,
2379    response_id: Value,
2380) -> Value {
2381    let Some(store) = store else {
2382        return memory_panel_unavailable(response_id);
2383    };
2384    let limit =
2385        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2386    let realms = match memory_panel_realms(store, params).await {
2387        Ok(realms) => realms,
2388        Err(err) => return memory_panel_store_error(response_id, err),
2389    };
2390    let mut verdicts = Vec::new();
2391    for realm in &realms {
2392        match store.open_dream_audit_verdicts(realm, limit).await {
2393            Ok(rows) => verdicts.extend(rows.into_iter().map(|row| (realm.clone(), row))),
2394            Err(err) => return memory_panel_store_error(response_id, err),
2395        }
2396    }
2397    verdicts.sort_by_key(|(_, row)| std::cmp::Reverse(row.created_at_ms));
2398    verdicts.truncate(limit);
2399    let rows: Vec<Value> = verdicts
2400        .iter()
2401        .map(|(realm, row)| {
2402            json!({
2403                "realm": realm,
2404                "run_id": row.run_id,
2405                "record_id": row.record_id,
2406                "verdict": row.verdict,
2407                "rationale": row.rationale,
2408                "created_at_ms": row.created_at_ms,
2409            })
2410        })
2411        .collect();
2412    response_value(
2413        response_id,
2414        Some(json!({ "verdicts": rows, "realms": realms })),
2415        None,
2416    )
2417}
2418
2419/// Store overview: per-scope record counts + bytes — Holdings + the STORE
2420/// FLOOR verdict tile (floors included so the client renders pressure
2421/// without duplicating the thresholds).
2422async fn handle_memory_panel_overview(
2423    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2424    params: &Value,
2425    response_id: Value,
2426) -> Value {
2427    let Some(store) = store else {
2428        return memory_panel_unavailable(response_id);
2429    };
2430    let realms = match memory_panel_realms(store, params).await {
2431        Ok(realms) => realms,
2432        Err(err) => return memory_panel_store_error(response_id, err),
2433    };
2434    let (floor_records, floor_bytes) = store.scope_floors();
2435    let mut scopes = Vec::new();
2436    for realm in &realms {
2437        match store.scope_overview(realm).await {
2438            Ok(rows) => scopes.extend(rows.into_iter().map(|row| {
2439                json!({
2440                    "realm": realm,
2441                    "scope_kind": row.scope.kind_str(),
2442                    "scope_key": row.scope.key(),
2443                    "active": row.active,
2444                    "quarantined": row.quarantined,
2445                    "superseded": row.superseded,
2446                    "tombstoned": row.tombstoned,
2447                    "body_bytes": row.body_bytes,
2448                    "floor_pressure": row.active as usize >= floor_records
2449                        || row.body_bytes as usize >= floor_bytes,
2450                })
2451            })),
2452            Err(err) => return memory_panel_store_error(response_id, err),
2453        }
2454    }
2455    response_value(
2456        response_id,
2457        Some(json!({
2458            "scopes": scopes,
2459            "realms": realms,
2460            "floors": { "records": floor_records, "bytes": floor_bytes },
2461        })),
2462        None,
2463    )
2464}
2465
2466/// Pending mob/operator-scope proposals awaiting a dream verdict — the
2467/// Pipeline view's inbound lane (bodies withheld: titles only; the record
2468/// body stays reachable through panel/record once committed).
2469async fn handle_memory_panel_proposals(
2470    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2471    params: &Value,
2472    response_id: Value,
2473) -> Value {
2474    let Some(store) = store else {
2475        return memory_panel_unavailable(response_id);
2476    };
2477    let limit =
2478        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2479    let realms = match memory_panel_realms(store, params).await {
2480        Ok(realms) => realms,
2481        Err(err) => return memory_panel_store_error(response_id, err),
2482    };
2483    let mut proposals = Vec::new();
2484    for realm in &realms {
2485        match store.pending_proposals(realm, limit).await {
2486            Ok(rows) => proposals.extend(rows.into_iter().map(|row| {
2487                json!({
2488                    "realm": realm,
2489                    "proposal_id": row.proposal_id,
2490                    "scope_kind": row.scope.kind_str(),
2491                    "scope_key": row.scope.key(),
2492                    "title": row.record.title,
2493                    "kind": row.record.kind.as_str(),
2494                    "author": format!("{:?}", row.author),
2495                    "status": row.status,
2496                    "created_at_ms": row.created_at_ms,
2497                    "tainted": row.taint.is_some(),
2498                })
2499            })),
2500            Err(err) => return memory_panel_store_error(response_id, err),
2501        }
2502    }
2503    proposals.truncate(limit);
2504    response_value(
2505        response_id,
2506        Some(json!({ "proposals": proposals, "realms": realms })),
2507        None,
2508    )
2509}
2510
2511/// The injection ledger (most recent first): the Knowledge Lens history and
2512/// the echo-safety/dup diagnostics feed.
2513async fn handle_memory_panel_injections(
2514    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2515    params: &Value,
2516    response_id: Value,
2517) -> Value {
2518    let Some(store) = store else {
2519        return memory_panel_unavailable(response_id);
2520    };
2521    let limit =
2522        memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2523    let realms = match memory_panel_realms(store, params).await {
2524        Ok(realms) => realms,
2525        Err(err) => return memory_panel_store_error(response_id, err),
2526    };
2527    let mut injections = Vec::new();
2528    for realm in &realms {
2529        match store.injection_log(realm, limit).await {
2530            Ok(rows) => injections.extend(rows.into_iter().map(|row| {
2531                json!({
2532                    "realm": realm,
2533                    "record_id": row.record_id,
2534                    "identity": row.identity,
2535                    "session_key": row.session_key,
2536                    "surface": row.surface.as_str(),
2537                    "at_ms": row.at_ms,
2538                })
2539            })),
2540            Err(err) => return memory_panel_store_error(response_id, err),
2541        }
2542    }
2543    injections.sort_by_key(|row| std::cmp::Reverse(row["at_ms"].as_u64().unwrap_or(0)));
2544    injections.truncate(limit);
2545    response_value(
2546        response_id,
2547        Some(json!({ "injections": injections, "realms": realms })),
2548        None,
2549    )
2550}
2551
2552/// Pending exit-interview harvests — the Health strip's harvest-queue lane.
2553async fn handle_memory_panel_harvests(
2554    store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
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 harvests = Vec::new();
2568    for realm in &realms {
2569        match store.pending_harvests(realm, limit).await {
2570            Ok(rows) => harvests.extend(rows.into_iter().map(|row| {
2571                json!({
2572                    "realm": realm,
2573                    "identity": row.identity,
2574                    "session_key": row.session_key,
2575                    "cause": row.cause,
2576                    "retired_at_ms": row.retired_at_ms,
2577                })
2578            })),
2579            Err(err) => return memory_panel_store_error(response_id, err),
2580        }
2581    }
2582    harvests.truncate(limit);
2583    response_value(
2584        response_id,
2585        Some(json!({ "harvests": harvests, "realms": realms })),
2586        None,
2587    )
2588}
2589
2590fn access_status_result(access: Option<&AccessController>, view: Option<&AccessView>) -> Value {
2591    match (access, view) {
2592        (Some(controller), Some(view)) => {
2593            let (_, revision) = controller.snapshot();
2594            json!({
2595                "available": true,
2596                "enabled": view.enforced(),
2597                "revision": revision,
2598                "subject": view.subject(),
2599                "groups": view.groups().iter().collect::<Vec<_>>(),
2600                "is_admin": view.is_admin(),
2601                "can_administer": view.can_administer(),
2602                "actions": ACCESS_ACTIONS,
2603            })
2604        }
2605        _ => json!({
2606            "available": false,
2607            "enabled": false,
2608            "actions": ACCESS_ACTIONS,
2609        }),
2610    }
2611}
2612
2613/// Handles every `mobkit/access/*` method. Returns `None` when the method
2614/// belongs to another namespace.
2615fn handle_access_admin_rpc(
2616    access: Option<&AccessController>,
2617    view: Option<&AccessView>,
2618    request: &JsonRpcRequest,
2619) -> Option<Value> {
2620    if !request.method.starts_with("mobkit/access/") {
2621        return None;
2622    }
2623    let response_id = request.id.clone().unwrap_or(Value::Null);
2624    let controller = match request.method.as_str() {
2625        "mobkit/access/status" => {
2626            return Some(response_value(
2627                response_id,
2628                Some(access_status_result(access, view)),
2629                None,
2630            ));
2631        }
2632        _ => {
2633            let Some(controller) = access else {
2634                return Some(access_unavailable_rpc_error(response_id));
2635            };
2636            if !view.is_some_and(AccessView::can_administer) {
2637                return Some(access_denied_rpc_error(
2638                    response_id,
2639                    "access denied: access.admin",
2640                ));
2641            }
2642            controller
2643        }
2644    };
2645    let result = match request.method.as_str() {
2646        "mobkit/access/get" => {
2647            let (config, revision) = controller.snapshot();
2648            Ok(json!({ "config": &*config, "revision": revision }))
2649        }
2650        "mobkit/access/set" => {
2651            match serde_json::from_value(request.params.get("config").cloned().unwrap_or_default())
2652            {
2653                Ok(config) => controller
2654                    .replace_config(config)
2655                    .map(|revision| json!({ "revision": revision })),
2656                Err(err) => {
2657                    return Some(invalid_params(
2658                        response_id,
2659                        format!("invalid access config: {err}"),
2660                    ));
2661                }
2662            }
2663        }
2664        "mobkit/access/rules/upsert" => {
2665            match serde_json::from_value::<AccessRule>(
2666                request.params.get("rule").cloned().unwrap_or_default(),
2667            ) {
2668                Ok(rule) => controller
2669                    .upsert_rule(rule)
2670                    .map(|revision| json!({ "revision": revision })),
2671                Err(err) => {
2672                    return Some(invalid_params(
2673                        response_id,
2674                        format!("invalid access rule: {err}"),
2675                    ));
2676                }
2677            }
2678        }
2679        "mobkit/access/rules/delete" => {
2680            let Some(rule_id) = request.params.get("id").and_then(Value::as_str) else {
2681                return Some(invalid_params(response_id, "id required"));
2682            };
2683            controller
2684                .delete_rule(rule_id)
2685                .map(|revision| json!({ "revision": revision }))
2686        }
2687        "mobkit/access/groups/set" => {
2688            let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2689                return Some(invalid_params(response_id, "name required"));
2690            };
2691            match serde_json::from_value::<AccessGroup>(
2692                request.params.get("group").cloned().unwrap_or_default(),
2693            ) {
2694                Ok(group) => controller
2695                    .set_group(name, group)
2696                    .map(|revision| json!({ "revision": revision })),
2697                Err(err) => {
2698                    return Some(invalid_params(
2699                        response_id,
2700                        format!("invalid access group: {err}"),
2701                    ));
2702                }
2703            }
2704        }
2705        "mobkit/access/groups/delete" => {
2706            let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2707                return Some(invalid_params(response_id, "name required"));
2708            };
2709            controller
2710                .delete_group(name)
2711                .map(|revision| json!({ "revision": revision }))
2712        }
2713        "mobkit/access/enable" => {
2714            let Some(enabled) = request.params.get("enabled").and_then(Value::as_bool) else {
2715                return Some(invalid_params(response_id, "enabled (bool) required"));
2716            };
2717            controller
2718                .set_enabled(enabled)
2719                .map(|revision| json!({ "revision": revision }))
2720        }
2721        "mobkit/access/preview" => {
2722            let subject = request.params.get("subject").and_then(Value::as_str);
2723            let Some(action) = request.params.get("action").and_then(Value::as_str) else {
2724                return Some(invalid_params(response_id, "action required"));
2725            };
2726            let identity = request.params.get("identity").and_then(Value::as_str);
2727            let preview_view = controller.view_for_subject(subject);
2728            let decision = match identity {
2729                Some(identity) => preview_view.decide_agent(action, identity),
2730                None => preview_view.decide(action, &AccessResource::none()),
2731            };
2732            Ok(json!({
2733                "subject": subject,
2734                "action": action,
2735                "identity": identity,
2736                "allowed": decision.is_allow(),
2737                "reason": decision.reason(),
2738                "groups": preview_view.groups().iter().collect::<Vec<_>>(),
2739                "is_admin": preview_view.is_admin(),
2740            }))
2741        }
2742        _ => {
2743            return Some(response_value(
2744                response_id,
2745                None,
2746                Some(JsonRpcError {
2747                    code: -32601,
2748                    message: "Method not found".to_string(),
2749                    data: None,
2750                }),
2751            ));
2752        }
2753    };
2754    Some(match result {
2755        Ok(value) => response_value(response_id, Some(value), None),
2756        Err(err) => access_config_rpc_error(response_id, err),
2757    })
2758}
2759
2760fn cursor_is_after(candidate: &ConsoleCursor, current: &ConsoleCursor) -> bool {
2761    match (candidate.seq(), current.seq()) {
2762        (Some(candidate), Some(current)) => candidate > current,
2763        _ => candidate > current,
2764    }
2765}
2766
2767fn sse_event_from_timeline_event(event: &ConsoleTimelineEvent) -> Option<Event> {
2768    let (event_name, id) = match event {
2769        ConsoleTimelineEvent::SnapshotStarted { .. } => ("snapshot_started", None),
2770        ConsoleTimelineEvent::ConsoleFrame { frame } => (
2771            if frame.kind == "frame_updated" {
2772                "frame_updated"
2773            } else {
2774                "console_frame"
2775            },
2776            Some(frame.cursor.to_string()),
2777        ),
2778        ConsoleTimelineEvent::FrameUpdated { frame } => {
2779            ("frame_updated", Some(frame.cursor.to_string()))
2780        }
2781        ConsoleTimelineEvent::SnapshotComplete { cursor } => (
2782            "snapshot_complete",
2783            cursor.as_ref().map(ToString::to_string),
2784        ),
2785        ConsoleTimelineEvent::ReplayUnavailable { .. } => ("replay_unavailable", None),
2786    };
2787    let data = match serde_json::to_string(event) {
2788        Ok(value) => value,
2789        Err(_) => return None,
2790    };
2791    let mut sse = Event::default().event(event_name).data(data);
2792    if let Some(id) = id {
2793        sse = sse.id(id);
2794    }
2795    Some(sse)
2796}
2797
2798pub async fn console_rpc_multipart_handler(
2799    State(state): State<ConsoleJsonState>,
2800    headers: HeaderMap,
2801    uri: Uri,
2802    mut multipart: Multipart,
2803) -> impl IntoResponse {
2804    let auth_context = match console_request_auth_context(&state, &headers, &uri) {
2805        Some(context) => context,
2806        None => {
2807            return (
2808                StatusCode::UNAUTHORIZED,
2809                Json::<Value>(serde_json::json!({
2810                    "jsonrpc": JSONRPC_VERSION,
2811                    "id": Value::Null,
2812                    "error": {
2813                        "code": -32600,
2814                        "message": "unauthorized: console rpc requires a valid auth token",
2815                    }
2816                })),
2817            );
2818        }
2819    };
2820
2821    let mut payload: Option<String> = None;
2822    let mut files: std::collections::BTreeMap<String, MultipartImageUpload> =
2823        std::collections::BTreeMap::new();
2824
2825    while let Some(mut field) = match multipart.next_field().await {
2826        Ok(field) => field,
2827        Err(err) => {
2828            return (
2829                StatusCode::BAD_REQUEST,
2830                Json::<Value>(json_rpc_error_value(
2831                    Value::Null,
2832                    -32602,
2833                    format!("invalid multipart body: {err}"),
2834                )),
2835            );
2836        }
2837    } {
2838        let name = field.name().unwrap_or("").to_string();
2839        if name == "payload" {
2840            if payload.is_some() {
2841                return (
2842                    StatusCode::BAD_REQUEST,
2843                    Json::<Value>(json_rpc_error_value(
2844                        Value::Null,
2845                        -32602,
2846                        "duplicate payload part",
2847                    )),
2848                );
2849            }
2850            payload = match field.text().await {
2851                Ok(text) => Some(text),
2852                Err(err) => {
2853                    return (
2854                        StatusCode::BAD_REQUEST,
2855                        Json::<Value>(json_rpc_error_value(
2856                            Value::Null,
2857                            -32602,
2858                            format!("invalid payload part: {err}"),
2859                        )),
2860                    );
2861                }
2862            };
2863            continue;
2864        }
2865
2866        let Some(upload_id) = name.strip_prefix("file:").filter(|id| !id.is_empty()) else {
2867            return (
2868                StatusCode::BAD_REQUEST,
2869                Json::<Value>(json_rpc_error_value(
2870                    Value::Null,
2871                    -32602,
2872                    format!("unexpected multipart field: {name}"),
2873                )),
2874            );
2875        };
2876        if files.len() >= MAX_MULTIPART_IMAGES {
2877            return (
2878                StatusCode::BAD_REQUEST,
2879                Json::<Value>(json_rpc_error_value(
2880                    Value::Null,
2881                    -32602,
2882                    format!("too many image attachments; max {MAX_MULTIPART_IMAGES}"),
2883                )),
2884            );
2885        }
2886        if files.contains_key(upload_id) {
2887            return (
2888                StatusCode::BAD_REQUEST,
2889                Json::<Value>(json_rpc_error_value(
2890                    Value::Null,
2891                    -32602,
2892                    format!("duplicate file part for upload_id {upload_id}"),
2893                )),
2894            );
2895        }
2896        let media_type = field
2897            .content_type()
2898            .map(str::to_string)
2899            .unwrap_or_else(|| "application/octet-stream".to_string());
2900        if !is_allowed_image_media_type(&media_type) {
2901            return (
2902                StatusCode::BAD_REQUEST,
2903                Json::<Value>(json_rpc_error_value(
2904                    Value::Null,
2905                    -32602,
2906                    format!("unsupported image media type: {media_type}"),
2907                )),
2908            );
2909        }
2910        let mut bytes = bytes::BytesMut::new();
2911        loop {
2912            let chunk = match field.chunk().await {
2913                Ok(chunk) => chunk,
2914                Err(err) => {
2915                    return (
2916                        StatusCode::BAD_REQUEST,
2917                        Json::<Value>(json_rpc_error_value(
2918                            Value::Null,
2919                            -32602,
2920                            format!("invalid file part {upload_id}: {err}"),
2921                        )),
2922                    );
2923                }
2924            };
2925            let Some(chunk) = chunk else {
2926                break;
2927            };
2928            if bytes.len() + chunk.len() > MAX_MULTIPART_IMAGE_BYTES {
2929                return (
2930                    StatusCode::BAD_REQUEST,
2931                    Json::<Value>(json_rpc_error_value(
2932                        Value::Null,
2933                        -32602,
2934                        format!("image attachment {upload_id} exceeds 25 MiB"),
2935                    )),
2936                );
2937            }
2938            bytes.extend_from_slice(&chunk);
2939        }
2940        files.insert(
2941            upload_id.to_string(),
2942            MultipartImageUpload {
2943                media_type,
2944                bytes: bytes.freeze(),
2945            },
2946        );
2947    }
2948
2949    let payload = match payload {
2950        Some(payload) => payload,
2951        None => {
2952            return (
2953                StatusCode::BAD_REQUEST,
2954                Json::<Value>(json_rpc_error_value(
2955                    Value::Null,
2956                    -32602,
2957                    "payload part required",
2958                )),
2959            );
2960        }
2961    };
2962    let mut parsed_request = match serde_json::from_str::<JsonRpcRequest>(&payload) {
2963        Ok(req) => req,
2964        Err(err) => {
2965            return (
2966                StatusCode::OK,
2967                Json::<Value>(json_rpc_error_value(
2968                    Value::Null,
2969                    -32600,
2970                    format!("Invalid Request: {err}"),
2971                )),
2972            );
2973        }
2974    };
2975    let response_id = parsed_request.id.clone().unwrap_or(Value::Null);
2976    if state.decisions.console.read_only && is_console_mutating_rpc_method(&parsed_request.method) {
2977        return (
2978            StatusCode::OK,
2979            Json::<Value>(console_read_only_rpc_error(response_id)),
2980        );
2981    }
2982    match parsed_request.method.as_str() {
2983        "mobkit/console/send" => {
2984            let Some(aggregator) = &state.console_aggregator else {
2985                return (
2986                    StatusCode::OK,
2987                    Json::<Value>(invalid_params(
2988                        response_id,
2989                        "mobkit/console/send multipart requires a console aggregator",
2990                    )),
2991                );
2992            };
2993            let Some(identity) = parsed_request
2994                .params
2995                .get("identity")
2996                .and_then(Value::as_str)
2997            else {
2998                return (
2999                    StatusCode::OK,
3000                    Json::<Value>(invalid_params(response_id, "identity required")),
3001                );
3002            };
3003            // Gate BEFORE externalizing/persisting uploads, mirroring the JSON
3004            // /console/send gate-first ordering. Without this, a caller denied
3005            // `agent.send` on this identity could still write attacker-supplied
3006            // image bytes into the target identity's blob store (a pre-auth
3007            // side effect / storage-amplification vector) — the send is denied
3008            // later, but the blob write already happened. Prime the attribute
3009            // cache first so role/label-scoped deny rules resolve fail-closed.
3010            if let Some(runtime) = &state.runtime
3011                && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
3012            {
3013                prime_access_cache_from_runtime(runtime, controller).await;
3014            }
3015            if let Some(violation) = console_rpc_access_violation(
3016                auth_context.access_view.as_ref(),
3017                "mobkit/console/send",
3018                &parsed_request.params,
3019            ) {
3020                return (
3021                    StatusCode::OK,
3022                    Json::<Value>(response_value(response_id, None, Some(violation))),
3023                );
3024            }
3025            let binary_blob_store =
3026                match Box::pin(aggregator.binary_blob_store_for_identity(identity)).await {
3027                    Ok(Some(store)) => store,
3028                    Ok(None) => {
3029                        return (
3030                            StatusCode::OK,
3031                            Json::<Value>(invalid_params(
3032                                response_id,
3033                                "binary blob store unavailable for identity",
3034                            )),
3035                        );
3036                    }
3037                    Err(err) => {
3038                        return (
3039                            StatusCode::OK,
3040                            Json::<Value>(console_send_rpc_error(response_id, err)),
3041                        );
3042                    }
3043                };
3044            if let Err(message) = externalize_image_upload_placeholders(
3045                &mut parsed_request.params,
3046                files,
3047                binary_blob_store,
3048            )
3049            .await
3050            {
3051                return (
3052                    StatusCode::OK,
3053                    Json::<Value>(invalid_params(response_id, message)),
3054                );
3055            }
3056        }
3057        "mobkit/blob/upload" => {
3058            let Some(runtime) = &state.runtime else {
3059                return (
3060                    StatusCode::NOT_FOUND,
3061                    Json::<Value>(json_rpc_error_value(
3062                        response_id,
3063                        -32600,
3064                        "mobkit/blob/upload multipart requires a unified runtime",
3065                    )),
3066                );
3067            };
3068            let Some(binary_blob_store) = runtime.binary_blob_store() else {
3069                return (
3070                    StatusCode::INTERNAL_SERVER_ERROR,
3071                    Json::<Value>(json_rpc_error_value(
3072                        response_id,
3073                        -32000,
3074                        "binary blob store unavailable",
3075                    )),
3076                );
3077            };
3078            let result = match externalize_single_image_upload(
3079                &parsed_request.params,
3080                files,
3081                binary_blob_store,
3082            )
3083            .await
3084            {
3085                Ok(result) => result,
3086                Err(message) => {
3087                    return (
3088                        StatusCode::OK,
3089                        Json::<Value>(invalid_params(response_id, message)),
3090                    );
3091                }
3092            };
3093            return (
3094                StatusCode::OK,
3095                Json::<Value>(response_value(response_id, Some(result), None)),
3096            );
3097        }
3098        _ => {
3099            return (
3100                StatusCode::OK,
3101                Json::<Value>(invalid_params(
3102                    response_id,
3103                    "multipart RPC supports mobkit/console/send and mobkit/blob/upload only",
3104                )),
3105            );
3106        }
3107    }
3108    let response_value =
3109        if parsed_request.method == "mobkit/console/send" && state.runtime.is_none() {
3110            Box::pin(handle_console_aggregator_rpc(
3111                state.console_aggregator.clone(),
3112                parsed_request,
3113                true,
3114                state.decisions.console.read_only,
3115                state.access.as_ref(),
3116                auth_context.access_view.as_ref(),
3117            ))
3118            .await
3119        } else {
3120            let Some(runtime) = &state.runtime else {
3121                return (
3122                    StatusCode::NOT_FOUND,
3123                    Json::<Value>(json_rpc_error_value(
3124                        response_id,
3125                        -32600,
3126                        "console rpc multipart requires a unified runtime",
3127                    )),
3128                );
3129            };
3130            Box::pin(handle_console_runtime_rpc_with_visibility(
3131                runtime,
3132                state.module_runtime.clone(),
3133                state.contact_directory.as_ref(),
3134                state.gateway_peer_keys.as_ref(),
3135                state.console_events.clone(),
3136                state.console_aggregator.clone(),
3137                state.identity_runtime.clone(),
3138                state.metadata_table.clone(),
3139                state.mob_events.clone(),
3140                state.visibility_policy.as_ref(),
3141                parsed_request,
3142                true,
3143                state.decisions.console.read_only,
3144                auth_context.principal.as_deref(),
3145                state.access.as_ref(),
3146                auth_context.access_view.as_ref(),
3147                state.memory_panel.as_ref(),
3148                state.identity_roster.clone(),
3149                state.workgraph.as_ref(),
3150                state.topology.as_ref(),
3151            ))
3152            .await
3153        };
3154    (StatusCode::OK, Json::<Value>(response_value))
3155}
3156
3157pub async fn blob_get_handler(
3158    State(state): State<ConsoleJsonState>,
3159    headers: HeaderMap,
3160    uri: Uri,
3161    AxumPath(blob_id): AxumPath<String>,
3162) -> impl IntoResponse {
3163    if !console_request_authorized(&state, &headers, &uri) {
3164        return (
3165            StatusCode::UNAUTHORIZED,
3166            Json::<Value>(serde_json::json!({ "error": "unauthorized" })),
3167        )
3168            .into_response();
3169    }
3170    if !is_valid_blob_id_value(&blob_id) {
3171        return (
3172            StatusCode::BAD_REQUEST,
3173            Json::<Value>(serde_json::json!({ "error": "invalid_blob_id" })),
3174        )
3175            .into_response();
3176    }
3177    let blob_id = meerkat_core::BlobId::from(blob_id.as_str());
3178    let mut stores: Vec<std::sync::Arc<dyn BinaryBlobStore>> = Vec::new();
3179    if let Some(runtime) = &state.runtime
3180        && let Some(store) = runtime.binary_blob_store()
3181    {
3182        stores.push(store);
3183    }
3184    if let Some(aggregator) = &state.console_aggregator {
3185        stores.extend(aggregator.binary_blob_stores());
3186    }
3187    if stores.is_empty() {
3188        return (
3189            StatusCode::NOT_FOUND,
3190            Json::<Value>(serde_json::json!({ "error": "blob_store_unavailable" })),
3191        )
3192            .into_response();
3193    }
3194    for store in stores {
3195        match store.get_bytes(&blob_id).await {
3196            Ok(payload) => return blob_payload_response(payload),
3197            Err(meerkat_core::BlobStoreError::NotFound(_)) => continue,
3198            Err(err) => {
3199                return (
3200                    StatusCode::INTERNAL_SERVER_ERROR,
3201                    Json::<Value>(serde_json::json!({ "error": err.to_string() })),
3202                )
3203                    .into_response();
3204            }
3205        }
3206    }
3207    (
3208        StatusCode::NOT_FOUND,
3209        Json::<Value>(serde_json::json!({ "error": "blob_not_found" })),
3210    )
3211        .into_response()
3212}
3213
3214fn blob_payload_response(payload: BinaryBlobPayload) -> axum::response::Response {
3215    let mut response_headers = HeaderMap::new();
3216    let content_type = HeaderValue::from_str(&payload.media_type)
3217        .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
3218    response_headers.insert(header::CONTENT_TYPE, content_type);
3219    if let Ok(content_length) = HeaderValue::from_str(&payload.size.to_string()) {
3220        response_headers.insert(header::CONTENT_LENGTH, content_length);
3221    }
3222    response_headers.insert(
3223        header::CACHE_CONTROL,
3224        HeaderValue::from_static("private, max-age=31536000, immutable"),
3225    );
3226    (StatusCode::OK, response_headers, payload.data).into_response()
3227}
3228
3229fn console_request_authorized(state: &ConsoleJsonState, headers: &HeaderMap, uri: &Uri) -> bool {
3230    console_request_auth_context(state, headers, uri).is_some()
3231}
3232
3233fn console_request_auth_context(
3234    state: &ConsoleJsonState,
3235    headers: &HeaderMap,
3236    uri: &Uri,
3237) -> Option<ConsoleHttpAuthContext> {
3238    if !state.decisions.console.require_app_auth {
3239        // Open console: identify callers that volunteered a valid token so
3240        // per-user ABAC grants still apply; everyone else is anonymous.
3241        let principal = state.access.as_ref().and_then(|_| {
3242            let token = console_request_token(headers, uri)?;
3243            resolve_authorized_console_auth_from_token(&state.decisions, &token)
3244                .map(|auth| auth.email)
3245        });
3246        return Some(ConsoleHttpAuthContext {
3247            access_view: state
3248                .access
3249                .as_ref()
3250                .map(|controller| controller.view_for_subject(principal.as_deref())),
3251            principal,
3252        });
3253    }
3254    let token = console_request_token(headers, uri)?;
3255    let auth = resolve_authorized_console_auth_from_token(&state.decisions, &token)?;
3256    Some(ConsoleHttpAuthContext {
3257        access_view: state
3258            .access
3259            .as_ref()
3260            .map(|controller| controller.view_for_subject(Some(auth.email.as_str()))),
3261        principal: Some(auth.email),
3262    })
3263}
3264
3265fn console_request_token(headers: &HeaderMap, uri: &Uri) -> Option<String> {
3266    let bearer_token = headers
3267        .get(header::AUTHORIZATION)
3268        .and_then(|v| v.to_str().ok())
3269        .and_then(extract_bearer_token_from_header)
3270        .map(String::from);
3271    // Parse with form_urlencoded so percent-encoded tokens decode and
3272    // `xauth_token=` substring shadowing does NOT match the real key.
3273    let query_token = uri.query().and_then(|q| {
3274        form_urlencoded::parse(q.as_bytes())
3275            .find(|(key, _)| key == "auth_token")
3276            .map(|(_, value)| value.into_owned())
3277    });
3278    bearer_token.or(query_token)
3279}
3280
3281#[derive(Debug)]
3282struct MultipartImageUpload {
3283    media_type: String,
3284    bytes: bytes::Bytes,
3285}
3286
3287fn json_rpc_error_value(id: Value, code: i64, message: impl Into<String>) -> Value {
3288    serde_json::json!({
3289        "jsonrpc": JSONRPC_VERSION,
3290        "id": id,
3291        "error": {
3292            "code": code,
3293            "message": message.into(),
3294        }
3295    })
3296}
3297
3298fn is_allowed_image_media_type(media_type: &str) -> bool {
3299    matches!(
3300        media_type,
3301        "image/png" | "image/jpeg" | "image/webp" | "image/gif"
3302    )
3303}
3304
3305fn image_upload_part_name<'a>(
3306    object: &'a serde_json::Map<String, Value>,
3307    context: &str,
3308) -> Result<&'a str, String> {
3309    object
3310        .get("upload_id")
3311        .or_else(|| object.get("part_name"))
3312        .and_then(Value::as_str)
3313        .map(str::trim)
3314        .filter(|value| !value.is_empty())
3315        .ok_or_else(|| format!("{context}.upload_id or {context}.part_name is required"))
3316}
3317
3318async fn externalize_image_upload_placeholders(
3319    params: &mut Value,
3320    files: std::collections::BTreeMap<String, MultipartImageUpload>,
3321    blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3322) -> Result<(), String> {
3323    let Some(content) = params.get_mut("content") else {
3324        return Err("multipart payload params.content is required".to_string());
3325    };
3326    let mut placeholders = std::collections::BTreeMap::<String, String>::new();
3327    collect_image_upload_placeholders(content, &mut placeholders)?;
3328    if placeholders.is_empty() {
3329        return Err(
3330            "multipart payload must contain at least one image_upload placeholder".to_string(),
3331        );
3332    }
3333    if placeholders.len() > MAX_MULTIPART_IMAGES {
3334        return Err(format!(
3335            "too many image_upload placeholders; max {MAX_MULTIPART_IMAGES}"
3336        ));
3337    }
3338    for upload_id in files.keys() {
3339        if !placeholders.contains_key(upload_id) {
3340            return Err(format!(
3341                "file part has no matching image_upload placeholder: {upload_id}"
3342            ));
3343        }
3344    }
3345    for upload_id in placeholders.keys() {
3346        if !files.contains_key(upload_id) {
3347            return Err(format!(
3348                "image_upload placeholder missing file part: {upload_id}"
3349            ));
3350        }
3351    }
3352
3353    let mut refs = std::collections::BTreeMap::<String, Value>::new();
3354    for (upload_id, file) in files {
3355        let declared_media_type = placeholders
3356            .get(&upload_id)
3357            .cloned()
3358            .unwrap_or_else(|| file.media_type.clone());
3359        if !is_allowed_image_media_type(&declared_media_type) {
3360            return Err(format!(
3361                "unsupported image media type in placeholder {upload_id}: {declared_media_type}"
3362            ));
3363        }
3364        if declared_media_type != file.media_type {
3365            return Err(format!(
3366                "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3367                file.media_type
3368            ));
3369        }
3370        let blob_ref = blob_store
3371            .put_bytes(&file.media_type, file.bytes)
3372            .await
3373            .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3374        refs.insert(
3375            upload_id,
3376            serde_json::json!({
3377                "type": "image",
3378                "media_type": blob_ref.media_type,
3379                "source": "blob",
3380                "blob_id": blob_ref.blob_id,
3381            }),
3382        );
3383    }
3384    replace_image_upload_placeholders(content, &refs)?;
3385    if let Some(object) = params.as_object_mut() {
3386        object.remove("message");
3387    }
3388    Ok(())
3389}
3390
3391async fn externalize_single_image_upload(
3392    params: &Value,
3393    files: std::collections::BTreeMap<String, MultipartImageUpload>,
3394    blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3395) -> Result<Value, String> {
3396    let upload = params.get("upload").unwrap_or(params);
3397    if upload
3398        .get("type")
3399        .and_then(Value::as_str)
3400        .is_some_and(|kind| kind != "image_upload")
3401    {
3402        return Err("upload.type must be image_upload".to_string());
3403    }
3404    let upload_object = upload
3405        .as_object()
3406        .ok_or_else(|| "upload must be an object".to_string())?;
3407    let upload_id = image_upload_part_name(upload_object, "upload")?;
3408    let Some(file) = files.get(upload_id) else {
3409        return Err(format!(
3410            "image_upload placeholder missing file part: {upload_id}"
3411        ));
3412    };
3413    if files.len() != 1 {
3414        return Err("mobkit/blob/upload accepts exactly one file part".to_string());
3415    }
3416    let declared_media_type = upload
3417        .get("media_type")
3418        .and_then(Value::as_str)
3419        .unwrap_or(file.media_type.as_str());
3420    if !is_allowed_image_media_type(declared_media_type) {
3421        return Err(format!(
3422            "unsupported image media type in upload {upload_id}: {declared_media_type}"
3423        ));
3424    }
3425    if declared_media_type != file.media_type {
3426        return Err(format!(
3427            "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3428            file.media_type
3429        ));
3430    }
3431    let size = file.bytes.len() as u64;
3432    let blob_ref = blob_store
3433        .put_bytes(&file.media_type, file.bytes.clone())
3434        .await
3435        .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3436    Ok(json!({
3437        "blob_id": blob_ref.blob_id,
3438        "media_type": blob_ref.media_type,
3439        "size": size,
3440    }))
3441}
3442
3443fn collect_image_upload_placeholders(
3444    value: &Value,
3445    placeholders: &mut std::collections::BTreeMap<String, String>,
3446) -> Result<(), String> {
3447    match value {
3448        Value::Array(items) => {
3449            for item in items {
3450                collect_image_upload_placeholders(item, placeholders)?;
3451            }
3452        }
3453        Value::Object(object) => {
3454            if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3455                let upload_id = image_upload_part_name(object, "image_upload")?;
3456                let media_type = object
3457                    .get("media_type")
3458                    .and_then(Value::as_str)
3459                    .map(str::trim)
3460                    .filter(|value| !value.is_empty())
3461                    .ok_or_else(|| format!("image_upload {upload_id} requires media_type"))?;
3462                if placeholders
3463                    .insert(upload_id.to_string(), media_type.to_string())
3464                    .is_some()
3465                {
3466                    return Err(format!("duplicate image_upload placeholder: {upload_id}"));
3467                }
3468            } else {
3469                for child in object.values() {
3470                    collect_image_upload_placeholders(child, placeholders)?;
3471                }
3472            }
3473        }
3474        _ => {}
3475    }
3476    Ok(())
3477}
3478
3479fn replace_image_upload_placeholders(
3480    value: &mut Value,
3481    refs: &std::collections::BTreeMap<String, Value>,
3482) -> Result<(), String> {
3483    match value {
3484        Value::Array(items) => {
3485            for item in items {
3486                replace_image_upload_placeholders(item, refs)?;
3487            }
3488        }
3489        Value::Object(object) => {
3490            if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3491                let upload_id = image_upload_part_name(object, "image_upload")?;
3492                let replacement = refs
3493                    .get(upload_id)
3494                    .ok_or_else(|| format!("missing blob replacement for {upload_id}"))?;
3495                *value = replacement.clone();
3496            } else {
3497                for child in object.values_mut() {
3498                    replace_image_upload_placeholders(child, refs)?;
3499                }
3500            }
3501        }
3502        _ => {}
3503    }
3504    Ok(())
3505}
3506
3507fn response_value(id: Value, result: Option<Value>, error: Option<JsonRpcError>) -> Value {
3508    serde_json::to_value(JsonRpcResponse {
3509        jsonrpc: JSONRPC_VERSION.to_string(),
3510        id,
3511        result,
3512        error,
3513    })
3514    .unwrap_or_else(|_| {
3515        serde_json::json!({
3516            "jsonrpc": JSONRPC_VERSION,
3517            "id": Value::Null,
3518            "error": {
3519                "code": -32603,
3520                "message": "serialization failed",
3521            }
3522        })
3523    })
3524}
3525
3526fn invalid_params(id: Value, message: impl Into<String>) -> Value {
3527    response_value(
3528        id,
3529        None,
3530        Some(JsonRpcError {
3531            code: -32602,
3532            message: message.into(),
3533            data: None,
3534        }),
3535    )
3536}
3537
3538fn gating_decision_failed_error(id: Value, err: impl std::fmt::Display) -> Value {
3539    tracing::warn!(
3540        target: "mobkit::console",
3541        error = %err,
3542        "console gating decision failed"
3543    );
3544    invalid_params(id, "gating decision failed")
3545}
3546
3547fn runtime_binding_from_wire(
3548    binding: WireRuntimeBinding,
3549) -> Result<meerkat_mob::RuntimeBinding, String> {
3550    match binding {
3551        WireRuntimeBinding::Session => Ok(meerkat_mob::RuntimeBinding::Session),
3552        WireRuntimeBinding::External {
3553            address,
3554            bootstrap_token,
3555            identity,
3556        } => {
3557            let resolved = identity.resolve().map_err(|err| err.to_string())?;
3558            Ok(meerkat_mob::RuntimeBinding::External {
3559                peer_id: resolved.peer_id.to_string(),
3560                address,
3561                bootstrap_token,
3562                pubkey: resolved.pubkey,
3563            })
3564        }
3565    }
3566}
3567
3568fn parse_optional_runtime_mode(params: &Value) -> Result<Option<MobRuntimeMode>, String> {
3569    match params.get("runtime_mode") {
3570        None | Some(Value::Null) => Ok(None),
3571        Some(value) => serde_json::from_value::<MobRuntimeMode>(value.clone())
3572            .map(Some)
3573            .map_err(|err| {
3574                format!("runtime_mode must be \"autonomous_host\" or \"turn_driven\": {err}")
3575            }),
3576    }
3577}
3578
3579fn parse_optional_backend(params: &Value) -> Result<Option<MobBackendKind>, String> {
3580    match params.get("backend") {
3581        None | Some(Value::Null) => Ok(None),
3582        Some(value) => serde_json::from_value::<MobBackendKind>(value.clone())
3583            .map(Some)
3584            .map_err(|err| format!("backend must be \"session\" or \"external\": {err}")),
3585    }
3586}
3587
3588fn parse_optional_runtime_binding(
3589    params: &Value,
3590) -> Result<Option<meerkat_mob::RuntimeBinding>, String> {
3591    match params.get("binding") {
3592        None | Some(Value::Null) => Ok(None),
3593        Some(value) => {
3594            let wire = serde_json::from_value::<WireRuntimeBinding>(value.clone())
3595                .map_err(|err| format!("binding: {err}"))?;
3596            runtime_binding_from_wire(wire).map(Some)
3597        }
3598    }
3599}
3600
3601async fn member_entry_to_console_json(
3602    runtime: &MobRuntime,
3603    entry: &meerkat_mob::runtime::MobMemberListEntry,
3604) -> Value {
3605    let mut value = member_entry_to_json(entry);
3606    if let Some(object) = value.as_object_mut() {
3607        object.insert(
3608            "model_capabilities".to_string(),
3609            serde_json::to_value(model_capabilities_for_member_entry(
3610                runtime.handle().definition(),
3611                entry,
3612            ))
3613            .unwrap_or(Value::Null),
3614        );
3615    }
3616    value
3617}
3618
3619fn internal_error(id: Value, message: impl Into<String>) -> Value {
3620    let message = message.into();
3621    tracing::warn!(
3622        target: "mobkit::console",
3623        error = %message,
3624        "console JSON-RPC internal error"
3625    );
3626    // The real failure reason goes on the wire. It used to be logged here
3627    // and replaced with a bare "internal_error", which made every -32000
3628    // undiagnosable from the client side (meerkat-studio ask K2: their K1
3629    // retire/respawn failures cost a day because this said nothing).
3630    // `error` stays "internal_error" as the stable kind discriminator for
3631    // existing clients; `detail` carries the human-readable chain.
3632    response_value(
3633        id,
3634        None,
3635        Some(JsonRpcError {
3636            code: -32000,
3637            message: message.clone(),
3638            data: Some(json!({ "error": "internal_error", "detail": message })),
3639        }),
3640    )
3641}
3642
3643/// Render a stale-cursor failure as a JSON-RPC envelope with code
3644/// `-32010`, a typed error body the SDKs can parse into the
3645/// `MobEventsStaleError` exception, and a `data` field carrying both
3646/// cursors so callers can rewind to the current frontier.
3647fn stale_event_cursor_response(id: Value, after_cursor: u64, latest_cursor: u64) -> Value {
3648    response_value(
3649        id,
3650        None,
3651        Some(JsonRpcError {
3652            code: crate::rpc::MOB_EVENTS_STALE_CURSOR_CODE,
3653            message: format!(
3654                "stale mob event cursor: requested {after_cursor}, latest {latest_cursor}"
3655            ),
3656            data: Some(serde_json::json!({
3657                "error": "event_query_stale",
3658                "after_cursor": after_cursor,
3659                "latest_cursor": latest_cursor,
3660            })),
3661        }),
3662    )
3663}
3664
3665fn console_timeline_replay_unavailable_response(
3666    id: Value,
3667    err: ConsoleLogError,
3668    requested_cursor: Option<&ConsoleCursor>,
3669    latest_cursor: Option<ConsoleCursor>,
3670) -> Value {
3671    tracing::warn!(
3672        target: "mobkit::console",
3673        error = %err,
3674        "console timeline replay unavailable"
3675    );
3676    response_value(
3677        id,
3678        None,
3679        Some(JsonRpcError {
3680            code: crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE,
3681            message: "timeline replay unavailable".to_string(),
3682            data: Some(json!({
3683                "error": "replay_unavailable",
3684                "stream": "timeline",
3685                "requested_cursor": requested_cursor.map(ToString::to_string),
3686                "latest_cursor": latest_cursor.map(|cursor| cursor.to_string()),
3687            })),
3688        }),
3689    )
3690}
3691
3692fn parse_console_helper_options(
3693    options_val: Option<&Value>,
3694) -> Result<meerkat_mob::HelperOptions, String> {
3695    crate::rpc::mob_methods::parse_helper_options(options_val)
3696}
3697
3698fn member_is_addressable(member: &meerkat_mob::runtime::MobMemberListEntry) -> bool {
3699    member
3700        .labels
3701        .get("addressable")
3702        .map(|value: &String| !value.eq_ignore_ascii_case("false"))
3703        .unwrap_or(true)
3704}
3705
3706fn member_addressability(member: &meerkat_mob::runtime::MobMemberListEntry) -> &'static str {
3707    if member_is_addressable(member) {
3708        "addressable"
3709    } else {
3710        "internal_only"
3711    }
3712}
3713
3714fn console_identity_status_json_for_identity(
3715    identity: &str,
3716    member: &meerkat_mob::runtime::MobMemberListEntry,
3717    session_id: Option<String>,
3718    response_phase: Option<String>,
3719) -> Value {
3720    json!({
3721        "identity": identity,
3722        "state": member_status_state_string(member.status),
3723        "role": member.role.to_string(),
3724        "addressability": member_addressability(member),
3725        "display_name": member.labels.get("display_name"),
3726        "labels": member.labels,
3727        "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3728        "session_id": session_id,
3729        "generation": Value::Null,
3730        "checkpoint_version": Value::Null,
3731        "continuity_health": Value::Null,
3732        "lease_healthy": Value::Null,
3733        "lease": Value::Null,
3734        "response_phase": response_phase,
3735    })
3736}
3737
3738fn console_identity_inspect_json_for_identity(
3739    identity: &str,
3740    member: &meerkat_mob::runtime::MobMemberListEntry,
3741    session_id: Option<String>,
3742    response_phase: Option<String>,
3743) -> Value {
3744    let peers: Vec<String> = member.wired_to.iter().map(ToString::to_string).collect();
3745    json!({
3746        "identity": identity,
3747        "state": member_status_state_string(member.status),
3748        "role": member.role.to_string(),
3749        "addressability": member_addressability(member),
3750        "display_name": member.labels.get("display_name"),
3751        "labels": member.labels,
3752        "continuity_health": Value::Null,
3753        "lease_healthy": Value::Null,
3754        "lease": Value::Null,
3755        "continuity": {
3756            "generation": Value::Null,
3757            "checkpoint_version": Value::Null,
3758            "session_id": session_id,
3759            "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3760        },
3761        "topology_peers": peers,
3762        "output_preview": Value::Null,
3763        "response_phase": response_phase,
3764    })
3765}
3766
3767/// Resolve a mob member by identity plus its current bridge session id.
3768///
3769/// Returns `None` if no member with the given identity exists.
3770async fn lookup_member_with_session(
3771    handle: &MobHandle,
3772    identity: &AgentIdentity,
3773) -> Option<(meerkat_mob::runtime::MobMemberListEntry, Option<String>)> {
3774    let entries = handle.list_members_including_retiring().await;
3775    let entry = entries
3776        .into_iter()
3777        .find(|e| &e.agent_identity == identity)?;
3778    let session_id = handle
3779        .resolve_bridge_session_id_observation(identity)
3780        .await
3781        .map(|s| s.to_string());
3782    Some((entry, session_id))
3783}
3784
3785#[derive(Debug, Clone)]
3786struct ConsoleRuntimeIdentityAlias {
3787    identity: String,
3788    runtime_member_id: String,
3789    member: meerkat_mob::runtime::MobMemberListEntry,
3790    session_id: Option<String>,
3791}
3792
3793fn durable_identity_for_member(member: &meerkat_mob::runtime::MobMemberListEntry) -> String {
3794    member
3795        .labels
3796        .get("agent_identity")
3797        .filter(|value| !value.trim().is_empty())
3798        .cloned()
3799        // Fallback surfaces the public alias, not the comms-safe roster id.
3800        .unwrap_or_else(|| {
3801            crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned()
3802        })
3803}
3804
3805async fn lookup_member_alias_with_session(
3806    handle: &MobHandle,
3807    visibility_policy: &dyn ConsoleVisibilityPolicy,
3808    requested_identity: &str,
3809) -> Result<Option<ConsoleRuntimeIdentityAlias>, JsonRpcError> {
3810    let all_matches = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
3811    let mut visible_matches = Vec::new();
3812    for alias in &all_matches {
3813        if runtime_alias_visible_to_console(handle, visibility_policy, alias) {
3814            visible_matches.push(alias.clone());
3815        }
3816    }
3817    let member = if visible_matches.len() > 1 {
3818        return Err(ambiguous_live_identity_alias_error(
3819            requested_identity,
3820            &visible_matches
3821                .iter()
3822                .map(|alias| alias.runtime_member_id.clone())
3823                .collect::<Vec<_>>(),
3824        ));
3825    } else if let Some(alias) = visible_matches.into_iter().next() {
3826        Some(alias)
3827    } else {
3828        all_matches.into_iter().next()
3829    };
3830    Ok(member)
3831}
3832
3833async fn lookup_visible_member_alias_candidates_with_session(
3834    handle: &MobHandle,
3835    visibility_policy: &dyn ConsoleVisibilityPolicy,
3836    requested_identity: &str,
3837) -> Vec<ConsoleRuntimeIdentityAlias> {
3838    let mut visible = Vec::new();
3839    for alias in lookup_member_alias_candidates_with_session(handle, requested_identity).await {
3840        if runtime_alias_visible_to_console(handle, visibility_policy, &alias) {
3841            visible.push(alias);
3842        }
3843    }
3844    visible
3845}
3846
3847async fn lookup_member_alias_candidates_with_session(
3848    handle: &MobHandle,
3849    requested_identity: &str,
3850) -> Vec<ConsoleRuntimeIdentityAlias> {
3851    // Requested identities arrive in the public alias space; roster ids are
3852    // their comms-safe encodings (meerkat 0.7 MemberCommsName).
3853    let requested_member_id = crate::member_comms_id::mob_member_id(requested_identity);
3854    let entries = handle.list_members_including_retiring().await;
3855    let exact_matches = entries
3856        .iter()
3857        .filter(|entry| entry.agent_identity == requested_member_id)
3858        .cloned()
3859        .collect::<Vec<_>>();
3860    let label_matches = entries
3861        .iter()
3862        .filter(|entry| {
3863            entry
3864                .labels
3865                .get("agent_identity")
3866                .is_some_and(|identity| identity == requested_identity)
3867        })
3868        .cloned()
3869        .collect::<Vec<_>>();
3870    let mut matches = exact_matches;
3871    matches.extend(label_matches);
3872    let mut seen_member_ids = BTreeSet::new();
3873    matches.retain(|entry| seen_member_ids.insert(entry.agent_identity.to_string()));
3874    let mut aliases = Vec::with_capacity(matches.len());
3875    for member in matches {
3876        let runtime_member_id =
3877            crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3878        let identity = durable_identity_for_member(&member);
3879        let session_id = handle
3880            .resolve_bridge_session_id_observation(&member.agent_identity)
3881            .await
3882            .map(|s| s.to_string());
3883        aliases.push(ConsoleRuntimeIdentityAlias {
3884            identity,
3885            runtime_member_id,
3886            member,
3887            session_id,
3888        });
3889    }
3890    aliases
3891}
3892
3893async fn reject_ambiguous_projected_live_identity(
3894    handle: &MobHandle,
3895    visibility_policy: &dyn ConsoleVisibilityPolicy,
3896    alias: &ConsoleRuntimeIdentityAlias,
3897) -> Result<(), JsonRpcError> {
3898    let candidates = lookup_visible_member_alias_candidates_with_session(
3899        handle,
3900        visibility_policy,
3901        &alias.identity,
3902    )
3903    .await;
3904    if candidates.len() > 1 {
3905        return Err(ambiguous_live_identity_alias_error(
3906            &alias.identity,
3907            &candidates
3908                .iter()
3909                .map(|candidate| candidate.runtime_member_id.clone())
3910                .collect::<Vec<_>>(),
3911        ));
3912    }
3913    Ok(())
3914}
3915
3916fn ambiguous_live_identity_alias_error(
3917    requested_identity: &str,
3918    candidates: &[String],
3919) -> JsonRpcError {
3920    JsonRpcError {
3921        code: -32602,
3922        message: format!(
3923            "ambiguous live identity alias {requested_identity}: candidates [{}]",
3924            candidates.join(", ")
3925        ),
3926        data: Some(json!({
3927            "kind": "ambiguous_live_identity_alias",
3928            "identity": requested_identity,
3929            "candidates": candidates,
3930        })),
3931    }
3932}
3933
3934async fn lookup_member_runtime_alias_with_session(
3935    handle: &MobHandle,
3936    runtime_member_id: &str,
3937) -> Option<ConsoleRuntimeIdentityAlias> {
3938    let requested_member_id = crate::member_comms_id::mob_member_id(runtime_member_id);
3939    let entries = handle.list_members_including_retiring().await;
3940    let member = entries
3941        .into_iter()
3942        .find(|entry| entry.agent_identity == requested_member_id)?;
3943    let runtime_member_id =
3944        crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3945    let identity = durable_identity_for_member(&member);
3946    let session_id = handle
3947        .resolve_bridge_session_id_observation(&member.agent_identity)
3948        .await
3949        .map(|s| s.to_string());
3950    Some(ConsoleRuntimeIdentityAlias {
3951        identity,
3952        runtime_member_id,
3953        member,
3954        session_id,
3955    })
3956}
3957
3958async fn identity_runtime_alias(
3959    identity_runtime: &crate::identity_first::IdentityRuntime,
3960    requested_identity: &str,
3961) -> Result<Option<(crate::identity_first::AgentIdentity, bool)>, String> {
3962    if requested_identity.starts_with("rt:") {
3963        for status in identity_runtime.statuses().await {
3964            if status
3965                .agent_runtime_id
3966                .as_ref()
3967                .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
3968            {
3969                return Ok(Some((status.identity, false)));
3970            }
3971        }
3972        return Ok(None);
3973    }
3974    if let Ok(identity) = crate::identity_first::AgentIdentity::parse(requested_identity) {
3975        match identity_runtime.status(&identity).await {
3976            Ok(_) => return Ok(Some((identity, true))),
3977            Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
3978            Err(err) => return Err(err.to_string()),
3979        }
3980    }
3981
3982    for status in identity_runtime.statuses().await {
3983        if status
3984            .agent_runtime_id
3985            .as_ref()
3986            .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
3987        {
3988            return Ok(Some((status.identity, false)));
3989        }
3990    }
3991    Ok(None)
3992}
3993
3994async fn resolve_console_identity_control_target(
3995    handle: &MobHandle,
3996    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
3997    visibility_policy: &dyn ConsoleVisibilityPolicy,
3998    requested_identity: &str,
3999) -> Result<
4000    Option<(
4001        crate::identity_first::AgentIdentity,
4002        bool,
4003        Option<ConsoleRuntimeIdentityAlias>,
4004    )>,
4005    JsonRpcError,
4006> {
4007    if let Some(identity_runtime) = identity_runtime {
4008        match identity_runtime_alias(identity_runtime, requested_identity).await {
4009            Ok(Some((identity, exact))) => {
4010                let live = if exact {
4011                    let registered_live = match identity_runtime.status(&identity).await {
4012                        Ok(status) => match status.agent_runtime_id.as_ref() {
4013                            Some(runtime_id) => {
4014                                lookup_member_runtime_alias_with_session(
4015                                    handle,
4016                                    runtime_id.as_str(),
4017                                )
4018                                .await
4019                            }
4020                            None => None,
4021                        },
4022                        Err(_) => None,
4023                    };
4024                    if let Some(alias) = registered_live.as_ref()
4025                        && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
4026                    {
4027                        return Err(identity_hidden_by_policy_error(requested_identity));
4028                    }
4029                    if let Some(registered) = registered_live {
4030                        return Ok(Some((identity, exact, Some(registered))));
4031                    }
4032                    let requested_live_candidates =
4033                        lookup_visible_member_alias_candidates_with_session(
4034                            handle,
4035                            visibility_policy,
4036                            requested_identity,
4037                        )
4038                        .await;
4039                    let requested_live = if requested_live_candidates.len() > 1 {
4040                        return Err(ambiguous_live_identity_alias_error(
4041                            requested_identity,
4042                            &requested_live_candidates
4043                                .iter()
4044                                .map(|alias| alias.runtime_member_id.clone())
4045                                .collect::<Vec<_>>(),
4046                        ));
4047                    } else {
4048                        requested_live_candidates.into_iter().next()
4049                    };
4050                    match (registered_live, requested_live) {
4051                        (Some(registered), Some(requested))
4052                            if registered.runtime_member_id == requested.runtime_member_id =>
4053                        {
4054                            Some(registered)
4055                        }
4056                        (Some(registered), None) => Some(registered),
4057                        (Some(_registered), Some(requested)) => Some(requested),
4058                        (None, requested) => requested,
4059                    }
4060                } else {
4061                    let registered_live =
4062                        lookup_member_runtime_alias_with_session(handle, requested_identity).await;
4063                    if let Some(alias) = registered_live.as_ref()
4064                        && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
4065                    {
4066                        return Err(identity_hidden_by_policy_error(requested_identity));
4067                    }
4068                    if let Some(registered) = registered_live {
4069                        return Ok(Some((identity, exact, Some(registered))));
4070                    }
4071                    let durable_live_candidates =
4072                        lookup_visible_member_alias_candidates_with_session(
4073                            handle,
4074                            visibility_policy,
4075                            identity.as_str(),
4076                        )
4077                        .await;
4078                    let durable_live = if durable_live_candidates.len() > 1 {
4079                        return Err(ambiguous_live_identity_alias_error(
4080                            identity.as_str(),
4081                            &durable_live_candidates
4082                                .iter()
4083                                .map(|alias| alias.runtime_member_id.clone())
4084                                .collect::<Vec<_>>(),
4085                        ));
4086                    } else {
4087                        durable_live_candidates.into_iter().next()
4088                    };
4089                    match (registered_live, durable_live) {
4090                        (Some(registered), Some(durable))
4091                            if registered.runtime_member_id == durable.runtime_member_id =>
4092                        {
4093                            Some(registered)
4094                        }
4095                        (Some(registered), None) => Some(registered),
4096                        (Some(_registered), Some(durable)) => Some(durable),
4097                        (None, durable) => durable,
4098                    }
4099                };
4100                return Ok(Some((identity, exact, live)));
4101            }
4102            Ok(None) => {}
4103            Err(err) => {
4104                return Err(JsonRpcError {
4105                    code: -32000,
4106                    message: err,
4107                    data: None,
4108                });
4109            }
4110        }
4111    }
4112
4113    let live_alias =
4114        lookup_member_alias_with_session(handle, visibility_policy, requested_identity).await?;
4115    let Some(alias) = live_alias else {
4116        return Ok(None);
4117    };
4118    if let Some(identity_runtime) = identity_runtime
4119        && let Some(bound_status) = identity_runtime
4120            .statuses()
4121            .await
4122            .into_iter()
4123            .find(|status| {
4124                status
4125                    .agent_runtime_id
4126                    .as_ref()
4127                    .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
4128            })
4129        && bound_status.identity.as_str() != alias.identity
4130    {
4131        return Err(JsonRpcError {
4132            code: -32000,
4133            message: format!(
4134                "stale live identity alias: live console alias {} resolves to {}, but identity runtime binding belongs to {}",
4135                alias.identity,
4136                alias.runtime_member_id,
4137                bound_status.identity.as_str(),
4138            ),
4139            data: Some(json!({
4140                "kind": "stale_live_identity_alias",
4141                "identity": alias.identity,
4142                "runtime_member_id": alias.runtime_member_id,
4143                "registered_identity": bound_status.identity.as_str(),
4144            })),
4145        });
4146    }
4147    let identity = crate::identity_first::AgentIdentity::parse(&alias.identity).map_err(|err| {
4148        JsonRpcError {
4149            code: -32602,
4150            message: format!("invalid identity: {err}"),
4151            data: None,
4152        }
4153    })?;
4154    let durable_live_candidates = lookup_visible_member_alias_candidates_with_session(
4155        handle,
4156        visibility_policy,
4157        identity.as_str(),
4158    )
4159    .await;
4160    if durable_live_candidates.len() > 1 {
4161        return Err(ambiguous_live_identity_alias_error(
4162            identity.as_str(),
4163            &durable_live_candidates
4164                .iter()
4165                .map(|alias| alias.runtime_member_id.clone())
4166                .collect::<Vec<_>>(),
4167        ));
4168    }
4169    Ok(Some((identity, false, Some(alias))))
4170}
4171
4172fn live_alias_matches_status_runtime(
4173    alias: Option<&ConsoleRuntimeIdentityAlias>,
4174    status: &crate::identity_first::IdentityStatus,
4175) -> bool {
4176    let Some(alias) = alias else {
4177        return true;
4178    };
4179    let session_matches = match (
4180        status.session_id.as_ref().map(ToString::to_string),
4181        alias.session_id.as_deref(),
4182    ) {
4183        (Some(status_session), Some(live_session)) => status_session == live_session,
4184        _ => true,
4185    };
4186    status
4187        .agent_runtime_id
4188        .as_ref()
4189        .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
4190        && alias.identity == status.identity.as_str()
4191        && session_matches
4192}
4193
4194async fn stale_live_alias_json_rpc_error(
4195    operation: &str,
4196    identity_runtime: &crate::identity_first::IdentityRuntime,
4197    identity: &crate::identity_first::AgentIdentity,
4198    live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4199) -> Option<JsonRpcError> {
4200    let live_alias = live_alias?;
4201    let Ok(status) = identity_runtime.status(identity).await else {
4202        return None;
4203    };
4204    if live_alias_matches_status_runtime(Some(live_alias), &status) {
4205        return None;
4206    }
4207    let registered_runtime_member_id = status
4208        .agent_runtime_id
4209        .as_ref()
4210        .map(crate::identity_first::AgentRuntimeId::as_str);
4211    Some(JsonRpcError {
4212        code: -32000,
4213        message: format!(
4214            "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
4215            identity.as_str(),
4216            registered_runtime_member_id.unwrap_or("<none>"),
4217            live_alias.runtime_member_id
4218        ),
4219        data: Some(json!({
4220            "kind": "stale_identity_runtime_binding",
4221            "identity": identity.as_str(),
4222            "registered_runtime_member_id": registered_runtime_member_id,
4223            "live_runtime_member_id": live_alias.runtime_member_id,
4224            "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
4225            "live_session_id": live_alias.session_id,
4226        })),
4227    })
4228}
4229
4230fn identity_from_runtime_alias(alias: &str) -> Option<crate::identity_first::AgentIdentity> {
4231    let rest = alias.strip_prefix("rt:")?;
4232    let (identity, generation) = rest.rsplit_once(':')?;
4233    if identity.is_empty()
4234        || generation.is_empty()
4235        || !generation.chars().all(|ch| ch.is_ascii_digit())
4236    {
4237        return None;
4238    }
4239    crate::identity_first::AgentIdentity::parse(identity).ok()
4240}
4241
4242async fn stale_runtime_alias_json_rpc_error(
4243    operation: &str,
4244    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
4245    alias: &str,
4246) -> Option<JsonRpcError> {
4247    let identity_runtime = identity_runtime?;
4248    let identity = identity_from_runtime_alias(alias)?;
4249    let Ok(status) = identity_runtime.status(&identity).await else {
4250        return None;
4251    };
4252    let registered_runtime_member_id = status
4253        .agent_runtime_id
4254        .as_ref()
4255        .map(crate::identity_first::AgentRuntimeId::as_str);
4256    if registered_runtime_member_id == Some(alias) {
4257        return None;
4258    }
4259    Some(JsonRpcError {
4260        code: -32000,
4261        message: format!(
4262            "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
4263            identity.as_str(),
4264            registered_runtime_member_id.unwrap_or("<none>"),
4265            alias
4266        ),
4267        data: Some(json!({
4268            "kind": "stale_identity_runtime_binding",
4269            "identity": identity.as_str(),
4270            "registered_runtime_member_id": registered_runtime_member_id,
4271            "live_runtime_member_id": alias,
4272            "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
4273            "live_session_id": Value::Null,
4274        })),
4275    })
4276}
4277
4278fn reset_requires_session_bridge_json_rpc_error() -> JsonRpcError {
4279    JsonRpcError {
4280        code: -32602,
4281        message: "reset requires an identity runtime with a session bridge".to_string(),
4282        data: Some(json!({
4283            "kind": "identity_reset_requires_session_bridge",
4284        })),
4285    }
4286}
4287
4288fn console_identity_status_json_from_record(
4289    record: &crate::console_aggregator::ConsoleIdentityRecord,
4290    response_phase: Option<String>,
4291) -> Value {
4292    json!({
4293        "identity": record.identity,
4294        "state": record.health,
4295        "role": record.labels.get("role"),
4296        "addressability": if record.addressable { "addressable" } else { "internal_only" },
4297        "display_name": record.display_name,
4298        "labels": record.labels,
4299        "agent_runtime_id": record.runtime_member_id,
4300        "session_id": record.session_id,
4301        "generation": Value::Null,
4302        "checkpoint_version": Value::Null,
4303        "continuity_health": Value::Null,
4304        "lease_healthy": Value::Null,
4305        "lease": Value::Null,
4306        "response_phase": response_phase,
4307    })
4308}
4309
4310fn console_addressability_json(
4311    addressability: crate::identity_first::AgentAddressability,
4312) -> &'static str {
4313    match addressability {
4314        crate::identity_first::AgentAddressability::Addressable => "addressable",
4315        crate::identity_first::AgentAddressability::InternalOnly => "internal_only",
4316    }
4317}
4318
4319fn console_identity_record_from_identity_status(
4320    status: &crate::identity_first::IdentityStatus,
4321) -> ConsoleIdentityRecord {
4322    let mut labels = status.labels.clone();
4323    if let Some(profile) = status.profile.as_ref() {
4324        labels
4325            .entry("role".to_string())
4326            .or_insert_with(|| profile.as_str().to_string());
4327    }
4328    let runtime_member_id = status
4329        .agent_runtime_id
4330        .as_ref()
4331        .map(crate::identity_first::AgentRuntimeId::as_str)
4332        .unwrap_or_else(|| status.identity.as_str())
4333        .to_string();
4334    let addressable = status.addressability
4335        == crate::identity_first::AgentAddressability::Addressable
4336        && matches!(
4337            status.state,
4338            crate::identity_first::IdentityLifecycleState::Active
4339                | crate::identity_first::IdentityLifecycleState::Dormant
4340                | crate::identity_first::IdentityLifecycleState::Uninitialized
4341        );
4342    let visibility = match status.state {
4343        crate::identity_first::IdentityLifecycleState::Retiring => {
4344            ConsoleVisibility::RetiredReadable
4345        }
4346        crate::identity_first::IdentityLifecycleState::Broken
4347        | crate::identity_first::IdentityLifecycleState::Suspended => {
4348            ConsoleVisibility::Unreachable
4349        }
4350        _ if addressable => ConsoleVisibility::Addressable,
4351        _ => ConsoleVisibility::Hidden,
4352    };
4353    let health = match status.state {
4354        crate::identity_first::IdentityLifecycleState::Active => "ready",
4355        crate::identity_first::IdentityLifecycleState::Dormant => "dormant",
4356        crate::identity_first::IdentityLifecycleState::Uninitialized => "uninitialized",
4357        crate::identity_first::IdentityLifecycleState::Broken => "broken",
4358        crate::identity_first::IdentityLifecycleState::Suspended => "suspended",
4359        crate::identity_first::IdentityLifecycleState::Retiring => "retired",
4360    }
4361    .to_string();
4362    ConsoleIdentityRecord {
4363        identity: status.identity.as_str().to_string(),
4364        display_name: status
4365            .display_name
4366            .as_ref()
4367            .map(crate::identity_first::DisplayName::as_str)
4368            .unwrap_or_else(|| status.identity.as_str())
4369            .to_string(),
4370        runtime_key: "identity-first".to_string(),
4371        runtime_member_id,
4372        session_id: status.session_id.as_ref().map(ToString::to_string),
4373        visibility,
4374        addressable,
4375        health,
4376        topology_peers: Vec::new(),
4377        labels,
4378    }
4379}
4380
4381fn identity_hidden_by_policy_response(response_id: Value, identity: &str) -> Value {
4382    response_value(
4383        response_id,
4384        None,
4385        Some(identity_hidden_by_policy_error(identity)),
4386    )
4387}
4388
4389fn identity_hidden_by_policy_error(identity: &str) -> JsonRpcError {
4390    JsonRpcError {
4391        code: -32001,
4392        message: format!("unknown identity: {identity}"),
4393        data: Some(json!({
4394            "kind": "identity_hidden_by_policy",
4395            "identity": identity,
4396        })),
4397    }
4398}
4399
4400fn identity_status_visible_to_console(
4401    visibility_policy: &dyn ConsoleVisibilityPolicy,
4402    status: &crate::identity_first::IdentityStatus,
4403) -> bool {
4404    visibility_policy.identity_visible(&console_identity_record_from_identity_status(status))
4405}
4406
4407fn console_member_from_runtime_alias(
4408    handle: &MobHandle,
4409    alias: &ConsoleRuntimeIdentityAlias,
4410) -> ConsoleMember {
4411    ConsoleMember {
4412        agent_identity: alias.runtime_member_id.clone(),
4413        role: alias.member.role.to_string(),
4414        state: member_status_state_string(alias.member.status),
4415        model_capabilities: model_capabilities_for_member_entry(handle.definition(), &alias.member),
4416        runtime_mode: Some(alias.member.runtime_mode.to_string()),
4417        session_id: alias.session_id.clone(),
4418        wired_to: alias
4419            .member
4420            .wired_to
4421            .iter()
4422            .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
4423            .collect(),
4424        labels: alias.member.labels.clone(),
4425        progress: None,
4426    }
4427}
4428
4429fn console_identity_record_from_runtime_alias(
4430    alias: &ConsoleRuntimeIdentityAlias,
4431) -> ConsoleIdentityRecord {
4432    let addressable = alias
4433        .member
4434        .labels
4435        .get("addressable")
4436        .map(|value| !value.eq_ignore_ascii_case("false"))
4437        .unwrap_or(true)
4438        && alias.member.status == meerkat_mob::MobMemberStatus::Active;
4439    let visibility = match alias.member.status {
4440        meerkat_mob::MobMemberStatus::Retiring | meerkat_mob::MobMemberStatus::Completed => {
4441            ConsoleVisibility::RetiredReadable
4442        }
4443        // Broken/unknown members have no live runtime binding; mirror the
4444        // identity-first projection, which marks them unreachable.
4445        meerkat_mob::MobMemberStatus::Broken | meerkat_mob::MobMemberStatus::Unknown => {
4446            ConsoleVisibility::Unreachable
4447        }
4448        _ if addressable => ConsoleVisibility::Addressable,
4449        _ => ConsoleVisibility::Hidden,
4450    };
4451    ConsoleIdentityRecord {
4452        identity: alias.identity.clone(),
4453        display_name: alias
4454            .member
4455            .labels
4456            .get("display_name")
4457            .cloned()
4458            .unwrap_or_else(|| alias.identity.clone()),
4459        runtime_key: "runtime".to_string(),
4460        runtime_member_id: alias.runtime_member_id.clone(),
4461        session_id: alias.session_id.clone(),
4462        visibility,
4463        addressable,
4464        health: match alias.member.status {
4465            meerkat_mob::MobMemberStatus::Active => "ready".to_string(),
4466            meerkat_mob::MobMemberStatus::Retiring => "retired".to_string(),
4467            // New machine statuses surface verbatim (broken/completed/unknown),
4468            // matching the identity-first health vocabulary.
4469            other => format!("{other:?}").to_ascii_lowercase(),
4470        },
4471        topology_peers: alias
4472            .member
4473            .wired_to
4474            .iter()
4475            .map(ToString::to_string)
4476            .collect(),
4477        labels: alias.member.labels.clone(),
4478    }
4479}
4480
4481fn runtime_alias_visible_to_console(
4482    handle: &MobHandle,
4483    visibility_policy: &dyn ConsoleVisibilityPolicy,
4484    alias: &ConsoleRuntimeIdentityAlias,
4485) -> bool {
4486    let member = console_member_from_runtime_alias(handle, alias);
4487    if !visibility_policy.member_visible(&member) {
4488        return false;
4489    }
4490    visibility_policy.identity_visible(&console_identity_record_from_runtime_alias(alias))
4491}
4492
4493fn console_identity_status_json_from_identity_status(
4494    status: &crate::identity_first::IdentityStatus,
4495    response_phase: Option<String>,
4496) -> Value {
4497    json!({
4498        "identity": status.identity.as_str(),
4499        "state": status.state.wire_str(),
4500        "role": status.profile.as_ref().map(ProfileName::as_str),
4501        "addressability": console_addressability_json(status.addressability),
4502        "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4503        "labels": status.labels,
4504        "agent_runtime_id": status.agent_runtime_id.as_ref().map(crate::identity_first::AgentRuntimeId::as_str),
4505        "session_id": status.session_id.as_ref().map(ToString::to_string),
4506        "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4507        "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4508        "continuity_health": status.continuity_health,
4509        "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4510        "lease": status.lease.as_ref().map(|lease| json!({
4511            "fencing_token": lease.fencing_token.get(),
4512            "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4513            "healthy": lease.healthy,
4514        })),
4515        "response_phase": response_phase,
4516    })
4517}
4518
4519fn console_identity_inspect_json_from_identity_status(
4520    status: &crate::identity_first::IdentityStatus,
4521    live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4522    response_phase: Option<String>,
4523) -> Value {
4524    let topology_peers = live_alias
4525        .map(|alias| {
4526            alias
4527                .member
4528                .wired_to
4529                .iter()
4530                .map(ToString::to_string)
4531                .map(Value::String)
4532                .collect::<Vec<_>>()
4533        })
4534        .unwrap_or_default();
4535    let session_id = status
4536        .session_id
4537        .as_ref()
4538        .map(ToString::to_string)
4539        .or_else(|| live_alias.and_then(|alias| alias.session_id.clone()));
4540    let agent_runtime_id = status
4541        .agent_runtime_id
4542        .as_ref()
4543        .map(crate::identity_first::AgentRuntimeId::as_str)
4544        .map(ToString::to_string)
4545        .or_else(|| live_alias.map(|alias| alias.runtime_member_id.clone()));
4546    json!({
4547        "identity": status.identity.as_str(),
4548        "state": status.state.wire_str(),
4549        "role": status.profile.as_ref().map(ProfileName::as_str),
4550        "addressability": console_addressability_json(status.addressability),
4551        "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4552        "labels": status.labels,
4553        "continuity_health": status.continuity_health,
4554        "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4555        "lease": status.lease.as_ref().map(|lease| json!({
4556            "fencing_token": lease.fencing_token.get(),
4557            "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4558            "healthy": lease.healthy,
4559        })),
4560        "continuity": {
4561            "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4562            "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4563            "session_id": session_id,
4564            "agent_runtime_id": agent_runtime_id,
4565        },
4566        "topology_peers": topology_peers,
4567        "output_preview": Value::Null,
4568        "response_phase": response_phase,
4569    })
4570}
4571
4572fn console_identity_inspect_json_from_record(
4573    inspection: &crate::console_aggregator::ConsoleIdentityInspection,
4574    response_phase: Option<String>,
4575) -> Value {
4576    let record = &inspection.identity;
4577    json!({
4578        "identity": record.identity,
4579        "state": record.health,
4580        "role": record.labels.get("role"),
4581        "addressability": if record.addressable { "addressable" } else { "internal_only" },
4582        "display_name": record.display_name,
4583        "labels": record.labels,
4584        "continuity_health": Value::Null,
4585        "lease_healthy": Value::Null,
4586        "lease": Value::Null,
4587        "continuity": {
4588            "generation": Value::Null,
4589            "checkpoint_version": Value::Null,
4590            "session_id": record.session_id,
4591            "agent_runtime_id": record.runtime_member_id,
4592        },
4593        "topology_peers": inspection.peers,
4594        "output_preview": Value::Null,
4595        "response_phase": response_phase,
4596    })
4597}
4598
4599fn lifecycle_archive_cleanup_completed(error: &str) -> bool {
4600    is_recoverable_lifecycle_cleanup_error(error)
4601}
4602
4603async fn respawn_console_member(
4604    handle: &MobHandle,
4605    runtime_member_id: &AgentIdentity,
4606) -> Result<Option<Value>, String> {
4607    // Best-effort repair material: a faulted lookup degrades to None (the
4608    // respawn itself surfaces real faults).
4609    let entry_before_respawn = handle.get_member(runtime_member_id).await.ok().flatten();
4610    match handle.respawn(runtime_member_id.clone(), None).await {
4611        Ok(_receipt) => Ok(None),
4612        Err(err) => {
4613            if let Some(failed_peer_ids) = topology_restore_failed_peer_ids(&err) {
4614                tracing::warn!(
4615                    member_id = %runtime_member_id,
4616                    failed_peer_count = failed_peer_ids.len(),
4617                    failed_peer_ids = ?failed_peer_ids,
4618                    "console member respawn restored member with isolated peer edges; continuing degraded respawn"
4619                );
4620                return Ok(Some(topology_restore_warning_json(&failed_peer_ids)));
4621            }
4622
4623            if lifecycle_archive_cleanup_completed(&err.to_string()) {
4624                // A faulted lookup must not read as "absent" (that would mint
4625                // a spurious replacement member); surface it instead.
4626                if handle
4627                    .get_member(runtime_member_id)
4628                    .await
4629                    .map_err(|lookup_err| lookup_err.to_string())?
4630                    .is_none()
4631                    && let Some(entry) = entry_before_respawn
4632                {
4633                    let mut spec =
4634                        SpawnMemberSpec::new(entry.role.clone(), runtime_member_id.clone());
4635                    if !entry.labels.is_empty() {
4636                        spec = spec.with_labels(entry.labels.clone());
4637                    }
4638                    handle
4639                        .ensure_member(spec)
4640                        .await
4641                        .map_err(|ensure_err| ensure_err.to_string())?;
4642                }
4643                return Ok(None);
4644            }
4645
4646            Err(err.to_string())
4647        }
4648    }
4649}
4650
4651/// Live-member fallback shared by `mobkit/respawn` and `mobkit/reset` when no
4652/// identity-first record backs the target: respawn the runtime member (fresh
4653/// session, same configuration) and report the projected member status.
4654async fn respawn_live_console_member_response(
4655    handle: &MobHandle,
4656    console_events: Option<&ConsoleEventStore>,
4657    alias: &ConsoleRuntimeIdentityAlias,
4658    lifecycle_kind: &str,
4659    action: &str,
4660    response_id: Value,
4661) -> Value {
4662    let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
4663    match Box::pin(respawn_console_member(handle, &mid)).await {
4664        Ok(topology_restore_warning) => {
4665            if let Some(store) = console_events {
4666                store
4667                    .record_lifecycle(
4668                        &alias.identity,
4669                        lifecycle_kind,
4670                        json!({ "topology_restore_warning": topology_restore_warning.clone() }),
4671                    )
4672                    .await;
4673            }
4674            let mut body = match lookup_member_with_session(handle, &mid).await {
4675                Some((entry, session_id)) => console_identity_status_json_for_identity(
4676                    &alias.identity,
4677                    &entry,
4678                    session_id,
4679                    None,
4680                ),
4681                None => json!({ "identity": alias.identity }),
4682            };
4683            if let Some(warning) = topology_restore_warning {
4684                body["topology_restore_warning"] = warning;
4685            }
4686            response_value(response_id, Some(body), None)
4687        }
4688        Err(err) => internal_error(response_id, format!("{action} failed: {err}")),
4689    }
4690}
4691
4692async fn retire_console_member(
4693    handle: &MobHandle,
4694    runtime_member_id: &AgentIdentity,
4695) -> Result<(), String> {
4696    match handle.retire(runtime_member_id.clone()).await {
4697        Ok(()) => Ok(()),
4698        Err(err) if lifecycle_archive_cleanup_completed(&err.to_string()) => Ok(()),
4699        Err(err) => Err(err.to_string()),
4700    }
4701}
4702
4703#[cfg(test)]
4704fn member_id_matches_durable_identity(member_id: &str, durable_identity: &str) -> bool {
4705    member_id == durable_identity
4706}
4707
4708async fn retire_stale_console_members_for_identity(
4709    handle: &MobHandle,
4710    visibility_policy: &dyn ConsoleVisibilityPolicy,
4711    durable_identity: &str,
4712    keep_runtime_member_id: Option<&str>,
4713) -> Result<(), String> {
4714    let stale_members = lookup_member_alias_candidates_with_session(handle, durable_identity)
4715        .await
4716        .into_iter()
4717        .filter(|alias| {
4718            runtime_alias_visible_to_console(handle, visibility_policy, alias)
4719                && keep_runtime_member_id
4720                    .map(|keep| alias.runtime_member_id != keep)
4721                    .unwrap_or(true)
4722        })
4723        .map(|alias| crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str()))
4724        .collect::<Vec<_>>();
4725    for member_id in stale_members {
4726        retire_console_member(handle, &member_id).await?;
4727    }
4728    Ok(())
4729}
4730
4731fn console_identity_error_response(
4732    response_id: Value,
4733    operation: &str,
4734    err: crate::identity_first::IdentityRuntimeError,
4735) -> Value {
4736    match err {
4737        crate::identity_first::IdentityRuntimeError::UnknownIdentity(identity) => {
4738            invalid_params(response_id, format!("identity not found: {identity}"))
4739        }
4740        other => internal_error(response_id, format!("{operation} failed: {other}")),
4741    }
4742}
4743
4744#[allow(clippy::too_many_arguments)]
4745async fn handle_console_aggregator_rpc(
4746    console_aggregator: Option<MobKitConsoleAggregator>,
4747    request: JsonRpcRequest,
4748    is_authenticated: bool,
4749    read_only: bool,
4750    access: Option<&AccessController>,
4751    access_view: Option<&AccessView>,
4752) -> Value {
4753    let response_id = request.id.clone().unwrap_or(Value::Null);
4754    let can_mutate = is_authenticated && !read_only;
4755    if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
4756        return console_read_only_rpc_error(response_id);
4757    }
4758    if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
4759        return response;
4760    }
4761    if let Some(error) =
4762        console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
4763    {
4764        return response_value(response_id, None, Some(error));
4765    }
4766    match request.method.as_str() {
4767        "mobkit/capabilities" => {
4768            let mut methods = vec![
4769                "mobkit/capabilities",
4770                "mobkit/console/list_identities",
4771                "mobkit/console/inspect_identity",
4772                "mobkit/console/query_timeline",
4773            ];
4774            if can_mutate {
4775                methods.extend_from_slice(&["mobkit/retire", "mobkit/console/send"]);
4776            }
4777            if access.is_some() {
4778                methods.push("mobkit/access/status");
4779                if access_view.is_some_and(AccessView::can_administer) {
4780                    methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
4781                    if can_mutate {
4782                        methods.extend_from_slice(&[
4783                            "mobkit/access/set",
4784                            "mobkit/access/enable",
4785                            "mobkit/access/rules/upsert",
4786                            "mobkit/access/rules/delete",
4787                            "mobkit/access/groups/set",
4788                            "mobkit/access/groups/delete",
4789                        ]);
4790                    }
4791                }
4792            }
4793            response_value(
4794                response_id,
4795                Some(json!({
4796                    "methods": methods,
4797                    "authenticated": is_authenticated,
4798                    "read_only": read_only,
4799                    "runtime_capabilities": {
4800                        "can_send_messages": can_mutate,
4801                        "can_retire_members": can_mutate,
4802                        "can_spawn_members": false,
4803                    },
4804                    "features": {
4805                        "console_aggregator": console_aggregator.is_some(),
4806                        "multi_runtime_console": console_aggregator.is_some(),
4807                    }
4808                })),
4809                None,
4810            )
4811        }
4812        "mobkit/console/list_identities" => {
4813            let Some(aggregator) = &console_aggregator else {
4814                return console_aggregator_unavailable(response_id);
4815            };
4816            match Box::pin(aggregator.list_identities()).await {
4817                Ok(mut identities) => {
4818                    retain_visible_identity_records(&mut identities, access_view);
4819                    response_value(response_id, Some(json!({ "identities": identities })), None)
4820                }
4821                Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
4822            }
4823        }
4824        "mobkit/console/inspect_identity" => {
4825            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4826                return invalid_params(response_id, "identity required");
4827            };
4828            let Some(aggregator) = &console_aggregator else {
4829                return console_aggregator_unavailable(response_id);
4830            };
4831            match Box::pin(aggregator.inspect_identity(identity)).await {
4832                Ok(Some(inspection)) => response_value(
4833                    response_id,
4834                    Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
4835                    None,
4836                ),
4837                Ok(None) => response_value(
4838                    response_id,
4839                    None,
4840                    Some(JsonRpcError {
4841                        code: -32001,
4842                        message: format!("unknown identity: {identity}"),
4843                        data: None,
4844                    }),
4845                ),
4846                Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
4847            }
4848        }
4849        "mobkit/console/query_timeline" => {
4850            let query: ConsoleTimelineWindowQuery =
4851                match serde_json::from_value(request.params.clone()) {
4852                    Ok(query) => query,
4853                    Err(err) => {
4854                        return invalid_params(response_id, format!("invalid query params: {err}"));
4855                    }
4856                };
4857            let Some(aggregator) = &console_aggregator else {
4858                return console_aggregator_unavailable(response_id);
4859            };
4860            match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
4861                Ok(mut page) => {
4862                    retain_visible_timeline_frames(&mut page, access_view);
4863                    response_value(
4864                        response_id,
4865                        Some(serde_json::to_value(page).unwrap_or(Value::Null)),
4866                        None,
4867                    )
4868                }
4869                Err(err) => {
4870                    let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
4871                    console_timeline_replay_unavailable_response(
4872                        response_id,
4873                        err,
4874                        query.after.as_ref(),
4875                        latest_cursor,
4876                    )
4877                }
4878            }
4879        }
4880        "mobkit/console/send" => {
4881            let send_request: ConsoleSendRequest =
4882                match serde_json::from_value(request.params.clone()) {
4883                    Ok(request) => request,
4884                    Err(err) => {
4885                        return invalid_params(response_id, format!("invalid send params: {err}"));
4886                    }
4887                };
4888            let Some(aggregator) = &console_aggregator else {
4889                return console_aggregator_unavailable(response_id);
4890            };
4891            match Box::pin(aggregator.send(send_request)).await {
4892                Ok(accepted) => response_value(
4893                    response_id,
4894                    Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
4895                    None,
4896                ),
4897                Err(err) => {
4898                    response_value(response_id, None, Some(console_send_json_rpc_error(err)))
4899                }
4900            }
4901        }
4902        "mobkit/retire" => {
4903            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4904                return invalid_params(response_id, "identity required");
4905            };
4906            let Some(aggregator) = &console_aggregator else {
4907                return console_aggregator_unavailable(response_id);
4908            };
4909            match Box::pin(aggregator.retire_identity(identity)).await {
4910                Ok(true) => {
4911                    response_value(response_id, Some(json!({ "identity": identity })), None)
4912                }
4913                Ok(false) => response_value(
4914                    response_id,
4915                    None,
4916                    Some(JsonRpcError {
4917                        code: -32001,
4918                        message: format!("unknown identity: {identity}"),
4919                        data: None,
4920                    }),
4921                ),
4922                Err(err) => internal_error(response_id, format!("retire failed: {err}")),
4923            }
4924        }
4925        "mobkit/reset_all" => {
4926            let Some(_aggregator) = &console_aggregator else {
4927                return console_aggregator_unavailable(response_id);
4928            };
4929            response_value(
4930                response_id,
4931                None,
4932                Some(JsonRpcError {
4933                    code: -32002,
4934                    message: "reset_all is not supported on the aggregator-only RPC surface"
4935                        .to_string(),
4936                    data: Some(json!({
4937                        "kind": "unsupported_reset_all_surface",
4938                        "reason": "aggregator reset_all cannot preserve baseline identity semantics",
4939                    })),
4940                }),
4941            )
4942        }
4943        _ => response_value(
4944            response_id,
4945            None,
4946            Some(JsonRpcError {
4947                code: -32601,
4948                message: "Method not found".to_string(),
4949                data: None,
4950            }),
4951        ),
4952    }
4953}
4954
4955fn console_aggregator_unavailable(response_id: Value) -> Value {
4956    response_value(
4957        response_id,
4958        None,
4959        Some(JsonRpcError {
4960            code: -32004,
4961            message: "console aggregator unavailable".to_string(),
4962            data: None,
4963        }),
4964    )
4965}
4966
4967fn resolve_gating_approver_id(
4968    params: &Value,
4969    authenticated_principal: Option<&str>,
4970) -> Result<String, &'static str> {
4971    if let Some(principal) = authenticated_principal.and_then(|value| {
4972        let trimmed = value.trim();
4973        (!trimmed.is_empty()).then_some(trimmed)
4974    }) {
4975        return Ok(principal.to_string());
4976    }
4977    let Some(approver_id) = params.get("approver_id").and_then(Value::as_str) else {
4978        return Err("approver_id required");
4979    };
4980    let trimmed = approver_id.trim();
4981    if trimmed.is_empty() {
4982        return Err("approver_id required");
4983    }
4984    Ok(trimmed.to_string())
4985}
4986
4987#[allow(clippy::large_futures, clippy::too_many_arguments)]
4988#[cfg(test)]
4989async fn handle_console_runtime_rpc(
4990    runtime: &MobRuntime,
4991    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
4992    contact_directory: Option<&ContactDirectory>,
4993    gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
4994    console_events: Option<ConsoleEventStore>,
4995    console_aggregator: Option<MobKitConsoleAggregator>,
4996    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
4997    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
4998    mob_events: Option<MobEventsStore>,
4999    request: JsonRpcRequest,
5000    is_authenticated: bool,
5001) -> Value {
5002    handle_console_runtime_rpc_with_visibility(
5003        runtime,
5004        module_runtime,
5005        contact_directory,
5006        gateway_peer_keys,
5007        console_events,
5008        console_aggregator,
5009        identity_runtime,
5010        metadata_table,
5011        mob_events,
5012        &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
5013        request,
5014        is_authenticated,
5015        false,
5016        None,
5017        None,
5018        None,
5019        None,
5020        None,
5021        None,
5022        None,
5023    )
5024    .await
5025}
5026
5027#[allow(clippy::too_many_arguments)]
5028async fn handle_console_runtime_rpc_with_visibility(
5029    runtime: &MobRuntime,
5030    module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
5031    contact_directory: Option<&ContactDirectory>,
5032    gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
5033    console_events: Option<ConsoleEventStore>,
5034    console_aggregator: Option<MobKitConsoleAggregator>,
5035    identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
5036    metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
5037    mob_events: Option<MobEventsStore>,
5038    visibility_policy: &dyn ConsoleVisibilityPolicy,
5039    request: JsonRpcRequest,
5040    is_authenticated: bool,
5041    read_only: bool,
5042    authenticated_principal: Option<&str>,
5043    access: Option<&AccessController>,
5044    access_view: Option<&AccessView>,
5045    memory_panel: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
5046    identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
5047    workgraph: Option<&meerkat::WorkGraphService>,
5048    topology: Option<&crate::topology_control::TopologyRuntimeHandle>,
5049) -> Value {
5050    let response_id = request.id.clone().unwrap_or(Value::Null);
5051    let can_mutate = is_authenticated && !read_only;
5052    if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
5053        return console_read_only_rpc_error(response_id);
5054    }
5055    // Refresh the attribute cache from the live roster before any per-agent
5056    // access decision (the gate below and per-row result filtering) so
5057    // label/role rules resolve here exactly as they do on
5058    // `/console/experience`. RPC callers must not depend on having polled the
5059    // experience endpoint first.
5060    if let Some(controller) = access.filter(|controller| controller.enabled()) {
5061        prime_access_cache_from_runtime(runtime, controller).await;
5062    }
5063    if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
5064        return response;
5065    }
5066    if let Some(error) =
5067        console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
5068    {
5069        return response_value(response_id, None, Some(error));
5070    }
5071
5072    match request.method.as_str() {
5073        "mobkit/capabilities" => {
5074            let mut methods = vec![
5075                "mobkit/status",
5076                "mobkit/capabilities",
5077                "mobkit/list_members",
5078                "mobkit/get_member",
5079                "mobkit/find_members",
5080                "mobkit/member_status",
5081                "mobkit/identity/resolved_tools",
5082                "mobkit/blob/get",
5083                "mobkit/wait_ready",
5084                "mobkit/flow_status",
5085                "mobkit/list_flows",
5086                "mobkit/list_runs",
5087                "mobkit/console/list_identities",
5088                "mobkit/console/inspect_identity",
5089                "mobkit/console/query_timeline",
5090                "mobkit/mob_events/query",
5091                "mobkit/mob_events/subscribe",
5092                "mobkit/cross_mob/peer_info",
5093                "mobkit/cross_mob/directory",
5094                "mobkit/peer_pubkey",
5095            ];
5096            if identity_runtime.is_some() {
5097                methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
5098                if let Some(identity_runtime) = &identity_runtime
5099                    && identity_runtime.agent_memory_supports_recall().await
5100                {
5101                    methods.push("mobkit/agent_memory/recall");
5102                    if can_mutate && identity_runtime.agent_memory_supports_remember().await {
5103                        methods.push("mobkit/agent_memory/remember");
5104                    }
5105                    if can_mutate && identity_runtime.agent_memory_supports_forget().await {
5106                        methods.push("mobkit/agent_memory/forget");
5107                    }
5108                    if can_mutate && identity_runtime.agent_memory_supports_update().await {
5109                        methods.push("mobkit/agent_memory/update");
5110                    }
5111                    if identity_runtime.agent_memory_supports_manifest().await {
5112                        methods.push("mobkit/agent_memory/manifest");
5113                    }
5114                }
5115                if can_mutate {
5116                    methods.push("mobkit/delete_identity");
5117                }
5118            } else if console_aggregator.is_some() {
5119                methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
5120            }
5121            if module_runtime.is_some() {
5122                methods.extend_from_slice(&[
5123                    "mobkit/routing/routes/list",
5124                    "mobkit/delivery/history",
5125                    "mobkit/gating/pending",
5126                    "mobkit/gating/audit",
5127                ]);
5128                if can_mutate {
5129                    methods.push("mobkit/gating/decide");
5130                }
5131            }
5132            if can_mutate {
5133                methods.extend_from_slice(&[
5134                    "mobkit/retire",
5135                    // Respawn/reset dispatch on every console runtime: with an
5136                    // identity-first runtime they refresh the durable record,
5137                    // and without one they fall back to a live-member respawn.
5138                    // Advertising them only under the identity runtime left
5139                    // the console buttons dead on plain runtimes even though
5140                    // the dispatcher accepted the calls.
5141                    "mobkit/respawn",
5142                    "mobkit/reset",
5143                    "mobkit/reset_all",
5144                    "mobkit/console/send",
5145                    "mobkit/blob/upload",
5146                    "mobkit/ensure_member",
5147                    "mobkit/retire_member",
5148                    "mobkit/respawn_member",
5149                    "mobkit/force_cancel_member",
5150                    "mobkit/cancel_flow",
5151                    "mobkit/collect_completed",
5152                    "mobkit/run_flow",
5153                    "mobkit/spawn_helper",
5154                    "mobkit/fork_helper",
5155                    "mobkit/attach_existing_session",
5156                    "mobkit/reconcile_edges",
5157                    "mobkit/cross_mob/wire_local",
5158                    "mobkit/cross_mob/unwire_local",
5159                ]);
5160            }
5161            if metadata_table.is_some() {
5162                methods.extend_from_slice(&["mobkit/mob_labels/get", "mobkit/run_labels/get"]);
5163                if can_mutate {
5164                    methods.extend_from_slice(&[
5165                        "mobkit/mob_labels/set",
5166                        "mobkit/mob_labels/delete",
5167                        "mobkit/run_labels/set",
5168                        "mobkit/run_labels/delete",
5169                    ]);
5170                }
5171            }
5172            if memory_panel.is_some() {
5173                // Provider-dependent supports pattern: the panel methods
5174                // exist only when the bundled sqlite store is wired. The
5175                // grant-intersection probe below additionally strips the
5176                // resource-less ones (dreams/quarantine) the caller could
5177                // never use.
5178                methods.extend_from_slice(&[
5179                    "mobkit/memory/panel/records",
5180                    "mobkit/memory/panel/record",
5181                    "mobkit/memory/panel/dreams",
5182                    "mobkit/memory/panel/dream_runs",
5183                    "mobkit/memory/panel/audit_verdicts",
5184                    "mobkit/memory/panel/overview",
5185                    "mobkit/memory/panel/proposals",
5186                    "mobkit/memory/panel/injections",
5187                    "mobkit/memory/panel/harvests",
5188                    "mobkit/memory/panel/quarantine",
5189                ]);
5190            }
5191            if workgraph.is_some() {
5192                methods.extend_from_slice(crate::rpc::workgraph_methods::WORKGRAPH_READ_METHODS);
5193                if can_mutate {
5194                    methods
5195                        .extend_from_slice(crate::rpc::workgraph_methods::WORKGRAPH_MUTATE_METHODS);
5196                    methods.extend_from_slice(
5197                        crate::rpc::workgraph_methods::WORKGRAPH_CONSOLE_MUTATE_METHODS,
5198                    );
5199                }
5200            }
5201            if access.is_some() {
5202                methods.push("mobkit/access/status");
5203                if access_view.is_some_and(AccessView::can_administer) {
5204                    methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
5205                    if can_mutate {
5206                        methods.extend_from_slice(&[
5207                            "mobkit/access/set",
5208                            "mobkit/access/enable",
5209                            "mobkit/access/rules/upsert",
5210                            "mobkit/access/rules/delete",
5211                            "mobkit/access/groups/set",
5212                            "mobkit/access/groups/delete",
5213                        ]);
5214                    }
5215                }
5216            }
5217            let topology_capabilities = topology.map(|topology| {
5218                let (topology_methods, capabilities) =
5219                    crate::rpc::topology_methods::capability_projection(
5220                        topology,
5221                        access_view,
5222                        !can_mutate,
5223                    );
5224                methods.extend(topology_methods);
5225                capabilities
5226            });
5227            // Intersect the advertised methods with the caller's grants, the
5228            // same way `/console/experience` does, so a non-admin doesn't get
5229            // a method list its panels act on only to hit `-32030`. A probe
5230            // identity reveals which mapped requirements are resource-less
5231            // (target `None` regardless of params): those are filtered by the
5232            // action grant; agent-scoped methods stay advertised and are
5233            // enforced per call.
5234            if let Some(view) = access_view.filter(|view| view.enforced()) {
5235                let probe = serde_json::json!({ "identity": "\u{0}cap-probe" });
5236                methods.retain(
5237                    |method| match console_rpc_access_requirements(method, &probe) {
5238                        Some(requirements) => requirements
5239                            .iter()
5240                            .all(|(action, target)| target.is_some() || view.allows(action)),
5241                        None => true,
5242                    },
5243                );
5244            }
5245            // Coarse capability flags, intersected with the caller's grants so
5246            // they agree with the per-agent affordances in `/console/experience`.
5247            let cap = |action: &str| -> bool {
5248                can_mutate
5249                    && access_view
5250                        .filter(|view| view.enforced())
5251                        .is_none_or(|view| view.may_perform_anywhere(action))
5252            };
5253            response_value(
5254                response_id,
5255                Some(serde_json::json!({
5256                    "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5257                    "methods": methods,
5258                    // Doctrine: consumers gate their migration on this flag —
5259                    // when true, the member RPCs re-dispatch durable targets
5260                    // through the identity authority and ensure_member stands
5261                    // up durable identities (pass plane:"worker" to opt a
5262                    // spawn onto the ephemeral mob plane).
5263                    "identity_first": identity_runtime.is_some(),
5264                    // True when a WorkGraph service is wired into this
5265                    // console runtime and the mobkit/workgraph/* group is live.
5266                    "workgraph": workgraph.is_some(),
5267                    "read_only": read_only,
5268                    // The console routes to MobRuntime directly and has no
5269                    // access to the module runtime, so loaded_modules is always [].
5270                    "loaded_modules": serde_json::json!([]),
5271                    "runtime_capabilities": {
5272                        "can_send_messages": cap(ACTION_AGENT_SEND),
5273                        "can_retire_members": cap(ACTION_AGENT_RETIRE),
5274                        "can_spawn_members": cap(ACTION_AGENT_SPAWN),
5275                    },
5276                    "topology_control": topology_capabilities,
5277                })),
5278                None,
5279            )
5280        }
5281        crate::rpc::topology_methods::TOPOLOGY_QUERY_METHOD => {
5282            let Some(topology) = topology else {
5283                return response_value(
5284                    response_id,
5285                    None,
5286                    Some(JsonRpcError {
5287                        code: -32601,
5288                        message: "Method not found".to_string(),
5289                        data: None,
5290                    }),
5291                );
5292            };
5293            serde_json::to_value(
5294                crate::rpc::topology_methods::handle_query(
5295                    topology,
5296                    response_id,
5297                    access_view,
5298                    !can_mutate,
5299                )
5300                .await,
5301            )
5302            .unwrap_or(Value::Null)
5303        }
5304        crate::rpc::topology_methods::TOPOLOGY_PLAN_METHOD => {
5305            let Some(topology) = topology else {
5306                return response_value(
5307                    response_id,
5308                    None,
5309                    Some(JsonRpcError {
5310                        code: -32601,
5311                        message: "Method not found".to_string(),
5312                        data: None,
5313                    }),
5314                );
5315            };
5316            serde_json::to_value(
5317                crate::rpc::topology_methods::handle_plan(
5318                    topology,
5319                    response_id,
5320                    &request.params,
5321                    access_view,
5322                )
5323                .await,
5324            )
5325            .unwrap_or(Value::Null)
5326        }
5327        crate::rpc::topology_methods::TOPOLOGY_APPLY_METHOD => {
5328            let Some(topology) = topology else {
5329                return response_value(
5330                    response_id,
5331                    None,
5332                    Some(JsonRpcError {
5333                        code: -32601,
5334                        message: "Method not found".to_string(),
5335                        data: None,
5336                    }),
5337                );
5338            };
5339            serde_json::to_value(
5340                crate::rpc::topology_methods::handle_apply(
5341                    topology,
5342                    response_id,
5343                    &request.params,
5344                    access_view,
5345                    authenticated_principal,
5346                )
5347                .await,
5348            )
5349            .unwrap_or(Value::Null)
5350        }
5351        crate::rpc::topology_methods::TOPOLOGY_OPERATION_METHOD => {
5352            let Some(topology) = topology else {
5353                return response_value(
5354                    response_id,
5355                    None,
5356                    Some(JsonRpcError {
5357                        code: -32601,
5358                        message: "Method not found".to_string(),
5359                        data: None,
5360                    }),
5361                );
5362            };
5363            serde_json::to_value(
5364                crate::rpc::topology_methods::handle_operation(
5365                    topology,
5366                    response_id,
5367                    &request.params,
5368                    access_view,
5369                )
5370                .await,
5371            )
5372            .unwrap_or(Value::Null)
5373        }
5374        crate::rpc::topology_methods::TOPOLOGY_AUDIT_METHOD => {
5375            let Some(topology) = topology else {
5376                return response_value(
5377                    response_id,
5378                    None,
5379                    Some(JsonRpcError {
5380                        code: -32601,
5381                        message: "Method not found".to_string(),
5382                        data: None,
5383                    }),
5384                );
5385            };
5386            serde_json::to_value(
5387                crate::rpc::topology_methods::handle_audit(
5388                    topology,
5389                    response_id,
5390                    &request.params,
5391                    access_view,
5392                )
5393                .await,
5394            )
5395            .unwrap_or(Value::Null)
5396        }
5397        "mobkit/agent_memory/remember" => {
5398            let Some(identity_runtime) = &identity_runtime else {
5399                return response_value(
5400                    response_id,
5401                    None,
5402                    Some(JsonRpcError {
5403                        code: -32601,
5404                        message: "agent memory is not configured".to_string(),
5405                        data: None,
5406                    }),
5407                );
5408            };
5409            match parse_agent_memory_remember_params(&request.params) {
5410                Ok(remember_request) => match identity_runtime
5411                    .remember_agent_memory(
5412                        &remember_request.realm,
5413                        &remember_request.identity,
5414                        remember_request.memory,
5415                    )
5416                    .await
5417                {
5418                    Ok(record) => response_value(
5419                        response_id,
5420                        Some(serde_json::to_value(record).unwrap_or(Value::Null)),
5421                        None,
5422                    ),
5423                    Err(err) => response_value(
5424                        response_id,
5425                        None,
5426                        Some(crate::rpc::agent_memory_rpc_error("write", err)),
5427                    ),
5428                },
5429                Err(err) => {
5430                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5431                }
5432            }
5433        }
5434        "mobkit/agent_memory/forget" => {
5435            let Some(identity_runtime) = &identity_runtime else {
5436                return response_value(
5437                    response_id,
5438                    None,
5439                    Some(JsonRpcError {
5440                        code: -32601,
5441                        message: "agent memory is not configured".to_string(),
5442                        data: None,
5443                    }),
5444                );
5445            };
5446            match parse_agent_memory_forget_params(&request.params) {
5447                Ok(forget_request) => match identity_runtime
5448                    .forget_agent_memory(
5449                        &forget_request.realm,
5450                        &forget_request.identity,
5451                        &forget_request.memory_id,
5452                    )
5453                    .await
5454                {
5455                    Ok(result) => response_value(
5456                        response_id,
5457                        Some(serde_json::to_value(result).unwrap_or(Value::Null)),
5458                        None,
5459                    ),
5460                    Err(err) => response_value(
5461                        response_id,
5462                        None,
5463                        Some(crate::rpc::agent_memory_rpc_error("forget", err)),
5464                    ),
5465                },
5466                Err(err) => {
5467                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5468                }
5469            }
5470        }
5471        "mobkit/agent_memory/recall" => {
5472            let Some(identity_runtime) = &identity_runtime else {
5473                return response_value(
5474                    response_id,
5475                    None,
5476                    Some(JsonRpcError {
5477                        code: -32601,
5478                        message: "agent memory is not configured".to_string(),
5479                        data: None,
5480                    }),
5481                );
5482            };
5483            match parse_agent_memory_recall_params(&request.params) {
5484                Ok(recall_request) => match identity_runtime
5485                    .recall_agent_memory(recall_request.request)
5486                    .await
5487                {
5488                    Ok(records) => {
5489                        response_value(response_id, Some(json!({ "records": records })), None)
5490                    }
5491                    Err(err) => response_value(
5492                        response_id,
5493                        None,
5494                        Some(crate::rpc::agent_memory_rpc_error("recall", err)),
5495                    ),
5496                },
5497                Err(err) => {
5498                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5499                }
5500            }
5501        }
5502        "mobkit/agent_memory/update" => {
5503            let Some(identity_runtime) = &identity_runtime else {
5504                return response_value(
5505                    response_id,
5506                    None,
5507                    Some(JsonRpcError {
5508                        code: -32601,
5509                        message: "agent memory is not configured".to_string(),
5510                        data: None,
5511                    }),
5512                );
5513            };
5514            match parse_agent_memory_update_params(&request.params) {
5515                Ok(update_request) => match identity_runtime
5516                    .update_agent_memory(
5517                        &update_request.realm,
5518                        &update_request.identity,
5519                        &update_request.memory_id,
5520                        update_request.memory,
5521                    )
5522                    .await
5523                {
5524                    Ok(new_id) => response_value(
5525                        response_id,
5526                        Some(json!({
5527                            "memory_id": new_id,
5528                            "supersedes": update_request.memory_id,
5529                        })),
5530                        None,
5531                    ),
5532                    Err(err) => response_value(
5533                        response_id,
5534                        None,
5535                        Some(crate::rpc::agent_memory_rpc_error("update", err)),
5536                    ),
5537                },
5538                Err(err) => {
5539                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5540                }
5541            }
5542        }
5543        "mobkit/agent_memory/manifest" => {
5544            let Some(identity_runtime) = &identity_runtime else {
5545                return response_value(
5546                    response_id,
5547                    None,
5548                    Some(JsonRpcError {
5549                        code: -32601,
5550                        message: "agent memory is not configured".to_string(),
5551                        data: None,
5552                    }),
5553                );
5554            };
5555            match parse_agent_memory_manifest_params(&request.params) {
5556                Ok(manifest_request) => match identity_runtime
5557                    .manifest_agent_memory(
5558                        &manifest_request.realm,
5559                        &manifest_request.identity,
5560                        manifest_request.tier,
5561                    )
5562                    .await
5563                {
5564                    Ok(records) => {
5565                        response_value(response_id, Some(json!({ "records": records })), None)
5566                    }
5567                    Err(err) => response_value(
5568                        response_id,
5569                        None,
5570                        Some(crate::rpc::agent_memory_rpc_error("manifest", err)),
5571                    ),
5572                },
5573                Err(err) => {
5574                    invalid_params(response_id, format!("Invalid params: {}", err.message()))
5575                }
5576            }
5577        }
5578        // §9.3 Memory panel reads. Read-only mode allows all of these (they
5579        // are reads); the ACL mapping lives in
5580        // `console_rpc_access_requirements` plus per-row scope filtering in
5581        // the handlers.
5582        "mobkit/memory/panel/records" => {
5583            handle_memory_panel_records(memory_panel, access_view, &request.params, response_id)
5584                .await
5585        }
5586        "mobkit/memory/panel/record" => {
5587            handle_memory_panel_record(memory_panel, access_view, &request.params, response_id)
5588                .await
5589        }
5590        "mobkit/memory/panel/quarantine" => {
5591            handle_memory_panel_quarantine(memory_panel, access_view, &request.params, response_id)
5592                .await
5593        }
5594        "mobkit/memory/panel/dreams" => {
5595            handle_memory_panel_dreams(memory_panel, &request.params, response_id).await
5596        }
5597        "mobkit/memory/panel/dream_runs" => {
5598            handle_memory_panel_dream_runs(memory_panel, &request.params, response_id).await
5599        }
5600        "mobkit/memory/panel/audit_verdicts" => {
5601            handle_memory_panel_audit_verdicts(memory_panel, &request.params, response_id).await
5602        }
5603        "mobkit/memory/panel/overview" => {
5604            handle_memory_panel_overview(memory_panel, &request.params, response_id).await
5605        }
5606        "mobkit/memory/panel/proposals" => {
5607            handle_memory_panel_proposals(memory_panel, &request.params, response_id).await
5608        }
5609        "mobkit/memory/panel/injections" => {
5610            handle_memory_panel_injections(memory_panel, &request.params, response_id).await
5611        }
5612        "mobkit/memory/panel/harvests" => {
5613            handle_memory_panel_harvests(memory_panel, &request.params, response_id).await
5614        }
5615        "mobkit/status" => {
5616            let mob_state = runtime.handle().status_observation_snapshot();
5617            response_value(
5618                response_id,
5619                Some(serde_json::json!({
5620                    "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5621                    "running": matches!(mob_state, MobState::Creating | MobState::Running),
5622                    // Console routes to MobRuntime directly — no module runtime available.
5623                    // Return [] to keep StatusResult.loaded_modules schema-consistent.
5624                    "loaded_modules": serde_json::json!([]),
5625                })),
5626                None,
5627            )
5628        }
5629        "mobkit/console/list_identities" => {
5630            let Some(aggregator) = &console_aggregator else {
5631                return response_value(
5632                    response_id,
5633                    None,
5634                    Some(JsonRpcError {
5635                        code: -32004,
5636                        message: "console aggregator unavailable".to_string(),
5637                        data: None,
5638                    }),
5639                );
5640            };
5641            match Box::pin(aggregator.list_identities()).await {
5642                Ok(mut identities) => {
5643                    retain_visible_identity_records(&mut identities, access_view);
5644                    response_value(response_id, Some(json!({ "identities": identities })), None)
5645                }
5646                Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
5647            }
5648        }
5649        "mobkit/console/inspect_identity" => {
5650            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5651                return invalid_params(response_id, "identity required");
5652            };
5653            let Some(aggregator) = &console_aggregator else {
5654                return response_value(
5655                    response_id,
5656                    None,
5657                    Some(JsonRpcError {
5658                        code: -32004,
5659                        message: "console aggregator unavailable".to_string(),
5660                        data: None,
5661                    }),
5662                );
5663            };
5664            match Box::pin(aggregator.inspect_identity(identity)).await {
5665                Ok(Some(inspection)) => response_value(
5666                    response_id,
5667                    Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
5668                    None,
5669                ),
5670                Ok(None) => response_value(
5671                    response_id,
5672                    None,
5673                    Some(JsonRpcError {
5674                        code: -32001,
5675                        message: format!("unknown identity: {identity}"),
5676                        data: None,
5677                    }),
5678                ),
5679                Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
5680            }
5681        }
5682        "mobkit/console/query_timeline" => {
5683            let query: ConsoleTimelineWindowQuery =
5684                match serde_json::from_value(request.params.clone()) {
5685                    Ok(query) => query,
5686                    Err(err) => {
5687                        return invalid_params(response_id, format!("invalid query params: {err}"));
5688                    }
5689                };
5690            let Some(aggregator) = &console_aggregator else {
5691                return response_value(
5692                    response_id,
5693                    None,
5694                    Some(JsonRpcError {
5695                        code: -32004,
5696                        message: "console aggregator unavailable".to_string(),
5697                        data: None,
5698                    }),
5699                );
5700            };
5701            match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
5702                Ok(mut page) => {
5703                    retain_visible_timeline_frames(&mut page, access_view);
5704                    response_value(
5705                        response_id,
5706                        Some(serde_json::to_value(page).unwrap_or(Value::Null)),
5707                        None,
5708                    )
5709                }
5710                Err(err) => {
5711                    let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
5712                    console_timeline_replay_unavailable_response(
5713                        response_id,
5714                        err,
5715                        query.after.as_ref(),
5716                        latest_cursor,
5717                    )
5718                }
5719            }
5720        }
5721        "mobkit/console/send" => {
5722            let send_request: ConsoleSendRequest =
5723                match serde_json::from_value(request.params.clone()) {
5724                    Ok(request) => request,
5725                    Err(err) => {
5726                        return invalid_params(response_id, format!("invalid send params: {err}"));
5727                    }
5728                };
5729            let Some(aggregator) = &console_aggregator else {
5730                return response_value(
5731                    response_id,
5732                    None,
5733                    Some(JsonRpcError {
5734                        code: -32004,
5735                        message: "console aggregator unavailable".to_string(),
5736                        data: None,
5737                    }),
5738                );
5739            };
5740            if let Some(identity_runtime) = &identity_runtime {
5741                return match Box::pin(console_send_with_identity_first_fallback(
5742                    aggregator,
5743                    identity_runtime.clone(),
5744                    Some(runtime),
5745                    console_events.as_ref(),
5746                    send_request,
5747                ))
5748                .await
5749                {
5750                    Ok(accepted) => response_value(
5751                        response_id,
5752                        Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5753                        None,
5754                    ),
5755                    Err(err) => {
5756                        response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5757                    }
5758                };
5759            }
5760            match Box::pin(aggregator.send(send_request)).await {
5761                Ok(accepted) => response_value(
5762                    response_id,
5763                    Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5764                    None,
5765                ),
5766                Err(err) => {
5767                    response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5768                }
5769            }
5770        }
5771        "mobkit/blob/get" => {
5772            let Some(blob_id) = request
5773                .params
5774                .get("blob_id")
5775                .or_else(|| request.params.get("id"))
5776                .and_then(Value::as_str)
5777            else {
5778                return invalid_params(response_id, "blob_id required");
5779            };
5780            if !is_valid_blob_id_value(blob_id) {
5781                return invalid_params(response_id, "invalid blob_id");
5782            }
5783            let Some(store) = runtime.binary_blob_store() else {
5784                return internal_error(response_id, "binary blob store unavailable");
5785            };
5786            match store.get_bytes(&meerkat_core::BlobId::from(blob_id)).await {
5787                Ok(payload) => response_value(
5788                    response_id,
5789                    Some(serde_json::json!({
5790                        "blob_id": payload.blob_id,
5791                        "media_type": payload.media_type,
5792                        "size": payload.size,
5793                        "data": base64::engine::general_purpose::STANDARD.encode(payload.data.as_ref()),
5794                    })),
5795                    None,
5796                ),
5797                Err(meerkat_core::BlobStoreError::NotFound(_)) => response_value(
5798                    response_id,
5799                    None,
5800                    Some(JsonRpcError {
5801                        code: -32001,
5802                        message: format!("blob not found: {blob_id}"),
5803                        data: Some(json!({ "kind": "not_found", "blob_id": blob_id })),
5804                    }),
5805                ),
5806                Err(err) => internal_error(response_id, format!("blob get failed: {err}")),
5807            }
5808        }
5809        "mobkit/list_members" => {
5810            let handle = runtime.handle();
5811            let entries = handle.list_members_including_retiring().await;
5812            let mut members = Vec::with_capacity(entries.len());
5813            for entry in &entries {
5814                let alias =
5815                    crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
5816                if stale_runtime_alias_json_rpc_error(
5817                    "list_members",
5818                    identity_runtime.as_ref(),
5819                    alias.as_ref(),
5820                )
5821                .await
5822                .is_some()
5823                {
5824                    continue;
5825                }
5826                members.push(member_entry_to_console_json(runtime, entry).await);
5827            }
5828            retain_visible_member_rows(&mut members, access_view);
5829            response_value(response_id, Some(Value::Array(members)), None)
5830        }
5831        "mobkit/get_member" => {
5832            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
5833                return invalid_params(response_id, "member_id required");
5834            };
5835            if let Some(error) = stale_runtime_alias_json_rpc_error(
5836                "get_member",
5837                identity_runtime.as_ref(),
5838                member_id,
5839            )
5840            .await
5841            {
5842                return response_value(response_id, None, Some(error));
5843            }
5844            let handle = runtime.handle();
5845            let identity = crate::member_comms_id::mob_member_id(member_id);
5846            let entries = handle.list_members_including_retiring().await;
5847            match entries.into_iter().find(|e| e.agent_identity == identity) {
5848                Some(entry) => response_value(
5849                    response_id,
5850                    Some(member_entry_to_console_json(runtime, &entry).await),
5851                    None,
5852                ),
5853                None => invalid_params(response_id, format!("member not found: {member_id}")),
5854            }
5855        }
5856        "mobkit/find_members" => {
5857            let Some(label_key) = request.params.get("label_key").and_then(Value::as_str) else {
5858                return invalid_params(response_id, "label_key required");
5859            };
5860            let Some(label_value) = request.params.get("label_value").and_then(Value::as_str)
5861            else {
5862                return invalid_params(response_id, "label_value required");
5863            };
5864            let handle = runtime.handle();
5865            let filter = MemberFilter {
5866                labels: std::collections::BTreeMap::from([(
5867                    label_key.to_string(),
5868                    label_value.to_string(),
5869                )]),
5870                role: None,
5871                status: None,
5872            };
5873            let entries = match handle.list_members_matching(filter).await {
5874                Ok(entries) => entries,
5875                Err(err) => {
5876                    return invalid_params(response_id, format!("member lookup failed: {err}"));
5877                }
5878            };
5879            let mut matches = Vec::with_capacity(entries.len());
5880            for entry in &entries {
5881                let alias =
5882                    crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
5883                if stale_runtime_alias_json_rpc_error(
5884                    "find_members",
5885                    identity_runtime.as_ref(),
5886                    alias.as_ref(),
5887                )
5888                .await
5889                .is_some()
5890                {
5891                    continue;
5892                }
5893                matches.push(member_entry_to_console_json(runtime, entry).await);
5894            }
5895            retain_visible_member_rows(&mut matches, access_view);
5896            response_value(response_id, Some(Value::Array(matches)), None)
5897        }
5898        "mobkit/status_identity" => {
5899            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5900                return invalid_params(response_id, "identity required");
5901            };
5902            let handle = runtime.handle();
5903            if let Some(identity_runtime) = &identity_runtime {
5904                let (parsed_identity, _requested_exact_identity, live_alias) =
5905                    match resolve_console_identity_control_target(
5906                        &handle,
5907                        Some(identity_runtime),
5908                        visibility_policy,
5909                        identity,
5910                    )
5911                    .await
5912                    {
5913                        Ok(Some(target)) => target,
5914                        Ok(None) => {
5915                            return invalid_params(
5916                                response_id,
5917                                format!("identity not found: {identity}"),
5918                            );
5919                        }
5920                        Err(err) => return response_value(response_id, None, Some(err)),
5921                    };
5922                match identity_runtime.status(&parsed_identity).await {
5923                    Ok(status) => {
5924                        if !identity_status_visible_to_console(visibility_policy, &status) {
5925                            return identity_hidden_by_policy_response(response_id, identity);
5926                        }
5927                        let phase = if let Some(store) = &console_events {
5928                            store
5929                                .response_phase_for_identity(status.identity.as_str())
5930                                .await
5931                        } else {
5932                            None
5933                        };
5934                        if let Some(error) = stale_live_alias_json_rpc_error(
5935                            "status_identity",
5936                            identity_runtime,
5937                            &parsed_identity,
5938                            live_alias.as_ref(),
5939                        )
5940                        .await
5941                        {
5942                            return response_value(response_id, None, Some(error));
5943                        }
5944                        return response_value(
5945                            response_id,
5946                            Some(console_identity_status_json_from_identity_status(
5947                                &status, phase,
5948                            )),
5949                            None,
5950                        );
5951                    }
5952                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
5953                    Err(err) => {
5954                        return console_identity_error_response(
5955                            response_id,
5956                            "status_identity",
5957                            err,
5958                        );
5959                    }
5960                }
5961            }
5962            if let Some(aggregator) = &console_aggregator {
5963                return match Box::pin(aggregator.inspect_identity(identity)).await {
5964                    Ok(Some(inspection)) => {
5965                        let phase = if let Some(store) = &console_events {
5966                            store
5967                                .response_phase_for_identity(&inspection.identity.identity)
5968                                .await
5969                        } else {
5970                            None
5971                        };
5972                        response_value(
5973                            response_id,
5974                            Some(console_identity_status_json_from_record(
5975                                &inspection.identity,
5976                                phase,
5977                            )),
5978                            None,
5979                        )
5980                    }
5981                    Ok(None) => response_value(
5982                        response_id,
5983                        None,
5984                        Some(JsonRpcError {
5985                            code: -32001,
5986                            message: format!("unknown identity: {identity}"),
5987                            data: None,
5988                        }),
5989                    ),
5990                    Err(err) => {
5991                        internal_error(response_id, format!("status_identity failed: {err}"))
5992                    }
5993                };
5994            }
5995            let live_alias = match lookup_member_alias_with_session(
5996                &handle,
5997                visibility_policy,
5998                identity,
5999            )
6000            .await
6001            {
6002                Ok(alias) => alias,
6003                Err(err) => return response_value(response_id, None, Some(err)),
6004            };
6005            let Some(alias) = live_alias else {
6006                return invalid_params(response_id, format!("identity not found: {identity}"));
6007            };
6008            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6009                return identity_hidden_by_policy_response(response_id, identity);
6010            }
6011            if let Err(err) =
6012                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6013            {
6014                return response_value(response_id, None, Some(err));
6015            }
6016            let phase = if let Some(store) = &console_events {
6017                store.response_phase_for_identity(&alias.identity).await
6018            } else {
6019                None
6020            };
6021            response_value(
6022                response_id,
6023                Some(console_identity_status_json_for_identity(
6024                    &alias.identity,
6025                    &alias.member,
6026                    alias.session_id,
6027                    phase,
6028                )),
6029                None,
6030            )
6031        }
6032        "mobkit/inspect_identity" => {
6033            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6034                return invalid_params(response_id, "identity required");
6035            };
6036            let handle = runtime.handle();
6037            if let Some(identity_runtime) = &identity_runtime {
6038                let (parsed_identity, _requested_exact_identity, live_alias) =
6039                    match resolve_console_identity_control_target(
6040                        &handle,
6041                        Some(identity_runtime),
6042                        visibility_policy,
6043                        identity,
6044                    )
6045                    .await
6046                    {
6047                        Ok(Some(target)) => target,
6048                        Ok(None) => {
6049                            return invalid_params(
6050                                response_id,
6051                                format!("identity not found: {identity}"),
6052                            );
6053                        }
6054                        Err(err) => return response_value(response_id, None, Some(err)),
6055                    };
6056                match identity_runtime.status(&parsed_identity).await {
6057                    Ok(status) => {
6058                        if !identity_status_visible_to_console(visibility_policy, &status) {
6059                            return identity_hidden_by_policy_response(response_id, identity);
6060                        }
6061                        let phase = if let Some(store) = &console_events {
6062                            store
6063                                .response_phase_for_identity(status.identity.as_str())
6064                                .await
6065                        } else {
6066                            None
6067                        };
6068                        if let Some(error) = stale_live_alias_json_rpc_error(
6069                            "inspect_identity",
6070                            identity_runtime,
6071                            &parsed_identity,
6072                            live_alias.as_ref(),
6073                        )
6074                        .await
6075                        {
6076                            return response_value(response_id, None, Some(error));
6077                        }
6078                        return response_value(
6079                            response_id,
6080                            Some(console_identity_inspect_json_from_identity_status(
6081                                &status,
6082                                live_alias.as_ref(),
6083                                phase,
6084                            )),
6085                            None,
6086                        );
6087                    }
6088                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
6089                    Err(err) => {
6090                        return console_identity_error_response(
6091                            response_id,
6092                            "inspect_identity",
6093                            err,
6094                        );
6095                    }
6096                }
6097            }
6098            if let Some(aggregator) = &console_aggregator {
6099                return match Box::pin(aggregator.inspect_identity(identity)).await {
6100                    Ok(Some(inspection)) => {
6101                        let phase = if let Some(store) = &console_events {
6102                            store
6103                                .response_phase_for_identity(&inspection.identity.identity)
6104                                .await
6105                        } else {
6106                            None
6107                        };
6108                        response_value(
6109                            response_id,
6110                            Some(console_identity_inspect_json_from_record(
6111                                &inspection,
6112                                phase,
6113                            )),
6114                            None,
6115                        )
6116                    }
6117                    Ok(None) => response_value(
6118                        response_id,
6119                        None,
6120                        Some(JsonRpcError {
6121                            code: -32001,
6122                            message: format!("unknown identity: {identity}"),
6123                            data: None,
6124                        }),
6125                    ),
6126                    Err(err) => {
6127                        internal_error(response_id, format!("inspect_identity failed: {err}"))
6128                    }
6129                };
6130            }
6131            let live_alias = match lookup_member_alias_with_session(
6132                &handle,
6133                visibility_policy,
6134                identity,
6135            )
6136            .await
6137            {
6138                Ok(alias) => alias,
6139                Err(err) => return response_value(response_id, None, Some(err)),
6140            };
6141            let Some(alias) = live_alias else {
6142                return invalid_params(response_id, format!("identity not found: {identity}"));
6143            };
6144            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6145                return identity_hidden_by_policy_response(response_id, identity);
6146            }
6147            if let Err(err) =
6148                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6149            {
6150                return response_value(response_id, None, Some(err));
6151            }
6152            let phase = if let Some(store) = &console_events {
6153                store.response_phase_for_identity(&alias.identity).await
6154            } else {
6155                None
6156            };
6157            response_value(
6158                response_id,
6159                Some(console_identity_inspect_json_for_identity(
6160                    &alias.identity,
6161                    &alias.member,
6162                    alias.session_id,
6163                    phase,
6164                )),
6165                None,
6166            )
6167        }
6168        "mobkit/retire" => {
6169            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6170                return invalid_params(response_id, "identity required");
6171            };
6172            let handle = runtime.handle();
6173            if let Some(identity_runtime) = &identity_runtime {
6174                let (parsed_identity, _requested_exact_identity, live_alias) =
6175                    match resolve_console_identity_control_target(
6176                        &handle,
6177                        Some(identity_runtime),
6178                        visibility_policy,
6179                        identity,
6180                    )
6181                    .await
6182                    {
6183                        Ok(Some(target)) => target,
6184                        Ok(None) => {
6185                            return invalid_params(
6186                                response_id,
6187                                format!("identity not found: {identity}"),
6188                            );
6189                        }
6190                        Err(err) => return response_value(response_id, None, Some(err)),
6191                    };
6192                let registered_status = match identity_runtime.status(&parsed_identity).await {
6193                    Ok(status) => Some(status),
6194                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
6195                    Err(err) => {
6196                        return console_identity_error_response(response_id, "retire", err);
6197                    }
6198                };
6199                if let Some(status) = registered_status.as_ref() {
6200                    if !identity_status_visible_to_console(visibility_policy, status) {
6201                        return identity_hidden_by_policy_response(response_id, identity);
6202                    }
6203                    if let Some(error) = stale_live_alias_json_rpc_error(
6204                        "retire",
6205                        identity_runtime,
6206                        &parsed_identity,
6207                        live_alias.as_ref(),
6208                    )
6209                    .await
6210                    {
6211                        return response_value(response_id, None, Some(error));
6212                    }
6213                }
6214                match identity_runtime.retire(&parsed_identity).await {
6215                    Ok(token) => {
6216                        let keep_runtime_member_id = registered_status
6217                            .as_ref()
6218                            .and_then(|status| status.agent_runtime_id.as_ref())
6219                            .filter(|_| identity_runtime.has_session_bridge())
6220                            .map(crate::identity_first::AgentRuntimeId::as_str);
6221                        let cleanup_warning = if registered_status.is_some()
6222                            && let Err(err) = retire_stale_console_members_for_identity(
6223                                &handle,
6224                                visibility_policy,
6225                                parsed_identity.as_str(),
6226                                keep_runtime_member_id,
6227                            )
6228                            .await
6229                        {
6230                            Some(json!({
6231                                "kind": "stale_member_cleanup_failed_after_identity_retire",
6232                                "identity": parsed_identity.as_str(),
6233                                "message": err,
6234                            }))
6235                        } else {
6236                            None
6237                        };
6238                        if let Some(store) = &console_events {
6239                            store
6240                                .record_lifecycle(
6241                                    parsed_identity.as_str(),
6242                                    "identity_retired",
6243                                    json!({
6244                                        "fencing_token": token.get(),
6245                                        "cleanup_warning": cleanup_warning.clone(),
6246                                    }),
6247                                )
6248                                .await;
6249                        }
6250                        return response_value(
6251                            response_id,
6252                            Some(json!({
6253                                "identity": parsed_identity.as_str(),
6254                                "fencing_token": token.get(),
6255                                "cleanup_warning": cleanup_warning,
6256                            })),
6257                            None,
6258                        );
6259                    }
6260                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6261                        if let Some(alias) = live_alias.as_ref() {
6262                            if !runtime_alias_visible_to_console(&handle, visibility_policy, alias)
6263                            {
6264                                return identity_hidden_by_policy_response(response_id, identity);
6265                            }
6266                            let mid = crate::member_comms_id::mob_member_id(
6267                                alias.runtime_member_id.as_str(),
6268                            );
6269                            return match retire_console_member(&handle, &mid).await {
6270                                Ok(()) => {
6271                                    if let Some(store) = &console_events {
6272                                        store
6273                                            .record_lifecycle(
6274                                                &alias.identity,
6275                                                "identity_retired",
6276                                                json!({}),
6277                                            )
6278                                            .await;
6279                                    }
6280                                    response_value(
6281                                        response_id,
6282                                        Some(json!({ "identity": alias.identity })),
6283                                        None,
6284                                    )
6285                                }
6286                                Err(err) => {
6287                                    internal_error(response_id, format!("retire failed: {err}"))
6288                                }
6289                            };
6290                        }
6291                    }
6292                    Err(err) => return console_identity_error_response(response_id, "retire", err),
6293                }
6294            }
6295            if let Some(aggregator) = &console_aggregator {
6296                let canonical_identity = match Box::pin(aggregator.inspect_identity(identity)).await
6297                {
6298                    Ok(Some(inspection)) => inspection.identity.identity,
6299                    Ok(None) => identity.to_string(),
6300                    Err(_) => identity.to_string(),
6301                };
6302                return match Box::pin(aggregator.retire_identity(identity)).await {
6303                    Ok(true) => {
6304                        if let Some(store) = &console_events {
6305                            store
6306                                .record_lifecycle(
6307                                    &canonical_identity,
6308                                    "identity_retired",
6309                                    json!({}),
6310                                )
6311                                .await;
6312                        }
6313                        response_value(
6314                            response_id,
6315                            Some(json!({ "identity": canonical_identity })),
6316                            None,
6317                        )
6318                    }
6319                    Ok(false) => response_value(
6320                        response_id,
6321                        None,
6322                        Some(JsonRpcError {
6323                            code: -32001,
6324                            message: format!("unknown identity: {identity}"),
6325                            data: None,
6326                        }),
6327                    ),
6328                    Err(err) => internal_error(response_id, format!("retire failed: {err}")),
6329                };
6330            }
6331            let live_alias = match lookup_member_alias_with_session(
6332                &handle,
6333                visibility_policy,
6334                identity,
6335            )
6336            .await
6337            {
6338                Ok(alias) => alias,
6339                Err(err) => return response_value(response_id, None, Some(err)),
6340            };
6341            let Some(alias) = live_alias else {
6342                return invalid_params(response_id, format!("identity not found: {identity}"));
6343            };
6344            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6345                return identity_hidden_by_policy_response(response_id, identity);
6346            }
6347            if let Err(err) =
6348                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6349            {
6350                return response_value(response_id, None, Some(err));
6351            }
6352            let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
6353            match retire_console_member(&handle, &mid).await {
6354                Ok(()) => {
6355                    if let Some(store) = &console_events {
6356                        store
6357                            .record_lifecycle(&alias.identity, "identity_retired", json!({}))
6358                            .await;
6359                    }
6360                    response_value(
6361                        response_id,
6362                        Some(json!({ "identity": alias.identity })),
6363                        None,
6364                    )
6365                }
6366                Err(err) => internal_error(response_id, format!("retire failed: {err}")),
6367            }
6368        }
6369        "mobkit/respawn" => {
6370            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6371                return invalid_params(response_id, "identity required");
6372            };
6373            let handle = runtime.handle();
6374            if let Some(identity_runtime) = &identity_runtime {
6375                let (parsed_identity, _requested_exact_identity, live_alias) =
6376                    match resolve_console_identity_control_target(
6377                        &handle,
6378                        Some(identity_runtime),
6379                        visibility_policy,
6380                        identity,
6381                    )
6382                    .await
6383                    {
6384                        Ok(Some(target)) => target,
6385                        Ok(None) => {
6386                            return invalid_params(
6387                                response_id,
6388                                format!("identity not found: {identity}"),
6389                            );
6390                        }
6391                        Err(err) => return response_value(response_id, None, Some(err)),
6392                    };
6393                let registered_status = match identity_runtime.status(&parsed_identity).await {
6394                    Ok(status) => Some(status),
6395                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
6396                    Err(err) => {
6397                        return console_identity_error_response(response_id, "respawn", err);
6398                    }
6399                };
6400                if let Some(status) = registered_status.as_ref() {
6401                    if !identity_status_visible_to_console(visibility_policy, status) {
6402                        return identity_hidden_by_policy_response(response_id, identity);
6403                    }
6404                    if let Some(error) = stale_live_alias_json_rpc_error(
6405                        "respawn",
6406                        identity_runtime,
6407                        &parsed_identity,
6408                        live_alias.as_ref(),
6409                    )
6410                    .await
6411                    {
6412                        return response_value(response_id, None, Some(error));
6413                    }
6414                }
6415                match identity_runtime.respawn(&parsed_identity).await {
6416                    Ok(mut record) => {
6417                        let live_respawn_warning = match Box::pin(respawn_console_member(
6418                            &handle,
6419                            &crate::member_comms_id::mob_member_id(
6420                                record.agent_runtime_id.as_str(),
6421                            ),
6422                        ))
6423                        .await
6424                        {
6425                            Ok(topology_restore_warning) => {
6426                                let live_session_id = handle
6427                                    .resolve_bridge_session_id_observation(
6428                                        &crate::member_comms_id::mob_member_id(
6429                                            record.agent_runtime_id.as_str(),
6430                                        ),
6431                                    )
6432                                    .await;
6433                                if let Some(live_session_id) = live_session_id {
6434                                    match identity_runtime
6435                                        .rebind_session_after_live_respawn(
6436                                            &parsed_identity,
6437                                            live_session_id.clone(),
6438                                        )
6439                                        .await
6440                                    {
6441                                        Ok(updated_record) => {
6442                                            record = updated_record;
6443                                            topology_restore_warning
6444                                        }
6445                                        Err(err) => Some(json!({
6446                                            "kind": "identity_rebind_failed_after_member_respawn",
6447                                            "identity": record.identity.as_str(),
6448                                            "agent_runtime_id": record.agent_runtime_id.as_str(),
6449                                            "live_session_id": live_session_id.to_string(),
6450                                            "message": err.to_string(),
6451                                        })),
6452                                    }
6453                                } else {
6454                                    topology_restore_warning
6455                                }
6456                            }
6457                            Err(err) => Some(json!({
6458                                "kind": "member_respawn_failed_after_identity_refresh",
6459                                "identity": record.identity.as_str(),
6460                                "agent_runtime_id": record.agent_runtime_id.as_str(),
6461                                "message": err,
6462                            })),
6463                        };
6464                        let cleanup_warning = if registered_status.is_some()
6465                            && let Err(err) = retire_stale_console_members_for_identity(
6466                                &handle,
6467                                visibility_policy,
6468                                parsed_identity.as_str(),
6469                                Some(record.agent_runtime_id.as_str()),
6470                            )
6471                            .await
6472                        {
6473                            Some(json!({
6474                                "kind": "stale_member_cleanup_failed_after_identity_respawn",
6475                                "identity": parsed_identity.as_str(),
6476                                "agent_runtime_id": record.agent_runtime_id.as_str(),
6477                                "message": err,
6478                            }))
6479                        } else {
6480                            None
6481                        };
6482                        if let Some(store) = &console_events {
6483                            store
6484                                .record_lifecycle(
6485                                    parsed_identity.as_str(),
6486                                    "identity_respawned",
6487                                    json!({
6488                                        "generation": record.generation.get(),
6489                                        "checkpoint_version": record.checkpoint_version.get(),
6490                                        "live_respawn_warning": live_respawn_warning.clone(),
6491                                        "cleanup_warning": cleanup_warning.clone(),
6492                                    }),
6493                                )
6494                                .await;
6495                        }
6496                        return response_value(
6497                            response_id,
6498                            Some(json!({
6499                                "identity": record.identity.as_str(),
6500                                "agent_runtime_id": record.agent_runtime_id.as_str(),
6501                                "session_id": record.session_id.to_string(),
6502                                "generation": record.generation.get(),
6503                                "checkpoint_version": record.checkpoint_version.get(),
6504                                "live_respawn_warning": live_respawn_warning,
6505                                "cleanup_warning": cleanup_warning,
6506                            })),
6507                            None,
6508                        );
6509                    }
6510                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6511                        if live_alias.is_none() {
6512                            return invalid_params(
6513                                response_id,
6514                                format!("identity not found: {identity}"),
6515                            );
6516                        }
6517                    }
6518                    Err(err) => {
6519                        return console_identity_error_response(response_id, "respawn", err);
6520                    }
6521                }
6522            }
6523            let live_alias = match lookup_member_alias_with_session(
6524                &handle,
6525                visibility_policy,
6526                identity,
6527            )
6528            .await
6529            {
6530                Ok(alias) => alias,
6531                Err(err) => return response_value(response_id, None, Some(err)),
6532            };
6533            let Some(alias) = live_alias else {
6534                return invalid_params(response_id, format!("identity not found: {identity}"));
6535            };
6536            if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6537                return identity_hidden_by_policy_response(response_id, identity);
6538            }
6539            if let Err(err) =
6540                reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6541            {
6542                return response_value(response_id, None, Some(err));
6543            }
6544            Box::pin(respawn_live_console_member_response(
6545                &handle,
6546                console_events.as_ref(),
6547                &alias,
6548                "identity_respawned",
6549                "respawn",
6550                response_id,
6551            ))
6552            .await
6553        }
6554        "mobkit/reset" => {
6555            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6556                return invalid_params(response_id, "identity required");
6557            };
6558            let handle = runtime.handle();
6559            let Some(identity_runtime) = &identity_runtime else {
6560                // No identity-first runtime: degrade to the same live-member
6561                // respawn fallback the identity path applies to live-only
6562                // members (fresh session, same configuration). This keeps the
6563                // console Reset button working on plain runtimes, matching
6564                // the advertised `mobkit/reset` capability.
6565                let live_alias =
6566                    match lookup_member_alias_with_session(&handle, visibility_policy, identity)
6567                        .await
6568                    {
6569                        Ok(alias) => alias,
6570                        Err(err) => return response_value(response_id, None, Some(err)),
6571                    };
6572                let Some(alias) = live_alias else {
6573                    return invalid_params(response_id, format!("identity not found: {identity}"));
6574                };
6575                if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6576                    return identity_hidden_by_policy_response(response_id, identity);
6577                }
6578                if let Err(err) =
6579                    reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias)
6580                        .await
6581                {
6582                    return response_value(response_id, None, Some(err));
6583                }
6584                return Box::pin(respawn_live_console_member_response(
6585                    &handle,
6586                    console_events.as_ref(),
6587                    &alias,
6588                    "identity_reset",
6589                    "reset",
6590                    response_id,
6591                ))
6592                .await;
6593            };
6594            let (parsed_identity, _requested_exact_identity, live_alias) =
6595                match resolve_console_identity_control_target(
6596                    &handle,
6597                    Some(identity_runtime),
6598                    visibility_policy,
6599                    identity,
6600                )
6601                .await
6602                {
6603                    Ok(Some(target)) => target,
6604                    Ok(None) => {
6605                        return invalid_params(
6606                            response_id,
6607                            format!("identity not found: {identity}"),
6608                        );
6609                    }
6610                    Err(err) => return response_value(response_id, None, Some(err)),
6611                };
6612            match identity_runtime.status(&parsed_identity).await {
6613                Ok(status) => {
6614                    if !identity_status_visible_to_console(visibility_policy, &status) {
6615                        return identity_hidden_by_policy_response(response_id, identity);
6616                    }
6617                    if let Some(error) = stale_live_alias_json_rpc_error(
6618                        "reset",
6619                        identity_runtime,
6620                        &parsed_identity,
6621                        live_alias.as_ref(),
6622                    )
6623                    .await
6624                    {
6625                        return response_value(response_id, None, Some(error));
6626                    }
6627                    if !identity_runtime.has_session_bridge() {
6628                        return response_value(
6629                            response_id,
6630                            None,
6631                            Some(reset_requires_session_bridge_json_rpc_error()),
6632                        );
6633                    }
6634                }
6635                Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6636                    if let Some(alias) = live_alias.as_ref() {
6637                        if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6638                            return identity_hidden_by_policy_response(response_id, identity);
6639                        }
6640                        return Box::pin(respawn_live_console_member_response(
6641                            &handle,
6642                            console_events.as_ref(),
6643                            alias,
6644                            "identity_reset",
6645                            "reset",
6646                            response_id,
6647                        ))
6648                        .await;
6649                    }
6650                    return invalid_params(response_id, format!("identity not found: {identity}"));
6651                }
6652                Err(err) => return console_identity_error_response(response_id, "reset", err),
6653            }
6654            match identity_runtime.reset(&parsed_identity).await {
6655                Ok(record) => {
6656                    let cleanup_warning = Some(json!({
6657                        "kind": "stale_member_cleanup_skipped_after_identity_reset",
6658                        "identity": parsed_identity.as_str(),
6659                        "agent_runtime_id": record.agent_runtime_id.as_str(),
6660                        "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
6661                    }));
6662                    if let Some(store) = &console_events {
6663                        store
6664                            .record_lifecycle(
6665                                parsed_identity.as_str(),
6666                                "identity_reset",
6667                                json!({
6668                                    "generation": record.generation.get(),
6669                                    "checkpoint_version": record.checkpoint_version.get(),
6670                                    "cleanup_warning": cleanup_warning.clone(),
6671                                }),
6672                            )
6673                            .await;
6674                    }
6675                    response_value(
6676                        response_id,
6677                        Some(json!({
6678                            "identity": record.identity.as_str(),
6679                            "agent_runtime_id": record.agent_runtime_id.as_str(),
6680                            "session_id": record.session_id.to_string(),
6681                            "generation": record.generation.get(),
6682                            "checkpoint_version": record.checkpoint_version.get(),
6683                            "cleanup_warning": cleanup_warning,
6684                        })),
6685                        None,
6686                    )
6687                }
6688                Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6689                    invalid_params(response_id, format!("identity not found: {identity}"))
6690                }
6691                Err(err) => console_identity_error_response(response_id, "reset", err),
6692            }
6693        }
6694        "mobkit/delete_identity" => {
6695            let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6696                return invalid_params(response_id, "identity required");
6697            };
6698            let handle = runtime.handle();
6699            let Some(identity_runtime) = &identity_runtime else {
6700                return invalid_params(
6701                    response_id,
6702                    "identity-first runtime required for delete_identity",
6703                );
6704            };
6705            let (parsed_identity, _requested_exact_identity, live_alias) =
6706                match resolve_console_identity_control_target(
6707                    &handle,
6708                    Some(identity_runtime),
6709                    visibility_policy,
6710                    identity,
6711                )
6712                .await
6713                {
6714                    Ok(Some(target)) => target,
6715                    Ok(None) => {
6716                        return invalid_params(
6717                            response_id,
6718                            format!("identity not found: {identity}"),
6719                        );
6720                    }
6721                    Err(err) => return response_value(response_id, None, Some(err)),
6722                };
6723            let registered_status = match identity_runtime.status(&parsed_identity).await {
6724                Ok(status) => status,
6725                Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6726                    if let Some(alias) = live_alias.as_ref() {
6727                        if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6728                            return identity_hidden_by_policy_response(response_id, identity);
6729                        }
6730                        return response_value(
6731                            response_id,
6732                            None,
6733                            Some(JsonRpcError {
6734                                code: -32602,
6735                                message: format!(
6736                                    "delete_identity requires durable identity: {} is live-only",
6737                                    parsed_identity.as_str()
6738                                ),
6739                                data: Some(json!({
6740                                    "kind": "live_only_identity_delete_unsupported",
6741                                    "identity": parsed_identity.as_str(),
6742                                })),
6743                            }),
6744                        );
6745                    }
6746                    return invalid_params(response_id, format!("identity not found: {identity}"));
6747                }
6748                Err(err) => {
6749                    return console_identity_error_response(response_id, "delete_identity", err);
6750                }
6751            };
6752            if !identity_status_visible_to_console(visibility_policy, &registered_status) {
6753                return identity_hidden_by_policy_response(response_id, identity);
6754            }
6755            if let Some(error) = stale_live_alias_json_rpc_error(
6756                "delete_identity",
6757                identity_runtime,
6758                &parsed_identity,
6759                live_alias.as_ref(),
6760            )
6761            .await
6762            {
6763                return response_value(response_id, None, Some(error));
6764            }
6765            match identity_runtime.delete_identity(&parsed_identity).await {
6766                Ok(()) => {
6767                    let keep_runtime_member_id = registered_status
6768                        .agent_runtime_id
6769                        .as_ref()
6770                        .filter(|_| identity_runtime.has_session_bridge())
6771                        .map(crate::identity_first::AgentRuntimeId::as_str);
6772                    let cleanup_warning = if let Err(err) =
6773                        retire_stale_console_members_for_identity(
6774                            &handle,
6775                            visibility_policy,
6776                            parsed_identity.as_str(),
6777                            keep_runtime_member_id,
6778                        )
6779                        .await
6780                    {
6781                        Some(json!({
6782                            "kind": "stale_member_cleanup_failed_after_identity_delete",
6783                            "identity": parsed_identity.as_str(),
6784                            "message": err,
6785                        }))
6786                    } else {
6787                        None
6788                    };
6789                    if let Some(store) = &console_events {
6790                        store
6791                            .record_lifecycle(
6792                                parsed_identity.as_str(),
6793                                "identity_deleted",
6794                                json!({
6795                                    "cleanup_warning": cleanup_warning.clone(),
6796                                }),
6797                            )
6798                            .await;
6799                    }
6800                    response_value(
6801                        response_id,
6802                        Some(json!({
6803                            "identity": parsed_identity.as_str(),
6804                            "cleanup_warning": cleanup_warning,
6805                        })),
6806                        None,
6807                    )
6808                }
6809                Err(err) => console_identity_error_response(response_id, "delete_identity", err),
6810            }
6811        }
6812        "mobkit/reset_all" => {
6813            match Box::pin(reset_all_live_console_agents(
6814                runtime,
6815                console_events.as_ref(),
6816                console_aggregator.as_ref(),
6817                identity_runtime.as_ref(),
6818                visibility_policy,
6819            ))
6820            .await
6821            {
6822                Ok(body) => {
6823                    if body
6824                        .get("failed")
6825                        .and_then(Value::as_array)
6826                        .is_some_and(|failed| !failed.is_empty())
6827                    {
6828                        response_value(
6829                            response_id,
6830                            None,
6831                            Some(JsonRpcError {
6832                                code: -32000,
6833                                message: "reset_all failed for one or more identities".to_string(),
6834                                data: Some(body),
6835                            }),
6836                        )
6837                    } else {
6838                        response_value(response_id, Some(body), None)
6839                    }
6840                }
6841                Err(err) => internal_error(response_id, format!("reset_all failed: {err}")),
6842            }
6843        }
6844        "mobkit/routing/routes/list" => {
6845            let Some(module_runtime) = &module_runtime else {
6846                return response_value(
6847                    response_id,
6848                    None,
6849                    Some(JsonRpcError {
6850                        code: -32601,
6851                        message: "Method not found".to_string(),
6852                        data: None,
6853                    }),
6854                );
6855            };
6856            let routes = module_runtime.lock().await.list_runtime_routes();
6857            response_value(response_id, Some(json!({ "routes": routes })), None)
6858        }
6859        "mobkit/delivery/history" => {
6860            let Some(module_runtime) = &module_runtime else {
6861                return response_value(
6862                    response_id,
6863                    None,
6864                    Some(JsonRpcError {
6865                        code: -32601,
6866                        message: "Method not found".to_string(),
6867                        data: None,
6868                    }),
6869                );
6870            };
6871            let limit = request
6872                .params
6873                .get("limit")
6874                .and_then(Value::as_u64)
6875                .unwrap_or(50) as usize;
6876            let history = module_runtime
6877                .lock()
6878                .await
6879                .delivery_history(DeliveryHistoryRequest {
6880                    recipient: None,
6881                    sink: None,
6882                    limit,
6883                });
6884            response_value(
6885                response_id,
6886                Some(serde_json::to_value(history).unwrap_or(Value::Null)),
6887                None,
6888            )
6889        }
6890        "mobkit/gating/pending" => {
6891            let Some(module_runtime) = &module_runtime else {
6892                return response_value(
6893                    response_id,
6894                    None,
6895                    Some(JsonRpcError {
6896                        code: -32601,
6897                        message: "Method not found".to_string(),
6898                        data: None,
6899                    }),
6900                );
6901            };
6902            let pending = module_runtime.lock().await.list_gating_pending();
6903            response_value(response_id, Some(json!({ "pending": pending })), None)
6904        }
6905        "mobkit/gating/audit" => {
6906            let Some(module_runtime) = &module_runtime else {
6907                return response_value(
6908                    response_id,
6909                    None,
6910                    Some(JsonRpcError {
6911                        code: -32601,
6912                        message: "Method not found".to_string(),
6913                        data: None,
6914                    }),
6915                );
6916            };
6917            let limit = request
6918                .params
6919                .get("limit")
6920                .and_then(Value::as_u64)
6921                .unwrap_or(50) as usize;
6922            let entries = module_runtime.lock().await.gating_audit_entries(limit);
6923            response_value(response_id, Some(json!({ "entries": entries })), None)
6924        }
6925        "mobkit/gating/decide" => {
6926            let Some(module_runtime) = &module_runtime else {
6927                return response_value(
6928                    response_id,
6929                    None,
6930                    Some(JsonRpcError {
6931                        code: -32601,
6932                        message: "Method not found".to_string(),
6933                        data: None,
6934                    }),
6935                );
6936            };
6937            let Some(pending_id) = request.params.get("pending_id").and_then(Value::as_str) else {
6938                return invalid_params(response_id, "pending_id required");
6939            };
6940            let approver_id =
6941                match resolve_gating_approver_id(&request.params, authenticated_principal) {
6942                    Ok(approver_id) => approver_id,
6943                    Err(message) => return invalid_params(response_id, message),
6944                };
6945            let Some(raw_decision) = request.params.get("decision").and_then(Value::as_str) else {
6946                return invalid_params(response_id, "decision required");
6947            };
6948            let decision = match raw_decision {
6949                "approve" => GatingDecision::Approve,
6950                "reject" | "deny" => GatingDecision::Reject,
6951                "escalate" => GatingDecision::Escalate,
6952                _ => {
6953                    return invalid_params(
6954                        response_id,
6955                        format!("unsupported decision: {raw_decision}"),
6956                    );
6957                }
6958            };
6959            let reason = request
6960                .params
6961                .get("reason")
6962                .and_then(Value::as_str)
6963                .map(ToString::to_string);
6964            match module_runtime
6965                .lock()
6966                .await
6967                .decide_gating_action(GatingDecideRequest {
6968                    pending_id: pending_id.to_string(),
6969                    approver_id,
6970                    decision,
6971                    reason,
6972                }) {
6973                Ok(result) => response_value(
6974                    response_id,
6975                    Some(serde_json::to_value(result).unwrap_or(Value::Null)),
6976                    None,
6977                ),
6978                Err(err) => gating_decision_failed_error(response_id, err),
6979            }
6980        }
6981        "mobkit/ensure_member" => {
6982            let Some(role) = request.params.get("role").and_then(Value::as_str) else {
6983                return invalid_params(response_id, "role required");
6984            };
6985            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
6986            else {
6987                return invalid_params(response_id, "agent_identity required");
6988            };
6989            let labels = match request.params.get("labels") {
6990                None | Some(Value::Null) => std::collections::BTreeMap::new(),
6991                Some(value) => match serde_json::from_value(value.clone()) {
6992                    Ok(map) => map,
6993                    Err(err) => {
6994                        return invalid_params(response_id, format!("invalid labels: {err}"));
6995                    }
6996                },
6997            };
6998            let context = request.params.get("context").cloned();
6999            let resume_session_id = match request.params.get("resume_session_id") {
7000                None => None,
7001                Some(Value::Null) => None,
7002                Some(v) => match v.as_str() {
7003                    Some(s) => match meerkat_core::types::SessionId::parse(s) {
7004                        Ok(sid) => Some(sid),
7005                        Err(_) => {
7006                            return invalid_params(
7007                                response_id,
7008                                format!("invalid resume_session_id: {s:?}"),
7009                            );
7010                        }
7011                    },
7012                    None => {
7013                        return invalid_params(
7014                            response_id,
7015                            "resume_session_id must be a string".to_string(),
7016                        );
7017                    }
7018                },
7019            };
7020            let additional_instructions = match request.params.get("additional_instructions") {
7021                None | Some(Value::Null) => None,
7022                Some(Value::Array(arr)) => {
7023                    let mut strs = Vec::with_capacity(arr.len());
7024                    for (i, entry) in arr.iter().enumerate() {
7025                        match entry.as_str() {
7026                            Some(s) => strs.push(s.to_string()),
7027                            None => {
7028                                return invalid_params(
7029                                    response_id,
7030                                    format!("additional_instructions[{i}] must be a string"),
7031                                );
7032                            }
7033                        }
7034                    }
7035                    if strs.is_empty() { None } else { Some(strs) }
7036                }
7037                Some(_) => {
7038                    return invalid_params(
7039                        response_id,
7040                        "additional_instructions must be an array of strings",
7041                    );
7042                }
7043            };
7044            let runtime_mode = match parse_optional_runtime_mode(&request.params) {
7045                Ok(value) => value,
7046                Err(message) => return invalid_params(response_id, message),
7047            };
7048            let backend = match parse_optional_backend(&request.params) {
7049                Ok(value) => value,
7050                Err(message) => return invalid_params(response_id, message),
7051            };
7052            let binding = match parse_optional_runtime_binding(&request.params) {
7053                Ok(value) => value,
7054                Err(message) => return invalid_params(response_id, message),
7055            };
7056            // Identity-first gateways (ask K0): ensure_member upserts the
7057            // desired-identity roster and reconciles — the durable-identity
7058            // equivalent of the mob-member spawn, with the tolerant identity
7059            // lifecycle underneath (the ask-20 retire/respawn class does not
7060            // exist on this surface). Doctrine escape hatch: plane:"worker"
7061            // pins the spawn to the ephemeral mob plane (idle-retire reaping,
7062            // no continuity record) for helper churn that must not become a
7063            // durable identity.
7064            let worker_plane = matches!(
7065                request.params.get("plane").and_then(Value::as_str),
7066                Some("worker")
7067            );
7068            if !worker_plane
7069                && let (Some(identity_runtime_ref), Some(roster)) =
7070                    (identity_runtime.as_ref(), identity_roster.as_ref())
7071            {
7072                let identity = match crate::identity_first::AgentIdentity::parse(agent_identity) {
7073                    Ok(identity) => identity,
7074                    Err(err) => {
7075                        return invalid_params(
7076                            response_id,
7077                            format!("invalid agent_identity: {err}"),
7078                        );
7079                    }
7080                };
7081                if resume_session_id.is_some() {
7082                    return invalid_params(
7083                        response_id,
7084                        "resume_session_id is not supported on an identity-first \
7085                         gateway: continuity records own session resumption",
7086                    );
7087                }
7088                if binding.is_some() {
7089                    return invalid_params(
7090                        response_id,
7091                        "external runtime bindings are not supported on an \
7092                         identity-first gateway yet",
7093                    );
7094                }
7095                let spec = crate::identity_first::DurableAgentSpec {
7096                    identity: identity.clone(),
7097                    profile: ProfileName::from(role),
7098                    addressability: crate::identity_first::AgentAddressability::Addressable,
7099                    display_name: None,
7100                    labels,
7101                    context,
7102                    additional_instructions: additional_instructions.unwrap_or_default(),
7103                    initial_message: None,
7104                    runtime_mode_override: runtime_mode,
7105                    backend,
7106                    binding: None,
7107                };
7108                roster.upsert(spec);
7109                return match crate::identity_first::restore_flow(
7110                    identity_runtime_ref,
7111                    &roster.snapshot(),
7112                    None,
7113                    None,
7114                )
7115                .await
7116                {
7117                    Ok(result) => {
7118                        // Converge declared definition wiring after every
7119                        // materialization — upstream spawn-time wiring is
7120                        // bring-up-order dependent (HomeCore, 2026-07-09).
7121                        let _ = reconcile_console_topology(runtime, topology).await;
7122                        let outcome = match result.outcomes.get(&identity) {
7123                            Some(crate::identity_first::RestoreOutcome::Created { .. }) => {
7124                                "created"
7125                            }
7126                            Some(crate::identity_first::RestoreOutcome::Resumed { .. }) => {
7127                                "resumed"
7128                            }
7129                            Some(crate::identity_first::RestoreOutcome::Dormant { .. }) => {
7130                                "dormant"
7131                            }
7132                            Some(crate::identity_first::RestoreOutcome::Broken(_)) => "broken",
7133                            None => "unchanged",
7134                        };
7135                        let state = identity_runtime_ref
7136                            .status(&identity)
7137                            .await
7138                            .map(|status| status.state.wire_str().to_string())
7139                            .unwrap_or_else(|_| "unknown".to_string());
7140                        response_value(
7141                            response_id,
7142                            Some(serde_json::json!({
7143                                "agent_identity": identity.as_str(),
7144                                "role": role,
7145                                "identity_first": true,
7146                                "outcome": outcome,
7147                                "state": state,
7148                            })),
7149                            None,
7150                        )
7151                    }
7152                    Err(err) => {
7153                        internal_error(response_id, format!("ensure_member (identity): {err}"))
7154                    }
7155                };
7156            }
7157            let mut spec = SpawnMemberSpec::new(
7158                ProfileName::from(role),
7159                crate::member_comms_id::mob_member_id(agent_identity),
7160            );
7161            if let Some(runtime_mode) = runtime_mode {
7162                spec = spec.with_runtime_mode(runtime_mode);
7163            }
7164            if let Some(backend) = backend {
7165                spec = spec.with_backend(backend);
7166            }
7167            if let Some(binding) = binding {
7168                spec.binding = Some(binding);
7169            }
7170            if !labels.is_empty() {
7171                spec = spec.with_labels(labels);
7172            }
7173            if let Some(ctx) = context {
7174                spec = spec.with_context(ctx);
7175            }
7176            if let Some(sid) = resume_session_id {
7177                spec = spec.with_resume_bridge_session_id(sid);
7178            }
7179            if let Some(instructions) = additional_instructions {
7180                spec = spec.with_additional_instructions(instructions);
7181            }
7182            let handle = runtime.handle();
7183            let mid = spec.identity.clone();
7184            match handle.ensure_member(spec).await {
7185                Ok(_outcome) => {
7186                    let _ = reconcile_console_topology(runtime, topology).await;
7187                    let body = match lookup_member_with_session(&handle, &mid).await {
7188                        Some((entry, _sid)) => member_entry_to_json(&entry),
7189                        None => Value::Null,
7190                    };
7191                    response_value(response_id, Some(body), None)
7192                }
7193                Err(err) => internal_error(response_id, format!("ensure_member failed: {err}")),
7194            }
7195        }
7196        "mobkit/retire_member" => {
7197            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7198                return invalid_params(response_id, "member_id required");
7199            };
7200            if let Some(error) = stale_runtime_alias_json_rpc_error(
7201                "retire_member",
7202                identity_runtime.as_ref(),
7203                member_id,
7204            )
7205            .await
7206            {
7207                return response_value(response_id, None, Some(error));
7208            }
7209            if let (Some(identity_runtime_ref), Some(roster)) =
7210                (identity_runtime.as_ref(), identity_roster.as_ref())
7211            {
7212                let identity = match crate::identity_first::AgentIdentity::parse(member_id) {
7213                    Ok(identity) => identity,
7214                    Err(err) => {
7215                        return invalid_params(response_id, format!("invalid member_id: {err}"));
7216                    }
7217                };
7218                return match identity_runtime_ref.retire(&identity).await {
7219                    Ok(_token) => {
7220                        // Off the desired roster too, or the next reconcile
7221                        // (or the repair task) re-creates it.
7222                        roster.remove(&identity);
7223                        response_value(
7224                            response_id,
7225                            Some(serde_json::json!({
7226                                "accepted": true,
7227                                "identity_first": true,
7228                            })),
7229                            None,
7230                        )
7231                    }
7232                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
7233                        response_value(
7234                            response_id,
7235                            None,
7236                            Some(JsonRpcError {
7237                                code: -32001,
7238                                message: format!("unknown identity: {member_id}"),
7239                                data: None,
7240                            }),
7241                        )
7242                    }
7243                    Err(err) => {
7244                        internal_error(response_id, format!("retire_member (identity): {err}"))
7245                    }
7246                };
7247            }
7248            // Doctrine: identity-owned members retire through the identity
7249            // authority even when this console has no roster slot (builder-
7250            // constructed identity runtimes serve these arms too).
7251            if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7252                && let Some(durable) = identity_runtime_ref
7253                    .owned_identity_for_member_alias(member_id)
7254                    .await
7255            {
7256                return match identity_runtime_ref.retire(&durable).await {
7257                    Ok(_token) => response_value(
7258                        response_id,
7259                        Some(serde_json::json!({
7260                            "accepted": true,
7261                            "identity_first": true,
7262                        })),
7263                        None,
7264                    ),
7265                    Err(err) => {
7266                        internal_error(response_id, format!("retire_member (identity): {err}"))
7267                    }
7268                };
7269            }
7270            if let Some(aggregator) = &console_aggregator {
7271                return match Box::pin(aggregator.retire_identity(member_id)).await {
7272                    Ok(true) => response_value(
7273                        response_id,
7274                        Some(serde_json::json!({ "accepted": true })),
7275                        None,
7276                    ),
7277                    Ok(false) => response_value(
7278                        response_id,
7279                        None,
7280                        Some(JsonRpcError {
7281                            code: -32001,
7282                            message: format!("unknown identity: {member_id}"),
7283                            data: None,
7284                        }),
7285                    ),
7286                    Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
7287                };
7288            }
7289            // Parity with the SDK dispatcher and the identity-named console
7290            // paths: a completed-disposal cleanup miss reads as retired.
7291            match retire_console_member(
7292                &runtime.handle(),
7293                &crate::member_comms_id::mob_member_id(member_id),
7294            )
7295            .await
7296            {
7297                Ok(()) => response_value(
7298                    response_id,
7299                    Some(serde_json::json!({ "accepted": true })),
7300                    None,
7301                ),
7302                Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
7303            }
7304        }
7305        "mobkit/respawn_member" => {
7306            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7307                return invalid_params(response_id, "member_id required");
7308            };
7309            if let Some(error) = stale_runtime_alias_json_rpc_error(
7310                "respawn_member",
7311                identity_runtime.as_ref(),
7312                member_id,
7313            )
7314            .await
7315            {
7316                return response_value(response_id, None, Some(error));
7317            }
7318            if identity_roster.is_some()
7319                && let Some(identity_runtime_ref) = identity_runtime.as_ref()
7320            {
7321                let identity = match crate::identity_first::AgentIdentity::parse(member_id) {
7322                    Ok(identity) => identity,
7323                    Err(err) => {
7324                        return invalid_params(response_id, format!("invalid member_id: {err}"));
7325                    }
7326                };
7327                // Identity-first respawn = reset: retire the live session and
7328                // rebuild from the current roster spec under the same durable
7329                // identity (fresh session, new generation).
7330                return match identity_runtime_ref.reset(&identity).await {
7331                    Ok(record) => response_value(
7332                        response_id,
7333                        Some(serde_json::json!({
7334                            "accepted": true,
7335                            "identity_first": true,
7336                            "session_id": record.session_id.to_string(),
7337                            "generation": record.generation.get(),
7338                        })),
7339                        None,
7340                    ),
7341                    Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
7342                        response_value(
7343                            response_id,
7344                            None,
7345                            Some(JsonRpcError {
7346                                code: -32001,
7347                                message: format!("unknown identity: {member_id}"),
7348                                data: None,
7349                            }),
7350                        )
7351                    }
7352                    Err(err) => {
7353                        internal_error(response_id, format!("respawn_member (identity): {err}"))
7354                    }
7355                };
7356            }
7357            if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7358                && let Some(durable) = identity_runtime_ref
7359                    .owned_identity_for_member_alias(member_id)
7360                    .await
7361            {
7362                return match identity_runtime_ref.reset(&durable).await {
7363                    Ok(record) => response_value(
7364                        response_id,
7365                        Some(serde_json::json!({
7366                            "accepted": true,
7367                            "identity_first": true,
7368                            "session_id": record.session_id.to_string(),
7369                            "generation": record.generation.get(),
7370                        })),
7371                        None,
7372                    ),
7373                    Err(err) => {
7374                        internal_error(response_id, format!("respawn_member (identity): {err}"))
7375                    }
7376                };
7377            }
7378            // Parity with respawn_console_member's tolerance set: topology
7379            // warnings degrade, completed-disposal cleanup misses repair.
7380            match Box::pin(respawn_console_member(
7381                &runtime.handle(),
7382                &crate::member_comms_id::mob_member_id(member_id),
7383            ))
7384            .await
7385            {
7386                Ok(topology_restore_warning) => {
7387                    let mut body = serde_json::json!({ "accepted": true });
7388                    if let Some(warning) = topology_restore_warning {
7389                        body["topology_restore_warning"] = warning;
7390                    }
7391                    response_value(response_id, Some(body), None)
7392                }
7393                Err(err) => internal_error(response_id, format!("respawn_member failed: {err}")),
7394            }
7395        }
7396        "mobkit/reconcile_edges" => {
7397            // Previously a hardcoded noop ("console runtime routes directly
7398            // to MobRuntime") — which left declared definition wiring
7399            // unreconcilable from the console surface while the stdin
7400            // surface had the real handler (HomeCore, 2026-07-09).
7401            let report = reconcile_console_topology(runtime, topology).await;
7402            response_value(
7403                response_id,
7404                Some(serde_json::to_value(&report).unwrap_or(serde_json::Value::Null)),
7405                None,
7406            )
7407        }
7408        "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => {
7409            let query: EventQuery = if request.params.is_null() {
7410                EventQuery::default()
7411            } else {
7412                match serde_json::from_value(request.params.clone()) {
7413                    Ok(q) => q,
7414                    Err(err) => {
7415                        return invalid_params(response_id, format!("invalid query params: {err}"));
7416                    }
7417                }
7418            };
7419            let Some(store) = mob_events.as_ref() else {
7420                return response_value(
7421                    response_id,
7422                    Some(serde_json::json!({
7423                        "events": [],
7424                        "next_after_seq": Value::Null,
7425                    })),
7426                    None,
7427                );
7428            };
7429            let events_view = runtime.handle().events();
7430            // Capture latest_cursor at handshake so the SSE continuation
7431            // URL still covers the empty-snapshot case without losing
7432            // events between the JSON-RPC response and the SSE connect.
7433            let latest_at_handshake = events_view.latest_cursor().await.unwrap_or(0);
7434            let result = crate::unified_runtime::mob_events::query_ledger_with_filter(
7435                &events_view,
7436                store,
7437                &query,
7438            )
7439            .await;
7440            match result {
7441                Ok(mut events) => {
7442                    // `mob.observe` gates the surface; agent-attributed
7443                    // ledger entries are still filtered by `agent.view` so a
7444                    // mob.observe grant cannot reveal the lifecycle of an
7445                    // agent the caller is denied. Mob-level entries (no
7446                    // attribution) pass on the mob.observe grant alone.
7447                    if let Some(view) = access_view.filter(|view| view.enforced()) {
7448                        events.retain(|event| {
7449                            event
7450                                .agent_identity
7451                                .as_deref()
7452                                .is_none_or(|identity| view.can_view_agent(identity))
7453                        });
7454                    }
7455                    let last_cursor = events.last().map(|event| event.cursor);
7456                    let body = if request.method == "mobkit/mob_events/subscribe" {
7457                        let subscribe_url = crate::unified_runtime::mob_events::build_subscribe_url(
7458                            &query,
7459                            last_cursor,
7460                            latest_at_handshake,
7461                        );
7462                        serde_json::json!({
7463                            "stream": "mob_events",
7464                            "events": events,
7465                            "next_after_seq": last_cursor,
7466                            "subscribe_url": subscribe_url,
7467                            "keep_alive": {
7468                                "interval_ms": 15_000_u64,
7469                                "event": "keep_alive",
7470                            },
7471                        })
7472                    } else {
7473                        serde_json::json!({
7474                            "events": events,
7475                            "next_after_seq": last_cursor,
7476                        })
7477                    };
7478                    response_value(response_id, Some(body), None)
7479                }
7480                Err(crate::unified_runtime::mob_events::MobEventsQueryError::Stale {
7481                    after_cursor,
7482                    latest_cursor,
7483                }) => stale_event_cursor_response(response_id, after_cursor, latest_cursor),
7484                Err(err) => internal_error(response_id, format!("mob_events query failed: {err}")),
7485            }
7486        }
7487        // 0.5 API methods
7488        "mobkit/member_status" => {
7489            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7490                return invalid_params(response_id, "member_id required");
7491            };
7492            if let Some(error) = stale_runtime_alias_json_rpc_error(
7493                "member_status",
7494                identity_runtime.as_ref(),
7495                member_id,
7496            )
7497            .await
7498            {
7499                return response_value(response_id, None, Some(error));
7500            }
7501            match runtime
7502                .handle()
7503                .member_status(&crate::member_comms_id::mob_member_id(member_id))
7504                .await
7505            {
7506                Ok(snapshot) => response_value(
7507                    response_id,
7508                    Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7509                    None,
7510                ),
7511                Err(err) => internal_error(response_id, format!("member_status failed: {err}")),
7512            }
7513        }
7514        "mobkit/identity/resolved_tools" => {
7515            let Some(identity) = request
7516                .params
7517                .get("identity")
7518                .or_else(|| request.params.get("member_id"))
7519                .and_then(Value::as_str)
7520            else {
7521                return invalid_params(response_id, "identity required");
7522            };
7523            if let Some(error) = stale_runtime_alias_json_rpc_error(
7524                "identity_resolved_tools",
7525                identity_runtime.as_ref(),
7526                identity,
7527            )
7528            .await
7529            {
7530                return response_value(response_id, None, Some(error));
7531            }
7532            if let Some(identity_runtime) = identity_runtime.as_ref()
7533                && let Ok(parsed) = crate::identity_first::AgentIdentity::parse(identity)
7534                && let Ok(status) = identity_runtime.status(&parsed).await
7535                && let Some(session_id) = status.session_id
7536            {
7537                match resolved_tools_for_session(runtime.session_service(), identity, session_id)
7538                    .await
7539                {
7540                    Ok(snapshot) => {
7541                        return response_value(
7542                            response_id,
7543                            Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7544                            None,
7545                        );
7546                    }
7547                    Err(err) => {
7548                        return internal_error(
7549                            response_id,
7550                            format!("resolved_tools failed: {err}"),
7551                        );
7552                    }
7553                }
7554            }
7555            match resolved_tools_for_member(&runtime.handle(), runtime.session_service(), identity)
7556                .await
7557            {
7558                Ok(snapshot) => response_value(
7559                    response_id,
7560                    Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7561                    None,
7562                ),
7563                Err(err) => internal_error(response_id, format!("resolved_tools failed: {err}")),
7564            }
7565        }
7566        "mobkit/force_cancel_member" => {
7567            let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7568                return invalid_params(response_id, "member_id required");
7569            };
7570            if let Some(error) = stale_runtime_alias_json_rpc_error(
7571                "force_cancel_member",
7572                identity_runtime.as_ref(),
7573                member_id,
7574            )
7575            .await
7576            {
7577                return response_value(response_id, None, Some(error));
7578            }
7579            match runtime
7580                .handle()
7581                .force_cancel_member(crate::member_comms_id::mob_member_id(member_id))
7582                .await
7583            {
7584                Ok(()) => response_value(
7585                    response_id,
7586                    Some(serde_json::json!({ "accepted": true })),
7587                    None,
7588                ),
7589                Err(err) => {
7590                    internal_error(response_id, format!("force_cancel_member failed: {err}"))
7591                }
7592            }
7593        }
7594        "mobkit/wait_ready" => {
7595            // Omit `timeout_ms` => mobkit's generous default ceiling (the SDK
7596            // contract is "wait until ready"), not meerkat-mob 0.7.9's lowered
7597            // 60s internal default that `None` would otherwise inherit.
7598            let timeout = Some(
7599                request
7600                    .params
7601                    .get("timeout_ms")
7602                    .and_then(Value::as_u64)
7603                    .map(std::time::Duration::from_millis)
7604                    .unwrap_or(crate::unified_runtime::mob_ops::DEFAULT_WAIT_READY_TIMEOUT),
7605            );
7606            match runtime.handle().wait_for_ready(timeout).await {
7607                Ok(ready) => {
7608                    let entries: Vec<Value> = ready
7609                        .into_iter()
7610                        // Per-agent visibility: a caller only sees readiness
7611                        // for agents they may `agent.view`. The cache was
7612                        // primed from the roster at dispatch.
7613                        .filter_map(|(identity, snapshot)| {
7614                            let identity = identity.to_string();
7615                            let visible = access_view
7616                                .filter(|view| view.enforced())
7617                                .is_none_or(|view| view.can_view_agent(&identity));
7618                            visible.then(|| {
7619                                serde_json::json!({
7620                                    "agent_identity": identity,
7621                                    "snapshot": serde_json::to_value(&snapshot)
7622                                        .unwrap_or(Value::Null),
7623                                })
7624                            })
7625                        })
7626                        .collect();
7627                    response_value(
7628                        response_id,
7629                        Some(serde_json::json!({
7630                            "ready": entries,
7631                            "timeout": false,
7632                        })),
7633                        None,
7634                    )
7635                }
7636                Err(err) => {
7637                    if crate::unified_runtime::mob_ops::is_ready_wait_timeout(&err) {
7638                        response_value(
7639                            response_id,
7640                            Some(serde_json::json!({
7641                                "ready": Vec::<Value>::new(),
7642                                "timeout": true,
7643                            })),
7644                            None,
7645                        )
7646                    } else {
7647                        internal_error(response_id, format!("wait_for_ready failed: {err}"))
7648                    }
7649                }
7650            }
7651        }
7652        "mobkit/collect_completed" => {
7653            let completed = runtime.handle().collect_completed().await;
7654            let entries: Vec<Value> = completed
7655                .into_iter()
7656                // Per-agent visibility: even an `agent.spawn`-gated caller only
7657                // collects completions for agents they may `agent.view`.
7658                .filter_map(|(mid, snapshot)| {
7659                    let mid = mid.to_string();
7660                    let visible = access_view
7661                        .filter(|view| view.enforced())
7662                        .is_none_or(|view| view.can_view_agent(&mid));
7663                    visible.then(|| {
7664                        serde_json::json!({
7665                            "member_id": mid,
7666                            "snapshot": serde_json::to_value(&snapshot).unwrap_or(Value::Null),
7667                        })
7668                    })
7669                })
7670                .collect();
7671            response_value(
7672                response_id,
7673                Some(serde_json::json!({ "completed": entries })),
7674                None,
7675            )
7676        }
7677        "mobkit/cancel_flow" => {
7678            let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
7679                return invalid_params(response_id, "run_id required");
7680            };
7681            let run_id: meerkat_mob::RunId = match run_id.parse() {
7682                Ok(id) => id,
7683                Err(_) => return invalid_params(response_id, "invalid run_id format"),
7684            };
7685            match runtime.handle().cancel_flow(run_id).await {
7686                Ok(()) => response_value(
7687                    response_id,
7688                    Some(serde_json::json!({ "accepted": true })),
7689                    None,
7690                ),
7691                Err(err) => internal_error(response_id, format!("cancel_flow failed: {err}")),
7692            }
7693        }
7694        "mobkit/flow_status" => {
7695            let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
7696                return invalid_params(response_id, "run_id required");
7697            };
7698            let run_id: meerkat_mob::RunId = match run_id.parse() {
7699                Ok(id) => id,
7700                Err(_) => return invalid_params(response_id, "invalid run_id format"),
7701            };
7702            match runtime.handle().flow_status(run_id).await {
7703                Ok(Some(mob_run)) => response_value(
7704                    response_id,
7705                    Some(serde_json::to_value(&mob_run).unwrap_or(Value::Null)),
7706                    None,
7707                ),
7708                Ok(None) => response_value(response_id, Some(Value::Null), None),
7709                Err(err) => internal_error(response_id, format!("flow_status failed: {err}")),
7710            }
7711        }
7712        "mobkit/list_flows" => {
7713            let flows: Vec<String> = runtime
7714                .handle()
7715                .list_flows()
7716                .into_iter()
7717                .map(|id| id.to_string())
7718                .collect();
7719            response_value(
7720                response_id,
7721                Some(serde_json::json!({ "flows": flows })),
7722                None,
7723            )
7724        }
7725        "mobkit/list_runs" => {
7726            let flow_id = request
7727                .params
7728                .get("flow_id")
7729                .and_then(Value::as_str)
7730                .filter(|value| !value.is_empty())
7731                .map(meerkat_mob::FlowId::from);
7732            match runtime.handle().list_runs(flow_id.as_ref()).await {
7733                Ok(runs) => response_value(
7734                    response_id,
7735                    Some(serde_json::json!({
7736                        "runs": serde_json::to_value(&runs).unwrap_or(Value::Null),
7737                    })),
7738                    None,
7739                ),
7740                Err(err) => internal_error(response_id, format!("list_runs failed: {err}")),
7741            }
7742        }
7743        "mobkit/run_flow" => {
7744            let Some(flow_id_str) = request.params.get("flow_id").and_then(Value::as_str) else {
7745                return invalid_params(response_id, "flow_id required");
7746            };
7747            if flow_id_str.is_empty() {
7748                return invalid_params(response_id, "flow_id required");
7749            }
7750            let flow_id = meerkat_mob::FlowId::from(flow_id_str);
7751            let flow_params = request.params.get("params").cloned().unwrap_or(Value::Null);
7752            if let Some(identity_runtime) = &identity_runtime
7753                && let Err(err) = identity_runtime.materialize_all_required().await
7754            {
7755                return internal_error(
7756                    response_id,
7757                    format!("identity-first flow materialization failed: {err}"),
7758                );
7759            }
7760            match Box::pin(runtime.handle().run_flow(flow_id, flow_params)).await {
7761                Ok(run_id) => response_value(
7762                    response_id,
7763                    Some(serde_json::json!({ "run_id": run_id.to_string() })),
7764                    None,
7765                ),
7766                Err(err) => invalid_params(response_id, format!("run_flow failed: {err}")),
7767            }
7768        }
7769        "mobkit/spawn_helper" => {
7770            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7771            else {
7772                return invalid_params(response_id, "agent_identity required");
7773            };
7774            let Some(task) = request.params.get("task").and_then(Value::as_str) else {
7775                return invalid_params(response_id, "task required");
7776            };
7777            let options = match parse_console_helper_options(request.params.get("options")) {
7778                Ok(opts) => opts,
7779                Err(msg) => return invalid_params(response_id, msg),
7780            };
7781            let handle = runtime.handle();
7782            match Box::pin(handle.spawn_helper(
7783                crate::member_comms_id::mob_member_id(agent_identity),
7784                task,
7785                options,
7786            ))
7787            .await
7788            {
7789                Ok(result) => {
7790                    // Meerkat 0.6 retires the helper before `spawn_helper`
7791                    // returns, so a post-hoc `resolve_bridge_session_id`
7792                    // call would come back `None`. We drop `session_id`
7793                    // from the response rather than emit a misleading null.
7794                    response_value(
7795                        response_id,
7796                        Some(serde_json::json!({
7797                            "output": result.output,
7798                            "tokens_used": result.tokens_used,
7799                        })),
7800                        None,
7801                    )
7802                }
7803                Err(err) => internal_error(response_id, format!("spawn_helper failed: {err}")),
7804            }
7805        }
7806        "mobkit/fork_helper" => {
7807            let Some(source) = request
7808                .params
7809                .get("source_member_id")
7810                .and_then(Value::as_str)
7811            else {
7812                return invalid_params(response_id, "source_member_id required");
7813            };
7814            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7815            else {
7816                return invalid_params(response_id, "agent_identity required");
7817            };
7818            let Some(task) = request.params.get("task").and_then(Value::as_str) else {
7819                return invalid_params(response_id, "task required");
7820            };
7821            let fork_context = match request.params.get("fork_context") {
7822                Some(v) if !v.is_null() => {
7823                    match serde_json::from_value::<meerkat_mob::launch::ForkContext>(v.clone()) {
7824                        Ok(ctx) => ctx,
7825                        Err(err) => {
7826                            return invalid_params(
7827                                response_id,
7828                                format!("invalid fork_context: {err}"),
7829                            );
7830                        }
7831                    }
7832                }
7833                _ => meerkat_mob::launch::ForkContext::default(),
7834            };
7835            let options = match parse_console_helper_options(request.params.get("options")) {
7836                Ok(opts) => opts,
7837                Err(msg) => return invalid_params(response_id, msg),
7838            };
7839            let handle = runtime.handle();
7840            match Box::pin(handle.fork_helper(
7841                &crate::member_comms_id::mob_member_id(source),
7842                crate::member_comms_id::mob_member_id(agent_identity),
7843                task,
7844                fork_context,
7845                options,
7846            ))
7847            .await
7848            {
7849                Ok(result) => {
7850                    // See `spawn_helper`: meerkat 0.6 retires the forked
7851                    // helper before returning, so session_id is omitted
7852                    // rather than silently null.
7853                    response_value(
7854                        response_id,
7855                        Some(serde_json::json!({
7856                            "output": result.output,
7857                            "tokens_used": result.tokens_used,
7858                        })),
7859                        None,
7860                    )
7861                }
7862                Err(err) => internal_error(response_id, format!("fork_helper failed: {err}")),
7863            }
7864        }
7865        "mobkit/attach_existing_session" => {
7866            let Some(role) = request.params.get("role").and_then(Value::as_str) else {
7867                return invalid_params(response_id, "role required");
7868            };
7869            let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7870            else {
7871                return invalid_params(response_id, "agent_identity required");
7872            };
7873            let Some(session_id_str) = request.params.get("session_id").and_then(Value::as_str)
7874            else {
7875                return invalid_params(response_id, "session_id required");
7876            };
7877            let bridge_session_id = match meerkat_core::types::SessionId::parse(session_id_str) {
7878                Ok(s) => s,
7879                Err(_) => return invalid_params(response_id, "invalid session_id format"),
7880            };
7881            let mid = crate::member_comms_id::mob_member_id(agent_identity);
7882            let spec = SpawnMemberSpec::new(ProfileName::from(role), mid.clone())
7883                .with_launch_mode(MemberLaunchMode::Resume { bridge_session_id });
7884            let handle = runtime.handle();
7885            match Box::pin(handle.spawn_spec(spec)).await {
7886                Ok(_) => match handle.member_status(&mid).await {
7887                    Ok(snapshot) => response_value(
7888                        response_id,
7889                        Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7890                        None,
7891                    ),
7892                    Err(err) => internal_error(
7893                        response_id,
7894                        format!("attach_existing_session status lookup failed: {err}"),
7895                    ),
7896                },
7897                Err(err) => internal_error(
7898                    response_id,
7899                    format!("attach_existing_session failed: {err}"),
7900                ),
7901            }
7902        }
7903        "mobkit/cross_mob/wire_local" => {
7904            handle_console_wire_local(runtime, &request.params, response_id, true).await
7905        }
7906        "mobkit/cross_mob/unwire_local" => {
7907            handle_console_wire_local(runtime, &request.params, response_id, false).await
7908        }
7909        "mobkit/peer_pubkey" => match gateway_peer_keys {
7910            Some(keys) => response_value(
7911                response_id,
7912                Some(serde_json::json!({ "pubkey_b64": keys.pubkey_b64() })),
7913                None,
7914            ),
7915            None => response_value(
7916                response_id,
7917                None,
7918                Some(JsonRpcError {
7919                    code: -32004,
7920                    message: "gateway has no signing keypair configured".to_string(),
7921                    data: None,
7922                }),
7923            ),
7924        },
7925        "mobkit/cross_mob/peer_info" => {
7926            let member_id = request.params.get("member_id").and_then(Value::as_str);
7927            match member_id {
7928                Some(mid) if !mid.is_empty() => {
7929                    let handle = runtime.handle();
7930                    let mob_id = handle.mob_id().to_string();
7931                    let meerkat_id = crate::member_comms_id::mob_member_id(mid);
7932                    match handle.get_member(&meerkat_id).await {
7933                        Err(err) => response_value(
7934                            response_id,
7935                            None,
7936                            Some(JsonRpcError {
7937                                code: -32000,
7938                                message: format!("member lookup failed: {err}"),
7939                                data: None,
7940                            }),
7941                        ),
7942                        Ok(Some(entry)) => match entry.peer_id() {
7943                            Some(peer_id) => {
7944                                let comms_name = format!("{}/{}/{}", mob_id, entry.role, mid);
7945                                let address = format!("inproc://{comms_name}");
7946                                response_value(
7947                                    response_id,
7948                                    Some(serde_json::json!({
7949                                        "member_id": mid,
7950                                        "mob_id": mob_id,
7951                                        "comms_name": comms_name,
7952                                        "peer_id": peer_id,
7953                                        "address": address,
7954                                    })),
7955                                    None,
7956                                )
7957                            }
7958                            None => response_value(
7959                                response_id,
7960                                None,
7961                                Some(JsonRpcError {
7962                                    code: -32000,
7963                                    message: format!("member {mid:?} has no comms runtime"),
7964                                    data: None,
7965                                }),
7966                            ),
7967                        },
7968                        Ok(None) => response_value(
7969                            response_id,
7970                            None,
7971                            Some(JsonRpcError {
7972                                code: -32000,
7973                                message: format!("member {mid:?} not found"),
7974                                data: None,
7975                            }),
7976                        ),
7977                    }
7978                }
7979                _ => invalid_params(response_id, "member_id required".to_string()),
7980            }
7981        }
7982        "mobkit/cross_mob/directory" => {
7983            let entries: Vec<Value> = contact_directory
7984                .map(|dir| {
7985                    dir.list()
7986                        .into_iter()
7987                        .filter_map(|e| serde_json::to_value(e).ok())
7988                        .collect()
7989                })
7990                .unwrap_or_default();
7991            response_value(
7992                response_id,
7993                Some(serde_json::json!({ "mobs": entries })),
7994                None,
7995            )
7996        }
7997        method
7998            if matches!(
7999                method,
8000                "mobkit/mob_labels/set"
8001                    | "mobkit/mob_labels/get"
8002                    | "mobkit/mob_labels/delete"
8003                    | "mobkit/run_labels/set"
8004                    | "mobkit/run_labels/get"
8005                    | "mobkit/run_labels/delete",
8006            ) =>
8007        {
8008            dispatch_label_method(
8009                method,
8010                metadata_table.as_deref(),
8011                runtime.handle().mob_id().as_str(),
8012                response_id,
8013                &request.params,
8014            )
8015            .await
8016        }
8017        method if crate::rpc::workgraph_methods::is_workgraph_method(method) => {
8018            // The read-only gate and the workgraph.view/manage ABAC checks
8019            // already ran above; the surface carries the authenticated
8020            // console principal, which goal/confirm promotes into the
8021            // trusted confirmation seam and break_glass_reassign records as
8022            // the audited operator. The admission always comes from the
8023            // runtime (never a parameter): whatever constructed the service,
8024            // the unified stdin surface acquires the runtime's admission, so
8025            // the console must acquire that same one.
8026            let admission = runtime.workgraph_admission();
8027            match crate::rpc::workgraph_methods::handle_workgraph_method(
8028                workgraph,
8029                &admission,
8030                crate::rpc::workgraph_methods::WorkgraphSurface::Console {
8031                    authenticated_principal,
8032                },
8033                method,
8034                &request.params,
8035            )
8036            .await
8037            {
8038                Ok(result) => response_value(response_id, Some(result), None),
8039                Err(error) => response_value(response_id, None, Some(error)),
8040            }
8041        }
8042        _ => response_value(
8043            response_id,
8044            None,
8045            Some(JsonRpcError {
8046                code: -32601,
8047                message: "Method not found".to_string(),
8048                data: None,
8049            }),
8050        ),
8051    }
8052}
8053
8054async fn reconcile_console_topology(
8055    runtime: &MobRuntime,
8056    topology: Option<&crate::topology_control::TopologyRuntimeHandle>,
8057) -> crate::unified_runtime::UnifiedRuntimeReconcileEdgesReport {
8058    match topology {
8059        Some(topology) => topology.reconcile().await,
8060        None => crate::unified_runtime::edge_reconcile::reconcile_definition_edges(runtime).await,
8061    }
8062}
8063
8064/// Dispatch the six `mobkit/{mob,run}_labels/*` RPCs against a metadata table.
8065///
8066/// Single entrypoint shared by every label method; the dispatch arms in
8067/// `handle_console_runtime_rpc` simply delegate based on the matched method
8068/// name. Mirrors the unified-runtime handlers in `rpc::mob_methods` — both
8069/// transports project the same outcomes to the same wire shape.
8070async fn dispatch_label_method(
8071    method: &str,
8072    metadata_table: Option<&RuntimeMetadataTable>,
8073    mob_id: &str,
8074    response_id: Value,
8075    params: &Value,
8076) -> Value {
8077    let Some(table) = metadata_table else {
8078        return invalid_params(
8079            response_id,
8080            "metadata table not configured for this runtime",
8081        );
8082    };
8083
8084    let scope = match method {
8085        "mobkit/mob_labels/set" | "mobkit/mob_labels/get" | "mobkit/mob_labels/delete" => {
8086            MetadataScope::Mob(mob_id.to_string())
8087        }
8088        _ => match crate::runtime::parse_run_id_param(params) {
8089            Ok(run_id) => MetadataScope::Run(mob_id.to_string(), run_id.to_string()),
8090            Err(message) => return invalid_params(response_id, message),
8091        },
8092    };
8093
8094    let outcome = match method {
8095        "mobkit/mob_labels/set" | "mobkit/run_labels/set" => {
8096            crate::runtime::dispatch_labels_set(table, scope, params).await
8097        }
8098        "mobkit/mob_labels/get" | "mobkit/run_labels/get" => {
8099            crate::runtime::dispatch_labels_get(table, scope).await
8100        }
8101        "mobkit/mob_labels/delete" | "mobkit/run_labels/delete" => {
8102            crate::runtime::dispatch_labels_delete(table, scope).await
8103        }
8104        _ => unreachable!("dispatch_label_method called with non-label method: {method}"),
8105    };
8106
8107    match outcome {
8108        crate::runtime::LabelRpcResult::Accepted => response_value(
8109            response_id,
8110            Some(serde_json::json!({"accepted": true})),
8111            None,
8112        ),
8113        crate::runtime::LabelRpcResult::Labels(labels) => response_value(
8114            response_id,
8115            Some(serde_json::json!({"labels": labels_to_json_value(&labels)})),
8116            None,
8117        ),
8118        crate::runtime::LabelRpcResult::InvalidParams(message) => {
8119            invalid_params(response_id, message)
8120        }
8121    }
8122}
8123
8124/// Shared body for `mobkit/cross_mob/wire_local` and `unwire_local` over
8125/// the console transport. `wire = true` calls `MobHandle::wire`, `false`
8126/// calls `MobHandle::unwire`. Both share param parsing and response shape.
8127///
8128/// Non-inproc transports (`tcp://`, `uds://`) require a non-zero pubkey;
8129/// the caller may supply it via `remote_pubkey_b64` or rely on TOFU
8130/// flows configured at the contact-directory layer (which this handler
8131/// does not consult — it only sees the explicit params).
8132async fn handle_console_wire_local(
8133    runtime: &MobRuntime,
8134    params: &Value,
8135    response_id: Value,
8136    wire: bool,
8137) -> Value {
8138    let local = params.get("local_member_id").and_then(Value::as_str);
8139    let comms_name = params.get("remote_comms_name").and_then(Value::as_str);
8140    let peer_id = params.get("remote_peer_id").and_then(Value::as_str);
8141    let addr = params.get("remote_address").and_then(Value::as_str);
8142
8143    let remote_pubkey = match params.get("remote_pubkey_b64") {
8144        None => None,
8145        Some(v) if v.is_null() => None,
8146        Some(v) => match v.as_str() {
8147            Some(s) if !s.is_empty() => match crate::auth::peer_keys::decode_pubkey_b64(s) {
8148                Ok(bytes) => Some(bytes),
8149                Err(err) => {
8150                    return invalid_params(response_id, format!("remote_pubkey_b64: {err}"));
8151                }
8152            },
8153            _ => None,
8154        },
8155    };
8156
8157    let (local_id, cname, pid, address) = match (local, comms_name, peer_id, addr) {
8158        (Some(l), Some(c), Some(p), Some(a))
8159            if !l.is_empty() && !c.is_empty() && !p.is_empty() && !a.is_empty() =>
8160        {
8161            (l, c, p, a)
8162        }
8163        _ => {
8164            return invalid_params(
8165                response_id,
8166                "local_member_id, remote_comms_name, remote_peer_id, and remote_address required",
8167            );
8168        }
8169    };
8170
8171    let is_inproc = address.starts_with("inproc://");
8172    let spec_result = match (is_inproc, remote_pubkey) {
8173        (true, None) => TrustedPeerDescriptor::test_only_unsigned(cname, pid, address),
8174        (true, Some(bytes)) => {
8175            TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
8176        }
8177        (false, None) => {
8178            return invalid_params(
8179                response_id,
8180                "remote_pubkey_b64 is required for non-inproc transports",
8181            );
8182        }
8183        (false, Some(bytes)) => {
8184            if bytes == [0u8; 32] {
8185                return invalid_params(
8186                    response_id,
8187                    "remote_pubkey_b64 must be non-zero for non-inproc transports",
8188                );
8189            }
8190            TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
8191        }
8192    };
8193
8194    let spec = match spec_result {
8195        Ok(spec) => spec,
8196        Err(err) => {
8197            return invalid_params(response_id, format!("invalid peer spec: {err}"));
8198        }
8199    };
8200
8201    let result = if wire {
8202        runtime
8203            .handle()
8204            .wire(
8205                crate::member_comms_id::mob_member_id(local_id),
8206                PeerTarget::External(spec),
8207            )
8208            .await
8209    } else {
8210        runtime
8211            .handle()
8212            .unwire(
8213                crate::member_comms_id::mob_member_id(local_id),
8214                PeerTarget::External(spec),
8215            )
8216            .await
8217    };
8218
8219    let action = if wire { "wire_local" } else { "unwire_local" };
8220    match result {
8221        Ok(()) => response_value(
8222            response_id,
8223            Some(serde_json::json!({
8224                "accepted": true,
8225                "local_member_id": local_id,
8226                "remote_comms_name": cname,
8227            })),
8228            None,
8229        ),
8230        Err(err) => internal_error(response_id, format!("cross_mob/{action} failed: {err}")),
8231    }
8232}
8233
8234async fn build_live_snapshot(
8235    runtime: &MobRuntime,
8236    config_module_ids: &[String],
8237    console_events: Option<&ConsoleEventStore>,
8238    visibility_policy: &dyn ConsoleVisibilityPolicy,
8239    read_model: &ConsoleSnapshotReadModel,
8240) -> ConsoleLiveSnapshot {
8241    let read_model_state = Box::pin(read_model.snapshot(runtime)).await;
8242    let running = read_model_state.running.unwrap_or(true);
8243    // Hot path: clone the pre-projected members from the cached read
8244    // model. NO `handle.*` async calls happen here — the background
8245    // refresh task is the only thing that walks the mob roster, so
8246    // snapshot requests never contend with spawn/retire activity.
8247    // First request on a cold cache pays one synchronous refresh via
8248    // `snapshot(runtime).await` above; subsequent requests just clone.
8249    let mut members = read_model_state.primary_members.clone();
8250    if visibility_policy.include_implicit_delegate_members() {
8251        for group in &read_model_state.delegate_member_groups {
8252            members.extend(group.iter().cloned());
8253        }
8254    }
8255    dedupe_console_members_by_identity(&mut members);
8256    members.retain(|member| {
8257        visibility_policy.member_visible(member)
8258            && visibility_policy
8259                .identity_visible(&console_identity_record_from_console_member(member))
8260    });
8261
8262    // Use configured module IDs when available because topology and health
8263    // surfaces describe loaded modules, not live mob members.
8264    // Fall back to member IDs only for pure mob runtimes with no module config.
8265    let loaded_modules = if config_module_ids.is_empty() {
8266        let mut mods: Vec<String> = members
8267            .iter()
8268            .filter(|member| member.state != MEMBER_STATE_RETIRING)
8269            .map(|member| member.agent_identity.clone())
8270            .collect();
8271        mods.sort();
8272        mods
8273    } else {
8274        let mut mods = config_module_ids.to_vec();
8275        mods.sort();
8276        mods
8277    };
8278
8279    let agents = members
8280        .iter()
8281        .map(|member| async move {
8282            let console_identity = console_member_console_identity(member);
8283            let label = member
8284                .labels
8285                .get("display_name")
8286                .cloned()
8287                .unwrap_or_else(|| member.agent_identity.clone());
8288            let watched = member
8289                .labels
8290                .get("console_watched")
8291                .map(|value: &String| value == "true");
8292            let alert_level = member
8293                .labels
8294                .get("console_alert_level")
8295                .filter(|value: &&String| matches!(value.as_str(), "elevated" | "critical"))
8296                .cloned();
8297            let degraded = member
8298                .labels
8299                .get("console_degraded")
8300                .map(|value: &String| value == "true");
8301            let degraded_reason = member.labels.get("console_degraded_reason").cloned();
8302            let response_phase = match console_events {
8303                Some(store) => store.response_phase_for_identity(console_identity).await,
8304                None => None,
8305            };
8306            ConsoleAgentLiveSnapshot {
8307                agent_id: member.agent_identity.clone(),
8308                member_id: member.agent_identity.clone(),
8309                label,
8310                kind: "meerkat".to_string(),
8311                identity: Some(console_identity.to_string()),
8312                role: Some(member.role.clone()),
8313                state: Some(member.state.clone()),
8314                session_id: member.session_id.clone(),
8315                model_capabilities: member.model_capabilities.clone(),
8316                response_phase,
8317                watched,
8318                alert_level,
8319                degraded,
8320                degraded_reason,
8321            }
8322        })
8323        .collect::<Vec<_>>();
8324    let mut agents = join_all(agents).await;
8325    agents.sort_by(|left, right| left.label.cmp(&right.label));
8326    ConsoleLiveSnapshot::new(
8327        Some(runtime.handle().mob_id().to_string()),
8328        running,
8329        loaded_modules,
8330        agents,
8331        members,
8332        true,
8333    )
8334}
8335
8336async fn collect_console_snapshot_read_model(
8337    runtime: &MobRuntime,
8338) -> ConsoleSnapshotReadModelState {
8339    let handle = runtime.handle();
8340    let mut state = ConsoleSnapshotReadModelState {
8341        running: Some(matches!(
8342            handle.status_observation_snapshot(),
8343            MobState::Creating | MobState::Running
8344        )),
8345        ..ConsoleSnapshotReadModelState::default()
8346    };
8347    collect_console_session_index_for_handle(&handle, &mut state).await;
8348    let registered_labels = runtime.console_identity_labels().await;
8349
8350    // Snapshot + project the primary mob into the cache. Done here
8351    // under the background refresh lock so per-request
8352    // `build_live_snapshot` calls never need to enter MobHandle async
8353    // methods. The session-id index in `state` was populated above by
8354    // `collect_console_session_index_for_handle`.
8355    let (primary_members, _primary_owner_index) =
8356        project_console_members_from_handle(&handle, None, None, &state, &registered_labels).await;
8357    state.primary_members = primary_members;
8358
8359    let Some(mcp_state) = runtime.agent_mob_mcp_state() else {
8360        return state;
8361    };
8362    let primary_mob_id = handle.mob_id().to_string();
8363    let mut processed = BTreeSet::from([primary_mob_id]);
8364    let mut delegate_groups: Vec<Vec<ConsoleMember>> = Vec::new();
8365    loop {
8366        let mut progressed = false;
8367        for (mob_id, delegate_handle) in Box::pin(mcp_state.mob_handles_snapshot())
8368            .await
8369            .unwrap_or_default()
8370        {
8371            if processed.contains(mob_id.as_str()) {
8372                continue;
8373            }
8374            // Meerkat 0.7: the owner bridge binding is machine-owned state,
8375            // no longer a definition-level index.
8376            let Some(owner_authority) = delegate_handle.owner_bridge_session_lifecycle_authority()
8377            else {
8378                processed.insert(mob_id.to_string());
8379                continue;
8380            };
8381            let owner_session_id = owner_authority.bridge_session_id.to_string();
8382            let Some(host_identity) = state
8383                .session_owner_by_id
8384                .get(owner_session_id.as_str())
8385                .cloned()
8386            else {
8387                continue;
8388            };
8389            collect_console_session_index_for_handle(&delegate_handle, &mut state).await;
8390            let (delegate_members, _delegate_owner_index) = project_console_members_from_handle(
8391                &delegate_handle,
8392                Some(&host_identity),
8393                Some(mob_id.as_str()),
8394                &state,
8395                &registered_labels,
8396            )
8397            .await;
8398            delegate_groups.push(delegate_members);
8399            processed.insert(mob_id.to_string());
8400            progressed = true;
8401        }
8402        if !progressed {
8403            break;
8404        }
8405    }
8406    state.delegate_member_groups = delegate_groups;
8407    state
8408}
8409
8410async fn collect_console_session_index_for_handle(
8411    handle: &MobHandle,
8412    state: &mut ConsoleSnapshotReadModelState,
8413) {
8414    for entry in handle.list_members_observation_snapshot().await {
8415        // Session read-model keys live in the public alias space (decoded
8416        // from the comms-safe roster id, meerkat 0.7).
8417        let identity =
8418            crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
8419        let Some(session_id) = handle
8420            .resolve_bridge_session_id_observation(&entry.agent_identity)
8421            .await
8422            .map(|session_id| session_id.to_string())
8423        else {
8424            state.session_id_by_identity.remove(&identity);
8425            continue;
8426        };
8427        state
8428            .session_owner_by_id
8429            .insert(session_id.clone(), identity.clone());
8430        state.session_id_by_identity.insert(identity, session_id);
8431    }
8432}
8433
8434fn apply_console_visibility_policy(
8435    snapshot: &mut ConsoleLiveSnapshot,
8436    visibility_policy: &dyn ConsoleVisibilityPolicy,
8437) {
8438    let mut hidden = BTreeSet::new();
8439    snapshot.members.retain(|member| {
8440        let visible = visibility_policy.member_visible(member)
8441            && visibility_policy
8442                .identity_visible(&console_identity_record_from_console_member(member));
8443        if !visible {
8444            hidden.insert(member.agent_identity.clone());
8445        }
8446        visible
8447    });
8448    snapshot
8449        .agents
8450        .retain(|agent| !hidden.contains(&agent.agent_id));
8451    snapshot
8452        .loaded_modules
8453        .retain(|module_id| !hidden.contains(module_id));
8454}
8455
8456async fn reset_all_live_console_agents(
8457    runtime: &MobRuntime,
8458    console_events: Option<&ConsoleEventStore>,
8459    console_aggregator: Option<&MobKitConsoleAggregator>,
8460    identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
8461    visibility_policy: &dyn ConsoleVisibilityPolicy,
8462) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
8463    let read_model = ConsoleSnapshotReadModel::default();
8464    *read_model.inner.write().await = Box::pin(collect_console_snapshot_read_model(runtime)).await;
8465    // Mark the freshly-built model primed so `build_live_snapshot` doesn't
8466    // try to re-prime; this is a one-shot read for the reset path.
8467    read_model
8468        .primed
8469        .store(true, std::sync::atomic::Ordering::Release);
8470    let snapshot = Box::pin(build_live_snapshot(
8471        runtime,
8472        &[],
8473        console_events,
8474        visibility_policy,
8475        &read_model,
8476    ))
8477    .await;
8478    let raw_snapshot = Box::pin(build_live_snapshot(
8479        runtime,
8480        &[],
8481        console_events,
8482        &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
8483        &read_model,
8484    ))
8485    .await;
8486    let identity_runtime_statuses = if let Some(identity_runtime) = identity_runtime {
8487        identity_runtime.statuses().await
8488    } else {
8489        Vec::new()
8490    };
8491    let identity_by_runtime_member_id = identity_runtime_statuses
8492        .iter()
8493        .filter_map(|status| {
8494            status
8495                .agent_runtime_id
8496                .as_ref()
8497                .map(|runtime_id| (runtime_id.as_str().to_string(), status.identity.to_string()))
8498        })
8499        .collect::<BTreeMap<_, _>>();
8500    let mut durable_identity_runtime_identities = identity_runtime_statuses
8501        .iter()
8502        .filter(|status| identity_status_visible_to_console(visibility_policy, status))
8503        .map(|status| status.identity.to_string())
8504        .collect::<BTreeSet<_>>();
8505    let mut main_identities = BTreeSet::new();
8506    let mut runtime_member_id_by_identity = BTreeMap::new();
8507    let mut runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8508    let mut session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
8509        BTreeMap::new();
8510    let mut live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
8511        BTreeMap::new();
8512    let mut visible_runtime_member_ids = BTreeSet::new();
8513    let mut duplicate_live_identities = BTreeSet::new();
8514    let mut delegate_members = BTreeSet::new();
8515    for member in snapshot.members {
8516        if member.state == MEMBER_STATE_RETIRING {
8517            continue;
8518        }
8519        if let Some(source_mob_id) = member.labels.get("source_mob_id").cloned() {
8520            delegate_members.insert((source_mob_id, member.agent_identity));
8521        } else {
8522            let identity = member
8523                .labels
8524                .get("agent_identity")
8525                .filter(|value| !value.trim().is_empty())
8526                .cloned()
8527                .or_else(|| {
8528                    identity_by_runtime_member_id
8529                        .get(&member.agent_identity)
8530                        .cloned()
8531                })
8532                .unwrap_or_else(|| member.agent_identity.clone());
8533            if let Some(existing) = runtime_member_id_by_identity.get(&identity)
8534                && existing != &member.agent_identity
8535            {
8536                duplicate_live_identities.insert(identity.clone());
8537            }
8538            runtime_member_ids_by_identity
8539                .entry(identity.clone())
8540                .or_default()
8541                .insert(member.agent_identity.clone());
8542            session_id_by_identity_runtime_member.insert(
8543                (identity.clone(), member.agent_identity.clone()),
8544                member.session_id.clone(),
8545            );
8546            live_alias_by_runtime_member_id.insert(
8547                member.agent_identity.clone(),
8548                (identity.clone(), member.session_id.clone()),
8549            );
8550            visible_runtime_member_ids.insert(member.agent_identity.clone());
8551            runtime_member_id_by_identity
8552                .entry(identity.clone())
8553                .or_insert(member.agent_identity);
8554            main_identities.insert(identity);
8555        }
8556    }
8557    let mut raw_runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> =
8558        BTreeMap::new();
8559    let mut raw_session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
8560        BTreeMap::new();
8561    let mut raw_live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
8562        BTreeMap::new();
8563    for member in raw_snapshot.members {
8564        if member.state == MEMBER_STATE_RETIRING || member.labels.contains_key("source_mob_id") {
8565            continue;
8566        }
8567        let identity = member
8568            .labels
8569            .get("agent_identity")
8570            .filter(|value| !value.trim().is_empty())
8571            .cloned()
8572            .or_else(|| {
8573                identity_by_runtime_member_id
8574                    .get(&member.agent_identity)
8575                    .cloned()
8576            })
8577            .unwrap_or_else(|| member.agent_identity.clone());
8578        raw_runtime_member_ids_by_identity
8579            .entry(identity.clone())
8580            .or_default()
8581            .insert(member.agent_identity.clone());
8582        raw_session_id_by_identity_runtime_member.insert(
8583            (identity.clone(), member.agent_identity.clone()),
8584            member.session_id.clone(),
8585        );
8586        raw_live_alias_by_runtime_member_id
8587            .insert(member.agent_identity, (identity, member.session_id));
8588    }
8589    durable_identity_runtime_identities.retain(|identity| {
8590        identity_runtime_statuses
8591            .iter()
8592            .find(|status| status.identity.as_str() == identity)
8593            .and_then(|status| status.agent_runtime_id.as_ref())
8594            .is_none_or(|runtime_id| {
8595                let runtime_id = runtime_id.as_str();
8596                !raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
8597                    || visible_runtime_member_ids.contains(runtime_id)
8598            })
8599    });
8600    let current_main_identities = main_identities.clone();
8601    let baseline_specs = runtime.baseline_member_specs().await;
8602    let baseline_identities = baseline_specs
8603        .iter()
8604        .filter(|spec| baseline_spec_visible_to_console(visibility_policy, spec))
8605        .map(|spec| spec.identity.to_string())
8606        .collect::<BTreeSet<_>>();
8607    main_identities.extend(baseline_identities.iter().cloned());
8608    main_identities.extend(durable_identity_runtime_identities.iter().cloned());
8609
8610    let mut retired_delegates = Vec::new();
8611    let mut reset_main = Vec::new();
8612    let mut retired_delegate_details = Vec::new();
8613    let mut reset_details = Vec::new();
8614    let mut failures = Vec::new();
8615    let mut warnings = Vec::new();
8616
8617    for identity in &main_identities {
8618        let parsed_identity = crate::identity_first::AgentIdentity::parse(identity).ok();
8619        let registered_status = if let (Some(identity_runtime), Some(parsed_identity)) =
8620            (identity_runtime, parsed_identity.as_ref())
8621        {
8622            identity_runtime.status(parsed_identity).await.ok()
8623        } else {
8624            None
8625        };
8626        let baseline_identity_runtime_registered = registered_status.is_some();
8627        if baseline_identities.contains(identity)
8628            && !current_main_identities.contains(identity)
8629            && !baseline_identity_runtime_registered
8630        {
8631            continue;
8632        }
8633        let registered_runtime_id = registered_status
8634            .as_ref()
8635            .and_then(|status| status.agent_runtime_id.as_ref())
8636            .map(crate::identity_first::AgentRuntimeId::as_str);
8637        let registered_visible = registered_runtime_id
8638            .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
8639        let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
8640            raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
8641                && !visible_runtime_member_ids.contains(runtime_id)
8642        });
8643        if registered_hidden {
8644            continue;
8645        }
8646        if duplicate_live_identities.contains(identity) && !registered_visible {
8647            failures.push(json!({
8648                "identity": identity,
8649                "error": "ambiguous live identity alias",
8650            }));
8651            continue;
8652        }
8653        if let Some(status) = registered_status.as_ref() {
8654            if let Some(registered_runtime_id) = registered_runtime_id
8655                && let Some((live_identity, _live_session_id)) =
8656                    raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
8657                && live_identity != identity
8658            {
8659                failures.push(json!({
8660                    "identity": identity,
8661                    "error": format!(
8662                        "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
8663                    ),
8664                    "kind": "stale_live_identity_alias",
8665                }));
8666                continue;
8667            }
8668            if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(identity) {
8669                if !registered_runtime_id
8670                    .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
8671                {
8672                    failures.push(json!({
8673                        "identity": identity,
8674                        "error": format!(
8675                            "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
8676                            registered_runtime_id.unwrap_or("<none>"),
8677                            live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
8678                        ),
8679                        "kind": "stale_live_identity_alias",
8680                    }));
8681                    continue;
8682                }
8683                if let Some(registered_runtime_id) = registered_runtime_id
8684                    && let Some(registered_session_id) =
8685                        status.session_id.as_ref().map(ToString::to_string)
8686                    && let Some(Some(live_session_id)) = raw_session_id_by_identity_runtime_member
8687                        .get(&(identity.clone(), registered_runtime_id.to_string()))
8688                    && live_session_id != &registered_session_id
8689                {
8690                    failures.push(json!({
8691                        "identity": identity,
8692                        "error": format!(
8693                            "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}"
8694                        ),
8695                        "kind": "stale_live_identity_alias",
8696                    }));
8697                    continue;
8698                }
8699            }
8700            if baseline_identities.contains(identity)
8701                && !identity_runtime
8702                    .is_some_and(|identity_runtime| identity_runtime.has_session_bridge())
8703            {
8704                failures.push(json!({
8705                    "identity": identity,
8706                    "error": "reset requires an identity runtime with a session bridge",
8707                    "kind": "identity_reset_requires_session_bridge",
8708                }));
8709            }
8710            continue;
8711        }
8712
8713        let runtime_member_id = runtime_member_id_by_identity
8714            .get(identity)
8715            .map(String::as_str)
8716            .unwrap_or(identity.as_str());
8717        if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
8718            && bound_identity != identity
8719        {
8720            failures.push(json!({
8721                "identity": identity,
8722                "error": format!(
8723                    "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
8724                ),
8725                "kind": "stale_live_identity_alias",
8726            }));
8727        }
8728    }
8729
8730    if !failures.is_empty() {
8731        return Ok(json!({
8732            "reset": reset_main,
8733            "retired_delegates": retired_delegates,
8734            "reset_details": reset_details,
8735            "retired_delegate_details": retired_delegate_details,
8736            "warnings": warnings,
8737            "failed": failures,
8738            "startup_history": Value::Null,
8739        }));
8740    }
8741
8742    if let Some(state) = runtime.agent_mob_mcp_state() {
8743        for (mob_id, identity) in delegate_members {
8744            match Box::pin(state.handle_for(&MobId::from(mob_id.as_str()))).await {
8745                Ok(handle) => {
8746                    match retire_console_member(
8747                        &handle,
8748                        &crate::member_comms_id::mob_member_id(identity.as_str()),
8749                    )
8750                    .await
8751                    {
8752                        Ok(()) => {
8753                            let detail = json!({
8754                                "identity": identity,
8755                                "mob_id": mob_id,
8756                            });
8757                            retired_delegates.push(detail.clone());
8758                            retired_delegate_details.push(detail);
8759                        }
8760                        Err(err) => failures.push(json!({
8761                            "identity": identity,
8762                            "mob_id": mob_id,
8763                            "error": err,
8764                        })),
8765                    }
8766                }
8767                Err(err) => failures.push(json!({
8768                    "identity": identity,
8769                    "mob_id": mob_id,
8770                    "error": err.to_string(),
8771                })),
8772            }
8773        }
8774    } else if let Some(aggregator) = console_aggregator {
8775        let identities = delegate_members
8776            .into_iter()
8777            .map(|(_, identity)| identity)
8778            .collect::<BTreeSet<_>>();
8779        for identity in identities {
8780            match Box::pin(aggregator.retire_identity(&identity)).await {
8781                Ok(true) => {
8782                    let detail = json!({ "identity": identity });
8783                    retired_delegates.push(detail.clone());
8784                    retired_delegate_details.push(detail);
8785                }
8786                Ok(false) => failures.push(json!({
8787                    "identity": identity,
8788                    "error": "unknown identity",
8789                })),
8790                Err(err) => failures.push(json!({
8791                    "identity": identity,
8792                    "error": err.to_string(),
8793                })),
8794            }
8795        }
8796    }
8797
8798    let handle = runtime.handle();
8799    for spec in baseline_specs {
8800        let identity = spec.identity.to_string();
8801        if !baseline_spec_visible_to_console(visibility_policy, &spec) {
8802            continue;
8803        }
8804        if current_main_identities.contains(&identity) {
8805            continue;
8806        }
8807        if let Some(identity_runtime) = identity_runtime
8808            && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8809            && identity_runtime.status(&parsed_identity).await.is_ok()
8810        {
8811            continue;
8812        }
8813        match handle.ensure_member(spec).await {
8814            Ok(_outcome) => {
8815                if let Some(store) = console_events {
8816                    store
8817                        .record_lifecycle(
8818                            &identity,
8819                            "identity_reset",
8820                            json!({ "scope": "reset_all", "restored": true }),
8821                        )
8822                        .await;
8823                }
8824                reset_main.push(identity.clone());
8825                reset_details.push(json!({ "identity": identity }));
8826            }
8827            Err(err) => failures.push(json!({
8828                "identity": identity,
8829                "error": err.to_string(),
8830            })),
8831        }
8832    }
8833    for identity in main_identities {
8834        let baseline_identity_runtime_registered = if let Some(identity_runtime) = identity_runtime
8835            && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8836        {
8837            identity_runtime.status(&parsed_identity).await.is_ok()
8838        } else {
8839            false
8840        };
8841        if baseline_identities.contains(&identity)
8842            && !current_main_identities.contains(&identity)
8843            && !baseline_identity_runtime_registered
8844        {
8845            continue;
8846        }
8847        let registered_status = if let Some(identity_runtime) = identity_runtime
8848            && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8849        {
8850            identity_runtime.status(&parsed_identity).await.ok()
8851        } else {
8852            None
8853        };
8854        let registered_runtime_id = registered_status
8855            .as_ref()
8856            .and_then(|status| status.agent_runtime_id.as_ref())
8857            .map(crate::identity_first::AgentRuntimeId::as_str);
8858        let registered_visible = registered_runtime_id
8859            .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
8860        let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
8861            raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
8862                && !visible_runtime_member_ids.contains(runtime_id)
8863        });
8864        if registered_hidden {
8865            continue;
8866        }
8867        if duplicate_live_identities.contains(&identity) && !registered_visible {
8868            failures.push(json!({
8869                "identity": identity,
8870                "error": "ambiguous live identity alias",
8871            }));
8872            continue;
8873        }
8874        if baseline_identities.contains(&identity) {
8875            if let Some(identity_runtime) = identity_runtime
8876                && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8877                && let Ok(status) = identity_runtime.status(&parsed_identity).await
8878            {
8879                let registered_runtime_id = status
8880                    .agent_runtime_id
8881                    .as_ref()
8882                    .map(crate::identity_first::AgentRuntimeId::as_str);
8883                if let Some(registered_runtime_id) = registered_runtime_id
8884                    && let Some((live_identity, _live_session_id)) =
8885                        raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
8886                    && live_identity != &identity
8887                {
8888                    failures.push(json!({
8889                        "identity": identity,
8890                        "error": format!(
8891                            "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
8892                        ),
8893                        "kind": "stale_live_identity_alias",
8894                    }));
8895                    continue;
8896                }
8897                if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
8898                    if !registered_runtime_id
8899                        .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
8900                    {
8901                        failures.push(json!({
8902                            "identity": identity,
8903                            "error": format!(
8904                                "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
8905                                registered_runtime_id.unwrap_or("<none>"),
8906                                live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
8907                            ),
8908                            "kind": "stale_live_identity_alias",
8909                        }));
8910                        continue;
8911                    }
8912                    if let Some(registered_runtime_id) = registered_runtime_id
8913                        && let Some(registered_session_id) =
8914                            status.session_id.as_ref().map(ToString::to_string)
8915                        && let Some(Some(live_session_id)) =
8916                            raw_session_id_by_identity_runtime_member
8917                                .get(&(identity.clone(), registered_runtime_id.to_string()))
8918                        && live_session_id != &registered_session_id
8919                    {
8920                        failures.push(json!({
8921                            "identity": identity,
8922                            "error": format!(
8923                                "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}"
8924                            ),
8925                            "kind": "stale_live_identity_alias",
8926                        }));
8927                        continue;
8928                    }
8929                }
8930                if !identity_runtime.has_session_bridge() {
8931                    failures.push(json!({
8932                        "identity": identity,
8933                        "error": "reset requires an identity runtime with a session bridge",
8934                        "kind": "identity_reset_requires_session_bridge",
8935                    }));
8936                    continue;
8937                }
8938                match identity_runtime.reset(&parsed_identity).await {
8939                    Ok(record) => {
8940                        let cleanup_warning = json!({
8941                            "identity": identity,
8942                            "kind": "stale_member_cleanup_skipped_after_identity_reset",
8943                            "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
8944                        });
8945                        warnings.push(cleanup_warning);
8946                        reset_details.push(json!({
8947                            "identity": identity,
8948                            "cleanup_warning": warnings.last().cloned(),
8949                        }));
8950                        reset_main.push(identity);
8951                        if let Some(store) = console_events {
8952                            store
8953                                .record_lifecycle(
8954                                    parsed_identity.as_str(),
8955                                    "identity_reset",
8956                                    json!({
8957                                        "scope": "reset_all",
8958                                        "generation": record.generation.get(),
8959                                        "checkpoint_version": record.checkpoint_version.get(),
8960                                        "cleanup_warning": warnings.last().cloned(),
8961                                    }),
8962                                )
8963                                .await;
8964                        }
8965                    }
8966                    Err(err) => failures.push(json!({
8967                        "identity": identity,
8968                        "error": err.to_string(),
8969                    })),
8970                }
8971                continue;
8972            }
8973            let runtime_member_id = runtime_member_id_by_identity
8974                .get(&identity)
8975                .map(String::as_str)
8976                .unwrap_or(identity.as_str());
8977            if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
8978                && bound_identity != &identity
8979            {
8980                failures.push(json!({
8981                    "identity": identity,
8982                    "error": format!(
8983                        "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
8984                    ),
8985                    "kind": "stale_live_identity_alias",
8986                }));
8987                continue;
8988            }
8989            match Box::pin(respawn_console_member(
8990                &handle,
8991                &crate::member_comms_id::mob_member_id(runtime_member_id),
8992            ))
8993            .await
8994            {
8995                Ok(topology_restore_warning) => {
8996                    if let Some(store) = console_events {
8997                        store
8998                            .record_lifecycle(
8999                                &identity,
9000                                "identity_reset",
9001                                json!({
9002                                    "scope": "reset_all",
9003                                    "topology_restore_warning": topology_restore_warning.clone(),
9004                                }),
9005                            )
9006                            .await;
9007                    }
9008                    reset_main.push(identity.clone());
9009                    let mut detail = json!({ "identity": identity });
9010                    if let Some(warning) = topology_restore_warning {
9011                        detail["topology_restore_warning"] = warning;
9012                    }
9013                    reset_details.push(detail);
9014                }
9015                Err(err) => failures.push(json!({
9016                    "identity": identity,
9017                    "error": err,
9018                })),
9019            }
9020        } else {
9021            if let Some(identity_runtime) = identity_runtime
9022                && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9023                && let Ok(registered_status) = identity_runtime.status(&parsed_identity).await
9024            {
9025                let registered_runtime_id = registered_status
9026                    .agent_runtime_id
9027                    .as_ref()
9028                    .map(crate::identity_first::AgentRuntimeId::as_str);
9029                let registered_visible = registered_runtime_id
9030                    .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
9031                if duplicate_live_identities.contains(&identity) && !registered_visible {
9032                    failures.push(json!({
9033                        "identity": identity,
9034                        "error": "ambiguous live identity alias",
9035                    }));
9036                    continue;
9037                }
9038                if let Some(registered_runtime_id) = registered_runtime_id
9039                    && let Some((live_identity, _live_session_id)) =
9040                        raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
9041                    && live_identity != &identity
9042                {
9043                    failures.push(json!({
9044                        "identity": identity,
9045                        "error": format!(
9046                            "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
9047                        ),
9048                        "kind": "stale_live_identity_alias",
9049                    }));
9050                    continue;
9051                }
9052                if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
9053                    if !registered_runtime_id
9054                        .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
9055                    {
9056                        failures.push(json!({
9057                            "identity": identity,
9058                            "error": format!(
9059                                "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
9060                                registered_runtime_id.unwrap_or("<none>"),
9061                                live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
9062                            ),
9063                            "kind": "stale_live_identity_alias",
9064                        }));
9065                        continue;
9066                    }
9067                    if let Some(registered_runtime_id) = registered_runtime_id
9068                        && let Some(registered_session_id) = registered_status
9069                            .session_id
9070                            .as_ref()
9071                            .map(ToString::to_string)
9072                        && let Some(Some(live_session_id)) =
9073                            raw_session_id_by_identity_runtime_member
9074                                .get(&(identity.clone(), registered_runtime_id.to_string()))
9075                        && live_session_id != &registered_session_id
9076                    {
9077                        failures.push(json!({
9078                            "identity": identity,
9079                            "error": format!(
9080                                "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}"
9081                            ),
9082                            "kind": "stale_live_identity_alias",
9083                        }));
9084                        continue;
9085                    }
9086                }
9087                match identity_runtime.retire(&parsed_identity).await {
9088                    Ok(token) => {
9089                        let keep_runtime_member_id = registered_status
9090                            .agent_runtime_id
9091                            .as_ref()
9092                            .filter(|_| identity_runtime.has_session_bridge())
9093                            .map(crate::identity_first::AgentRuntimeId::as_str);
9094                        match retire_stale_console_members_for_identity(
9095                            &handle,
9096                            visibility_policy,
9097                            parsed_identity.as_str(),
9098                            keep_runtime_member_id,
9099                        )
9100                        .await
9101                        {
9102                            Ok(()) => {
9103                                retired_delegate_details.push(json!({ "identity": identity }));
9104                                retired_delegates.push(json!({ "identity": identity }));
9105                                if let Some(store) = console_events {
9106                                    store
9107                                        .record_lifecycle(
9108                                            parsed_identity.as_str(),
9109                                            "identity_retired",
9110                                            json!({
9111                                                "scope": "reset_all",
9112                                                "dynamic": true,
9113                                                "fencing_token": token.get(),
9114                                            }),
9115                                        )
9116                                        .await;
9117                                }
9118                            }
9119                            Err(err) => {
9120                                warnings.push(json!({
9121                                    "identity": identity,
9122                                    "kind": "stale_member_cleanup_failed_after_identity_retire",
9123                                    "message": err,
9124                                }));
9125                                retired_delegate_details.push(json!({
9126                                    "identity": identity,
9127                                    "cleanup_warning": warnings.last().cloned(),
9128                                }));
9129                                retired_delegates.push(json!({ "identity": identity }));
9130                                if let Some(store) = console_events {
9131                                    store
9132                                        .record_lifecycle(
9133                                            parsed_identity.as_str(),
9134                                            "identity_retired",
9135                                            json!({
9136                                                "scope": "reset_all",
9137                                                "dynamic": true,
9138                                                "fencing_token": token.get(),
9139                                                "cleanup_warning": warnings.last().cloned(),
9140                                            }),
9141                                        )
9142                                        .await;
9143                                }
9144                            }
9145                        }
9146                    }
9147                    Err(err) => failures.push(json!({
9148                        "identity": identity,
9149                        "error": err.to_string(),
9150                    })),
9151                }
9152                continue;
9153            }
9154            let runtime_member_id = runtime_member_id_by_identity
9155                .get(&identity)
9156                .map(String::as_str)
9157                .unwrap_or(identity.as_str());
9158            if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
9159                && bound_identity != &identity
9160            {
9161                failures.push(json!({
9162                    "identity": identity,
9163                    "error": format!(
9164                        "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
9165                    ),
9166                    "kind": "stale_live_identity_alias",
9167                }));
9168                continue;
9169            }
9170            match retire_console_member(
9171                &handle,
9172                &crate::member_comms_id::mob_member_id(runtime_member_id),
9173            )
9174            .await
9175            {
9176                Ok(()) => {
9177                    if let Some(store) = console_events {
9178                        store
9179                            .record_lifecycle(
9180                                &identity,
9181                                "identity_retired",
9182                                json!({ "scope": "reset_all", "dynamic": true }),
9183                            )
9184                            .await;
9185                    }
9186                    retired_delegates.push(json!({ "identity": identity }));
9187                    retired_delegate_details.push(json!({ "identity": identity }));
9188                }
9189                Err(err) => failures.push(json!({
9190                    "identity": identity,
9191                    "error": err,
9192                })),
9193            }
9194        }
9195    }
9196
9197    let startup_history = if failures.is_empty() {
9198        if let Some(aggregator) = console_aggregator {
9199            Box::pin(wait_for_reset_startup_history(
9200                aggregator,
9201                reset_main.iter().cloned().collect::<BTreeSet<_>>(),
9202                Duration::from_secs(10),
9203            ))
9204            .await
9205            .unwrap_or_else(|err| json!({ "error": err.to_string() }))
9206        } else {
9207            Value::Null
9208        }
9209    } else {
9210        Value::Null
9211    };
9212
9213    Ok(json!({
9214        "reset": reset_main,
9215        "retired_delegates": retired_delegates,
9216        "reset_details": reset_details,
9217        "retired_delegate_details": retired_delegate_details,
9218        "warnings": warnings,
9219        "failed": failures,
9220        "startup_history": startup_history,
9221    }))
9222}
9223
9224async fn wait_for_reset_startup_history(
9225    aggregator: &MobKitConsoleAggregator,
9226    identities: BTreeSet<String>,
9227    timeout: Duration,
9228) -> ConsoleLogResult<Value> {
9229    if identities.is_empty() {
9230        return Ok(json!({
9231            "timeout": false,
9232            "ready": Vec::<String>::new(),
9233            "pending": Vec::<String>::new(),
9234        }));
9235    }
9236
9237    let deadline = Instant::now() + timeout;
9238    let mut pending = identities;
9239    let mut ready = BTreeSet::new();
9240    while !pending.is_empty() {
9241        for identity in pending.clone() {
9242            let page = Box::pin(aggregator.query_timeline(ConsoleTimelineQuery {
9243                identity: Some(identity.clone()),
9244                limit: 1000,
9245                ..ConsoleTimelineQuery::default()
9246            }))
9247            .await?;
9248            let startup_completed = page.frames.iter().any(|frame| {
9249                matches!(
9250                    frame.kind.as_str(),
9251                    "interaction_complete" | "turn_completed"
9252                )
9253            });
9254            if startup_completed {
9255                pending.remove(&identity);
9256                ready.insert(identity);
9257            }
9258        }
9259
9260        if pending.is_empty() {
9261            break;
9262        }
9263        if Instant::now() >= deadline {
9264            return Ok(json!({
9265                "timeout": true,
9266                "ready": ready.into_iter().collect::<Vec<_>>(),
9267                "pending": pending.into_iter().collect::<Vec<_>>(),
9268            }));
9269        }
9270        tokio::time::sleep(Duration::from_millis(250)).await;
9271    }
9272
9273    Ok(json!({
9274        "timeout": false,
9275        "ready": ready.into_iter().collect::<Vec<_>>(),
9276        "pending": Vec::<String>::new(),
9277    }))
9278}
9279
9280fn dedupe_console_members_by_identity(members: &mut Vec<ConsoleMember>) {
9281    let mut seen_member_ids = BTreeSet::new();
9282    members.retain(|member| seen_member_ids.insert(member.agent_identity.clone()));
9283}
9284
9285fn console_member_console_identity(member: &ConsoleMember) -> &str {
9286    member
9287        .labels
9288        .get("agent_identity")
9289        .filter(|value| !value.trim().is_empty())
9290        .map_or(member.agent_identity.as_str(), String::as_str)
9291}
9292
9293fn console_identity_record_from_console_member(member: &ConsoleMember) -> ConsoleIdentityRecord {
9294    let identity = console_member_console_identity(member).to_string();
9295    let addressable = member
9296        .labels
9297        .get("addressable")
9298        .map(|value| !value.eq_ignore_ascii_case("false"))
9299        .unwrap_or(true)
9300        && member.state == MEMBER_STATE_ACTIVE;
9301    let visibility = if member.state == MEMBER_STATE_RETIRING {
9302        ConsoleVisibility::RetiredReadable
9303    } else if addressable {
9304        ConsoleVisibility::Addressable
9305    } else {
9306        ConsoleVisibility::Hidden
9307    };
9308    ConsoleIdentityRecord {
9309        identity: identity.clone(),
9310        display_name: member
9311            .labels
9312            .get("display_name")
9313            .cloned()
9314            .unwrap_or(identity),
9315        runtime_key: "runtime".to_string(),
9316        runtime_member_id: member.agent_identity.clone(),
9317        session_id: member.session_id.clone(),
9318        visibility,
9319        addressable,
9320        health: member.state.clone(),
9321        topology_peers: member.wired_to.clone(),
9322        labels: member.labels.clone(),
9323    }
9324}
9325
9326fn baseline_spec_visible_to_console(
9327    visibility_policy: &dyn ConsoleVisibilityPolicy,
9328    spec: &SpawnMemberSpec,
9329) -> bool {
9330    let mut labels = spec.labels.clone().unwrap_or_default();
9331    labels
9332        .entry("role".to_string())
9333        .or_insert_with(|| spec.role_name.to_string());
9334    let record = ConsoleIdentityRecord {
9335        identity: spec.identity.to_string(),
9336        display_name: spec.identity.to_string(),
9337        runtime_key: "baseline".to_string(),
9338        runtime_member_id: spec.identity.to_string(),
9339        session_id: None,
9340        visibility: ConsoleVisibility::Addressable,
9341        addressable: true,
9342        health: "baseline".to_string(),
9343        topology_peers: Vec::new(),
9344        labels,
9345    };
9346    let member = ConsoleMember {
9347        agent_identity: spec.identity.to_string(),
9348        role: spec.role_name.to_string(),
9349        state: MEMBER_STATE_ACTIVE.to_string(),
9350        model_capabilities: ConsoleModelCapabilities::default(),
9351        runtime_mode: spec
9352            .runtime_mode
9353            .as_ref()
9354            .map(std::string::ToString::to_string),
9355        session_id: None,
9356        wired_to: Vec::new(),
9357        labels: record.labels.clone(),
9358        progress: None,
9359    };
9360    visibility_policy.member_visible(&member) && visibility_policy.identity_visible(&record)
9361}
9362
9363/// Prime the access-control attribute cache from the live primary-mob roster
9364/// so label/role rules resolve on surfaces that carry only an identity string
9365/// (SSE event streams, timeline frames, `mob_events`). Without this, those
9366/// surfaces evaluate role/label rules with attributes unknown and can fail
9367/// open for "broad allow + label-keyed deny" policies — the cache must not be
9368/// gated on a prior `/console/experience` call. Additive (it does not evict),
9369/// so it never clears delegate-member attributes primed by the experience
9370/// projection; the experience path owns wholesale rebuild + eviction.
9371///
9372/// Console metadata registered by agent-tool spawns (`spawned_by` lineage,
9373/// spawn labels) fills label gaps the roster does not carry, and identities
9374/// known only to the spawn registry (e.g. members of agent-created sub-mobs)
9375/// are primed registry-only so lineage rules resolve for them too.
9376pub(crate) async fn prime_access_cache_from_runtime(
9377    runtime: &MobRuntime,
9378    access: &AccessController,
9379) {
9380    if !access.enabled() {
9381        return;
9382    }
9383    let registered = runtime.console_identity_labels().await;
9384    prime_access_cache_from_handle_with_registry(&runtime.handle(), access, &registered).await;
9385}
9386
9387pub(crate) async fn prime_access_cache_from_handle(handle: &MobHandle, access: &AccessController) {
9388    if !access.enabled() {
9389        return;
9390    }
9391    prime_access_cache_from_handle_with_registry(handle, access, &BTreeMap::new()).await;
9392}
9393
9394async fn prime_access_cache_from_handle_with_registry(
9395    handle: &MobHandle,
9396    access: &AccessController,
9397    registered: &BTreeMap<String, BTreeMap<String, String>>,
9398) {
9399    let mut registry_only: BTreeMap<&String, &BTreeMap<String, String>> =
9400        registered.iter().collect();
9401    for entry in handle.list_all_members().await {
9402        // Roster ids are comms-safe encodings (meerkat 0.7); the console/ABAC
9403        // attribute space uses the public alias.
9404        let member_identity =
9405            crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
9406        // Console identity may be overridden by a label, mirroring
9407        // `console_member_console_identity`.
9408        let console_identity = entry
9409            .labels
9410            .get("agent_identity")
9411            .map(|value| value.trim())
9412            .filter(|value| !value.is_empty())
9413            .map_or_else(|| member_identity.clone(), ToString::to_string);
9414        let mut labels = entry.labels.clone();
9415        // Roster labels are caller-controlled; only the spawn registry may
9416        // assert lineage, or a spoofed `spawned_by` could mint inherited
9417        // visibility (or hide a member behind a denied parent).
9418        crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
9419        if let Some(spawn_labels) = registry_only.remove(&console_identity) {
9420            crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
9421        }
9422        access.record_agent_attributes(AgentResourceAttributes {
9423            identity: console_identity,
9424            agent_id: Some(member_identity),
9425            role: Some(entry.role.to_string()),
9426            labels,
9427        });
9428    }
9429    for (identity, spawn_labels) in registry_only {
9430        access.record_agent_attributes(AgentResourceAttributes {
9431            identity: identity.clone(),
9432            agent_id: None,
9433            role: spawn_labels.get("role").cloned(),
9434            labels: spawn_labels.clone(),
9435        });
9436    }
9437}
9438
9439async fn project_console_members_from_handle(
9440    handle: &MobHandle,
9441    host_identity: Option<&str>,
9442    source_mob_id: Option<&str>,
9443    read_model: &ConsoleSnapshotReadModelState,
9444    registered_labels: &BTreeMap<String, BTreeMap<String, String>>,
9445) -> (Vec<ConsoleMember>, BTreeMap<String, String>) {
9446    // Meerkat 0.7: lifecycle status is machine-projected onto
9447    // `MobMemberListEntry`; the structural `list_all_members` roster no longer
9448    // carries it, so the console projection reads the operational list.
9449    let entries = handle.list_members_including_retiring().await;
9450    let mut members = Vec::with_capacity(entries.len());
9451    let mut session_owner_by_id = BTreeMap::new();
9452    for entry in &entries {
9453        // Roster ids are comms-safe encodings (meerkat 0.7 MemberCommsName);
9454        // console members surface the public alias.
9455        let identity =
9456            crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
9457        let session_id = read_model.session_id_by_identity.get(&identity).cloned();
9458        if let Some(session_id) = session_id.as_ref() {
9459            session_owner_by_id.insert(session_id.clone(), identity.clone());
9460        }
9461        let model_capabilities =
9462            model_capabilities_for_role(handle.definition(), entry.role.as_str());
9463        let mut labels = entry.labels.clone();
9464        // Spawn-time console metadata (group/display labels, spawned_by
9465        // lineage) fills gaps the roster does not carry; roster labels win —
9466        // except lineage, which only the spawn registry may assert (this
9467        // projection feeds the experience-path ABAC attribute rebuild).
9468        crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
9469        let console_identity_key = entry
9470            .labels
9471            .get("agent_identity")
9472            .map(|value| value.trim())
9473            .filter(|value| !value.is_empty())
9474            .map_or(identity.as_str(), |value| value);
9475        if let Some(spawn_labels) = registered_labels.get(console_identity_key) {
9476            crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
9477        }
9478        if let Some(host_identity) = host_identity {
9479            labels
9480                .entry("delegate_host_identity".to_string())
9481                .or_insert_with(|| host_identity.to_string());
9482            labels
9483                .entry("group".to_string())
9484                .or_insert_with(|| "Coordinators".to_string());
9485        }
9486        if let Some(source_mob_id) = source_mob_id {
9487            labels
9488                .entry("source_mob_id".to_string())
9489                .or_insert_with(|| source_mob_id.to_string());
9490        }
9491        let mut wired_to: Vec<String> = entry
9492            .wired_to
9493            .iter()
9494            .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
9495            .collect();
9496        if let Some(host_identity) = host_identity
9497            && !wired_to.iter().any(|peer| peer == host_identity)
9498        {
9499            wired_to.push(host_identity.to_string());
9500        }
9501        members.push(ConsoleMember {
9502            agent_identity: identity,
9503            role: entry.role.to_string(),
9504            state: member_status_state_string(entry.status),
9505            model_capabilities,
9506            runtime_mode: Some(entry.runtime_mode.to_string()),
9507            session_id,
9508            wired_to,
9509            labels,
9510            progress: None,
9511        });
9512    }
9513    attach_member_progress(handle, &entries, &mut members).await;
9514    (members, session_owner_by_id)
9515}
9516
9517/// Console progress projection cap: `member_status` is an actor-mailbox
9518/// roundtrip per member, and the experience endpoint refreshes every ~15s
9519/// (plus SSE-triggered refetches). Small durable rosters (HomeCore: 16) get
9520/// liveness for free; whole-mob fan-out at OB3 scale (hundreds of members)
9521/// would compete with real work on the mob actor mailbox, so large rosters
9522/// skip the projection unless the operator raises the cap.
9523/// `MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP` overrides; `0` disables entirely.
9524const CONSOLE_PROGRESS_MEMBER_CAP: usize = 64;
9525
9526fn console_progress_member_cap() -> usize {
9527    std::env::var("MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP")
9528        .ok()
9529        .and_then(|value| value.trim().parse::<usize>().ok())
9530        .unwrap_or(CONSOLE_PROGRESS_MEMBER_CAP)
9531}
9532
9533/// Attach the machine-owned liveness projection (meerkat 0.7.29+, ask 14) to
9534/// non-final console members. Best-effort: a failed or absent snapshot leaves
9535/// `progress: None` — the console renders nothing rather than a lie.
9536async fn attach_member_progress(
9537    handle: &MobHandle,
9538    entries: &[meerkat_mob::runtime::MobMemberListEntry],
9539    members: &mut [ConsoleMember],
9540) {
9541    let cap = console_progress_member_cap();
9542    if entries.len() > cap {
9543        tracing::debug!(
9544            member_count = entries.len(),
9545            cap,
9546            "skipping console progress projection: roster exceeds \
9547             MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP"
9548        );
9549        return;
9550    }
9551    for (entry, member) in entries.iter().zip(members.iter_mut()) {
9552        if entry.is_final {
9553            continue;
9554        }
9555        let Ok(snapshot) = handle.member_status(&entry.agent_identity).await else {
9556            continue;
9557        };
9558        member.progress = snapshot
9559            .progress
9560            .as_ref()
9561            .and_then(|progress| serde_json::to_value(progress).ok());
9562    }
9563}
9564
9565async fn build_aggregator_live_snapshot(
9566    aggregator: &MobKitConsoleAggregator,
9567    config_module_ids: &[String],
9568) -> Result<ConsoleLiveSnapshot, Box<dyn std::error::Error + Send + Sync>> {
9569    let identities = Box::pin(aggregator.list_identities()).await?;
9570    let mut members = Vec::with_capacity(identities.len());
9571    for identity in &identities {
9572        let mut labels = identity.labels.clone();
9573        labels
9574            .entry("display_name".to_string())
9575            .or_insert_with(|| identity.display_name.clone());
9576        labels
9577            .entry("addressable".to_string())
9578            .or_insert_with(|| identity.addressable.to_string());
9579        // Keep /console/experience on the cached identity read model. Live
9580        // peer inspection walks the actor-backed mob/member path, and a stuck
9581        // turn must not make the console shell fail to load.
9582        let wired_to = identity.topology_peers.clone();
9583        members.push(ConsoleMember {
9584            agent_identity: identity.identity.clone(),
9585            role: labels
9586                .get("role")
9587                .cloned()
9588                .unwrap_or_else(|| "identity".to_string()),
9589            state: identity.health.clone(),
9590            model_capabilities: ConsoleModelCapabilities::default(),
9591            runtime_mode: Some("console_aggregator".to_string()),
9592            session_id: identity.session_id.clone(),
9593            wired_to,
9594            labels,
9595            progress: None,
9596        });
9597    }
9598    members.sort_by(|left, right| left.agent_identity.cmp(&right.agent_identity));
9599    let agents = members
9600        .iter()
9601        .map(|member| ConsoleAgentLiveSnapshot {
9602            agent_id: member.agent_identity.clone(),
9603            member_id: member.agent_identity.clone(),
9604            label: member
9605                .labels
9606                .get("display_name")
9607                .cloned()
9608                .unwrap_or_else(|| member.agent_identity.clone()),
9609            kind: "meerkat".to_string(),
9610            identity: Some(member.agent_identity.clone()),
9611            role: Some(member.role.clone()),
9612            state: Some(member.state.clone()),
9613            session_id: member.session_id.clone(),
9614            model_capabilities: member.model_capabilities.clone(),
9615            response_phase: None,
9616            watched: None,
9617            alert_level: None,
9618            degraded: None,
9619            degraded_reason: None,
9620        })
9621        .collect::<Vec<_>>();
9622    let loaded_modules = if config_module_ids.is_empty() {
9623        members
9624            .iter()
9625            .map(|member| member.agent_identity.clone())
9626            .collect()
9627    } else {
9628        config_module_ids.to_vec()
9629    };
9630    Ok(ConsoleLiveSnapshot::new(
9631        Some("console-aggregator".to_string()),
9632        true,
9633        loaded_modules,
9634        agents,
9635        members,
9636        true,
9637    ))
9638}
9639
9640pub async fn console_frontend_index_handler() -> impl IntoResponse {
9641    (
9642        [
9643            (header::CONTENT_TYPE, "text/html; charset=utf-8"),
9644            (header::CACHE_CONTROL, "no-store"),
9645        ],
9646        CONSOLE_FRONTEND_INDEX_HTML,
9647    )
9648}
9649
9650pub async fn console_frontend_app_js_handler() -> impl IntoResponse {
9651    (
9652        [
9653            (
9654                header::CONTENT_TYPE,
9655                "application/javascript; charset=utf-8",
9656            ),
9657            (header::CACHE_CONTROL, "no-store"),
9658        ],
9659        CONSOLE_FRONTEND_APP_JS,
9660    )
9661}
9662
9663pub async fn console_frontend_app_css_handler() -> impl IntoResponse {
9664    (
9665        [
9666            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
9667            (header::CACHE_CONTROL, "no-store"),
9668        ],
9669        CONSOLE_FRONTEND_APP_CSS,
9670    )
9671}
9672
9673#[cfg(test)]
9674#[allow(clippy::expect_used, clippy::large_futures)]
9675mod tests {
9676    use super::ConsoleTimelineHttpQuery;
9677    use super::{
9678        ConsoleSnapshotReadModel, ConsoleSnapshotReadModelState, MAX_MULTIPART_BODY_BYTES,
9679        MAX_MULTIPART_IMAGE_BYTES, MultipartImageUpload, apply_console_visibility_policy,
9680        build_aggregator_live_snapshot, collect_console_snapshot_read_model,
9681        console_send_identity_first, console_send_with_identity_first_fallback,
9682        console_timeline_replay_unavailable_response, cursor_is_after,
9683        dedupe_console_members_by_identity, externalize_image_upload_placeholders,
9684        externalize_single_image_upload, handle_console_aggregator_rpc, handle_console_runtime_rpc,
9685        handle_console_runtime_rpc_with_visibility, member_id_matches_durable_identity,
9686        project_console_members_from_handle, query_timeline_snapshot, timeline_query_from_http,
9687    };
9688    use crate::access::{
9689        ACTION_AGENT_MEMORY_DELETE, ACTION_AGENT_MEMORY_READ, ACTION_AGENT_MEMORY_WRITE,
9690        ACTION_AGENT_SEND, ACTION_AGENT_VIEW, AccessController,
9691    };
9692    use crate::blob_store::{BinaryBlobStore, ObjectStoreBlobStore};
9693    use crate::console_aggregator::{
9694        AllowAllConsoleVisibilityPolicy, ConsoleIdentityRecord,
9695        HideImplicitDelegateMembersConsoleVisibilityPolicy,
9696    };
9697    use crate::console_aggregator::{
9698        ConsoleCursor, ConsoleFrameSource, ConsoleFrameSourceKind, ConsoleFrameStatus,
9699        ConsoleTimelineQuery, ConsoleTimelineWindowQuery, ConsoleVisibilityPolicy,
9700        MobKitConsoleAggregator, NewConsoleFrame,
9701    };
9702    use crate::identity_first::contracts::{ContinuityStore, LeaseProvider};
9703    use crate::identity_first::{
9704        AgentAddressability, AgentBuildDraft, AgentIdentity, AgentMemoryConfig,
9705        AgentMemoryRuntimeInjector, AgentRuntimeId, BridgeError, CheckpointVersion,
9706        ContinuityGeneration, ContinuityRecord, DurabilityPolicy, DurableAgentSpec, FencingToken,
9707        IdentityLifecycleState, IdentityRuntime, IdentityRuntimeConfig, LeaseAcquireResult,
9708        LeaseGrant, LocalContinuityStore, LocalLeaseProvider, ManagedPeerEdge,
9709        MarkdownAgentMemoryStore, ResumeSessionOutcome, SessionBridge, SessionSnapshot,
9710    };
9711    use crate::mob_handle_runtime::{MobRuntime, model_capabilities_for_role};
9712    use crate::rpc::{JSONRPC_VERSION, JsonRpcRequest};
9713    use crate::runtime::{ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember};
9714    use crate::unified_runtime::ConsoleEventStore;
9715    use crate::{MobBootstrapOptions, MobBootstrapSpec};
9716    use bytes::Bytes;
9717    use meerkat::{AgentFactory, Config, build_ephemeral_service};
9718    use meerkat_client::TestClient;
9719    use meerkat_core::types::HandlingMode;
9720    use meerkat_mob::ProfileName;
9721    use meerkat_mob::{MobDefinition, MobStorage, SpawnMemberSpec};
9722    use serde_json::{Value, json};
9723    use std::collections::BTreeMap;
9724    use std::sync::atomic::{AtomicUsize, Ordering};
9725    use std::sync::{Arc, Mutex};
9726    use std::time::Duration;
9727
9728    struct BlockingIdentityBridge {
9729        deliver_calls: Arc<AtomicUsize>,
9730    }
9731
9732    struct RecordingIdentityBridge {
9733        session_id: meerkat_core::types::SessionId,
9734        handling_modes: Arc<Mutex<Vec<HandlingMode>>>,
9735    }
9736
9737    #[async_trait::async_trait]
9738    impl SessionBridge for BlockingIdentityBridge {
9739        async fn create_session(
9740            &self,
9741            _identity: &AgentIdentity,
9742            _runtime_id: &AgentRuntimeId,
9743            _spec: &DurableAgentSpec,
9744            _draft: &AgentBuildDraft,
9745            session_id: &meerkat_core::types::SessionId,
9746        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9747            Ok(session_id.clone())
9748        }
9749
9750        async fn resume_session(
9751            &self,
9752            _identity: &AgentIdentity,
9753            _runtime_id: &AgentRuntimeId,
9754            _spec: &DurableAgentSpec,
9755            _draft: &AgentBuildDraft,
9756            session_id: &meerkat_core::types::SessionId,
9757            _snapshot: &SessionSnapshot,
9758        ) -> Result<ResumeSessionOutcome, BridgeError> {
9759            Ok(ResumeSessionOutcome::Resumed {
9760                session_id: session_id.clone(),
9761            })
9762        }
9763
9764        async fn deliver(
9765            &self,
9766            _runtime_id: &AgentRuntimeId,
9767            _content: &meerkat_core::ContentInput,
9768        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9769            self.deliver_calls.fetch_add(1, Ordering::SeqCst);
9770            std::future::pending().await
9771        }
9772
9773        async fn checkpoint_session(
9774            &self,
9775            _runtime_id: &AgentRuntimeId,
9776            _session_id: &meerkat_core::types::SessionId,
9777        ) -> Result<SessionSnapshot, BridgeError> {
9778            Err(BridgeError::Mob("checkpoint not used in test".to_string()))
9779        }
9780
9781        async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
9782            Ok(())
9783        }
9784    }
9785
9786    #[async_trait::async_trait]
9787    impl SessionBridge for RecordingIdentityBridge {
9788        async fn create_session(
9789            &self,
9790            _identity: &AgentIdentity,
9791            _runtime_id: &AgentRuntimeId,
9792            _spec: &DurableAgentSpec,
9793            _draft: &AgentBuildDraft,
9794            session_id: &meerkat_core::types::SessionId,
9795        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9796            Ok(session_id.clone())
9797        }
9798
9799        async fn resume_session(
9800            &self,
9801            _identity: &AgentIdentity,
9802            _runtime_id: &AgentRuntimeId,
9803            _spec: &DurableAgentSpec,
9804            _draft: &AgentBuildDraft,
9805            session_id: &meerkat_core::types::SessionId,
9806            _snapshot: &SessionSnapshot,
9807        ) -> Result<ResumeSessionOutcome, BridgeError> {
9808            Ok(ResumeSessionOutcome::Resumed {
9809                session_id: session_id.clone(),
9810            })
9811        }
9812
9813        async fn deliver(
9814            &self,
9815            runtime_id: &AgentRuntimeId,
9816            content: &meerkat_core::ContentInput,
9817        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9818            self.deliver_with_mode(runtime_id, content, HandlingMode::Queue)
9819                .await
9820        }
9821
9822        async fn deliver_with_mode(
9823            &self,
9824            _runtime_id: &AgentRuntimeId,
9825            _content: &meerkat_core::ContentInput,
9826            handling_mode: HandlingMode,
9827        ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9828            self.handling_modes
9829                .lock()
9830                .map_err(|_| BridgeError::Mob("handling modes mutex poisoned".to_string()))?
9831                .push(handling_mode);
9832            Ok(self.session_id.clone())
9833        }
9834
9835        async fn checkpoint_session(
9836            &self,
9837            _runtime_id: &AgentRuntimeId,
9838            _session_id: &meerkat_core::types::SessionId,
9839        ) -> Result<SessionSnapshot, BridgeError> {
9840            Err(BridgeError::Mob("checkpoint not used in test".to_string()))
9841        }
9842
9843        async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
9844            Ok(())
9845        }
9846    }
9847
9848    async fn build_empty_console_test_runtime(
9849        mob_id: &str,
9850    ) -> Result<(tempfile::TempDir, MobRuntime), Box<dyn std::error::Error + Send + Sync>> {
9851        let temp_dir = tempfile::tempdir()?;
9852        let session_path = temp_dir.path().join("sessions");
9853        std::fs::create_dir_all(&session_path)?;
9854        let factory = AgentFactory::new(&session_path).comms(true);
9855        let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
9856        let definition = MobDefinition::from_toml(&format!(
9857            r#"
9858[mob]
9859id = "{mob_id}"
9860
9861[profiles.worker]
9862model = "gpt-5.5"
9863external_addressable = true
9864
9865[profiles.worker.tools]
9866comms = true
9867"#
9868        ))?;
9869        let runtime = MobRuntime::bootstrap(
9870            MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
9871                .with_options(MobBootstrapOptions {
9872                    allow_ephemeral_sessions: true,
9873                    notify_orchestrator_on_resume: true,
9874                    default_llm_client: Some(Arc::new(TestClient::default())),
9875                }),
9876        )
9877        .await?;
9878        Ok((temp_dir, runtime))
9879    }
9880
9881    fn rpc_request(method: &str) -> JsonRpcRequest {
9882        JsonRpcRequest {
9883            jsonrpc: JSONRPC_VERSION.to_string(),
9884            id: Some(json!(1)),
9885            method: method.to_string(),
9886            params: json!({}),
9887        }
9888    }
9889
9890    fn rpc_request_with_params(method: &str, params: Value) -> JsonRpcRequest {
9891        JsonRpcRequest {
9892            jsonrpc: JSONRPC_VERSION.to_string(),
9893            id: Some(json!(1)),
9894            method: method.to_string(),
9895            params,
9896        }
9897    }
9898
9899    #[tokio::test]
9900    async fn read_only_aggregator_capabilities_omit_mutating_methods() {
9901        let response = handle_console_aggregator_rpc(
9902            None,
9903            rpc_request("mobkit/capabilities"),
9904            true,
9905            true,
9906            None,
9907            None,
9908        )
9909        .await;
9910
9911        let methods = response["result"]["methods"]
9912            .as_array()
9913            .expect("capabilities methods");
9914        assert!(
9915            methods.iter().all(|method| method != "mobkit/console/send"),
9916            "read-only capabilities must omit send: {methods:#?}"
9917        );
9918        assert_eq!(response["result"]["read_only"], json!(true));
9919        assert_eq!(
9920            response["result"]["runtime_capabilities"]["can_send_messages"],
9921            json!(false)
9922        );
9923    }
9924
9925    #[tokio::test]
9926    async fn read_only_aggregator_denies_direct_mutating_rpc() {
9927        let response = handle_console_aggregator_rpc(
9928            None,
9929            rpc_request_with_params(
9930                "mobkit/console/send",
9931                json!({
9932                    "identity": "worker",
9933                    "content": "hello",
9934                    "origin": "test",
9935                    "idempotency_key": "read-only-send",
9936                }),
9937            ),
9938            true,
9939            true,
9940            None,
9941            None,
9942        )
9943        .await;
9944
9945        assert_eq!(response["result"], Value::Null);
9946        assert_eq!(response["error"]["code"], json!(-32010));
9947        assert_eq!(response["error"]["data"]["kind"], json!("read_only"));
9948    }
9949
9950    #[test]
9951    fn read_only_mutating_methods_include_state_draining_collect_completed() {
9952        assert!(super::is_console_mutating_rpc_method(
9953            "mobkit/collect_completed"
9954        ));
9955    }
9956
9957    #[test]
9958    fn authenticated_gating_approver_comes_from_console_principal() {
9959        let forged_params = json!({ "approver_id": "forged-browser-value" });
9960
9961        assert_eq!(
9962            super::resolve_gating_approver_id(&forged_params, Some("admin@example.com"),),
9963            Ok("admin@example.com".to_string()),
9964        );
9965    }
9966
9967    #[test]
9968    fn unauthenticated_local_gating_approver_requires_request_param() {
9969        assert_eq!(
9970            super::resolve_gating_approver_id(&json!({ "approver_id": " local-operator " }), None,),
9971            Ok("local-operator".to_string()),
9972        );
9973        assert_eq!(
9974            super::resolve_gating_approver_id(&json!({}), None),
9975            Err("approver_id required"),
9976        );
9977    }
9978
9979    /// DECISION (2026-07-06, meerkat-studio ask K2): console JSON-RPC internal
9980    /// errors DO disclose the failure reason. Every caller that reaches these
9981    /// handlers is an operator by construction — an auth-gated console 401s
9982    /// unauthenticated callers before dispatch, and an open console is a
9983    /// trusted-local deployment choice — while the previous redaction made
9984    /// every -32000 undiagnosable from the client (the K1 retire/respawn
9985    /// failures cost the reporter a day). The `error` field stays a stable
9986    /// kind discriminator; `console_send_public_message` keeps ITS redaction
9987    /// because that surface can reflect into agent-visible space.
9988    #[test]
9989    fn internal_error_carries_detail_and_stable_kind() {
9990        let response = super::internal_error(json!(7), "retire failed: backend says no");
9991
9992        assert_eq!(response["error"]["code"], json!(-32000));
9993        assert_eq!(
9994            response["error"]["message"],
9995            json!("retire failed: backend says no")
9996        );
9997        assert_eq!(response["error"]["data"]["error"], json!("internal_error"));
9998        assert_eq!(
9999            response["error"]["data"]["detail"],
10000            json!("retire failed: backend says no")
10001        );
10002    }
10003
10004    #[test]
10005    fn console_send_public_message_hides_dispatch_details() {
10006        let message = super::console_send_public_message(
10007            &crate::console_aggregator::ConsoleSendError::Dispatch(
10008                "secret backend DSN".to_string(),
10009            ),
10010        );
10011
10012        assert_eq!(message, "console send failed");
10013        assert!(!message.contains("secret backend DSN"));
10014    }
10015
10016    #[test]
10017    fn console_send_json_rpc_error_hides_backend_details() {
10018        let response = super::console_send_rpc_error(
10019            json!(7),
10020            crate::console_aggregator::ConsoleSendError::Dispatch("secret backend DSN".to_string()),
10021        );
10022
10023        assert_eq!(response["error"]["code"], json!(-32000));
10024        assert_eq!(response["error"]["message"], json!("console send failed"));
10025        assert!(!response.to_string().contains("secret backend DSN"));
10026    }
10027
10028    #[test]
10029    fn console_timeline_replay_error_hides_backend_details() {
10030        let response = super::console_timeline_replay_unavailable_response(
10031            json!(7),
10032            Box::new(std::io::Error::other("secret backend DSN")),
10033            None,
10034            None,
10035        );
10036
10037        assert_eq!(
10038            response["error"]["code"],
10039            json!(crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE)
10040        );
10041        assert_eq!(
10042            response["error"]["message"],
10043            json!("timeline replay unavailable")
10044        );
10045        assert!(!response.to_string().contains("secret backend DSN"));
10046    }
10047
10048    #[test]
10049    fn gating_decision_error_hides_backend_details() {
10050        let response = super::gating_decision_failed_error(json!(7), "secret backend DSN");
10051
10052        assert_eq!(response["error"]["code"], json!(-32602));
10053        assert_eq!(
10054            response["error"]["message"],
10055            json!("gating decision failed")
10056        );
10057        assert!(!response.to_string().contains("secret backend DSN"));
10058    }
10059
10060    #[tokio::test]
10061    async fn console_runtime_identity_controls_resolve_durable_member_aliases()
10062    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10063        let (_temp_dir, runtime) =
10064            build_empty_console_test_runtime("console-identity-control-alias").await?;
10065        let mut labels = BTreeMap::new();
10066        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10067        labels.insert("display_name".to_string(), "Review Agent".to_string());
10068        runtime
10069            .handle()
10070            .spawn_spec(
10071                SpawnMemberSpec::from_wire(
10072                    "worker".to_string(),
10073                    // meerkat 0.7: roster ids are comms-safe encodings of the
10074                    // public alias (MemberCommsName rejects ":").
10075                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10076                    Some("You are Review Agent.".into()),
10077                    None,
10078                    None,
10079                )
10080                .with_labels(labels),
10081            )
10082            .await?;
10083
10084        let durable_status = Box::pin(handle_console_runtime_rpc(
10085            &runtime,
10086            None,
10087            None,
10088            None,
10089            None,
10090            None,
10091            None,
10092            None,
10093            None,
10094            rpc_request_with_params(
10095                "mobkit/status_identity",
10096                json!({ "identity": "review:singleton" }),
10097            ),
10098            true,
10099        ))
10100        .await;
10101        assert_eq!(durable_status["error"], Value::Null);
10102        assert_eq!(
10103            durable_status["result"]["identity"],
10104            json!("review:singleton")
10105        );
10106        assert_eq!(
10107            durable_status["result"]["agent_runtime_id"],
10108            json!("rt:review:singleton:0")
10109        );
10110
10111        let runtime_id_status = Box::pin(handle_console_runtime_rpc(
10112            &runtime,
10113            None,
10114            None,
10115            None,
10116            None,
10117            None,
10118            None,
10119            None,
10120            None,
10121            rpc_request_with_params(
10122                "mobkit/status_identity",
10123                json!({ "identity": "rt:review:singleton:0" }),
10124            ),
10125            true,
10126        ))
10127        .await;
10128        assert_eq!(runtime_id_status["error"], Value::Null);
10129        assert_eq!(
10130            runtime_id_status["result"]["identity"],
10131            json!("review:singleton")
10132        );
10133
10134        let runtime_id_inspect = Box::pin(handle_console_runtime_rpc(
10135            &runtime,
10136            None,
10137            None,
10138            None,
10139            None,
10140            None,
10141            None,
10142            None,
10143            None,
10144            rpc_request_with_params(
10145                "mobkit/inspect_identity",
10146                json!({ "identity": "rt:review:singleton:0" }),
10147            ),
10148            true,
10149        ))
10150        .await;
10151        assert_eq!(runtime_id_inspect["error"], Value::Null);
10152        assert_eq!(
10153            runtime_id_inspect["result"]["identity"],
10154            json!("review:singleton")
10155        );
10156
10157        let respawn = Box::pin(handle_console_runtime_rpc(
10158            &runtime,
10159            None,
10160            None,
10161            None,
10162            Some(ConsoleEventStore::new()),
10163            None,
10164            None,
10165            None,
10166            None,
10167            rpc_request_with_params("mobkit/respawn", json!({ "identity": "review:singleton" })),
10168            true,
10169        ))
10170        .await;
10171        assert_eq!(respawn["error"], Value::Null);
10172        assert_eq!(respawn["result"]["identity"], json!("review:singleton"));
10173        assert_eq!(
10174            respawn["result"]["agent_runtime_id"],
10175            json!("rt:review:singleton:0")
10176        );
10177
10178        // Without an identity-first runtime, reset degrades to the live
10179        // member respawn fallback instead of erroring; the console Reset
10180        // button stays functional on plain runtimes.
10181        let reset_without_identity_runtime = Box::pin(handle_console_runtime_rpc(
10182            &runtime,
10183            None,
10184            None,
10185            None,
10186            Some(ConsoleEventStore::new()),
10187            None,
10188            None,
10189            None,
10190            None,
10191            rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
10192            true,
10193        ))
10194        .await;
10195        assert_eq!(
10196            reset_without_identity_runtime["error"],
10197            Value::Null,
10198            "{reset_without_identity_runtime:#?}"
10199        );
10200        assert_eq!(
10201            reset_without_identity_runtime["result"]["identity"],
10202            json!("review:singleton")
10203        );
10204        assert_eq!(
10205            reset_without_identity_runtime["result"]["agent_runtime_id"],
10206            json!("rt:review:singleton:0")
10207        );
10208
10209        let reset_unknown_identity = Box::pin(handle_console_runtime_rpc(
10210            &runtime,
10211            None,
10212            None,
10213            None,
10214            Some(ConsoleEventStore::new()),
10215            None,
10216            None,
10217            None,
10218            None,
10219            rpc_request_with_params("mobkit/reset", json!({ "identity": "missing:member" })),
10220            true,
10221        ))
10222        .await;
10223        assert_ne!(reset_unknown_identity["error"], Value::Null);
10224        assert!(
10225            reset_unknown_identity["error"]["message"]
10226                .as_str()
10227                .unwrap_or_default()
10228                .contains("identity not found")
10229        );
10230
10231        let _ = runtime.handle().stop().await;
10232        Ok(())
10233    }
10234
10235    #[tokio::test]
10236    async fn console_runtime_identity_controls_reject_ambiguous_live_label_aliases()
10237    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10238        let (_temp_dir, runtime) =
10239            build_empty_console_test_runtime("console-identity-ambiguous-live-alias").await?;
10240        for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
10241            let mut labels = BTreeMap::new();
10242            labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10243            runtime
10244                .handle()
10245                .spawn_spec(
10246                    SpawnMemberSpec::from_wire(
10247                        "worker".to_string(),
10248                        // meerkat 0.7: roster ids are comms-safe encodings of
10249                        // the public alias (MemberCommsName rejects ":").
10250                        crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
10251                        Some("You are a duplicate Review Agent.".into()),
10252                        None,
10253                        None,
10254                    )
10255                    .with_labels(labels),
10256                )
10257                .await?;
10258        }
10259
10260        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10261            for method in [
10262                "mobkit/status_identity",
10263                "mobkit/inspect_identity",
10264                "mobkit/retire",
10265                "mobkit/respawn",
10266                "mobkit/reset",
10267            ] {
10268                let response = Box::pin(handle_console_runtime_rpc(
10269                    &runtime,
10270                    None,
10271                    None,
10272                    None,
10273                    Some(ConsoleEventStore::new()),
10274                    None,
10275                    None,
10276                    None,
10277                    None,
10278                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
10279                    true,
10280                ))
10281                .await;
10282                assert_ne!(
10283                    response["error"],
10284                    Value::Null,
10285                    "{method} must reject ambiguous live alias for {requested_identity}"
10286                );
10287                assert_eq!(
10288                    response["error"]["data"]["kind"],
10289                    json!("ambiguous_live_identity_alias"),
10290                    "unexpected response for {method}/{requested_identity}: {response:#?}"
10291                );
10292            }
10293        }
10294
10295        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10296            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10297            lease_provider: Arc::new(LocalLeaseProvider::new()),
10298            runtime_instance_id: "console-identity-ambiguous-live-alias".to_string(),
10299            has_runtime_store: true,
10300            durability_policy: DurabilityPolicy::SyncWriteThrough,
10301            bridge: None,
10302            default_timeout: None,
10303        }));
10304        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10305            for method in ["mobkit/reset", "mobkit/delete_identity"] {
10306                let response = Box::pin(handle_console_runtime_rpc(
10307                    &runtime,
10308                    None,
10309                    None,
10310                    None,
10311                    Some(ConsoleEventStore::new()),
10312                    None,
10313                    Some(identity_runtime.clone()),
10314                    None,
10315                    None,
10316                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
10317                    true,
10318                ))
10319                .await;
10320                assert_ne!(
10321                    response["error"],
10322                    Value::Null,
10323                    "{method} must reject ambiguous live alias for {requested_identity}"
10324                );
10325                assert_eq!(
10326                    response["error"]["data"]["kind"],
10327                    json!("ambiguous_live_identity_alias"),
10328                    "unexpected response for {method}/{requested_identity}: {response:#?}"
10329                );
10330            }
10331        }
10332
10333        let _ = runtime.handle().stop().await;
10334        Ok(())
10335    }
10336
10337    #[tokio::test]
10338    async fn console_runtime_durable_identity_prefers_registered_live_over_duplicate_labels()
10339    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10340        let (_temp_dir, runtime) =
10341            build_empty_console_test_runtime("console-durable-wins-duplicate-live-labels").await?;
10342        for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
10343            let mut labels = BTreeMap::new();
10344            labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10345            runtime
10346                .handle()
10347                .spawn_spec(
10348                    SpawnMemberSpec::from_wire(
10349                        "worker".to_string(),
10350                        // meerkat 0.7: roster ids are comms-safe encodings of
10351                        // the public alias (MemberCommsName rejects ":").
10352                        crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
10353                        Some("You are a Review Agent candidate.".into()),
10354                        None,
10355                        None,
10356                    )
10357                    .with_labels(labels),
10358                )
10359                .await?;
10360        }
10361
10362        let store = Arc::new(LocalContinuityStore::in_memory()?);
10363        let lease_provider = Arc::new(LocalLeaseProvider::new());
10364        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10365            continuity_store: store.clone(),
10366            lease_provider: lease_provider.clone(),
10367            runtime_instance_id: "test-runtime".to_string(),
10368            has_runtime_store: true,
10369            durability_policy: DurabilityPolicy::SyncWriteThrough,
10370            bridge: None,
10371            default_timeout: None,
10372        }));
10373        let identity = AgentIdentity::parse("review:singleton")?;
10374        let registered_session_id = runtime
10375            .handle()
10376            .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
10377                "rt:review:singleton:0",
10378            ))
10379            .await
10380            .unwrap_or_else(meerkat_core::types::SessionId::new);
10381        let record = ContinuityRecord {
10382            identity: identity.clone(),
10383            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10384            session_id: registered_session_id,
10385            generation: ContinuityGeneration::new(0),
10386            checkpoint_version: CheckpointVersion::new(0),
10387        };
10388        let grants = lease_provider
10389            .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
10390            .await?;
10391        let grant = match grants.get(&identity).cloned() {
10392            Some(LeaseAcquireResult::Acquired(grant)) => grant,
10393            other => return Err(format!("expected acquired lease, got {other:?}").into()),
10394        };
10395        store
10396            .upsert_continuity_record(&record, grant.fencing_token)
10397            .await?;
10398        identity_runtime
10399            .register(
10400                DurableAgentSpec {
10401                    identity: identity.clone(),
10402                    profile: ProfileName::from("worker"),
10403                    addressability: AgentAddressability::Addressable,
10404                    display_name: None,
10405                    labels: BTreeMap::new(),
10406                    context: None,
10407                    additional_instructions: Vec::new(),
10408                    initial_message: None,
10409                    runtime_mode_override: None,
10410                    backend: None,
10411                    binding: None,
10412                },
10413                IdentityLifecycleState::Active,
10414                Some(record),
10415                Some(grant),
10416            )
10417            .await;
10418
10419        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10420            for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
10421                let response = Box::pin(handle_console_runtime_rpc(
10422                    &runtime,
10423                    None,
10424                    None,
10425                    None,
10426                    Some(ConsoleEventStore::new()),
10427                    None,
10428                    Some(identity_runtime.clone()),
10429                    None,
10430                    None,
10431                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
10432                    true,
10433                ))
10434                .await;
10435                assert_eq!(
10436                    response["error"],
10437                    Value::Null,
10438                    "{method} must use durable registered live binding despite duplicate labels for {requested_identity}: {response:#?}"
10439                );
10440            }
10441        }
10442        let reset_all_response = Box::pin(handle_console_runtime_rpc(
10443            &runtime,
10444            None,
10445            None,
10446            None,
10447            Some(ConsoleEventStore::new()),
10448            None,
10449            Some(identity_runtime.clone()),
10450            None,
10451            None,
10452            rpc_request("mobkit/reset_all"),
10453            true,
10454        ))
10455        .await;
10456        assert_eq!(
10457            reset_all_response["error"],
10458            Value::Null,
10459            "reset_all must also prefer the durable registered live binding despite duplicate labels: {reset_all_response:#?}"
10460        );
10461        assert!(
10462            reset_all_response["result"]["failed"]
10463                .as_array()
10464                .is_some_and(Vec::is_empty),
10465            "reset_all should not report duplicate-label failure for durable registered binding: {reset_all_response:#?}"
10466        );
10467
10468        let _ = runtime.handle().stop().await;
10469        Ok(())
10470    }
10471
10472    #[tokio::test]
10473    async fn console_runtime_identity_controls_reject_wrong_projected_live_only_alias()
10474    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10475        let (_temp_dir, runtime) =
10476            build_empty_console_test_runtime("console-identity-wrong-projected-live-only").await?;
10477
10478        let mut labels = BTreeMap::new();
10479        labels.insert("agent_identity".to_string(), "other:singleton".to_string());
10480        runtime
10481            .handle()
10482            .spawn_spec(
10483                SpawnMemberSpec::from_wire(
10484                    "worker".to_string(),
10485                    // meerkat 0.7: roster ids are comms-safe encodings of the
10486                    // public alias (MemberCommsName rejects ":").
10487                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10488                    Some("You are a wrong-projected Review Agent.".into()),
10489                    None,
10490                    None,
10491                )
10492                .with_labels(labels),
10493            )
10494            .await?;
10495
10496        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10497            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10498            lease_provider: Arc::new(LocalLeaseProvider::new()),
10499            runtime_instance_id: "console-identity-wrong-projected-live-only".to_string(),
10500            has_runtime_store: true,
10501            durability_policy: DurabilityPolicy::SyncWriteThrough,
10502            bridge: None,
10503            default_timeout: None,
10504        }));
10505        let identity = AgentIdentity::parse("review:singleton")?;
10506        let record = ContinuityRecord {
10507            identity: identity.clone(),
10508            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10509            session_id: meerkat_core::types::SessionId::new(),
10510            generation: ContinuityGeneration::new(0),
10511            checkpoint_version: CheckpointVersion::new(0),
10512        };
10513        identity_runtime
10514            .register(
10515                DurableAgentSpec {
10516                    identity: identity.clone(),
10517                    profile: ProfileName::from("worker"),
10518                    addressability: AgentAddressability::Addressable,
10519                    display_name: None,
10520                    labels: BTreeMap::new(),
10521                    context: None,
10522                    additional_instructions: Vec::new(),
10523                    initial_message: None,
10524                    runtime_mode_override: None,
10525                    backend: None,
10526                    binding: None,
10527                },
10528                IdentityLifecycleState::Active,
10529                Some(record),
10530                Some(LeaseGrant {
10531                    identity,
10532                    fencing_token: FencingToken::new(1),
10533                    ttl: Duration::from_mins(1),
10534                }),
10535            )
10536            .await;
10537
10538        for method in [
10539            "mobkit/status_identity",
10540            "mobkit/inspect_identity",
10541            "mobkit/retire",
10542        ] {
10543            let response = Box::pin(handle_console_runtime_rpc(
10544                &runtime,
10545                None,
10546                None,
10547                None,
10548                Some(ConsoleEventStore::new()),
10549                None,
10550                Some(identity_runtime.clone()),
10551                None,
10552                None,
10553                rpc_request_with_params(method, json!({ "identity": "other:singleton" })),
10554                true,
10555            ))
10556            .await;
10557            assert_ne!(
10558                response["error"],
10559                Value::Null,
10560                "{method} must reject wrong-projected live-only alias"
10561            );
10562            assert_eq!(
10563                response["error"]["data"]["kind"],
10564                json!("stale_live_identity_alias"),
10565                "unexpected response for {method}: {response:#?}"
10566            );
10567        }
10568        assert!(
10569            runtime
10570                .handle()
10571                .get_member(&crate::member_comms_id::mob_member_id(
10572                    "rt:review:singleton:0"
10573                ))
10574                .await
10575                .ok()
10576                .flatten()
10577                .is_some(),
10578            "wrong-projected durable runtime member must not be retired through projected alias"
10579        );
10580
10581        let _ = runtime.handle().stop().await;
10582        Ok(())
10583    }
10584
10585    #[derive(Debug)]
10586    struct HideIdentityPolicy(&'static str);
10587
10588    impl ConsoleVisibilityPolicy for HideIdentityPolicy {
10589        fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
10590            record.identity != self.0
10591        }
10592    }
10593
10594    #[derive(Debug)]
10595    struct HideMemberPolicy(&'static str);
10596
10597    impl ConsoleVisibilityPolicy for HideMemberPolicy {
10598        fn member_visible(&self, member: &ConsoleMember) -> bool {
10599            member.agent_identity != self.0
10600                && member
10601                    .labels
10602                    .get("agent_identity")
10603                    .is_none_or(|identity| identity != self.0)
10604        }
10605
10606        fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
10607            record.runtime_member_id != self.0
10608        }
10609    }
10610
10611    #[derive(Debug)]
10612    struct HideOnlyMemberPolicy(&'static str);
10613
10614    impl ConsoleVisibilityPolicy for HideOnlyMemberPolicy {
10615        fn member_visible(&self, member: &ConsoleMember) -> bool {
10616            member.agent_identity != self.0
10617        }
10618    }
10619
10620    #[tokio::test]
10621    async fn console_runtime_identity_controls_respect_visibility_policy()
10622    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10623        let (_temp_dir, runtime) =
10624            build_empty_console_test_runtime("console-identity-hidden-controls").await?;
10625        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10626            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10627            lease_provider: Arc::new(LocalLeaseProvider::new()),
10628            runtime_instance_id: "console-identity-hidden-controls".to_string(),
10629            has_runtime_store: true,
10630            durability_policy: DurabilityPolicy::SyncWriteThrough,
10631            bridge: None,
10632            default_timeout: None,
10633        }));
10634        let identity = AgentIdentity::parse("review:singleton")?;
10635        let record = ContinuityRecord {
10636            identity: identity.clone(),
10637            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10638            session_id: meerkat_core::types::SessionId::new(),
10639            generation: ContinuityGeneration::new(0),
10640            checkpoint_version: CheckpointVersion::new(0),
10641        };
10642        identity_runtime
10643            .register(
10644                DurableAgentSpec {
10645                    identity: identity.clone(),
10646                    profile: ProfileName::from("worker"),
10647                    addressability: AgentAddressability::Addressable,
10648                    display_name: None,
10649                    labels: BTreeMap::new(),
10650                    context: None,
10651                    additional_instructions: Vec::new(),
10652                    initial_message: None,
10653                    runtime_mode_override: None,
10654                    backend: None,
10655                    binding: None,
10656                },
10657                IdentityLifecycleState::Active,
10658                Some(record),
10659                Some(LeaseGrant {
10660                    identity: identity.clone(),
10661                    fencing_token: FencingToken::new(7),
10662                    ttl: Duration::from_mins(1),
10663                }),
10664            )
10665            .await;
10666
10667        for method in [
10668            "mobkit/status_identity",
10669            "mobkit/inspect_identity",
10670            "mobkit/retire",
10671            "mobkit/respawn",
10672            "mobkit/reset",
10673            "mobkit/delete_identity",
10674        ] {
10675            let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10676                &runtime,
10677                None,
10678                None,
10679                None,
10680                Some(ConsoleEventStore::new()),
10681                None,
10682                Some(identity_runtime.clone()),
10683                None,
10684                None,
10685                &HideIdentityPolicy("review:singleton"),
10686                rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
10687                true,
10688                false,
10689                None,
10690                None,
10691                None,
10692                None,
10693                None,
10694                None,
10695                None,
10696            ))
10697            .await;
10698            assert_ne!(
10699                response["error"],
10700                Value::Null,
10701                "{method} must reject hidden durable identity"
10702            );
10703            assert_eq!(
10704                response["error"]["data"]["kind"],
10705                json!("identity_hidden_by_policy"),
10706                "unexpected hidden response for {method}: {response:#?}"
10707            );
10708        }
10709        identity_runtime
10710            .status(&AgentIdentity::parse("review:singleton")?)
10711            .await
10712            .expect("hidden control RPCs must not mutate the durable identity");
10713
10714        let _ = runtime.handle().stop().await;
10715        Ok(())
10716    }
10717
10718    #[tokio::test]
10719    async fn console_runtime_durable_identity_controls_reject_hidden_bound_member()
10720    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10721        let (_temp_dir, runtime) =
10722            build_empty_console_test_runtime("console-durable-hidden-bound-member").await?;
10723        runtime
10724            .handle()
10725            .spawn_spec(SpawnMemberSpec::from_wire(
10726                "worker".to_string(),
10727                // meerkat 0.7: roster ids are comms-safe encodings of the
10728                // public alias (MemberCommsName rejects ":").
10729                crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10730                Some("You are the live Review Agent.".into()),
10731                None,
10732                None,
10733            ))
10734            .await?;
10735
10736        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10737            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10738            lease_provider: Arc::new(LocalLeaseProvider::new()),
10739            runtime_instance_id: "console-durable-hidden-bound-member".to_string(),
10740            has_runtime_store: true,
10741            durability_policy: DurabilityPolicy::SyncWriteThrough,
10742            bridge: None,
10743            default_timeout: None,
10744        }));
10745        let identity = AgentIdentity::parse("review:singleton")?;
10746        let record = ContinuityRecord {
10747            identity: identity.clone(),
10748            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10749            session_id: meerkat_core::types::SessionId::new(),
10750            generation: ContinuityGeneration::new(0),
10751            checkpoint_version: CheckpointVersion::new(0),
10752        };
10753        identity_runtime
10754            .register(
10755                DurableAgentSpec {
10756                    identity: identity.clone(),
10757                    profile: ProfileName::from("worker"),
10758                    addressability: AgentAddressability::Addressable,
10759                    display_name: None,
10760                    labels: BTreeMap::new(),
10761                    context: None,
10762                    additional_instructions: Vec::new(),
10763                    initial_message: None,
10764                    runtime_mode_override: None,
10765                    backend: None,
10766                    binding: None,
10767                },
10768                IdentityLifecycleState::Active,
10769                Some(record),
10770                Some(LeaseGrant {
10771                    identity: identity.clone(),
10772                    fencing_token: FencingToken::new(7),
10773                    ttl: Duration::from_mins(1),
10774                }),
10775            )
10776            .await;
10777
10778        for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10779            for method in [
10780                "mobkit/status_identity",
10781                "mobkit/inspect_identity",
10782                "mobkit/retire",
10783                "mobkit/respawn",
10784                "mobkit/reset",
10785                "mobkit/delete_identity",
10786            ] {
10787                let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10788                    &runtime,
10789                    None,
10790                    None,
10791                    None,
10792                    Some(ConsoleEventStore::new()),
10793                    None,
10794                    Some(identity_runtime.clone()),
10795                    None,
10796                    None,
10797                    &HideOnlyMemberPolicy("rt:review:singleton:0"),
10798                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
10799                    true,
10800                    false,
10801                    None,
10802                    None,
10803                    None,
10804                    None,
10805                    None,
10806                    None,
10807                    None,
10808                ))
10809                .await;
10810                assert_eq!(
10811                    response["error"]["data"]["kind"],
10812                    json!("identity_hidden_by_policy"),
10813                    "durable {method} must reject hidden bound live member for {requested_identity}: {response:#?}"
10814                );
10815            }
10816        }
10817
10818        let _ = runtime.handle().stop().await;
10819        Ok(())
10820    }
10821
10822    #[tokio::test]
10823    async fn console_runtime_live_only_identity_controls_respect_visibility_policy()
10824    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10825        let (_temp_dir, runtime) =
10826            build_empty_console_test_runtime("console-live-only-hidden-controls").await?;
10827
10828        let mut labels = BTreeMap::new();
10829        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10830        runtime
10831            .handle()
10832            .spawn_spec(
10833                SpawnMemberSpec::from_wire(
10834                    "worker".to_string(),
10835                    // meerkat 0.7: roster ids are comms-safe encodings of the
10836                    // public alias (MemberCommsName rejects ":").
10837                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10838                    Some("You are the live Review Agent.".into()),
10839                    None,
10840                    None,
10841                )
10842                .with_labels(labels),
10843            )
10844            .await?;
10845
10846        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10847            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10848            lease_provider: Arc::new(LocalLeaseProvider::new()),
10849            runtime_instance_id: "console-live-only-hidden-controls".to_string(),
10850            has_runtime_store: true,
10851            durability_policy: DurabilityPolicy::SyncWriteThrough,
10852            bridge: None,
10853            default_timeout: None,
10854        }));
10855
10856        for method in [
10857            "mobkit/status_identity",
10858            "mobkit/inspect_identity",
10859            "mobkit/retire",
10860            "mobkit/respawn",
10861            "mobkit/reset",
10862            "mobkit/delete_identity",
10863        ] {
10864            let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10865                &runtime,
10866                None,
10867                None,
10868                None,
10869                Some(ConsoleEventStore::new()),
10870                None,
10871                Some(identity_runtime.clone()),
10872                None,
10873                None,
10874                &HideMemberPolicy("rt:review:singleton:0"),
10875                rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
10876                true,
10877                false,
10878                None,
10879                None,
10880                None,
10881                None,
10882                None,
10883                None,
10884                None,
10885            ))
10886            .await;
10887            assert_ne!(
10888                response["error"],
10889                Value::Null,
10890                "{method} must reject hidden live-only identity"
10891            );
10892            assert_eq!(
10893                response["error"]["data"]["kind"],
10894                json!("identity_hidden_by_policy"),
10895                "unexpected hidden live-only response for {method}: {response:#?}"
10896            );
10897        }
10898        assert!(
10899            runtime
10900                .handle()
10901                .get_member(&crate::member_comms_id::mob_member_id(
10902                    "rt:review:singleton:0"
10903                ))
10904                .await
10905                .ok()
10906                .flatten()
10907                .is_some(),
10908            "hidden live-only controls must not mutate the live member"
10909        );
10910
10911        let _ = runtime.handle().stop().await;
10912        Ok(())
10913    }
10914
10915    #[tokio::test]
10916    async fn console_runtime_reset_live_only_alias_without_session_bridge_uses_live_fallback()
10917    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10918        let (_temp_dir, runtime) =
10919            build_empty_console_test_runtime("console-reset-live-only-no-bridge").await?;
10920
10921        let mut labels = BTreeMap::new();
10922        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10923        runtime
10924            .handle()
10925            .spawn_spec(
10926                SpawnMemberSpec::from_wire(
10927                    "worker".to_string(),
10928                    // meerkat 0.7: roster ids are comms-safe encodings of the
10929                    // public alias (MemberCommsName rejects ":").
10930                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10931                    Some("You are the live Review Agent.".into()),
10932                    None,
10933                    None,
10934                )
10935                .with_labels(labels),
10936            )
10937            .await?;
10938
10939        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10940            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10941            lease_provider: Arc::new(LocalLeaseProvider::new()),
10942            runtime_instance_id: "console-reset-live-only-no-bridge".to_string(),
10943            has_runtime_store: true,
10944            durability_policy: DurabilityPolicy::SyncWriteThrough,
10945            bridge: None,
10946            default_timeout: None,
10947        }));
10948
10949        let response = Box::pin(handle_console_runtime_rpc(
10950            &runtime,
10951            None,
10952            None,
10953            None,
10954            Some(ConsoleEventStore::new()),
10955            None,
10956            Some(identity_runtime),
10957            None,
10958            None,
10959            rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
10960            true,
10961        ))
10962        .await;
10963        assert_eq!(
10964            response["error"],
10965            Value::Null,
10966            "live-only reset should use live fallback instead of requiring session bridge: {response:#?}"
10967        );
10968        assert_eq!(response["result"]["identity"], json!("review:singleton"));
10969
10970        let _ = runtime.handle().stop().await;
10971        Ok(())
10972    }
10973
10974    #[tokio::test]
10975    async fn reset_all_rejects_registered_runtime_projected_under_wrong_identity()
10976    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10977        let (_temp_dir, runtime) =
10978            build_empty_console_test_runtime("console-reset-all-stale-projection").await?;
10979
10980        let mut labels = BTreeMap::new();
10981        labels.insert("agent_identity".to_string(), "other:singleton".to_string());
10982        runtime
10983            .handle()
10984            .spawn_spec(
10985                SpawnMemberSpec::from_wire(
10986                    "worker".to_string(),
10987                    // meerkat 0.7: roster ids are comms-safe encodings of the
10988                    // public alias (MemberCommsName rejects ":").
10989                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10990                    Some("You are a mislabeled Review Agent.".into()),
10991                    None,
10992                    None,
10993                )
10994                .with_labels(labels),
10995            )
10996            .await?;
10997
10998        let store = Arc::new(LocalContinuityStore::in_memory()?);
10999        let lease_provider = Arc::new(LocalLeaseProvider::new());
11000        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11001            continuity_store: store.clone(),
11002            lease_provider: lease_provider.clone(),
11003            runtime_instance_id: "test-runtime".to_string(),
11004            has_runtime_store: true,
11005            durability_policy: DurabilityPolicy::SyncWriteThrough,
11006            bridge: None,
11007            default_timeout: None,
11008        }));
11009        let identity = AgentIdentity::parse("review:singleton")?;
11010        let record = ContinuityRecord {
11011            identity: identity.clone(),
11012            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11013            session_id: meerkat_core::types::SessionId::new(),
11014            generation: ContinuityGeneration::new(0),
11015            checkpoint_version: CheckpointVersion::new(0),
11016        };
11017        let grants = lease_provider
11018            .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
11019            .await?;
11020        let grant = match grants.get(&identity).cloned() {
11021            Some(LeaseAcquireResult::Acquired(grant)) => grant,
11022            other => return Err(format!("expected acquired lease, got {other:?}").into()),
11023        };
11024        store
11025            .upsert_continuity_record(&record, grant.fencing_token)
11026            .await?;
11027        identity_runtime
11028            .register(
11029                DurableAgentSpec {
11030                    identity: identity.clone(),
11031                    profile: ProfileName::from("worker"),
11032                    addressability: AgentAddressability::Addressable,
11033                    display_name: None,
11034                    labels: BTreeMap::new(),
11035                    context: None,
11036                    additional_instructions: Vec::new(),
11037                    initial_message: None,
11038                    runtime_mode_override: None,
11039                    backend: None,
11040                    binding: None,
11041                },
11042                IdentityLifecycleState::Active,
11043                Some(record),
11044                Some(grant),
11045            )
11046            .await;
11047
11048        let response = Box::pin(handle_console_runtime_rpc(
11049            &runtime,
11050            None,
11051            None,
11052            None,
11053            Some(ConsoleEventStore::new()),
11054            None,
11055            Some(identity_runtime),
11056            None,
11057            None,
11058            rpc_request("mobkit/reset_all"),
11059            true,
11060        ))
11061        .await;
11062        assert_ne!(response["error"], Value::Null);
11063        let failed = response["error"]["data"]["failed"]
11064            .as_array()
11065            .expect("reset_all should report failed identities");
11066        let stale_failure = failed
11067            .iter()
11068            .find(|failure| failure["identity"] == json!("review:singleton"))
11069            .expect("review identity should fail stale alias validation");
11070        assert_eq!(
11071            stale_failure["kind"],
11072            json!("stale_live_identity_alias"),
11073            "unexpected reset_all response: {response:#?}"
11074        );
11075        assert!(
11076            stale_failure["error"]
11077                .as_str()
11078                .unwrap_or_default()
11079                .contains("projects identity other:singleton"),
11080            "unexpected stale failure: {stale_failure:#?}"
11081        );
11082        let retired = response["error"]["data"]["retired_delegates"]
11083            .as_array()
11084            .expect("reset_all should return retired delegates");
11085        assert!(
11086            !retired
11087                .iter()
11088                .any(|entry| entry["identity"] == json!("other:singleton")),
11089            "wrong-projected live alias must not be destructively retired before stale validation; response: {response:#?}"
11090        );
11091        assert!(
11092            runtime
11093                .handle()
11094                .get_member(&crate::member_comms_id::mob_member_id(
11095                    "rt:review:singleton:0"
11096                ))
11097                .await
11098                .ok()
11099                .flatten()
11100                .is_some(),
11101            "wrong-projected durable runtime member must remain present after reset_all rejection"
11102        );
11103
11104        let _ = runtime.handle().stop().await;
11105        Ok(())
11106    }
11107
11108    #[tokio::test]
11109    async fn reset_all_respects_console_visibility_policy_for_live_members()
11110    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11111        let (_temp_dir, runtime) =
11112            build_empty_console_test_runtime("console-reset-all-hidden-live").await?;
11113
11114        runtime
11115            .handle()
11116            .spawn_spec(
11117                SpawnMemberSpec::from_wire(
11118                    "worker".to_string(),
11119                    // meerkat 0.7: roster ids are comms-safe encodings of the
11120                    // public alias (MemberCommsName rejects ":").
11121                    crate::member_comms_id::mob_member_id_str("hidden:singleton").into_owned(),
11122                    Some("You are hidden from console lifecycle controls.".into()),
11123                    None,
11124                    None,
11125                )
11126                .with_labels(BTreeMap::from([(
11127                    "agent_identity".to_string(),
11128                    "hidden:singleton".to_string(),
11129                )])),
11130            )
11131            .await?;
11132
11133        let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11134            &runtime,
11135            None,
11136            None,
11137            None,
11138            Some(ConsoleEventStore::new()),
11139            None,
11140            None,
11141            None,
11142            None,
11143            &HideMemberPolicy("hidden:singleton"),
11144            rpc_request("mobkit/reset_all"),
11145            true,
11146            false,
11147            None,
11148            None,
11149            None,
11150            None,
11151            None,
11152            None,
11153            None,
11154        ))
11155        .await;
11156        assert_eq!(
11157            response["error"],
11158            Value::Null,
11159            "hidden live member should be outside reset_all target set: {response:#?}"
11160        );
11161        assert!(
11162            runtime
11163                .handle()
11164                .get_member(&crate::member_comms_id::mob_member_id("hidden:singleton"))
11165                .await
11166                .ok()
11167                .flatten()
11168                .is_some(),
11169            "reset_all must not retire hidden live members"
11170        );
11171
11172        let _ = runtime.handle().stop().await;
11173        Ok(())
11174    }
11175
11176    #[tokio::test]
11177    async fn reset_all_skips_durable_identity_with_hidden_bound_member()
11178    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11179        let (_temp_dir, runtime) =
11180            build_empty_console_test_runtime("console-reset-all-hidden-durable-bound").await?;
11181        runtime
11182            .handle()
11183            .spawn_spec(SpawnMemberSpec::from_wire(
11184                "worker".to_string(),
11185                // meerkat 0.7: roster ids are comms-safe encodings of the
11186                // public alias (MemberCommsName rejects ":").
11187                crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11188                Some("You are the hidden Review Agent.".into()),
11189                None,
11190                None,
11191            ))
11192            .await?;
11193
11194        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11195            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11196            lease_provider: Arc::new(LocalLeaseProvider::new()),
11197            runtime_instance_id: "console-reset-all-hidden-durable-bound".to_string(),
11198            has_runtime_store: true,
11199            durability_policy: DurabilityPolicy::SyncWriteThrough,
11200            bridge: None,
11201            default_timeout: None,
11202        }));
11203        let identity = AgentIdentity::parse("review:singleton")?;
11204        identity_runtime
11205            .register(
11206                DurableAgentSpec {
11207                    identity: identity.clone(),
11208                    profile: ProfileName::from("worker"),
11209                    addressability: AgentAddressability::Addressable,
11210                    display_name: None,
11211                    labels: BTreeMap::new(),
11212                    context: None,
11213                    additional_instructions: Vec::new(),
11214                    initial_message: None,
11215                    runtime_mode_override: None,
11216                    backend: None,
11217                    binding: None,
11218                },
11219                IdentityLifecycleState::Active,
11220                Some(ContinuityRecord {
11221                    identity: identity.clone(),
11222                    agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11223                    session_id: meerkat_core::types::SessionId::new(),
11224                    generation: ContinuityGeneration::new(0),
11225                    checkpoint_version: CheckpointVersion::new(0),
11226                }),
11227                Some(LeaseGrant {
11228                    identity: identity.clone(),
11229                    fencing_token: FencingToken::new(9),
11230                    ttl: Duration::from_mins(1),
11231                }),
11232            )
11233            .await;
11234
11235        let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11236            &runtime,
11237            None,
11238            None,
11239            None,
11240            Some(ConsoleEventStore::new()),
11241            None,
11242            Some(identity_runtime.clone()),
11243            None,
11244            None,
11245            &HideOnlyMemberPolicy("rt:review:singleton:0"),
11246            rpc_request("mobkit/reset_all"),
11247            true,
11248            false,
11249            None,
11250            None,
11251            None,
11252            None,
11253            None,
11254            None,
11255            None,
11256        ))
11257        .await;
11258        assert_eq!(
11259            response["error"],
11260            Value::Null,
11261            "hidden durable bound member should be outside reset_all target set: {response:#?}"
11262        );
11263        assert_eq!(
11264            identity_runtime.status(&identity).await?.state,
11265            IdentityLifecycleState::Active
11266        );
11267
11268        let _ = runtime.handle().stop().await;
11269        Ok(())
11270    }
11271
11272    #[tokio::test]
11273    async fn identity_lifecycle_cleanup_skips_hidden_projected_duplicates()
11274    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11275        let (_temp_dir, runtime) =
11276            build_empty_console_test_runtime("console-hidden-stale-duplicate-cleanup").await?;
11277        for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
11278            runtime
11279                .handle()
11280                .spawn_spec(
11281                    SpawnMemberSpec::from_wire(
11282                        "worker".to_string(),
11283                        // meerkat 0.7: roster ids are comms-safe encodings of
11284                        // the public alias (MemberCommsName rejects ":").
11285                        crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
11286                        Some("You are a Review Agent candidate.".into()),
11287                        None,
11288                        None,
11289                    )
11290                    .with_labels(BTreeMap::from([(
11291                        "agent_identity".to_string(),
11292                        "review:singleton".to_string(),
11293                    )])),
11294                )
11295                .await?;
11296        }
11297
11298        let store = Arc::new(LocalContinuityStore::in_memory()?);
11299        let lease_provider = Arc::new(LocalLeaseProvider::new());
11300        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11301            continuity_store: store.clone(),
11302            lease_provider: lease_provider.clone(),
11303            runtime_instance_id: "console-hidden-stale-duplicate-cleanup".to_string(),
11304            has_runtime_store: true,
11305            durability_policy: DurabilityPolicy::SyncWriteThrough,
11306            bridge: None,
11307            default_timeout: None,
11308        }));
11309        let identity = AgentIdentity::parse("review:singleton")?;
11310        let record = ContinuityRecord {
11311            identity: identity.clone(),
11312            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11313            session_id: runtime
11314                .handle()
11315                .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
11316                    "rt:review:singleton:0",
11317                ))
11318                .await
11319                .unwrap_or_else(meerkat_core::types::SessionId::new),
11320            generation: ContinuityGeneration::new(0),
11321            checkpoint_version: CheckpointVersion::new(0),
11322        };
11323        let grants = lease_provider
11324            .acquire_leases(
11325                std::slice::from_ref(&identity),
11326                "console-hidden-stale-duplicate-cleanup",
11327            )
11328            .await?;
11329        let grant = match grants.get(&identity).cloned() {
11330            Some(LeaseAcquireResult::Acquired(grant)) => grant,
11331            other => return Err(format!("expected acquired lease, got {other:?}").into()),
11332        };
11333        store
11334            .upsert_continuity_record(&record, grant.fencing_token)
11335            .await?;
11336        identity_runtime
11337            .register(
11338                DurableAgentSpec {
11339                    identity: identity.clone(),
11340                    profile: ProfileName::from("worker"),
11341                    addressability: AgentAddressability::Addressable,
11342                    display_name: None,
11343                    labels: BTreeMap::new(),
11344                    context: None,
11345                    additional_instructions: Vec::new(),
11346                    initial_message: None,
11347                    runtime_mode_override: None,
11348                    backend: None,
11349                    binding: None,
11350                },
11351                IdentityLifecycleState::Active,
11352                Some(record),
11353                Some(grant),
11354            )
11355            .await;
11356
11357        let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11358            &runtime,
11359            None,
11360            None,
11361            None,
11362            Some(ConsoleEventStore::new()),
11363            None,
11364            Some(identity_runtime),
11365            None,
11366            None,
11367            &HideOnlyMemberPolicy("rt:review:singleton:1"),
11368            rpc_request_with_params("mobkit/retire", json!({ "identity": "review:singleton" })),
11369            true,
11370            false,
11371            None,
11372            None,
11373            None,
11374            None,
11375            None,
11376            None,
11377            None,
11378        ))
11379        .await;
11380        assert_eq!(
11381            response["error"],
11382            Value::Null,
11383            "visible durable retire should succeed without touching hidden duplicate: {response:#?}"
11384        );
11385        assert!(
11386            runtime
11387                .handle()
11388                .get_member(&crate::member_comms_id::mob_member_id(
11389                    "rt:review:singleton:1"
11390                ))
11391                .await
11392                .ok()
11393                .flatten()
11394                .is_some(),
11395            "post-mutation stale cleanup must not retire member-hidden projected duplicates"
11396        );
11397
11398        let _ = runtime.handle().stop().await;
11399        Ok(())
11400    }
11401
11402    #[tokio::test]
11403    async fn console_runtime_capabilities_advertise_identity_controls_when_identity_runtime_exists()
11404    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11405        let (_temp_dir, runtime) =
11406            build_empty_console_test_runtime("console-identity-capabilities").await?;
11407        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11408            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11409            lease_provider: Arc::new(LocalLeaseProvider::new()),
11410            runtime_instance_id: "console-identity-capabilities".to_string(),
11411            has_runtime_store: true,
11412            durability_policy: DurabilityPolicy::SyncWriteThrough,
11413            bridge: None,
11414            default_timeout: None,
11415        }));
11416
11417        let response = Box::pin(handle_console_runtime_rpc(
11418            &runtime,
11419            None,
11420            None,
11421            None,
11422            None,
11423            None,
11424            Some(identity_runtime.clone()),
11425            None,
11426            None,
11427            rpc_request("mobkit/capabilities"),
11428            true,
11429        ))
11430        .await;
11431
11432        assert_eq!(response["error"], Value::Null, "{response:#?}");
11433        let methods = response["result"]["methods"]
11434            .as_array()
11435            .ok_or("capabilities methods should be an array")?;
11436        for method in [
11437            "mobkit/status_identity",
11438            "mobkit/inspect_identity",
11439            "mobkit/respawn",
11440            "mobkit/reset",
11441            "mobkit/delete_identity",
11442        ] {
11443            assert!(
11444                methods.iter().any(|candidate| candidate == method),
11445                "identity runtime capabilities should advertise {method}: {methods:#?}"
11446            );
11447        }
11448        for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
11449            assert!(
11450                !methods.iter().any(|candidate| candidate == method),
11451                "console runtime capabilities must not advertise flow-editor method {method}: {methods:#?}"
11452            );
11453        }
11454
11455        let mobpack_response = Box::pin(handle_console_runtime_rpc(
11456            &runtime,
11457            None,
11458            None,
11459            None,
11460            None,
11461            None,
11462            Some(identity_runtime),
11463            None,
11464            None,
11465            rpc_request("mobkit/mobpacks/schema"),
11466            true,
11467        ))
11468        .await;
11469        assert_eq!(
11470            mobpack_response["error"]["code"],
11471            json!(-32601),
11472            "console runtime RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
11473        );
11474
11475        let _ = runtime.handle().stop().await;
11476        Ok(())
11477    }
11478
11479    #[tokio::test]
11480    async fn console_runtime_capabilities_advertise_agent_memory_with_read_only_gating()
11481    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11482        let (_temp_dir, runtime) =
11483            build_empty_console_test_runtime("console-agent-memory-capabilities").await?;
11484        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11485            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11486            lease_provider: Arc::new(LocalLeaseProvider::new()),
11487            runtime_instance_id: "console-agent-memory-capabilities".to_string(),
11488            has_runtime_store: true,
11489            durability_policy: DurabilityPolicy::SyncWriteThrough,
11490            bridge: None,
11491            default_timeout: None,
11492        }));
11493        let memory_dir = tempfile::tempdir()?;
11494        let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
11495        identity_runtime
11496            .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
11497                memory_store,
11498                AgentMemoryConfig::default(),
11499            )))
11500            .await;
11501
11502        let response = Box::pin(handle_console_runtime_rpc(
11503            &runtime,
11504            None,
11505            None,
11506            None,
11507            None,
11508            None,
11509            Some(identity_runtime.clone()),
11510            None,
11511            None,
11512            rpc_request("mobkit/capabilities"),
11513            true,
11514        ))
11515        .await;
11516        assert_eq!(response["error"], Value::Null, "{response:#?}");
11517        let methods = response["result"]["methods"]
11518            .as_array()
11519            .ok_or("capabilities methods should be an array")?;
11520        for method in [
11521            "mobkit/agent_memory/recall",
11522            "mobkit/agent_memory/remember",
11523            "mobkit/agent_memory/forget",
11524        ] {
11525            assert!(
11526                methods.iter().any(|candidate| candidate == method),
11527                "authenticated capabilities should advertise {method}: {methods:#?}"
11528            );
11529        }
11530
11531        let read_only_response = Box::pin(handle_console_runtime_rpc(
11532            &runtime,
11533            None,
11534            None,
11535            None,
11536            None,
11537            None,
11538            Some(identity_runtime),
11539            None,
11540            None,
11541            rpc_request("mobkit/capabilities"),
11542            false,
11543        ))
11544        .await;
11545        assert_eq!(
11546            read_only_response["error"],
11547            Value::Null,
11548            "{read_only_response:#?}"
11549        );
11550        let read_only_methods = read_only_response["result"]["methods"]
11551            .as_array()
11552            .ok_or("read-only capabilities methods should be an array")?;
11553        assert!(
11554            read_only_methods
11555                .iter()
11556                .any(|candidate| candidate == "mobkit/agent_memory/recall"),
11557            "read-only capabilities should keep recall: {read_only_methods:#?}"
11558        );
11559        for method in ["mobkit/agent_memory/remember", "mobkit/agent_memory/forget"] {
11560            assert!(
11561                !read_only_methods
11562                    .iter()
11563                    .any(|candidate| candidate == method),
11564                "read-only capabilities must omit {method}: {read_only_methods:#?}"
11565            );
11566        }
11567
11568        let _ = runtime.handle().stop().await;
11569        Ok(())
11570    }
11571
11572    #[tokio::test]
11573    async fn console_runtime_agent_memory_methods_round_trip()
11574    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11575        let (_temp_dir, runtime) =
11576            build_empty_console_test_runtime("console-agent-memory-round-trip").await?;
11577        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11578            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11579            lease_provider: Arc::new(LocalLeaseProvider::new()),
11580            runtime_instance_id: "console-agent-memory-round-trip".to_string(),
11581            has_runtime_store: true,
11582            durability_policy: DurabilityPolicy::SyncWriteThrough,
11583            bridge: None,
11584            default_timeout: None,
11585        }));
11586        let identity = AgentIdentity::parse("identity:memory-console")?;
11587        identity_runtime
11588            .register(
11589                DurableAgentSpec {
11590                    identity: identity.clone(),
11591                    profile: ProfileName::from("default"),
11592                    addressability: AgentAddressability::Addressable,
11593                    display_name: None,
11594                    labels: BTreeMap::new(),
11595                    context: None,
11596                    additional_instructions: Vec::new(),
11597                    initial_message: None,
11598                    runtime_mode_override: None,
11599                    backend: None,
11600                    binding: None,
11601                },
11602                IdentityLifecycleState::Active,
11603                None,
11604                None,
11605            )
11606            .await;
11607        let memory_dir = tempfile::tempdir()?;
11608        let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
11609        identity_runtime
11610            .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
11611                memory_store,
11612                AgentMemoryConfig::default(),
11613            )))
11614            .await;
11615
11616        let remember = Box::pin(handle_console_runtime_rpc(
11617            &runtime,
11618            None,
11619            None,
11620            None,
11621            None,
11622            None,
11623            Some(identity_runtime.clone()),
11624            None,
11625            None,
11626            rpc_request_with_params(
11627                "mobkit/agent_memory/remember",
11628                json!({
11629                    "identity": "identity:memory-console",
11630                    "title": "Console memory token",
11631                    "body": "The console memory token is CONSOLE-MEM-17.",
11632                    "tags": ["console", "memory"]
11633                }),
11634            ),
11635            true,
11636        ))
11637        .await;
11638        assert_eq!(remember["error"], Value::Null, "{remember:#?}");
11639        let memory_id = remember["result"]["memory_id"]
11640            .as_str()
11641            .ok_or("remember result should include memory_id")?
11642            .to_string();
11643
11644        let recall = Box::pin(handle_console_runtime_rpc(
11645            &runtime,
11646            None,
11647            None,
11648            None,
11649            None,
11650            None,
11651            Some(identity_runtime.clone()),
11652            None,
11653            None,
11654            rpc_request_with_params(
11655                "mobkit/agent_memory/recall",
11656                json!({
11657                    "identity": "identity:memory-console",
11658                    "selection": "contextual",
11659                    "query_text": "Where is CONSOLE-MEM-17?",
11660                    "query_terms": ["CONSOLE-MEM-17"]
11661                }),
11662            ),
11663            false,
11664        ))
11665        .await;
11666        assert_eq!(recall["error"], Value::Null, "{recall:#?}");
11667        assert_eq!(
11668            recall["result"]["records"]
11669                .as_array()
11670                .map(std::vec::Vec::len),
11671            Some(1)
11672        );
11673        assert_eq!(
11674            recall["result"]["records"][0]["memory_id"],
11675            json!(memory_id)
11676        );
11677
11678        let read_only_remember = Box::pin(handle_console_runtime_rpc(
11679            &runtime,
11680            None,
11681            None,
11682            None,
11683            None,
11684            None,
11685            Some(identity_runtime.clone()),
11686            None,
11687            None,
11688            rpc_request_with_params(
11689                "mobkit/agent_memory/remember",
11690                json!({
11691                    "identity": "identity:memory-console",
11692                    "title": "Denied",
11693                    "body": "Denied"
11694                }),
11695            ),
11696            false,
11697        ))
11698        .await;
11699        assert_eq!(
11700            read_only_remember["error"]["data"]["kind"],
11701            json!("read_only"),
11702            "{read_only_remember:#?}"
11703        );
11704
11705        let access_controller = AccessController::new(crate::access::AccessControlConfig {
11706            enabled: true,
11707            admins: vec!["admin@example.test".to_string()],
11708            ..crate::access::AccessControlConfig::default()
11709        })?;
11710        let denied_view = access_controller.view_for_subject(Some("viewer@example.test"));
11711        let denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
11712            &runtime,
11713            None,
11714            None,
11715            None,
11716            None,
11717            None,
11718            Some(identity_runtime.clone()),
11719            None,
11720            None,
11721            &AllowAllConsoleVisibilityPolicy,
11722            rpc_request_with_params(
11723                "mobkit/agent_memory/recall",
11724                json!({
11725                    "identity": "identity:memory-console",
11726                    "selection": "always"
11727                }),
11728            ),
11729            true,
11730            false,
11731            None,
11732            Some(&access_controller),
11733            Some(&denied_view),
11734            None,
11735            None,
11736            None,
11737            None,
11738        ))
11739        .await;
11740        assert_eq!(
11741            denied_recall["error"]["data"]["kind"],
11742            json!("access_denied"),
11743            "{denied_recall:#?}"
11744        );
11745        // §10.3: recall requires agent.memory.read AND agent.view; the read
11746        // action is checked (and reported) first.
11747        assert_eq!(
11748            denied_recall["error"]["data"]["action"],
11749            json!(ACTION_AGENT_MEMORY_READ),
11750            "{denied_recall:#?}"
11751        );
11752
11753        let send_only_controller = AccessController::new(crate::access::AccessControlConfig {
11754            enabled: true,
11755            admins: vec!["admin@example.test".to_string()],
11756            rules: vec![crate::access::AccessRule {
11757                id: "send-only-memory-console".to_string(),
11758                subjects: vec!["sender@example.test".to_string()],
11759                actions: vec![ACTION_AGENT_SEND.to_string()],
11760                agents: vec!["identity:memory-console".to_string()],
11761                ..crate::access::AccessRule::default()
11762            }],
11763            ..crate::access::AccessControlConfig::default()
11764        })?;
11765        let send_only_view = send_only_controller.view_for_subject(Some("sender@example.test"));
11766        let send_only_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
11767            &runtime,
11768            None,
11769            None,
11770            None,
11771            None,
11772            None,
11773            Some(identity_runtime.clone()),
11774            None,
11775            None,
11776            &AllowAllConsoleVisibilityPolicy,
11777            rpc_request_with_params(
11778                "mobkit/agent_memory/remember",
11779                json!({
11780                    "identity": "identity:memory-console",
11781                    "title": "Send-only denied",
11782                    "body": "Send-only users must not persist durable memory."
11783                }),
11784            ),
11785            true,
11786            false,
11787            None,
11788            Some(&send_only_controller),
11789            Some(&send_only_view),
11790            None,
11791            None,
11792            None,
11793            None,
11794        ))
11795        .await;
11796        assert_eq!(
11797            send_only_remember["error"]["data"]["kind"],
11798            json!("access_denied"),
11799            "{send_only_remember:#?}"
11800        );
11801        assert_eq!(
11802            send_only_remember["error"]["data"]["action"],
11803            json!(ACTION_AGENT_MEMORY_WRITE),
11804            "{send_only_remember:#?}"
11805        );
11806
11807        let send_only_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
11808            &runtime,
11809            None,
11810            None,
11811            None,
11812            None,
11813            None,
11814            Some(identity_runtime.clone()),
11815            None,
11816            None,
11817            &AllowAllConsoleVisibilityPolicy,
11818            rpc_request_with_params(
11819                "mobkit/agent_memory/forget",
11820                json!({
11821                    "identity": "identity:memory-console",
11822                    "memory_id": memory_id.clone()
11823                }),
11824            ),
11825            true,
11826            false,
11827            None,
11828            Some(&send_only_controller),
11829            Some(&send_only_view),
11830            None,
11831            None,
11832            None,
11833            None,
11834        ))
11835        .await;
11836        assert_eq!(
11837            send_only_forget["error"]["data"]["kind"],
11838            json!("access_denied"),
11839            "{send_only_forget:#?}"
11840        );
11841        assert_eq!(
11842            send_only_forget["error"]["data"]["action"],
11843            json!(ACTION_AGENT_MEMORY_DELETE),
11844            "{send_only_forget:#?}"
11845        );
11846
11847        let wildcard_allow_exact_deny_controller =
11848            AccessController::new(crate::access::AccessControlConfig {
11849                enabled: true,
11850                admins: vec!["admin@example.test".to_string()],
11851                rules: vec![
11852                    crate::access::AccessRule {
11853                        id: "deny-canonical-memory-console".to_string(),
11854                        effect: crate::access::AccessEffect::Deny,
11855                        subjects: vec!["wildcard@example.test".to_string()],
11856                        actions: vec![
11857                            ACTION_AGENT_VIEW.to_string(),
11858                            ACTION_AGENT_MEMORY_WRITE.to_string(),
11859                            ACTION_AGENT_MEMORY_DELETE.to_string(),
11860                        ],
11861                        agents: vec!["identity:memory-console".to_string()],
11862                        ..crate::access::AccessRule::default()
11863                    },
11864                    crate::access::AccessRule {
11865                        id: "allow-wildcard-memory-console".to_string(),
11866                        subjects: vec!["wildcard@example.test".to_string()],
11867                        actions: vec![
11868                            ACTION_AGENT_VIEW.to_string(),
11869                            ACTION_AGENT_MEMORY_WRITE.to_string(),
11870                            ACTION_AGENT_MEMORY_DELETE.to_string(),
11871                        ],
11872                        agents: vec!["*".to_string()],
11873                        ..crate::access::AccessRule::default()
11874                    },
11875                ],
11876                ..crate::access::AccessControlConfig::default()
11877            })?;
11878        let wildcard_allow_exact_deny_view =
11879            wildcard_allow_exact_deny_controller.view_for_subject(Some("wildcard@example.test"));
11880
11881        let whitespace_denied_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
11882            &runtime,
11883            None,
11884            None,
11885            None,
11886            None,
11887            None,
11888            Some(identity_runtime.clone()),
11889            None,
11890            None,
11891            &AllowAllConsoleVisibilityPolicy,
11892            rpc_request_with_params(
11893                "mobkit/agent_memory/remember",
11894                json!({
11895                    "identity": " identity:memory-console ",
11896                    "title": "Whitespace denied",
11897                    "body": "Whitespace-wrapped identities must be authorized canonically."
11898                }),
11899            ),
11900            true,
11901            false,
11902            None,
11903            Some(&wildcard_allow_exact_deny_controller),
11904            Some(&wildcard_allow_exact_deny_view),
11905            None,
11906            None,
11907            None,
11908            None,
11909        ))
11910        .await;
11911        assert_eq!(
11912            whitespace_denied_remember["error"]["data"]["kind"],
11913            json!("access_denied"),
11914            "{whitespace_denied_remember:#?}"
11915        );
11916        assert_eq!(
11917            whitespace_denied_remember["error"]["data"]["action"],
11918            json!(ACTION_AGENT_MEMORY_WRITE),
11919            "{whitespace_denied_remember:#?}"
11920        );
11921
11922        let whitespace_denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
11923            &runtime,
11924            None,
11925            None,
11926            None,
11927            None,
11928            None,
11929            Some(identity_runtime.clone()),
11930            None,
11931            None,
11932            &AllowAllConsoleVisibilityPolicy,
11933            rpc_request_with_params(
11934                "mobkit/agent_memory/recall",
11935                json!({
11936                    "identity": " identity:memory-console ",
11937                    "selection": "always"
11938                }),
11939            ),
11940            true,
11941            false,
11942            None,
11943            Some(&wildcard_allow_exact_deny_controller),
11944            Some(&wildcard_allow_exact_deny_view),
11945            None,
11946            None,
11947            None,
11948            None,
11949        ))
11950        .await;
11951        assert_eq!(
11952            whitespace_denied_recall["error"]["data"]["kind"],
11953            json!("access_denied"),
11954            "{whitespace_denied_recall:#?}"
11955        );
11956        // The exact-identity deny matches agent.memory.read through its
11957        // `agent.*` pattern; read is checked first (§10.3 mapping).
11958        assert_eq!(
11959            whitespace_denied_recall["error"]["data"]["action"],
11960            json!(ACTION_AGENT_MEMORY_READ),
11961            "{whitespace_denied_recall:#?}"
11962        );
11963
11964        let whitespace_denied_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
11965            &runtime,
11966            None,
11967            None,
11968            None,
11969            None,
11970            None,
11971            Some(identity_runtime.clone()),
11972            None,
11973            None,
11974            &AllowAllConsoleVisibilityPolicy,
11975            rpc_request_with_params(
11976                "mobkit/agent_memory/forget",
11977                json!({
11978                    "identity": " identity:memory-console ",
11979                    "memory_id": memory_id.clone()
11980                }),
11981            ),
11982            true,
11983            false,
11984            None,
11985            Some(&wildcard_allow_exact_deny_controller),
11986            Some(&wildcard_allow_exact_deny_view),
11987            None,
11988            None,
11989            None,
11990            None,
11991        ))
11992        .await;
11993        assert_eq!(
11994            whitespace_denied_forget["error"]["data"]["kind"],
11995            json!("access_denied"),
11996            "{whitespace_denied_forget:#?}"
11997        );
11998        assert_eq!(
11999            whitespace_denied_forget["error"]["data"]["action"],
12000            json!(ACTION_AGENT_MEMORY_DELETE),
12001            "{whitespace_denied_forget:#?}"
12002        );
12003
12004        let forget = Box::pin(handle_console_runtime_rpc(
12005            &runtime,
12006            None,
12007            None,
12008            None,
12009            None,
12010            None,
12011            Some(identity_runtime.clone()),
12012            None,
12013            None,
12014            rpc_request_with_params(
12015                "mobkit/agent_memory/forget",
12016                json!({
12017                    "identity": "identity:memory-console",
12018                    "memory_id": memory_id
12019                }),
12020            ),
12021            true,
12022        ))
12023        .await;
12024        assert_eq!(forget["error"], Value::Null, "{forget:#?}");
12025        assert_eq!(forget["result"]["deleted"], json!(true));
12026
12027        let after_forget = Box::pin(handle_console_runtime_rpc(
12028            &runtime,
12029            None,
12030            None,
12031            None,
12032            None,
12033            None,
12034            Some(identity_runtime),
12035            None,
12036            None,
12037            rpc_request_with_params(
12038                "mobkit/agent_memory/recall",
12039                json!({
12040                    "identity": "identity:memory-console",
12041                    "selection": "always"
12042                }),
12043            ),
12044            false,
12045        ))
12046        .await;
12047        assert_eq!(after_forget["error"], Value::Null, "{after_forget:#?}");
12048        assert_eq!(
12049            after_forget["result"]["records"]
12050                .as_array()
12051                .map(std::vec::Vec::len),
12052            Some(0)
12053        );
12054
12055        let _ = runtime.handle().stop().await;
12056        Ok(())
12057    }
12058
12059    #[tokio::test]
12060    async fn console_runtime_capabilities_advertise_respawn_and_reset_without_identity_runtime()
12061    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12062        let (_temp_dir, runtime) =
12063            build_empty_console_test_runtime("console-plain-lifecycle-capabilities").await?;
12064
12065        // Mutating caller, no identity runtime: respawn/reset dispatch via the
12066        // live-member fallback, so they must be advertised (the console gates
12067        // its Respawn/Reset buttons on this list).
12068        let response = Box::pin(handle_console_runtime_rpc(
12069            &runtime,
12070            None,
12071            None,
12072            None,
12073            None,
12074            None,
12075            None,
12076            None,
12077            None,
12078            rpc_request("mobkit/capabilities"),
12079            true,
12080        ))
12081        .await;
12082        assert_eq!(response["error"], Value::Null, "{response:#?}");
12083        let methods = response["result"]["methods"]
12084            .as_array()
12085            .ok_or("capabilities methods should be an array")?;
12086        for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
12087            assert!(
12088                methods.iter().any(|candidate| candidate == method),
12089                "plain runtime capabilities should advertise {method}: {methods:#?}"
12090            );
12091        }
12092        assert!(
12093            !methods
12094                .iter()
12095                .any(|candidate| candidate == "mobkit/delete_identity"),
12096            "delete_identity requires an identity runtime: {methods:#?}"
12097        );
12098
12099        // Unauthenticated callers keep the read-only projection: no lifecycle
12100        // mutations advertised.
12101        let read_only_response = Box::pin(handle_console_runtime_rpc(
12102            &runtime,
12103            None,
12104            None,
12105            None,
12106            None,
12107            None,
12108            None,
12109            None,
12110            None,
12111            rpc_request("mobkit/capabilities"),
12112            false,
12113        ))
12114        .await;
12115        assert_eq!(
12116            read_only_response["error"],
12117            Value::Null,
12118            "{read_only_response:#?}"
12119        );
12120        let read_only_methods = read_only_response["result"]["methods"]
12121            .as_array()
12122            .ok_or("capabilities methods should be an array")?;
12123        for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
12124            assert!(
12125                !read_only_methods
12126                    .iter()
12127                    .any(|candidate| candidate == method),
12128                "unauthenticated capabilities must omit {method}: {read_only_methods:#?}"
12129            );
12130        }
12131
12132        let _ = runtime.handle().stop().await;
12133        Ok(())
12134    }
12135
12136    #[tokio::test]
12137    async fn console_aggregator_rpc_does_not_expose_flow_editor_authoring_methods() {
12138        let response = Box::pin(handle_console_aggregator_rpc(
12139            None,
12140            rpc_request("mobkit/capabilities"),
12141            true,
12142            false,
12143            None,
12144            None,
12145        ))
12146        .await;
12147
12148        assert_eq!(response["error"], Value::Null, "{response:#?}");
12149        let methods = response["result"]["methods"]
12150            .as_array()
12151            .expect("capabilities methods should be an array");
12152        for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
12153            assert!(
12154                !methods.iter().any(|candidate| candidate == method),
12155                "console aggregator capabilities must not advertise flow-editor method {method}: {methods:#?}"
12156            );
12157        }
12158
12159        let mobpack_response = Box::pin(handle_console_aggregator_rpc(
12160            None,
12161            rpc_request("mobkit/mobpacks/schema"),
12162            true,
12163            false,
12164            None,
12165            None,
12166        ))
12167        .await;
12168        assert_eq!(
12169            mobpack_response["error"]["code"],
12170            json!(-32601),
12171            "console aggregator RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
12172        );
12173    }
12174
12175    #[tokio::test]
12176    async fn console_runtime_identity_reads_reject_stale_runtime_aliases()
12177    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12178        let (_temp_dir, runtime) =
12179            build_empty_console_test_runtime("console-identity-stale-read-alias").await?;
12180        let mut labels = BTreeMap::new();
12181        labels.insert("agent_identity".to_string(), "review:singleton".to_string());
12182        runtime
12183            .handle()
12184            .spawn_spec(
12185                SpawnMemberSpec::from_wire(
12186                    "worker".to_string(),
12187                    // meerkat 0.7: roster ids are comms-safe encodings of the
12188                    // public alias (MemberCommsName rejects ":").
12189                    crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
12190                    Some("You are the stale Review Agent.".into()),
12191                    None,
12192                    None,
12193                )
12194                .with_labels(labels),
12195            )
12196            .await?;
12197
12198        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12199            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12200            lease_provider: Arc::new(LocalLeaseProvider::new()),
12201            runtime_instance_id: "console-identity-stale-read-alias".to_string(),
12202            has_runtime_store: true,
12203            durability_policy: DurabilityPolicy::SyncWriteThrough,
12204            bridge: None,
12205            default_timeout: None,
12206        }));
12207        let identity = AgentIdentity::parse("review:singleton")?;
12208        let record = ContinuityRecord {
12209            identity: identity.clone(),
12210            agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:1")?,
12211            session_id: meerkat_core::types::SessionId::new(),
12212            generation: ContinuityGeneration::new(1),
12213            checkpoint_version: CheckpointVersion::new(0),
12214        };
12215        identity_runtime
12216            .register(
12217                DurableAgentSpec {
12218                    identity: identity.clone(),
12219                    profile: ProfileName::from("worker"),
12220                    addressability: AgentAddressability::Addressable,
12221                    display_name: None,
12222                    labels: BTreeMap::new(),
12223                    context: None,
12224                    additional_instructions: Vec::new(),
12225                    initial_message: None,
12226                    runtime_mode_override: None,
12227                    backend: None,
12228                    binding: None,
12229                },
12230                IdentityLifecycleState::Active,
12231                Some(record),
12232                Some(LeaseGrant {
12233                    identity,
12234                    fencing_token: FencingToken::new(7),
12235                    ttl: Duration::from_mins(1),
12236                }),
12237            )
12238            .await;
12239
12240        for requested_identity in ["rt:review:singleton:0", "review:singleton"] {
12241            for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
12242                let response = Box::pin(handle_console_runtime_rpc(
12243                    &runtime,
12244                    None,
12245                    None,
12246                    None,
12247                    None,
12248                    None,
12249                    Some(identity_runtime.clone()),
12250                    None,
12251                    None,
12252                    rpc_request_with_params(method, json!({ "identity": requested_identity })),
12253                    true,
12254                ))
12255                .await;
12256                assert_ne!(
12257                    response["error"],
12258                    Value::Null,
12259                    "{method} must reject stale alias for {requested_identity}"
12260                );
12261                let message = response["error"]["message"].as_str().unwrap_or_default();
12262                assert!(
12263                    message.contains(
12264                        "identity runtime binding for review:singleton points at rt:review:singleton:1"
12265                    ),
12266                    "unexpected stale-alias message for {method}/{requested_identity}: {message}"
12267                );
12268                assert_eq!(
12269                    response["error"]["data"]["kind"],
12270                    json!("stale_identity_runtime_binding")
12271                );
12272                assert_eq!(
12273                    response["error"]["data"]["registered_runtime_member_id"],
12274                    json!("rt:review:singleton:1")
12275                );
12276                assert_eq!(
12277                    response["error"]["data"]["live_runtime_member_id"],
12278                    json!("rt:review:singleton:0")
12279                );
12280            }
12281        }
12282
12283        let list_response = Box::pin(handle_console_runtime_rpc(
12284            &runtime,
12285            None,
12286            None,
12287            None,
12288            None,
12289            None,
12290            Some(identity_runtime.clone()),
12291            None,
12292            None,
12293            rpc_request("mobkit/list_members"),
12294            true,
12295        ))
12296        .await;
12297        let listed = list_response["result"].as_array().expect("list members");
12298        assert!(
12299            listed
12300                .iter()
12301                .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
12302            "list_members must filter stale runtime aliases: {list_response:#?}"
12303        );
12304
12305        let find_response = Box::pin(handle_console_runtime_rpc(
12306            &runtime,
12307            None,
12308            None,
12309            None,
12310            None,
12311            None,
12312            Some(identity_runtime.clone()),
12313            None,
12314            None,
12315            rpc_request_with_params(
12316                "mobkit/find_members",
12317                json!({ "label_key": "agent_identity", "label_value": "review:singleton" }),
12318            ),
12319            true,
12320        ))
12321        .await;
12322        let found = find_response["result"].as_array().expect("find members");
12323        assert!(
12324            found
12325                .iter()
12326                .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
12327            "find_members must filter stale runtime aliases: {find_response:#?}"
12328        );
12329
12330        for method in [
12331            "mobkit/get_member",
12332            "mobkit/member_status",
12333            "mobkit/identity/resolved_tools",
12334            "mobkit/retire_member",
12335            "mobkit/respawn_member",
12336            "mobkit/force_cancel_member",
12337        ] {
12338            let param_name = if method == "mobkit/identity/resolved_tools" {
12339                "identity"
12340            } else {
12341                "member_id"
12342            };
12343            let response = 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(method, json!({ param_name: "rt:review:singleton:0" })),
12354                true,
12355            ))
12356            .await;
12357            assert_ne!(
12358                response["error"],
12359                Value::Null,
12360                "{method} must reject stale runtime alias"
12361            );
12362            assert_eq!(
12363                response["error"]["data"]["kind"],
12364                json!("stale_identity_runtime_binding")
12365            );
12366            assert_eq!(
12367                response["error"]["data"]["live_runtime_member_id"],
12368                json!("rt:review:singleton:0")
12369            );
12370        }
12371
12372        let (_temp_dir_without_stale, runtime_without_stale) =
12373            build_empty_console_test_runtime("console-identity-no-live-stale-alias").await?;
12374
12375        for method in [
12376            "mobkit/status_identity",
12377            "mobkit/inspect_identity",
12378            "mobkit/retire",
12379            "mobkit/respawn",
12380            "mobkit/reset",
12381        ] {
12382            let response = Box::pin(handle_console_runtime_rpc(
12383                &runtime_without_stale,
12384                None,
12385                None,
12386                None,
12387                Some(ConsoleEventStore::new()),
12388                None,
12389                Some(identity_runtime.clone()),
12390                None,
12391                None,
12392                rpc_request_with_params(method, json!({ "identity": "rt:review:singleton:0" })),
12393                true,
12394            ))
12395            .await;
12396            assert_ne!(
12397                response["error"],
12398                Value::Null,
12399                "{method} must reject stale synthetic runtime alias"
12400            );
12401            assert!(
12402                response["error"]["message"]
12403                    .as_str()
12404                    .unwrap_or_default()
12405                    .contains("identity not found: rt:review:singleton:0"),
12406                "unexpected no-live stale-alias response for {method}: {response:#?}"
12407            );
12408        }
12409        let _ = runtime_without_stale.handle().stop().await;
12410
12411        let _ = runtime.handle().stop().await;
12412        Ok(())
12413    }
12414
12415    #[tokio::test]
12416    async fn aggregator_live_snapshot_projects_identity_first_topology_peers()
12417    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12418        let (_temp_dir, mob_runtime) =
12419            build_empty_console_test_runtime("identity-topology-snapshot-test").await?;
12420        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12421            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12422            lease_provider: Arc::new(LocalLeaseProvider::new()),
12423            runtime_instance_id: "console-topology-snapshot-test".to_string(),
12424            has_runtime_store: true,
12425            durability_policy: DurabilityPolicy::SyncWriteThrough,
12426            bridge: None,
12427            default_timeout: None,
12428        }));
12429
12430        for name in ["agent:alpha", "agent:beta"] {
12431            let identity = AgentIdentity::parse(name)?;
12432            let record = ContinuityRecord {
12433                identity: identity.clone(),
12434                agent_runtime_id: AgentRuntimeId::parse(&format!("rt:{name}:0"))?,
12435                session_id: meerkat_core::types::SessionId::new(),
12436                generation: ContinuityGeneration::new(0),
12437                checkpoint_version: CheckpointVersion::new(0),
12438            };
12439            identity_runtime
12440                .register(
12441                    DurableAgentSpec {
12442                        identity: identity.clone(),
12443                        profile: ProfileName::from("default"),
12444                        addressability: AgentAddressability::Addressable,
12445                        display_name: None,
12446                        labels: BTreeMap::new(),
12447                        context: None,
12448                        additional_instructions: Vec::new(),
12449                        initial_message: None,
12450                        runtime_mode_override: None,
12451                        backend: None,
12452                        binding: None,
12453                    },
12454                    IdentityLifecycleState::Active,
12455                    Some(record),
12456                    Some(LeaseGrant {
12457                        identity,
12458                        fencing_token: FencingToken::new(7),
12459                        ttl: Duration::from_mins(1),
12460                    }),
12461                )
12462                .await;
12463        }
12464        identity_runtime
12465            .set_desired_peer_edges(vec![ManagedPeerEdge::new(
12466                AgentIdentity::parse("agent:alpha")?,
12467                AgentIdentity::parse("agent:beta")?,
12468            )?])
12469            .await;
12470
12471        let aggregator = MobKitConsoleAggregator::in_memory();
12472        aggregator.register_runtime_handles_with_policy(
12473            "identity-first",
12474            "",
12475            mob_runtime.clone(),
12476            Some(identity_runtime),
12477            ConsoleEventStore::new(),
12478            Arc::new(AllowAllConsoleVisibilityPolicy),
12479        );
12480
12481        let snapshot = build_aggregator_live_snapshot(&aggregator, &[]).await?;
12482        let alpha = snapshot
12483            .members
12484            .iter()
12485            .find(|member| member.agent_identity == "agent:alpha")
12486            .ok_or("agent:alpha missing from live snapshot")?;
12487        assert_eq!(alpha.wired_to, vec!["agent:beta".to_string()]);
12488
12489        let _ = mob_runtime.handle().stop().await;
12490        Ok(())
12491    }
12492
12493    #[tokio::test]
12494    async fn identity_first_console_send_reserves_timeline_and_uses_identity_runtime()
12495    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12496        let (_temp_dir, mob_runtime) =
12497            build_empty_console_test_runtime("identity-send-runtime-key-test").await?;
12498        let identity = AgentIdentity::parse("agent:console")?;
12499        let record = ContinuityRecord {
12500            identity: identity.clone(),
12501            agent_runtime_id: AgentRuntimeId::parse("rt:agent:console:0")?,
12502            session_id: meerkat_core::types::SessionId::new(),
12503            generation: ContinuityGeneration::new(0),
12504            checkpoint_version: CheckpointVersion::new(0),
12505        };
12506        let runtime = IdentityRuntime::new(IdentityRuntimeConfig {
12507            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12508            lease_provider: Arc::new(LocalLeaseProvider::new()),
12509            runtime_instance_id: "console-test".to_string(),
12510            has_runtime_store: true,
12511            durability_policy: DurabilityPolicy::SyncWriteThrough,
12512            bridge: None,
12513            default_timeout: None,
12514        });
12515        runtime
12516            .register(
12517                DurableAgentSpec {
12518                    identity: identity.clone(),
12519                    profile: ProfileName::from("default"),
12520                    addressability: AgentAddressability::Addressable,
12521                    display_name: None,
12522                    labels: BTreeMap::new(),
12523                    context: None,
12524                    additional_instructions: Vec::new(),
12525                    initial_message: None,
12526                    runtime_mode_override: None,
12527                    backend: None,
12528                    binding: None,
12529                },
12530                IdentityLifecycleState::Active,
12531                Some(record.clone()),
12532                Some(LeaseGrant {
12533                    identity: identity.clone(),
12534                    fencing_token: FencingToken::new(7),
12535                    ttl: Duration::from_mins(1),
12536                }),
12537            )
12538            .await;
12539
12540        let aggregator = MobKitConsoleAggregator::in_memory();
12541        let events = ConsoleEventStore::new();
12542        let runtime = Arc::new(runtime);
12543        aggregator.register_runtime_handles_with_policy(
12544            "default",
12545            "",
12546            mob_runtime.clone(),
12547            Some(runtime.clone()),
12548            events.clone(),
12549            Arc::new(AllowAllConsoleVisibilityPolicy),
12550        );
12551        let accepted = console_send_identity_first(
12552            &aggregator,
12553            runtime.clone(),
12554            Some(&mob_runtime),
12555            Some(&events),
12556            crate::console_aggregator::ConsoleSendRequest {
12557                identity: identity.as_str().to_string(),
12558                content: serde_json::to_value(meerkat_core::ContentInput::Text(
12559                    "hello".to_string(),
12560                ))?,
12561                origin: "test".to_string(),
12562                idempotency_key: "idem-1".to_string(),
12563                handling_mode: None,
12564            },
12565        )
12566        .await?;
12567
12568        assert_eq!(accepted.identity, identity.as_str());
12569        assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
12570        assert_eq!(accepted.session_id, Some(record.session_id.to_string()));
12571
12572        let page = aggregator
12573            .query_timeline(ConsoleTimelineQuery {
12574                identity: Some(identity.as_str().to_string()),
12575                ..ConsoleTimelineQuery::default()
12576            })
12577            .await?;
12578        assert_eq!(page.frames.len(), 1);
12579        assert_eq!(page.frames[0].runtime_key, "default");
12580        assert_eq!(page.frames[0].status, ConsoleFrameStatus::Accepted);
12581        assert_eq!(
12582            page.frames[0].session_id,
12583            Some(record.session_id.to_string())
12584        );
12585        let _ = mob_runtime.handle().stop().await;
12586        Ok(())
12587    }
12588
12589    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12590    async fn identity_first_console_send_falls_back_to_member_only_spawned_worker()
12591    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12592        let (_temp_dir, mob_runtime) =
12593            build_empty_console_test_runtime("identity-send-member-only-test").await?;
12594        let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12595            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12596            lease_provider: Arc::new(LocalLeaseProvider::new()),
12597            runtime_instance_id: "console-member-only-send-test".to_string(),
12598            has_runtime_store: true,
12599            durability_policy: DurabilityPolicy::SyncWriteThrough,
12600            bridge: None,
12601            default_timeout: None,
12602        }));
12603
12604        let aggregator = MobKitConsoleAggregator::in_memory();
12605        let events = ConsoleEventStore::new();
12606        aggregator.register_runtime_handles_with_policy(
12607            "identity-first",
12608            "",
12609            mob_runtime.clone(),
12610            Some(identity_runtime.clone()),
12611            events.clone(),
12612            Arc::new(AllowAllConsoleVisibilityPolicy),
12613        );
12614
12615        mob_runtime
12616            .handle()
12617            .spawn_spec(SpawnMemberSpec::from_wire(
12618                "worker".to_string(),
12619                // meerkat 0.7: roster ids are comms-safe encodings of the
12620                // public alias (MemberCommsName rejects ":").
12621                crate::member_comms_id::mob_member_id_str("agent:member-only").into_owned(),
12622                Some("You are a member-only spawned worker.".into()),
12623                None,
12624                None,
12625            ))
12626            .await?;
12627
12628        let accepted = console_send_with_identity_first_fallback(
12629            &aggregator,
12630            identity_runtime,
12631            Some(&mob_runtime),
12632            Some(&events),
12633            crate::console_aggregator::ConsoleSendRequest {
12634                identity: "agent:member-only".to_string(),
12635                content: serde_json::to_value(meerkat_core::ContentInput::Text(
12636                    "hello spawned worker".to_string(),
12637                ))?,
12638                origin: "test".to_string(),
12639                idempotency_key: "member-only-idem-1".to_string(),
12640                handling_mode: None,
12641            },
12642        )
12643        .await?;
12644
12645        assert_eq!(accepted.identity, "agent:member-only");
12646        assert!(accepted.session_id.is_some());
12647
12648        let page = aggregator
12649            .query_timeline(ConsoleTimelineQuery {
12650                identity: Some("agent:member-only".to_string()),
12651                ..ConsoleTimelineQuery::default()
12652            })
12653            .await?;
12654        assert!(
12655            page.frames.iter().any(|frame| frame.kind == "user_input"),
12656            "fallback send should persist a user input frame for the member-only worker: {page:#?}"
12657        );
12658
12659        let _ = mob_runtime.handle().stop().await;
12660        Ok(())
12661    }
12662
12663    #[tokio::test]
12664    async fn identity_first_console_send_returns_before_bridge_delivery_completes()
12665    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12666        let identity = AgentIdentity::parse("agent:slow-console")?;
12667        let record = ContinuityRecord {
12668            identity: identity.clone(),
12669            agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-console:0")?,
12670            session_id: meerkat_core::types::SessionId::new(),
12671            generation: ContinuityGeneration::new(0),
12672            checkpoint_version: CheckpointVersion::new(0),
12673        };
12674        let deliver_calls = Arc::new(AtomicUsize::new(0));
12675        let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12676            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12677            lease_provider: Arc::new(LocalLeaseProvider::new()),
12678            runtime_instance_id: "console-slow-send-test".to_string(),
12679            has_runtime_store: true,
12680            durability_policy: DurabilityPolicy::SyncWriteThrough,
12681            bridge: Some(Arc::new(BlockingIdentityBridge {
12682                deliver_calls: deliver_calls.clone(),
12683            })),
12684            default_timeout: None,
12685        }));
12686        runtime
12687            .register(
12688                DurableAgentSpec {
12689                    identity: identity.clone(),
12690                    profile: ProfileName::from("default"),
12691                    addressability: AgentAddressability::Addressable,
12692                    display_name: None,
12693                    labels: BTreeMap::new(),
12694                    context: None,
12695                    additional_instructions: Vec::new(),
12696                    initial_message: None,
12697                    runtime_mode_override: None,
12698                    backend: None,
12699                    binding: None,
12700                },
12701                IdentityLifecycleState::Active,
12702                Some(record.clone()),
12703                Some(LeaseGrant {
12704                    identity: identity.clone(),
12705                    fencing_token: FencingToken::new(7),
12706                    ttl: Duration::from_mins(1),
12707                }),
12708            )
12709            .await;
12710
12711        let aggregator = MobKitConsoleAggregator::in_memory();
12712        let accepted = match tokio::time::timeout(
12713            Duration::from_millis(100),
12714            console_send_identity_first(
12715                &aggregator,
12716                runtime,
12717                None,
12718                None,
12719                crate::console_aggregator::ConsoleSendRequest {
12720                    identity: identity.as_str().to_string(),
12721                    content: serde_json::to_value(meerkat_core::ContentInput::Text(
12722                        "hello slow bridge".to_string(),
12723                    ))?,
12724                    origin: "test".to_string(),
12725                    idempotency_key: "idem-slow-bridge".to_string(),
12726                    handling_mode: None,
12727                },
12728            ),
12729        )
12730        .await
12731        {
12732            Ok(Ok(accepted)) => accepted,
12733            Ok(Err(err)) => return Err(format!("send should be accepted: {err}").into()),
12734            Err(err) => {
12735                return Err(
12736                    format!("console send should not wait for bridge delivery: {err}").into(),
12737                );
12738            }
12739        };
12740
12741        assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
12742        if tokio::time::timeout(Duration::from_millis(100), async {
12743            while deliver_calls.load(Ordering::SeqCst) == 0 {
12744                tokio::time::sleep(Duration::from_millis(5)).await;
12745            }
12746        })
12747        .await
12748        .is_err()
12749        {
12750            return Err("delivery should be spawned in the background".into());
12751        }
12752        Ok(())
12753    }
12754
12755    #[tokio::test]
12756    async fn identity_first_console_steer_waits_for_bridge_delivery()
12757    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12758        let identity = AgentIdentity::parse("agent:slow-steer-console")?;
12759        let record = ContinuityRecord {
12760            identity: identity.clone(),
12761            agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-steer-console:0")?,
12762            session_id: meerkat_core::types::SessionId::new(),
12763            generation: ContinuityGeneration::new(0),
12764            checkpoint_version: CheckpointVersion::new(0),
12765        };
12766        let deliver_calls = Arc::new(AtomicUsize::new(0));
12767        let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12768            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12769            lease_provider: Arc::new(LocalLeaseProvider::new()),
12770            runtime_instance_id: "console-slow-steer-send-test".to_string(),
12771            has_runtime_store: true,
12772            durability_policy: DurabilityPolicy::SyncWriteThrough,
12773            bridge: Some(Arc::new(BlockingIdentityBridge {
12774                deliver_calls: deliver_calls.clone(),
12775            })),
12776            default_timeout: None,
12777        }));
12778        runtime
12779            .register(
12780                DurableAgentSpec {
12781                    identity: identity.clone(),
12782                    profile: ProfileName::from("default"),
12783                    addressability: AgentAddressability::Addressable,
12784                    display_name: None,
12785                    labels: BTreeMap::new(),
12786                    context: None,
12787                    additional_instructions: Vec::new(),
12788                    initial_message: None,
12789                    runtime_mode_override: None,
12790                    backend: None,
12791                    binding: None,
12792                },
12793                IdentityLifecycleState::Active,
12794                Some(record),
12795                Some(LeaseGrant {
12796                    identity: identity.clone(),
12797                    fencing_token: FencingToken::new(7),
12798                    ttl: Duration::from_mins(1),
12799                }),
12800            )
12801            .await;
12802
12803        let aggregator = MobKitConsoleAggregator::in_memory();
12804        let result = tokio::time::timeout(
12805            Duration::from_millis(100),
12806            console_send_identity_first(
12807                &aggregator,
12808                runtime,
12809                None,
12810                None,
12811                crate::console_aggregator::ConsoleSendRequest {
12812                    identity: identity.as_str().to_string(),
12813                    content: serde_json::to_value(meerkat_core::ContentInput::Text(
12814                        "hello slow steer bridge".to_string(),
12815                    ))?,
12816                    origin: "test".to_string(),
12817                    idempotency_key: "idem-slow-steer-bridge".to_string(),
12818                    handling_mode: Some("steer".to_string()),
12819                },
12820            ),
12821        )
12822        .await;
12823
12824        if result.is_ok() {
12825            return Err("steer send must wait for bridge delivery admission".into());
12826        }
12827        assert_eq!(
12828            deliver_calls.load(Ordering::SeqCst),
12829            1,
12830            "steer delivery should have reached the bridge before the console response waits"
12831        );
12832        Ok(())
12833    }
12834
12835    #[tokio::test]
12836    async fn identity_first_console_send_forwards_handling_mode()
12837    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12838        let identity = AgentIdentity::parse("agent:mode-console")?;
12839        let record = ContinuityRecord {
12840            identity: identity.clone(),
12841            agent_runtime_id: AgentRuntimeId::parse("rt:agent:mode-console:0")?,
12842            session_id: meerkat_core::types::SessionId::new(),
12843            generation: ContinuityGeneration::new(0),
12844            checkpoint_version: CheckpointVersion::new(0),
12845        };
12846        let handling_modes = Arc::new(Mutex::new(Vec::new()));
12847        let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12848            continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12849            lease_provider: Arc::new(LocalLeaseProvider::new()),
12850            runtime_instance_id: "console-mode-send-test".to_string(),
12851            has_runtime_store: true,
12852            durability_policy: DurabilityPolicy::SyncWriteThrough,
12853            bridge: Some(Arc::new(RecordingIdentityBridge {
12854                session_id: record.session_id.clone(),
12855                handling_modes: handling_modes.clone(),
12856            })),
12857            default_timeout: None,
12858        }));
12859        runtime
12860            .register(
12861                DurableAgentSpec {
12862                    identity: identity.clone(),
12863                    profile: ProfileName::from("default"),
12864                    addressability: AgentAddressability::Addressable,
12865                    display_name: None,
12866                    labels: BTreeMap::new(),
12867                    context: None,
12868                    additional_instructions: Vec::new(),
12869                    initial_message: None,
12870                    runtime_mode_override: None,
12871                    backend: None,
12872                    binding: None,
12873                },
12874                IdentityLifecycleState::Active,
12875                Some(record),
12876                Some(LeaseGrant {
12877                    identity: identity.clone(),
12878                    fencing_token: FencingToken::new(7),
12879                    ttl: Duration::from_mins(1),
12880                }),
12881            )
12882            .await;
12883
12884        let aggregator = MobKitConsoleAggregator::in_memory();
12885        let accepted = console_send_identity_first(
12886            &aggregator,
12887            runtime,
12888            None,
12889            None,
12890            crate::console_aggregator::ConsoleSendRequest {
12891                identity: identity.as_str().to_string(),
12892                content: serde_json::to_value(meerkat_core::ContentInput::Text(
12893                    "hello steer bridge".to_string(),
12894                ))?,
12895                origin: "test".to_string(),
12896                idempotency_key: "idem-steer-bridge".to_string(),
12897                handling_mode: Some("steer".to_string()),
12898            },
12899        )
12900        .await?;
12901
12902        if tokio::time::timeout(Duration::from_millis(100), async {
12903            loop {
12904                if handling_modes
12905                    .lock()
12906                    .map(|modes| modes.contains(&HandlingMode::Steer))
12907                    .unwrap_or(false)
12908                {
12909                    break;
12910                }
12911                tokio::time::sleep(Duration::from_millis(5)).await;
12912            }
12913        })
12914        .await
12915        .is_err()
12916        {
12917            return Err("identity-first console send should forward steer mode".into());
12918        }
12919
12920        let terminal_frame = tokio::time::timeout(Duration::from_millis(500), async {
12921            loop {
12922                let page = aggregator
12923                    .query_timeline(ConsoleTimelineQuery {
12924                        identity: Some(identity.as_str().to_string()),
12925                        ..ConsoleTimelineQuery::default()
12926                    })
12927                    .await
12928                    .map_err(|err| format!("query timeline: {err}"))?;
12929                if page.frames.iter().any(|frame| {
12930                    frame.kind == "interaction_complete"
12931                        && frame.interaction_id.as_deref() == Some(accepted.interaction_id.as_str())
12932                        && frame.payload.get("reason").and_then(Value::as_str)
12933                            == Some("steer_delivered")
12934                }) {
12935                    return Ok::<(), String>(());
12936                }
12937                tokio::time::sleep(Duration::from_millis(5)).await;
12938            }
12939        })
12940        .await;
12941        match terminal_frame {
12942            Ok(Ok(())) => {}
12943            Ok(Err(err)) => return Err(err.into()),
12944            Err(_) => {
12945                return Err(
12946                    "identity-first steer send should terminalize its console interaction".into(),
12947                );
12948            }
12949        }
12950        Ok(())
12951    }
12952
12953    #[test]
12954    fn multipart_body_limit_covers_configured_image_limit() {
12955        const _: () = assert!(MAX_MULTIPART_BODY_BYTES > MAX_MULTIPART_IMAGE_BYTES);
12956        const _: () = assert!(MAX_MULTIPART_BODY_BYTES > 2 * 1024 * 1024);
12957    }
12958
12959    /// Cold-cache contract: a `prime_now` waiter that arrives while
12960    /// another task holds `refresh_lock` must park on the lock and
12961    /// resume after that task releases it. No race-prone signaling
12962    /// involved — the lock acquisition itself IS the signal that the
12963    /// in-flight refresh has finished and `primed` is true.
12964    ///
12965    /// Test shape: hold `refresh_lock` from the test thread (no real
12966    /// refresh task), spawn a `prime_now`-style waiter, then set
12967    /// `primed` + drop the lock. The waiter must observe `primed`
12968    /// after acquiring the lock and return without redoing the
12969    /// refresh (we'd otherwise deadlock since we don't supply a
12970    /// real `MobRuntime`).
12971    #[tokio::test]
12972    async fn cold_cache_waiter_resumes_when_refresh_lock_drops()
12973    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12974        use std::sync::atomic::Ordering;
12975        use tokio::time::Duration;
12976
12977        let model = ConsoleSnapshotReadModel::default();
12978        let guard = model
12979            .refresh_lock
12980            .clone()
12981            .try_lock_owned()
12982            .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
12983
12984        let model_for_waiter = model.clone();
12985        let waiter = tokio::spawn(async move {
12986            // Inlined `prime_now` shape (skips the runtime call,
12987            // since the test will set `primed` before this acquires).
12988            if model_for_waiter
12989                .primed
12990                .load(std::sync::atomic::Ordering::Acquire)
12991            {
12992                return;
12993            }
12994            let _wait_guard = model_for_waiter.refresh_lock.clone().lock_owned().await;
12995            // After acquiring, `primed` must be true (the "refresher"
12996            // — i.e., the test thread — set it before releasing).
12997            assert!(
12998                model_for_waiter
12999                    .primed
13000                    .load(std::sync::atomic::Ordering::Acquire),
13001                "waiter acquired lock but primed is still false"
13002            );
13003        });
13004
13005        // Give the waiter time to reach `lock_owned().await`.
13006        tokio::time::sleep(Duration::from_millis(20)).await;
13007
13008        // Set primed, then release the lock. The waiter parked on
13009        // `lock_owned()` should acquire it immediately.
13010        model.primed.store(true, Ordering::Release);
13011        drop(guard);
13012
13013        let result = tokio::time::timeout(Duration::from_secs(1), waiter).await;
13014        assert!(
13015            result.is_ok(),
13016            "waiter should resume once the refresh lock drops"
13017        );
13018        Ok(())
13019    }
13020
13021    /// Companion: when `primed` is already set, `snapshot()` returns
13022    /// without touching the refresh lock at all. Guards against an
13023    /// over-eager `prime_now` that would deadlock during normal
13024    /// (hot-cache) traffic.
13025    #[tokio::test]
13026    async fn snapshot_skips_refresh_lock_when_already_primed()
13027    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13028        use std::sync::atomic::Ordering;
13029        use tokio::time::Duration;
13030
13031        let model = ConsoleSnapshotReadModel::default();
13032        model.primed.store(true, Ordering::Release);
13033        // Pre-acquire the refresh lock to prove it isn't touched.
13034        let _guard = model
13035            .refresh_lock
13036            .clone()
13037            .try_lock_owned()
13038            .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
13039
13040        // `snapshot()` calls `prime_now` only on cold cache; with
13041        // primed=true the lock-await branch must not be reached.
13042        // We test the contract via direct inspection: if `prime_now`
13043        // accidentally tried `lock_owned().await` here, this would
13044        // hang. The timeout below is the deadlock guard.
13045        let snap_fast_path = async {
13046            assert!(
13047                model.primed.load(Ordering::Acquire),
13048                "primed precondition for hot-cache path"
13049            );
13050        };
13051        let result = tokio::time::timeout(Duration::from_millis(100), snap_fast_path).await;
13052        assert!(result.is_ok(), "hot-cache snapshot path should not block");
13053        Ok(())
13054    }
13055
13056    #[tokio::test]
13057    async fn console_aggregator_reset_all_rpc_rejects_destructive_retire_all_semantics()
13058    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13059        let (_temp_dir, runtime) =
13060            build_empty_console_test_runtime("console-reset-fresh-identity-cache").await?;
13061        let aggregator = MobKitConsoleAggregator::in_memory();
13062        aggregator.register_runtime_handles_with_policy(
13063            "runtime-reset",
13064            "reset",
13065            runtime.clone(),
13066            None,
13067            ConsoleEventStore::new(),
13068            Arc::new(AllowAllConsoleVisibilityPolicy),
13069        );
13070        let primed_empty = aggregator.list_identities().await?;
13071        assert!(
13072            primed_empty.is_empty(),
13073            "test precondition: identity cache should be primed empty before late spawn"
13074        );
13075
13076        runtime
13077            .handle()
13078            .spawn_spec(SpawnMemberSpec::from_wire(
13079                "worker".to_string(),
13080                "agent-reset".to_string(),
13081                Some("You are agent-reset.".into()),
13082                None,
13083                None,
13084            ))
13085            .await?;
13086
13087        let response = Box::pin(handle_console_aggregator_rpc(
13088            Some(aggregator),
13089            rpc_request("mobkit/reset_all"),
13090            true,
13091            false,
13092            None,
13093            None,
13094        ))
13095        .await;
13096
13097        assert_eq!(response["result"], Value::Null);
13098        assert_eq!(
13099            response["error"]["data"]["kind"],
13100            json!("unsupported_reset_all_surface")
13101        );
13102        assert!(
13103            runtime
13104                .handle()
13105                .get_member(&meerkat_mob::ids::AgentIdentity::from("agent-reset"))
13106                .await
13107                .ok()
13108                .flatten()
13109                .is_some(),
13110            "aggregator reset_all must not retire live members while reporting unsupported"
13111        );
13112        let _ = runtime.handle().stop().await;
13113        Ok(())
13114    }
13115
13116    #[test]
13117    fn timeline_stream_cursor_filter_uses_numeric_console_sequence() {
13118        assert!(cursor_is_after(
13119            &ConsoleCursor::from("console:10"),
13120            &ConsoleCursor::from("console:9")
13121        ));
13122        assert!(!cursor_is_after(
13123            &ConsoleCursor::from("console:9"),
13124            &ConsoleCursor::from("console:10")
13125        ));
13126    }
13127
13128    #[test]
13129    fn console_live_snapshot_dedupes_repeated_delegate_identities() {
13130        let mut members = vec![
13131            ConsoleMember {
13132                agent_identity: "incident-commander".to_string(),
13133                role: "commander".to_string(),
13134                state: "active".to_string(),
13135                model_capabilities: Default::default(),
13136                runtime_mode: None,
13137                session_id: None,
13138                wired_to: Vec::new(),
13139                labels: BTreeMap::new(),
13140                progress: None,
13141            },
13142            ConsoleMember {
13143                agent_identity: "qa-child".to_string(),
13144                role: "delegate".to_string(),
13145                state: "active".to_string(),
13146                model_capabilities: Default::default(),
13147                runtime_mode: None,
13148                session_id: Some("first".to_string()),
13149                wired_to: vec!["qa-parent".to_string()],
13150                labels: BTreeMap::from([(
13151                    "delegate_host_identity".to_string(),
13152                    "qa-parent".to_string(),
13153                )]),
13154                progress: None,
13155            },
13156            ConsoleMember {
13157                agent_identity: "qa-child".to_string(),
13158                role: "delegate".to_string(),
13159                state: "active".to_string(),
13160                model_capabilities: Default::default(),
13161                runtime_mode: None,
13162                session_id: Some("second".to_string()),
13163                wired_to: vec!["qa-parent".to_string()],
13164                labels: BTreeMap::from([(
13165                    "delegate_host_identity".to_string(),
13166                    "qa-parent".to_string(),
13167                )]),
13168                progress: None,
13169            },
13170        ];
13171
13172        dedupe_console_members_by_identity(&mut members);
13173
13174        assert_eq!(
13175            members
13176                .iter()
13177                .map(|member| member.agent_identity.as_str())
13178                .collect::<Vec<_>>(),
13179            vec!["incident-commander", "qa-child"]
13180        );
13181        assert_eq!(members[1].session_id.as_deref(), Some("first"));
13182    }
13183
13184    #[test]
13185    fn console_visibility_policy_hides_implicit_delegate_members_from_snapshot() {
13186        let mut snapshot = ConsoleLiveSnapshot::new(
13187            Some("runtime".to_string()),
13188            true,
13189            vec!["incident-commander".to_string(), "qa-child".to_string()],
13190            vec![
13191                ConsoleAgentLiveSnapshot {
13192                    agent_id: "incident-commander".to_string(),
13193                    member_id: "incident-commander".to_string(),
13194                    label: "Incident Commander".to_string(),
13195                    kind: "meerkat".to_string(),
13196                    identity: Some("incident-commander".to_string()),
13197                    role: Some("commander".to_string()),
13198                    state: Some("active".to_string()),
13199                    session_id: None,
13200                    model_capabilities: Default::default(),
13201                    response_phase: None,
13202                    watched: None,
13203                    alert_level: None,
13204                    degraded: None,
13205                    degraded_reason: None,
13206                },
13207                ConsoleAgentLiveSnapshot {
13208                    agent_id: "qa-child".to_string(),
13209                    member_id: "qa-child".to_string(),
13210                    label: "QA Child".to_string(),
13211                    kind: "meerkat".to_string(),
13212                    identity: Some("qa-child".to_string()),
13213                    role: Some("delegate".to_string()),
13214                    state: Some("active".to_string()),
13215                    session_id: Some("delegate-session".to_string()),
13216                    model_capabilities: Default::default(),
13217                    response_phase: None,
13218                    watched: None,
13219                    alert_level: None,
13220                    degraded: None,
13221                    degraded_reason: None,
13222                },
13223            ],
13224            vec![
13225                ConsoleMember {
13226                    agent_identity: "incident-commander".to_string(),
13227                    role: "commander".to_string(),
13228                    state: "active".to_string(),
13229                    model_capabilities: Default::default(),
13230                    runtime_mode: None,
13231                    session_id: None,
13232                    wired_to: Vec::new(),
13233                    labels: BTreeMap::new(),
13234                    progress: None,
13235                },
13236                ConsoleMember {
13237                    agent_identity: "qa-child".to_string(),
13238                    role: "delegate".to_string(),
13239                    state: "active".to_string(),
13240                    model_capabilities: Default::default(),
13241                    runtime_mode: None,
13242                    session_id: Some("delegate-session".to_string()),
13243                    wired_to: vec!["qa-parent".to_string()],
13244                    labels: BTreeMap::from([(
13245                        "source_mob_id".to_string(),
13246                        "implicit-qa-mob".to_string(),
13247                    )]),
13248                    progress: None,
13249                },
13250            ],
13251            true,
13252        );
13253
13254        apply_console_visibility_policy(
13255            &mut snapshot,
13256            &HideImplicitDelegateMembersConsoleVisibilityPolicy,
13257        );
13258
13259        assert_eq!(
13260            snapshot
13261                .members
13262                .iter()
13263                .map(|member| member.agent_identity.as_str())
13264                .collect::<Vec<_>>(),
13265            vec!["incident-commander"]
13266        );
13267        assert_eq!(
13268            snapshot
13269                .agents
13270                .iter()
13271                .map(|agent| agent.agent_id.as_str())
13272                .collect::<Vec<_>>(),
13273            vec!["incident-commander"]
13274        );
13275        assert_eq!(snapshot.loaded_modules, vec!["incident-commander"]);
13276    }
13277
13278    #[tokio::test]
13279    async fn live_snapshot_member_projection_uses_roster_profile_capabilities()
13280    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13281        let temp_dir = tempfile::tempdir()?;
13282        let session_path = temp_dir.path().join("sessions");
13283        std::fs::create_dir_all(&session_path)?;
13284        let factory = AgentFactory::new(&session_path).comms(true);
13285        let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
13286        let definition = MobDefinition::from_toml(
13287            r#"
13288[mob]
13289id = "console-snapshot-test"
13290
13291[profiles.worker]
13292model = "gpt-5.5"
13293
13294[profiles.worker.tools]
13295comms = true
13296"#,
13297        )?;
13298        let expected = model_capabilities_for_role(&definition, "worker");
13299        let runtime = MobRuntime::bootstrap(
13300            MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
13301                .with_options(MobBootstrapOptions {
13302                    allow_ephemeral_sessions: true,
13303                    notify_orchestrator_on_resume: true,
13304                    default_llm_client: Some(Arc::new(TestClient::default())),
13305                }),
13306        )
13307        .await?;
13308        runtime
13309            .handle()
13310            .spawn_spec(SpawnMemberSpec::from_wire(
13311                "worker".to_string(),
13312                // meerkat 0.7: roster ids are comms-safe encodings of the
13313                // public alias (MemberCommsName rejects ":").
13314                crate::member_comms_id::mob_member_id_str("worker:one").into_owned(),
13315                Some("You are worker one.".into()),
13316                None,
13317                None,
13318            ))
13319            .await?;
13320
13321        let empty_read_model = ConsoleSnapshotReadModelState::default();
13322        let (members, session_owner_by_id) = project_console_members_from_handle(
13323            &runtime.handle(),
13324            None,
13325            None,
13326            &empty_read_model,
13327            &BTreeMap::new(),
13328        )
13329        .await;
13330
13331        assert_eq!(members.len(), 1);
13332        assert_eq!(members[0].model_capabilities, expected);
13333        assert_eq!(members[0].session_id, None);
13334        assert!(session_owner_by_id.is_empty());
13335
13336        let refreshed_read_model = collect_console_snapshot_read_model(&runtime).await;
13337        let (members, session_owner_by_id) = project_console_members_from_handle(
13338            &runtime.handle(),
13339            None,
13340            None,
13341            &refreshed_read_model,
13342            &BTreeMap::new(),
13343        )
13344        .await;
13345        assert_eq!(
13346            members[0].session_id.as_ref(),
13347            session_owner_by_id.keys().next()
13348        );
13349
13350        // Materialized cache: the refresh should have populated
13351        // `primary_members` with exactly the same shape that the
13352        // synchronous projection produces. `build_live_snapshot` reads
13353        // straight from this slot — never calls `handle.list_all_members`
13354        // — so this assertion is the cache's contract.
13355        assert_eq!(
13356            refreshed_read_model.primary_members.len(),
13357            members.len(),
13358            "primary_members cache should hold the same members as live projection"
13359        );
13360        assert_eq!(
13361            refreshed_read_model.primary_members[0].agent_identity,
13362            members[0].agent_identity
13363        );
13364        assert_eq!(
13365            refreshed_read_model.primary_members[0].session_id,
13366            members[0].session_id
13367        );
13368        Ok(())
13369    }
13370
13371    #[tokio::test]
13372    async fn fresh_timeline_snapshot_reads_tail_without_full_log_replay()
13373    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13374        let aggregator = MobKitConsoleAggregator::in_memory();
13375        for idx in 0..250_000 {
13376            aggregator
13377                .store()
13378                .append_if_absent(NewConsoleFrame {
13379                    id: None,
13380                    dedupe_key: format!("event-{idx}"),
13381                    timestamp_ms: idx,
13382                    runtime_key: "runtime-a".to_string(),
13383                    identity: "agent-a".to_string(),
13384                    conversation_id: Some("agent-a".to_string()),
13385                    session_id: None,
13386                    kind: "text_delta".to_string(),
13387                    status: ConsoleFrameStatus::Completed,
13388                    payload: json!({ "delta": idx }),
13389                    source: ConsoleFrameSource {
13390                        kind: ConsoleFrameSourceKind::ConsoleEvent,
13391                        source_cursor: None,
13392                    },
13393                    source_event_id: Some(format!("event-{idx}")),
13394                    interaction_id: None,
13395                    turn_id: None,
13396                    run_id: None,
13397                    parent_frame_id: None,
13398                    caused_by_frame_id: None,
13399                })
13400                .await?;
13401        }
13402
13403        let (frames, cursor) = query_timeline_snapshot(
13404            &aggregator,
13405            ConsoleTimelineWindowQuery {
13406                identity: Some("agent-a".to_string()),
13407                after: None,
13408                limit: 200,
13409                ..ConsoleTimelineWindowQuery::default()
13410            },
13411        )
13412        .await?;
13413
13414        assert!(!frames.is_empty());
13415        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(250_000));
13416        assert_eq!(
13417            frames.last().and_then(|frame| frame.cursor.seq()),
13418            Some(250_000)
13419        );
13420        Ok(())
13421    }
13422
13423    #[tokio::test]
13424    async fn fresh_timeline_snapshot_keeps_sparse_identity_frames()
13425    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13426        let aggregator = MobKitConsoleAggregator::in_memory();
13427        aggregator
13428            .store()
13429            .append_if_absent(NewConsoleFrame {
13430                id: None,
13431                dedupe_key: "sparse-event".to_string(),
13432                timestamp_ms: 1,
13433                runtime_key: "runtime-a".to_string(),
13434                identity: "sparse-agent".to_string(),
13435                conversation_id: Some("sparse-agent".to_string()),
13436                session_id: None,
13437                kind: "text_complete".to_string(),
13438                status: ConsoleFrameStatus::Completed,
13439                payload: json!({ "text": "still visible" }),
13440                source: ConsoleFrameSource {
13441                    kind: ConsoleFrameSourceKind::ConsoleEvent,
13442                    source_cursor: None,
13443                },
13444                source_event_id: Some("sparse-event".to_string()),
13445                interaction_id: None,
13446                turn_id: None,
13447                run_id: None,
13448                parent_frame_id: None,
13449                caused_by_frame_id: None,
13450            })
13451            .await?;
13452        for idx in 0..25_000 {
13453            aggregator
13454                .store()
13455                .append_if_absent(NewConsoleFrame {
13456                    id: None,
13457                    dedupe_key: format!("other-event-{idx}"),
13458                    timestamp_ms: idx + 2,
13459                    runtime_key: "runtime-a".to_string(),
13460                    identity: "busy-agent".to_string(),
13461                    conversation_id: Some("busy-agent".to_string()),
13462                    session_id: None,
13463                    kind: "text_delta".to_string(),
13464                    status: ConsoleFrameStatus::Completed,
13465                    payload: json!({ "delta": idx }),
13466                    source: ConsoleFrameSource {
13467                        kind: ConsoleFrameSourceKind::ConsoleEvent,
13468                        source_cursor: None,
13469                    },
13470                    source_event_id: Some(format!("other-event-{idx}")),
13471                    interaction_id: None,
13472                    turn_id: None,
13473                    run_id: None,
13474                    parent_frame_id: None,
13475                    caused_by_frame_id: None,
13476                })
13477                .await?;
13478        }
13479
13480        let (frames, cursor) = query_timeline_snapshot(
13481            &aggregator,
13482            ConsoleTimelineWindowQuery {
13483                identity: Some("sparse-agent".to_string()),
13484                after: None,
13485                limit: 200,
13486                ..ConsoleTimelineWindowQuery::default()
13487            },
13488        )
13489        .await?;
13490
13491        assert_eq!(frames.len(), 1);
13492        assert_eq!(frames[0].identity, "sparse-agent");
13493        assert_eq!(frames[0].payload["text"], json!("still visible"));
13494        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1));
13495        Ok(())
13496    }
13497
13498    #[tokio::test]
13499    async fn fresh_identity_snapshot_keeps_user_input_anchor_before_noisy_tail()
13500    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13501        let aggregator = MobKitConsoleAggregator::in_memory();
13502        aggregator
13503            .store()
13504            .append_if_absent(NewConsoleFrame {
13505                id: None,
13506                dedupe_key: "worker-kickoff".to_string(),
13507                timestamp_ms: 1,
13508                runtime_key: "runtime-a".to_string(),
13509                identity: "review-worker-a".to_string(),
13510                conversation_id: Some("review-worker-a".to_string()),
13511                session_id: None,
13512                kind: "user_input".to_string(),
13513                status: ConsoleFrameStatus::Delivered,
13514                payload: json!({
13515                    "content": [
13516                        {
13517                            "type": "text",
13518                            "text": "Console chat smoke: review this initiative"
13519                        }
13520                    ]
13521                }),
13522                source: ConsoleFrameSource {
13523                    kind: ConsoleFrameSourceKind::Synthetic,
13524                    source_cursor: None,
13525                },
13526                source_event_id: Some("worker-kickoff".to_string()),
13527                interaction_id: Some("kickoff-1".to_string()),
13528                turn_id: None,
13529                run_id: None,
13530                parent_frame_id: None,
13531                caused_by_frame_id: None,
13532            })
13533            .await?;
13534        for idx in 0..1_500 {
13535            aggregator
13536                .store()
13537                .append_if_absent(NewConsoleFrame {
13538                    id: None,
13539                    dedupe_key: format!("worker-delta-{idx}"),
13540                    timestamp_ms: idx + 2,
13541                    runtime_key: "runtime-a".to_string(),
13542                    identity: "review-worker-a".to_string(),
13543                    conversation_id: Some("review-worker-a".to_string()),
13544                    session_id: None,
13545                    kind: "reasoning_delta".to_string(),
13546                    status: ConsoleFrameStatus::Delivered,
13547                    payload: json!({ "delta": idx }),
13548                    source: ConsoleFrameSource {
13549                        kind: ConsoleFrameSourceKind::ConsoleEvent,
13550                        source_cursor: None,
13551                    },
13552                    source_event_id: Some(format!("worker-delta-{idx}")),
13553                    interaction_id: Some("kickoff-1".to_string()),
13554                    turn_id: None,
13555                    run_id: None,
13556                    parent_frame_id: None,
13557                    caused_by_frame_id: None,
13558                })
13559                .await?;
13560        }
13561
13562        let (frames, cursor) = query_timeline_snapshot(
13563            &aggregator,
13564            ConsoleTimelineWindowQuery {
13565                identity: Some("review-worker-a".to_string()),
13566                after: None,
13567                limit: 200,
13568                ..ConsoleTimelineWindowQuery::default()
13569            },
13570        )
13571        .await?;
13572
13573        assert!(
13574            frames.iter().any(|frame| {
13575                frame.kind == "user_input"
13576                    && frame.payload.to_string().contains("Console chat smoke")
13577            }),
13578            "identity chat snapshot must keep the worker kickoff prompt before a noisy tail: {frames:#?}",
13579        );
13580        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1_501));
13581        assert_eq!(
13582            frames.last().and_then(|frame| frame.cursor.seq()),
13583            Some(1_501)
13584        );
13585        Ok(())
13586    }
13587
13588    #[tokio::test]
13589    async fn timeline_snapshot_drains_since_backlog_across_store_pages()
13590    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13591        let aggregator = MobKitConsoleAggregator::in_memory();
13592        for idx in 0..2_500 {
13593            aggregator
13594                .store()
13595                .append_if_absent(NewConsoleFrame {
13596                    id: None,
13597                    dedupe_key: format!("clamp-event-{idx}"),
13598                    timestamp_ms: idx,
13599                    runtime_key: "runtime-a".to_string(),
13600                    identity: "agent-a".to_string(),
13601                    conversation_id: Some("agent-a".to_string()),
13602                    session_id: None,
13603                    kind: "text_delta".to_string(),
13604                    status: ConsoleFrameStatus::Completed,
13605                    payload: json!({ "delta": idx }),
13606                    source: ConsoleFrameSource {
13607                        kind: ConsoleFrameSourceKind::ConsoleEvent,
13608                        source_cursor: None,
13609                    },
13610                    source_event_id: Some(format!("clamp-event-{idx}")),
13611                    interaction_id: None,
13612                    turn_id: None,
13613                    run_id: None,
13614                    parent_frame_id: None,
13615                    caused_by_frame_id: None,
13616                })
13617                .await?;
13618        }
13619
13620        let (frames, cursor) = query_timeline_snapshot(
13621            &aggregator,
13622            ConsoleTimelineWindowQuery {
13623                identity: Some("agent-a".to_string()),
13624                after: Some(ConsoleCursor::from("console:100")),
13625                limit: 5_000,
13626                ..ConsoleTimelineWindowQuery::default()
13627            },
13628        )
13629        .await?;
13630
13631        assert_eq!(frames.len(), 2_400);
13632        assert_eq!(
13633            frames.first().and_then(|frame| frame.cursor.seq()),
13634            Some(101)
13635        );
13636        assert_eq!(
13637            frames.last().and_then(|frame| frame.cursor.seq()),
13638            Some(2_500)
13639        );
13640        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(2_500));
13641        Ok(())
13642    }
13643
13644    #[tokio::test]
13645    async fn timeline_snapshot_drains_since_backlog_beyond_old_page_budget()
13646    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13647        let aggregator = MobKitConsoleAggregator::in_memory();
13648        for idx in 1..=150 {
13649            aggregator
13650                .store()
13651                .append_if_absent(NewConsoleFrame {
13652                    id: None,
13653                    dedupe_key: format!("deep-backlog-event-{idx}"),
13654                    timestamp_ms: idx,
13655                    runtime_key: "runtime-a".to_string(),
13656                    identity: "agent-a".to_string(),
13657                    conversation_id: Some("agent-a".to_string()),
13658                    session_id: None,
13659                    kind: "text_delta".to_string(),
13660                    status: ConsoleFrameStatus::Completed,
13661                    payload: json!({ "delta": idx }),
13662                    source: ConsoleFrameSource {
13663                        kind: ConsoleFrameSourceKind::ConsoleEvent,
13664                        source_cursor: None,
13665                    },
13666                    source_event_id: Some(format!("deep-backlog-event-{idx}")),
13667                    interaction_id: None,
13668                    turn_id: None,
13669                    run_id: None,
13670                    parent_frame_id: None,
13671                    caused_by_frame_id: None,
13672                })
13673                .await?;
13674        }
13675
13676        let (frames, cursor) = query_timeline_snapshot(
13677            &aggregator,
13678            ConsoleTimelineWindowQuery {
13679                identity: Some("agent-a".to_string()),
13680                after: Some(ConsoleCursor::from_seq(1)),
13681                limit: 1,
13682                ..ConsoleTimelineWindowQuery::default()
13683            },
13684        )
13685        .await?;
13686
13687        assert_eq!(frames.len(), 149);
13688        assert_eq!(frames.first().and_then(|frame| frame.cursor.seq()), Some(2));
13689        assert_eq!(
13690            frames.last().and_then(|frame| frame.cursor.seq()),
13691            Some(150)
13692        );
13693        assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(150));
13694        Ok(())
13695    }
13696
13697    #[tokio::test]
13698    async fn timeline_snapshot_rejects_after_cursor_beyond_store_frontier()
13699    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13700        let aggregator = MobKitConsoleAggregator::in_memory();
13701        aggregator
13702            .store()
13703            .append_if_absent(NewConsoleFrame {
13704                id: None,
13705                dedupe_key: "stale-frontier-event".to_string(),
13706                timestamp_ms: 1,
13707                runtime_key: "runtime-a".to_string(),
13708                identity: "agent-a".to_string(),
13709                conversation_id: Some("agent-a".to_string()),
13710                session_id: None,
13711                kind: "text_delta".to_string(),
13712                status: ConsoleFrameStatus::Completed,
13713                payload: json!({ "delta": 1 }),
13714                source: ConsoleFrameSource {
13715                    kind: ConsoleFrameSourceKind::ConsoleEvent,
13716                    source_cursor: None,
13717                },
13718                source_event_id: Some("stale-frontier-event".to_string()),
13719                interaction_id: None,
13720                turn_id: None,
13721                run_id: None,
13722                parent_frame_id: None,
13723                caused_by_frame_id: None,
13724            })
13725            .await?;
13726
13727        let err = match query_timeline_snapshot(
13728            &aggregator,
13729            ConsoleTimelineWindowQuery {
13730                after: Some(ConsoleCursor::from("console:99")),
13731                limit: 200,
13732                ..ConsoleTimelineWindowQuery::default()
13733            },
13734        )
13735        .await
13736        {
13737            Ok(_) => {
13738                return Err(
13739                    std::io::Error::other("future cursor must be replay-unavailable").into(),
13740                );
13741            }
13742            Err(err) => err,
13743        };
13744
13745        assert!(
13746            err.to_string()
13747                .contains("beyond the current store frontier"),
13748            "unexpected error: {err}"
13749        );
13750        Ok(())
13751    }
13752
13753    #[tokio::test]
13754    async fn timeline_snapshot_rejects_after_cursor_beyond_empty_store_frontier()
13755    -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13756        let aggregator = MobKitConsoleAggregator::in_memory();
13757
13758        let err = match query_timeline_snapshot(
13759            &aggregator,
13760            ConsoleTimelineWindowQuery {
13761                after: Some(ConsoleCursor::from("console:99")),
13762                limit: 200,
13763                ..ConsoleTimelineWindowQuery::default()
13764            },
13765        )
13766        .await
13767        {
13768            Ok(_) => {
13769                return Err(std::io::Error::other("empty store future cursor must fail").into());
13770            }
13771            Err(err) => err,
13772        };
13773
13774        assert!(
13775            err.to_string()
13776                .contains("beyond the current store frontier"),
13777            "unexpected error: {err}"
13778        );
13779        Ok(())
13780    }
13781
13782    #[test]
13783    fn timeline_query_prefers_last_event_id_over_url_after_cursor() {
13784        let query = timeline_query_from_http(
13785            ConsoleTimelineHttpQuery {
13786                identity: None,
13787                conversation_id: None,
13788                after: Some("console:100".to_string()),
13789                before: None,
13790                mode: None,
13791                limit: None,
13792            },
13793            Some("console:150".to_string()),
13794        );
13795
13796        assert_eq!(query.after.as_ref().and_then(ConsoleCursor::seq), Some(150));
13797    }
13798
13799    #[test]
13800    fn console_timeline_replay_unavailable_rpc_uses_dedicated_error_code() {
13801        let response = console_timeline_replay_unavailable_response(
13802            json!("rid"),
13803            std::io::Error::other("timeline replay cursor is beyond the current store frontier")
13804                .into(),
13805            Some(&ConsoleCursor::from_seq(100)),
13806            Some(ConsoleCursor::from_seq(42)),
13807        );
13808
13809        assert_eq!(response["error"]["code"], json!(-32013));
13810        assert_eq!(
13811            response["error"]["data"],
13812            json!({
13813                "error": "replay_unavailable",
13814                "stream": "timeline",
13815                "requested_cursor": "console:100",
13816                "latest_cursor": "console:42",
13817            })
13818        );
13819    }
13820
13821    #[tokio::test]
13822    async fn multipart_blob_upload_stores_one_file() -> Result<(), Box<dyn std::error::Error>> {
13823        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13824        let mut files = BTreeMap::new();
13825        files.insert(
13826            "upload-1".to_string(),
13827            MultipartImageUpload {
13828                media_type: "image/png".to_string(),
13829                bytes: Bytes::from_static(b"png-data"),
13830            },
13831        );
13832        let result = externalize_single_image_upload(
13833            &json!({
13834                "upload": {
13835                    "type": "image_upload",
13836                    "upload_id": "upload-1",
13837                    "media_type": "image/png"
13838                }
13839            }),
13840            files,
13841            store.clone(),
13842        )
13843        .await
13844        .map_err(std::io::Error::other)?;
13845
13846        assert_eq!(result["media_type"], json!("image/png"));
13847        assert_eq!(result["size"], json!(8));
13848        let Some(blob_id) = result["blob_id"].as_str() else {
13849            return Err(std::io::Error::other("blob id").into());
13850        };
13851        let payload = store
13852            .get_bytes(&meerkat_core::BlobId::from(blob_id))
13853            .await?;
13854        assert_eq!(payload.data.as_ref(), b"png-data");
13855        Ok(())
13856    }
13857
13858    #[tokio::test]
13859    async fn multipart_blob_upload_accepts_part_name_alias()
13860    -> Result<(), Box<dyn std::error::Error>> {
13861        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13862        let mut files = BTreeMap::new();
13863        files.insert(
13864            "image-field".to_string(),
13865            MultipartImageUpload {
13866                media_type: "image/png".to_string(),
13867                bytes: Bytes::from_static(b"png-data"),
13868            },
13869        );
13870        let result = externalize_single_image_upload(
13871            &json!({
13872                "upload": {
13873                    "type": "image_upload",
13874                    "part_name": "image-field",
13875                    "media_type": "image/png"
13876                }
13877            }),
13878            files,
13879            store,
13880        )
13881        .await
13882        .map_err(std::io::Error::other)?;
13883
13884        assert_eq!(result["media_type"], json!("image/png"));
13885        assert!(
13886            result["blob_id"]
13887                .as_str()
13888                .is_some_and(|value| value.starts_with("sha256:"))
13889        );
13890        Ok(())
13891    }
13892
13893    #[tokio::test]
13894    async fn multipart_blob_upload_rejects_media_mismatch() -> Result<(), Box<dyn std::error::Error>>
13895    {
13896        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13897        let mut files = BTreeMap::new();
13898        files.insert(
13899            "upload-1".to_string(),
13900            MultipartImageUpload {
13901                media_type: "image/jpeg".to_string(),
13902                bytes: Bytes::from_static(b"jpeg-data"),
13903            },
13904        );
13905        let err = match externalize_single_image_upload(
13906            &json!({
13907                "upload": {
13908                    "type": "image_upload",
13909                    "upload_id": "upload-1",
13910                    "media_type": "image/png"
13911                }
13912            }),
13913            files,
13914            store,
13915        )
13916        .await
13917        {
13918            Ok(_) => return Err(std::io::Error::other("media mismatch").into()),
13919            Err(err) => err,
13920        };
13921        assert!(
13922            err.contains("media type mismatch"),
13923            "unexpected error: {err}"
13924        );
13925        Ok(())
13926    }
13927
13928    #[tokio::test]
13929    async fn multipart_blob_upload_rejects_extra_file() -> Result<(), Box<dyn std::error::Error>> {
13930        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13931        let mut files = BTreeMap::new();
13932        for id in ["upload-1", "upload-2"] {
13933            files.insert(
13934                id.to_string(),
13935                MultipartImageUpload {
13936                    media_type: "image/png".to_string(),
13937                    bytes: Bytes::from_static(b"png"),
13938                },
13939            );
13940        }
13941        let err = match externalize_single_image_upload(
13942            &json!({
13943                "upload": {
13944                    "type": "image_upload",
13945                    "upload_id": "upload-1",
13946                    "media_type": "image/png"
13947                }
13948            }),
13949            files,
13950            store,
13951        )
13952        .await
13953        {
13954            Ok(_) => return Err(std::io::Error::other("one file only").into()),
13955            Err(err) => err,
13956        };
13957        assert!(
13958            err.contains("exactly one file part"),
13959            "unexpected error: {err}"
13960        );
13961        Ok(())
13962    }
13963
13964    #[tokio::test]
13965    async fn multipart_send_replaces_placeholders_and_removes_shadow_message()
13966    -> Result<(), Box<dyn std::error::Error>> {
13967        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13968        let mut files = BTreeMap::new();
13969        files.insert(
13970            "upload-1".to_string(),
13971            MultipartImageUpload {
13972                media_type: "image/webp".to_string(),
13973                bytes: Bytes::from_static(b"webp-data"),
13974            },
13975        );
13976        let mut params = json!({
13977            "member_id": "artist",
13978            "message": "stale shadow text",
13979            "content": [
13980                { "type": "text", "text": "describe" },
13981                {
13982                    "type": "image_upload",
13983                    "upload_id": "upload-1",
13984                    "media_type": "image/webp"
13985                }
13986            ]
13987        });
13988        externalize_image_upload_placeholders(&mut params, files, store)
13989            .await
13990            .map_err(std::io::Error::other)?;
13991
13992        assert!(params.get("message").is_none());
13993        assert_eq!(params["content"][1]["type"], json!("image"));
13994        assert_eq!(params["content"][1]["source"], json!("blob"));
13995        assert_eq!(params["content"][1]["media_type"], json!("image/webp"));
13996        assert!(
13997            params["content"][1]["blob_id"]
13998                .as_str()
13999                .is_some_and(|value| value.starts_with("sha256:"))
14000        );
14001        Ok(())
14002    }
14003
14004    #[tokio::test]
14005    async fn multipart_send_accepts_part_name_placeholder() -> Result<(), Box<dyn std::error::Error>>
14006    {
14007        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14008        let mut files = BTreeMap::new();
14009        files.insert(
14010            "image-field".to_string(),
14011            MultipartImageUpload {
14012                media_type: "image/png".to_string(),
14013                bytes: Bytes::from_static(b"png-data"),
14014            },
14015        );
14016        let mut params = json!({
14017            "member_id": "analyst",
14018            "content": [
14019                { "type": "text", "text": "describe" },
14020                {
14021                    "type": "image_upload",
14022                    "part_name": "image-field",
14023                    "media_type": "image/png"
14024                }
14025            ]
14026        });
14027
14028        externalize_image_upload_placeholders(&mut params, files, store)
14029            .await
14030            .map_err(std::io::Error::other)?;
14031
14032        assert_eq!(params["content"][1]["type"], json!("image"));
14033        assert_eq!(params["content"][1]["source"], json!("blob"));
14034        assert_eq!(params["content"][1]["media_type"], json!("image/png"));
14035        Ok(())
14036    }
14037
14038    #[tokio::test]
14039    async fn multipart_send_rejects_placeholder_without_file()
14040    -> Result<(), Box<dyn std::error::Error>> {
14041        let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14042        let mut params = json!({
14043            "content": [{
14044                "type": "image_upload",
14045                "upload_id": "missing",
14046                "media_type": "image/png"
14047            }]
14048        });
14049        let err = match externalize_image_upload_placeholders(&mut params, BTreeMap::new(), store)
14050            .await
14051        {
14052            Ok(()) => return Err(std::io::Error::other("missing file").into()),
14053            Err(err) => err,
14054        };
14055        assert!(err.contains("missing file part"), "unexpected error: {err}");
14056        Ok(())
14057    }
14058
14059    #[test]
14060    fn generated_runtime_ids_do_not_match_sibling_colon_identities() {
14061        assert!(!member_id_matches_durable_identity(
14062            "rt:review:singleton:0",
14063            "review:singleton"
14064        ));
14065        assert!(!member_id_matches_durable_identity(
14066            "review:singleton:gen1",
14067            "review:singleton"
14068        ));
14069        assert!(!member_id_matches_durable_identity(
14070            "review:singleton:1",
14071            "review:singleton"
14072        ));
14073        assert!(!member_id_matches_durable_identity(
14074            "rt:review:singleton:qa:0",
14075            "review:singleton"
14076        ));
14077        assert!(!member_id_matches_durable_identity(
14078            "review:singleton:qa",
14079            "review:singleton"
14080        ));
14081    }
14082}