Skip to main content

meerkat_mobkit/
runtime.rs

1//! Runtime subsystem types — routing, delivery, gating, memory, scheduling, and session persistence.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs::{self, OpenOptions};
5use std::io::Write;
6use std::io::{BufRead, BufReader};
7use std::net::{TcpStream, ToSocketAddrs};
8use std::path::{Path, PathBuf};
9use std::process::{Child, Command, Stdio};
10use std::sync::Arc;
11use std::sync::mpsc;
12use std::time::Duration;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use chrono::{Datelike, Offset, Timelike, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::auth::{
20    JwtClaimsValidationConfig, build_jwt_verification_key, inspect_jwt_header, parse_jwks_json,
21    parse_oidc_discovery_json, select_jwk_for_token, validate_jwt_with_verification_key,
22};
23use crate::baseline::{
24    BaselineVerificationError, BaselineVerificationReport, verify_meerkat_baseline_symbols,
25};
26use crate::decisions::{
27    AuthPolicy, AuthProvider, BigQueryNaming, ConsoleAccessRequest, ConsolePolicy,
28    DecisionPolicyError, ReleaseMetadata, RuntimeOpsPolicy, enforce_console_route_access,
29    load_trusted_mobkit_modules_from_toml, parse_release_metadata_json, validate_bigquery_naming,
30    validate_release_metadata, validate_runtime_ops_policy,
31};
32use crate::process::{ProcessBoundaryError, run_process_json_line};
33use crate::protocol::parse_unified_event_line;
34use crate::rpc::{RpcCapabilities, RpcCapabilitiesError, parse_rpc_capabilities};
35use crate::types::{
36    EventEnvelope, MobKitConfig, ModuleConfig, ModuleEvent, PreSpawnData, RestartPolicy,
37    UnifiedEvent,
38};
39
40mod bootstrap;
41mod console_ingress;
42pub mod cross_mob_control;
43pub mod cross_mob_remote;
44mod delivery;
45mod event_transport;
46mod gating;
47mod memory;
48pub mod metadata;
49mod module_boundary;
50mod routing;
51mod rpc;
52pub(crate) mod scheduling;
53mod session_store;
54mod supervisor;
55
56pub use bootstrap::{start_mobkit_runtime, start_mobkit_runtime_with_options};
57pub(crate) use console_ingress::resolve_authorized_console_auth_from_token;
58pub use console_ingress::{
59    ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember, ConsoleModelCapabilities,
60    ConsoleRestJsonRequest, ConsoleRestJsonResponse, extract_bearer_token_from_header,
61    handle_console_rest_json_route, handle_console_rest_json_route_with_snapshot,
62    handle_console_rest_json_route_with_snapshot_access_and_memory,
63    handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph,
64    handle_console_rest_json_route_with_snapshot_and_access, validate_console_token,
65};
66pub use event_transport::normalize_event_line;
67pub use metadata::{
68    InMemoryMetadataStore, LabelRpcResult, MetadataScope, MetadataStoreError,
69    PersistentMetadataStore, RuntimeMetadataTable, SqliteMetadataStore, dispatch_labels_delete,
70    dispatch_labels_get, dispatch_labels_set, labels_to_json_value, parse_labels_param,
71    parse_run_id_param,
72};
73pub use routing::WILDCARD_ROUTE;
74pub use routing::route_module_call;
75pub use rpc::{
76    route_module_call_rpc_json, route_module_call_rpc_subprocess,
77    run_rpc_capabilities_boundary_once,
78};
79pub use scheduling::evaluate_schedules_at_tick;
80pub use session_store::{
81    BigQueryGcConfig, BigQuerySessionStoreAdapter, BigQuerySessionStoreError, GcErrorCallback,
82    JsonFileSessionStore, JsonFileSessionStoreError, JsonStoreLockRecord, SessionPersistenceRow,
83    SessionStoreContract, SessionStoreKind, materialize_latest_session_rows,
84    materialize_live_session_rows, run_periodic_gc, run_periodic_gc_with_error_callback,
85    session_store_contracts,
86};
87pub use supervisor::{run_discovered_module_once, run_module_boundary_once};
88
89pub(crate) use scheduling::validate_schedules;
90
91use event_transport::{insert_event_sorted, merge_unified_events};
92use supervisor::supervise_module_start;
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum NormalizationError {
96    InvalidJson,
97    InvalidSchema,
98    MissingField(&'static str),
99    InvalidFieldType(&'static str),
100    SourceMismatch { expected: &'static str, got: String },
101}
102
103impl std::fmt::Display for NormalizationError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::InvalidJson => write!(f, "invalid JSON"),
107            Self::InvalidSchema => write!(f, "invalid schema"),
108            Self::MissingField(field) => write!(f, "missing field: {field}"),
109            Self::InvalidFieldType(field) => write!(f, "invalid field type: {field}"),
110            Self::SourceMismatch { expected, got } => {
111                write!(f, "source mismatch: expected {expected}, got {got}")
112            }
113        }
114    }
115}
116
117impl std::error::Error for NormalizationError {}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum RuntimeBoundaryError {
121    Process(ProcessBoundaryError),
122    Normalize(NormalizationError),
123    Mcp(McpBoundaryError),
124}
125
126impl std::fmt::Display for RuntimeBoundaryError {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            Self::Process(err) => write!(f, "process boundary: {err}"),
130            Self::Normalize(err) => write!(f, "normalization: {err}"),
131            Self::Mcp(err) => write!(f, "MCP boundary: {err}"),
132        }
133    }
134}
135
136impl std::error::Error for RuntimeBoundaryError {
137    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
138        match self {
139            Self::Process(err) => Some(err),
140            Self::Normalize(err) => Some(err),
141            Self::Mcp(err) => Some(err),
142        }
143    }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum McpBoundaryError {
148    RuntimeUnavailable(String),
149    McpRequired {
150        module_id: String,
151        flow: String,
152    },
153    Timeout {
154        module_id: String,
155        operation: String,
156        timeout_ms: u64,
157    },
158    ConnectionFailed {
159        module_id: String,
160        reason: String,
161    },
162    ToolListFailed {
163        module_id: String,
164        reason: String,
165    },
166    ToolNotFound {
167        module_id: String,
168        tool: String,
169        available_tools: Vec<String>,
170    },
171    ToolCallFailed {
172        module_id: String,
173        tool: String,
174        reason: String,
175    },
176    CloseFailed {
177        module_id: String,
178        reason: String,
179    },
180    OperationFailedWithCloseFailure {
181        primary: Box<McpBoundaryError>,
182        close: Box<McpBoundaryError>,
183    },
184    InvalidToolPayload {
185        module_id: String,
186        tool: String,
187        reason: String,
188    },
189    InvalidJsonResponse {
190        module_id: String,
191        tool: String,
192        response: String,
193    },
194}
195
196impl std::fmt::Display for McpBoundaryError {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            Self::RuntimeUnavailable(msg) => write!(f, "runtime unavailable: {msg}"),
200            Self::McpRequired { module_id, flow } => {
201                write!(f, "MCP required for module {module_id} flow {flow}")
202            }
203            Self::Timeout {
204                module_id,
205                operation,
206                timeout_ms,
207            } => {
208                write!(
209                    f,
210                    "timeout for module {module_id} operation {operation} after {timeout_ms}ms"
211                )
212            }
213            Self::ConnectionFailed { module_id, reason } => {
214                write!(f, "connection failed for module {module_id}: {reason}")
215            }
216            Self::ToolListFailed { module_id, reason } => {
217                write!(f, "tool list failed for module {module_id}: {reason}")
218            }
219            Self::ToolNotFound {
220                module_id,
221                tool,
222                available_tools,
223            } => {
224                write!(
225                    f,
226                    "tool {tool} not found for module {module_id} (available: {})",
227                    available_tools.join(", ")
228                )
229            }
230            Self::ToolCallFailed {
231                module_id,
232                tool,
233                reason,
234            } => {
235                write!(
236                    f,
237                    "tool call {tool} failed for module {module_id}: {reason}"
238                )
239            }
240            Self::CloseFailed { module_id, reason } => {
241                write!(f, "close failed for module {module_id}: {reason}")
242            }
243            Self::OperationFailedWithCloseFailure { primary, close } => {
244                write!(f, "operation failed: {primary}; close also failed: {close}")
245            }
246            Self::InvalidToolPayload {
247                module_id,
248                tool,
249                reason,
250            } => {
251                write!(
252                    f,
253                    "invalid tool payload for module {module_id} tool {tool}: {reason}"
254                )
255            }
256            Self::InvalidJsonResponse {
257                module_id,
258                tool,
259                response,
260            } => {
261                write!(
262                    f,
263                    "invalid JSON response for module {module_id} tool {tool}: {response}"
264                )
265            }
266        }
267    }
268}
269
270impl std::error::Error for McpBoundaryError {}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub enum ConfigResolutionError {
274    ModuleNotConfigured(String),
275    ModuleNotDiscovered(String),
276}
277
278impl std::fmt::Display for ConfigResolutionError {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        match self {
281            Self::ModuleNotConfigured(id) => write!(f, "module not configured: {id}"),
282            Self::ModuleNotDiscovered(id) => write!(f, "module not discovered: {id}"),
283        }
284    }
285}
286
287impl std::error::Error for ConfigResolutionError {}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum RuntimeFromConfigError {
291    Config(ConfigResolutionError),
292    Runtime(RuntimeBoundaryError),
293}
294
295impl std::fmt::Display for RuntimeFromConfigError {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        match self {
298            Self::Config(err) => write!(f, "config resolution: {err}"),
299            Self::Runtime(err) => write!(f, "runtime boundary: {err}"),
300        }
301    }
302}
303
304impl std::error::Error for RuntimeFromConfigError {
305    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
306        match self {
307            Self::Config(err) => Some(err),
308            Self::Runtime(err) => Some(err),
309        }
310    }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub enum RpcRuntimeError {
315    Process(ProcessBoundaryError),
316    Capabilities(RpcCapabilitiesError),
317}
318
319impl std::fmt::Display for RpcRuntimeError {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        match self {
322            Self::Process(err) => write!(f, "process boundary: {err}"),
323            Self::Capabilities(err) => write!(f, "capabilities: {err}"),
324        }
325    }
326}
327
328impl std::error::Error for RpcRuntimeError {
329    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
330        match self {
331            Self::Process(err) => Some(err),
332            Self::Capabilities(err) => Some(err),
333        }
334    }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub enum BaselineRuntimeError {
339    Process(ProcessBoundaryError),
340    InvalidRepoPathJson,
341    MissingRepoRoot,
342    InvalidRepoRoot,
343    Baseline(BaselineVerificationError),
344}
345
346impl std::fmt::Display for BaselineRuntimeError {
347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348        match self {
349            Self::Process(err) => write!(f, "process boundary: {err}"),
350            Self::InvalidRepoPathJson => write!(f, "invalid repo path JSON"),
351            Self::MissingRepoRoot => write!(f, "missing repo root"),
352            Self::InvalidRepoRoot => write!(f, "invalid repo root"),
353            Self::Baseline(err) => write!(f, "baseline verification: {err}"),
354        }
355    }
356}
357
358impl std::error::Error for BaselineRuntimeError {
359    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
360        match self {
361            Self::Process(err) => Some(err),
362            Self::Baseline(err) => Some(err),
363            _ => None,
364        }
365    }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub enum MobkitRuntimeError {
370    Config(ConfigResolutionError),
371    MemoryBackend(LocalJsonMemoryStoreError),
372}
373
374impl std::fmt::Display for MobkitRuntimeError {
375    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376        match self {
377            Self::Config(err) => write!(f, "config resolution: {err}"),
378            Self::MemoryBackend(err) => write!(f, "memory backend: {err}"),
379        }
380    }
381}
382
383impl std::error::Error for MobkitRuntimeError {
384    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
385        match self {
386            Self::Config(err) => Some(err),
387            Self::MemoryBackend(err) => Some(err),
388        }
389    }
390}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub enum DecisionRuntimeError {
394    Policy(DecisionPolicyError),
395}
396
397impl std::fmt::Display for DecisionRuntimeError {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        match self {
400            Self::Policy(err) => write!(f, "decision policy: {err}"),
401        }
402    }
403}
404
405impl std::error::Error for DecisionRuntimeError {
406    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
407        match self {
408            Self::Policy(err) => Some(err),
409        }
410    }
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct RuntimeDecisionInputs {
415    pub bigquery: BigQueryNaming,
416    pub trusted_mobkit_toml: String,
417    pub auth: AuthPolicy,
418    pub trusted_oidc: TrustedOidcRuntimeConfig,
419    pub console: ConsolePolicy,
420    pub ops: RuntimeOpsPolicy,
421    pub release_metadata_json: String,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct RuntimeDecisionState {
426    pub bigquery: BigQueryNaming,
427    pub modules: Vec<ModuleConfig>,
428    pub auth: AuthPolicy,
429    pub trusted_oidc: TrustedOidcRuntimeConfig,
430    pub console: ConsolePolicy,
431    pub ops: RuntimeOpsPolicy,
432    pub release_metadata: ReleaseMetadata,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct TrustedOidcRuntimeConfig {
437    pub discovery_json: String,
438    pub jwks_json: String,
439    pub audience: String,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq)]
443pub enum LocalJsonMemoryStoreError {
444    InvalidConfig(String),
445    Io(String),
446    Serialize(String),
447    InvalidStoreData(String),
448    ExternalCallFailed(String),
449}
450
451impl std::fmt::Display for LocalJsonMemoryStoreError {
452    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453        match self {
454            Self::InvalidConfig(msg) => write!(f, "invalid config: {msg}"),
455            Self::Io(msg) => write!(f, "I/O error: {msg}"),
456            Self::Serialize(msg) => write!(f, "serialization error: {msg}"),
457            Self::InvalidStoreData(msg) => write!(f, "invalid store data: {msg}"),
458            Self::ExternalCallFailed(msg) => write!(f, "external call failed: {msg}"),
459        }
460    }
461}
462
463impl std::error::Error for LocalJsonMemoryStoreError {}
464
465/// Deprecated: kept as an alias for the pre-rename error type. The backend
466/// never sent data to Elephant, so the type is now named for what it does.
467pub type ElephantMemoryStoreError = LocalJsonMemoryStoreError;
468
469/// Operational-ledger backend that persists assertions/conflicts as local
470/// JSON, with an optional HTTP health gate applied before every read/write.
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472pub struct LocalJsonMemoryBackendConfig {
473    pub state_path: String,
474    #[serde(default)]
475    pub health_check_endpoint: Option<String>,
476}
477
478/// Deprecated legacy config shape. Despite the name, this backend never
479/// wrote to Elephant: `endpoint` is only health-checked and the ledger is
480/// persisted as local JSON at `state_path`. Use
481/// [`LocalJsonMemoryBackendConfig`]; this shape is still accepted for wire
482/// and config compatibility.
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484pub struct ElephantMemoryBackendConfig {
485    pub endpoint: String,
486    pub state_path: String,
487}
488
489impl From<ElephantMemoryBackendConfig> for LocalJsonMemoryBackendConfig {
490    fn from(legacy: ElephantMemoryBackendConfig) -> Self {
491        Self {
492            state_path: legacy.state_path,
493            health_check_endpoint: Some(legacy.endpoint),
494        }
495    }
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499#[serde(tag = "kind", rename_all = "snake_case")]
500pub enum MemoryBackendConfig {
501    LocalJson(LocalJsonMemoryBackendConfig),
502    /// Deprecated: legacy `kind = "elephant"` shape. Accepted and mapped to
503    /// the local-JSON backend (endpoint becomes the health-check endpoint);
504    /// a deprecation warning is logged at runtime start.
505    Elephant(ElephantMemoryBackendConfig),
506}
507
508#[derive(Debug, Clone, PartialEq, Eq)]
509struct LocalJsonMemoryStoreAdapter {
510    health_check_endpoint: Option<String>,
511    state_path: PathBuf,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
515pub struct RuntimeOptions {
516    pub on_failure_retry_budget: u32,
517    pub always_restart_budget: u32,
518    #[serde(default)]
519    pub supervisor_restart_backoff_ms: u64,
520    #[serde(default)]
521    pub supervisor_test_force_terminate_failure: bool,
522    #[serde(default)]
523    pub memory_backend: Option<MemoryBackendConfig>,
524    #[serde(default = "default_implicit_delegate_idle_retire_secs")]
525    pub implicit_delegate_idle_retire_secs: Option<u64>,
526    #[serde(default = "default_implicit_delegate_idle_sweep_interval_ms")]
527    pub implicit_delegate_idle_sweep_interval_ms: u64,
528}
529
530fn default_implicit_delegate_idle_retire_secs() -> Option<u64> {
531    Some(300)
532}
533
534fn default_implicit_delegate_idle_sweep_interval_ms() -> u64 {
535    10_000
536}
537
538impl Default for RuntimeOptions {
539    fn default() -> Self {
540        Self {
541            on_failure_retry_budget: 1,
542            always_restart_budget: 1,
543            supervisor_restart_backoff_ms: 0,
544            supervisor_test_force_terminate_failure: false,
545            memory_backend: None,
546            implicit_delegate_idle_retire_secs: default_implicit_delegate_idle_retire_secs(),
547            implicit_delegate_idle_sweep_interval_ms:
548                default_implicit_delegate_idle_sweep_interval_ms(),
549        }
550    }
551}
552
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554pub enum LifecycleStage {
555    MobStarted,
556    ModulesStarted,
557    MergedStreamStarted,
558    ShutdownRequested,
559    ShutdownComplete,
560}
561
562#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
563pub struct LifecycleEvent {
564    pub seq: u64,
565    pub stage: LifecycleStage,
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
569pub enum ModuleHealthState {
570    Starting,
571    Healthy,
572    Failed,
573    Restarting,
574    Stopped,
575}
576
577#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
578pub struct ModuleHealthTransition {
579    pub module_id: String,
580    pub from: Option<ModuleHealthState>,
581    pub to: ModuleHealthState,
582    pub attempt: u32,
583}
584
585#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
586pub struct SupervisorReport {
587    pub transitions: Vec<ModuleHealthTransition>,
588}
589
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
591pub struct RuntimeShutdownReport {
592    pub terminated_modules: Vec<String>,
593    pub orphan_processes: u32,
594}
595
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597pub struct ScheduleDefinition {
598    pub schedule_id: String,
599    pub interval: String,
600    pub timezone: String,
601    pub enabled: bool,
602    #[serde(default)]
603    pub jitter_ms: u64,
604    #[serde(default)]
605    pub catch_up: bool,
606}
607
608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
609pub struct ScheduleTrigger {
610    pub schedule_id: String,
611    pub interval: String,
612    pub timezone: String,
613    pub due_tick_ms: u64,
614}
615
616#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
617pub struct ScheduleEvaluation {
618    pub tick_ms: u64,
619    pub due_triggers: Vec<ScheduleTrigger>,
620}
621
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623pub struct SchedulingSupervisorSignal {
624    pub module_id: String,
625    pub latest_state: ModuleHealthState,
626    pub latest_attempt: u32,
627    pub restart_observed: bool,
628}
629
630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
631pub struct ScheduleDispatch {
632    pub claim_key: String,
633    pub schedule_id: String,
634    pub interval: String,
635    pub timezone: String,
636    pub due_tick_ms: u64,
637    pub tick_ms: u64,
638    pub event_id: String,
639    pub supervisor_signal: Option<SchedulingSupervisorSignal>,
640    #[serde(default)]
641    pub runtime_injection: Option<ScheduleRuntimeInjection>,
642    #[serde(default)]
643    pub runtime_injection_error: Option<String>,
644}
645
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
647pub struct ScheduleRuntimeInjection {
648    pub member_id: String,
649    pub message: String,
650    pub injection_event_id: String,
651}
652
653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654pub struct ScheduleDispatchReport {
655    pub tick_ms: u64,
656    pub due_count: usize,
657    pub dispatched: Vec<ScheduleDispatch>,
658    pub skipped_claims: Vec<String>,
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
662pub struct RoutingResolveRequest {
663    pub recipient: String,
664    #[serde(default)]
665    pub channel: Option<String>,
666    #[serde(default)]
667    pub retry_max: Option<u32>,
668    #[serde(default)]
669    pub backoff_ms: Option<u64>,
670    #[serde(default)]
671    pub rate_limit_per_minute: Option<u32>,
672}
673
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
675pub struct RuntimeRoute {
676    pub route_key: String,
677    pub recipient: String,
678    #[serde(default)]
679    pub channel: Option<String>,
680    pub sink: String,
681    pub target_module: String,
682    #[serde(default)]
683    pub retry_max: Option<u32>,
684    #[serde(default)]
685    pub backoff_ms: Option<u64>,
686    #[serde(default)]
687    pub rate_limit_per_minute: Option<u32>,
688}
689
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
691pub struct RoutingResolution {
692    pub route_id: String,
693    pub recipient: String,
694    pub channel: String,
695    pub sink: String,
696    pub target_module: String,
697    pub retry_max: u32,
698    pub backoff_ms: u64,
699    pub rate_limit_per_minute: u32,
700}
701
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703pub struct DeliverySendRequest {
704    pub resolution: RoutingResolution,
705    pub payload: Value,
706    #[serde(default)]
707    pub idempotency_key: Option<String>,
708}
709
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711pub struct DeliveryAttempt {
712    pub attempt: u32,
713    pub status: String,
714    pub backoff_ms: u64,
715}
716
717#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
718pub struct DeliveryRecord {
719    pub delivery_id: String,
720    pub route_id: String,
721    pub recipient: String,
722    pub sink: String,
723    pub target_module: String,
724    pub payload: Value,
725    pub status: String,
726    pub attempts: Vec<DeliveryAttempt>,
727    pub first_attempt_ms: u64,
728    pub final_attempt_ms: u64,
729    pub idempotency_key: Option<String>,
730    pub sink_adapter: Option<String>,
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734pub struct DeliveryHistoryRequest {
735    #[serde(default)]
736    pub recipient: Option<String>,
737    #[serde(default)]
738    pub sink: Option<String>,
739    #[serde(default = "default_delivery_history_limit")]
740    pub limit: usize,
741}
742
743#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
744pub struct DeliveryHistoryResponse {
745    pub deliveries: Vec<DeliveryRecord>,
746}
747
748#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
749pub struct MemoryStoreInfo {
750    pub store: String,
751    pub record_count: usize,
752}
753
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
755pub struct MemoryIndexRequest {
756    pub entity: String,
757    pub topic: String,
758    #[serde(default)]
759    pub store: Option<String>,
760    #[serde(default)]
761    pub fact: Option<String>,
762    #[serde(default)]
763    pub metadata: Option<Value>,
764    #[serde(default)]
765    pub conflict: Option<bool>,
766    #[serde(default)]
767    pub conflict_reason: Option<String>,
768}
769
770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771pub struct MemoryAssertion {
772    pub assertion_id: String,
773    pub entity: String,
774    pub topic: String,
775    pub store: String,
776    pub fact: String,
777    #[serde(default)]
778    pub metadata: Option<Value>,
779    pub indexed_at_ms: u64,
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783pub struct MemoryConflictSignal {
784    pub entity: String,
785    pub topic: String,
786    pub store: String,
787    #[serde(default)]
788    pub reason: Option<String>,
789    pub updated_at_ms: u64,
790}
791
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
793pub struct MemoryIndexResult {
794    pub entity: String,
795    pub topic: String,
796    pub store: String,
797    #[serde(default)]
798    pub assertion_id: Option<String>,
799    pub conflict_active: bool,
800}
801
802#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
803pub struct MemoryQueryRequest {
804    #[serde(default)]
805    pub entity: Option<String>,
806    #[serde(default)]
807    pub topic: Option<String>,
808    #[serde(default)]
809    pub store: Option<String>,
810    /// Case-insensitive substring filter applied across entity, topic, and
811    /// fact (reason for conflict signals), after the exact filters above.
812    #[serde(default)]
813    pub query: Option<String>,
814}
815
816#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
817pub struct MemoryQueryResult {
818    pub assertions: Vec<MemoryAssertion>,
819    pub conflicts: Vec<MemoryConflictSignal>,
820}
821
822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
823struct PersistedMemoryState {
824    #[serde(default)]
825    assertions: Vec<MemoryAssertion>,
826    #[serde(default)]
827    conflicts: Vec<MemoryConflictSignal>,
828}
829
830#[derive(Debug, Clone, PartialEq, Eq)]
831pub enum MemoryIndexError {
832    EntityRequired,
833    TopicRequired,
834    UnsupportedStore(String),
835    FactRequiredWhenConflictUnset,
836    BackendPersistFailed(LocalJsonMemoryStoreError),
837}
838
839impl std::fmt::Display for MemoryIndexError {
840    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
841        match self {
842            Self::EntityRequired => write!(f, "entity is required"),
843            Self::TopicRequired => write!(f, "topic is required"),
844            Self::UnsupportedStore(store) => write!(f, "unsupported store: {store}"),
845            Self::FactRequiredWhenConflictUnset => {
846                write!(f, "fact is required when conflict is unset")
847            }
848            Self::BackendPersistFailed(err) => write!(f, "backend persist failed: {err}"),
849        }
850    }
851}
852
853impl std::error::Error for MemoryIndexError {
854    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
855        match self {
856            Self::BackendPersistFailed(err) => Some(err),
857            _ => None,
858        }
859    }
860}
861
862#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
863#[serde(rename_all = "snake_case")]
864pub enum GatingRiskTier {
865    R0,
866    R1,
867    R2,
868    R3,
869}
870
871#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
872pub struct GatingEvaluateRequest {
873    pub action: String,
874    pub actor_id: String,
875    pub risk_tier: GatingRiskTier,
876    #[serde(default)]
877    pub rationale: Option<String>,
878    #[serde(default)]
879    pub requested_approver: Option<String>,
880    #[serde(default)]
881    pub approval_recipient: Option<String>,
882    #[serde(default)]
883    pub approval_channel: Option<String>,
884    #[serde(default)]
885    pub approval_timeout_ms: Option<u64>,
886    #[serde(default)]
887    pub entity: Option<String>,
888    #[serde(default)]
889    pub topic: Option<String>,
890}
891
892#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
893#[serde(rename_all = "snake_case")]
894pub enum GatingOutcome {
895    Allowed,
896    AllowedWithAudit,
897    PendingApproval,
898    SafeDraft,
899}
900
901#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
902pub struct GatingEvaluateResult {
903    pub action_id: String,
904    pub action: String,
905    pub actor_id: String,
906    pub risk_tier: GatingRiskTier,
907    pub outcome: GatingOutcome,
908    #[serde(default)]
909    pub pending_id: Option<String>,
910    #[serde(default)]
911    pub fallback_reason: Option<String>,
912}
913
914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915pub struct GatingPendingEntry {
916    pub pending_id: String,
917    pub action_id: String,
918    pub action: String,
919    pub actor_id: String,
920    pub risk_tier: GatingRiskTier,
921    #[serde(default)]
922    pub requested_approver: Option<String>,
923    #[serde(default)]
924    pub approval_recipient: Option<String>,
925    #[serde(default)]
926    pub approval_channel: Option<String>,
927    #[serde(default)]
928    pub approval_route_id: Option<String>,
929    #[serde(default)]
930    pub approval_delivery_id: Option<String>,
931    pub created_at_ms: u64,
932    pub deadline_at_ms: u64,
933}
934
935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
936#[serde(rename_all = "snake_case")]
937pub enum GatingDecision {
938    Approve,
939    Reject,
940    Escalate,
941}
942
943#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
944pub struct GatingDecideRequest {
945    pub pending_id: String,
946    pub approver_id: String,
947    pub decision: GatingDecision,
948    #[serde(default)]
949    pub reason: Option<String>,
950}
951
952#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
953pub struct GatingDecisionResult {
954    pub pending_id: String,
955    pub action_id: String,
956    pub approver_id: String,
957    pub decision: GatingDecision,
958    pub outcome: GatingOutcome,
959    pub decided_at_ms: u64,
960    #[serde(default)]
961    pub reason: Option<String>,
962    #[serde(default)]
963    pub next_pending_id: Option<String>,
964}
965
966#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
967pub struct GatingAuditEntry {
968    pub audit_id: String,
969    pub timestamp_ms: u64,
970    pub event_type: String,
971    pub action_id: String,
972    #[serde(default)]
973    pub pending_id: Option<String>,
974    pub actor_id: String,
975    pub risk_tier: GatingRiskTier,
976    pub outcome: GatingOutcome,
977    pub detail: Value,
978}
979
980/// One resolved gating pending entry (decision or timeout), pushed
981/// synchronously to registered observers. The gating subsystem itself is
982/// poll-only; this seam exists so components that staged work behind a
983/// pending entry (the memory steward's quarantine-promotions, §10.2) can
984/// react without polling. Observers must not block — defer real work onto
985/// the runtime.
986#[derive(Debug, Clone, PartialEq, Eq)]
987pub struct GatingResolutionNotice {
988    pub pending_id: String,
989    pub action_id: String,
990    pub approved: bool,
991    /// Set when an escalation minted a successor pending entry.
992    pub next_pending_id: Option<String>,
993    /// `approval_decided`, `rejection_decided`, `escalation_decided`, or
994    /// `timeout_fallback`.
995    pub cause: String,
996}
997
998pub trait GatingResolutionObserver: Send + Sync {
999    fn on_gating_resolution(&self, notice: &GatingResolutionNotice);
1000}
1001
1002/// Registered observers; newtype so `MobkitRuntimeHandle` keeps `Debug`.
1003#[derive(Default)]
1004pub(crate) struct GatingResolutionObservers(Vec<Arc<dyn GatingResolutionObserver>>);
1005
1006impl std::fmt::Debug for GatingResolutionObservers {
1007    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1008        write!(f, "GatingResolutionObservers({})", self.0.len())
1009    }
1010}
1011
1012impl GatingResolutionObservers {
1013    pub(crate) fn push(&mut self, observer: Arc<dyn GatingResolutionObserver>) {
1014        self.0.push(observer);
1015    }
1016
1017    pub(crate) fn notify(&self, notice: &GatingResolutionNotice) {
1018        for observer in &self.0 {
1019            observer.on_gating_resolution(notice);
1020        }
1021    }
1022}
1023
1024#[derive(Debug, Clone, PartialEq, Eq)]
1025pub enum GatingDecideError {
1026    UnknownPendingId(String),
1027    SelfApprovalForbidden,
1028    ApproverMismatch { expected: String, provided: String },
1029}
1030
1031impl std::fmt::Display for GatingDecideError {
1032    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1033        match self {
1034            Self::UnknownPendingId(id) => write!(f, "unknown pending id: {id}"),
1035            Self::SelfApprovalForbidden => write!(f, "self-approval is forbidden"),
1036            Self::ApproverMismatch { expected, provided } => {
1037                write!(f, "approver mismatch: expected {expected}, got {provided}")
1038            }
1039        }
1040    }
1041}
1042
1043impl std::error::Error for GatingDecideError {}
1044
1045#[derive(Debug, Clone, PartialEq, Eq)]
1046struct DeliveryIdempotencyEntry {
1047    delivery_id: String,
1048    payload: Value,
1049    canonical_resolution: RoutingResolution,
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Eq, Default)]
1053struct RouterBoundaryOverrides {
1054    channel: Option<String>,
1055    sink: Option<String>,
1056    target_module: Option<String>,
1057    retry_max: Option<u32>,
1058    backoff_ms: Option<u64>,
1059    rate_limit_per_minute: Option<u32>,
1060}
1061
1062#[derive(Debug, Clone, PartialEq, Eq, Default)]
1063struct DeliveryBoundaryOutcome {
1064    sink_adapter: Option<String>,
1065    force_fail: bool,
1066}
1067
1068#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1069struct DeliveryRateWindowKey {
1070    route_id: String,
1071    recipient: String,
1072    sink: String,
1073    window_start_ms: u64,
1074}
1075
1076#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1077struct MemoryConflictKey {
1078    entity: String,
1079    topic: String,
1080    store: String,
1081}
1082
1083#[derive(Debug)]
1084pub struct MobkitRuntimeHandle {
1085    config: MobKitConfig,
1086    runtime_options: RuntimeOptions,
1087    loaded_modules: BTreeSet<String>,
1088    live_children: BTreeMap<String, Child>,
1089    pub lifecycle_events: Vec<LifecycleEvent>,
1090    pub supervisor_report: SupervisorReport,
1091    pub merged_events: Vec<EventEnvelope<UnifiedEvent>>,
1092    scheduling_claims: BTreeSet<String>,
1093    scheduling_claim_ticks: BTreeMap<u64, Vec<String>>,
1094    scheduling_last_due_ticks: BTreeMap<String, u64>,
1095    scheduling_dispatch_sequence: u64,
1096    routing_sequence: u64,
1097    routing_resolutions: BTreeMap<String, RoutingResolution>,
1098    routing_resolution_order: Vec<String>,
1099    runtime_routes: BTreeMap<String, RuntimeRoute>,
1100    delivery_sequence: u64,
1101    delivery_runtime_epoch_ms: u64,
1102    delivery_now_floor_ms: u64,
1103    delivery_clock_ms: u64,
1104    delivery_history: Vec<DeliveryRecord>,
1105    delivery_idempotency: BTreeMap<String, DeliveryIdempotencyEntry>,
1106    delivery_idempotency_by_delivery: BTreeMap<String, Vec<String>>,
1107    delivery_rate_window_counts: BTreeMap<DeliveryRateWindowKey, u32>,
1108    gating_sequence: u64,
1109    gating_pending: BTreeMap<String, GatingPendingEntry>,
1110    gating_pending_order: Vec<String>,
1111    gating_audit: Vec<GatingAuditEntry>,
1112    gating_resolution_observers: GatingResolutionObservers,
1113    memory_sequence: u64,
1114    memory_assertions: Vec<MemoryAssertion>,
1115    memory_conflicts: BTreeMap<MemoryConflictKey, MemoryConflictSignal>,
1116    memory_backend: Option<LocalJsonMemoryStoreAdapter>,
1117    running: bool,
1118}
1119
1120impl MobkitRuntimeHandle {
1121    /// Register a gating-resolution observer (see
1122    /// [`GatingResolutionObserver`]). Registration is append-only for the
1123    /// process lifetime.
1124    pub fn register_gating_resolution_observer(
1125        &mut self,
1126        observer: Arc<dyn GatingResolutionObserver>,
1127    ) {
1128        self.gating_resolution_observers.push(observer);
1129    }
1130
1131    pub fn lifecycle_events(&self) -> &[LifecycleEvent] {
1132        &self.lifecycle_events
1133    }
1134
1135    pub fn supervisor_report(&self) -> &SupervisorReport {
1136        &self.supervisor_report
1137    }
1138
1139    #[doc(hidden)]
1140    pub fn inject_test_events(&mut self, events: Vec<EventEnvelope<UnifiedEvent>>) {
1141        for event in events {
1142            insert_event_sorted(&mut self.merged_events, event);
1143        }
1144    }
1145
1146    fn next_sequence(counter: &mut u64) -> u64 {
1147        let seq = *counter;
1148        *counter = counter.saturating_add(1);
1149        seq
1150    }
1151}
1152
1153#[derive(Debug, Clone, PartialEq, Eq)]
1154pub enum ScheduleValidationError {
1155    EmptyScheduleId,
1156    DuplicateScheduleId(String),
1157    InvalidTickMs(u64),
1158    InvalidInterval {
1159        schedule_id: String,
1160        interval: String,
1161    },
1162    InvalidTimezone {
1163        schedule_id: String,
1164        timezone: String,
1165    },
1166    /// The total cron lookback work for this request exceeded
1167    /// `CRON_LOOKBACK_BUDGET_PER_REQUEST`. Surfaced (rather than silently
1168    /// stalling) when a request packs enough sparse crons to blow past the
1169    /// per-request iteration ceiling — a soft-DoS guard.
1170    LookbackBudgetExceeded {
1171        schedule_id: String,
1172    },
1173}
1174
1175impl std::fmt::Display for ScheduleValidationError {
1176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1177        match self {
1178            Self::EmptyScheduleId => write!(f, "empty schedule id"),
1179            Self::DuplicateScheduleId(id) => write!(f, "duplicate schedule id: {id}"),
1180            Self::InvalidTickMs(ms) => write!(f, "invalid tick ms: {ms}"),
1181            Self::InvalidInterval {
1182                schedule_id,
1183                interval,
1184            } => {
1185                write!(f, "invalid interval for schedule {schedule_id}: {interval}")
1186            }
1187            Self::InvalidTimezone {
1188                schedule_id,
1189                timezone,
1190            } => {
1191                write!(f, "invalid timezone for schedule {schedule_id}: {timezone}")
1192            }
1193            Self::LookbackBudgetExceeded { schedule_id } => {
1194                write!(
1195                    f,
1196                    "cron lookback budget exceeded while resolving schedule {schedule_id}"
1197                )
1198            }
1199        }
1200    }
1201}
1202
1203impl std::error::Error for ScheduleValidationError {}
1204
1205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1206pub struct ModuleRouteRequest {
1207    pub module_id: String,
1208    pub method: String,
1209    pub params: Value,
1210}
1211
1212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1213pub struct ModuleRouteResponse {
1214    pub module_id: String,
1215    pub method: String,
1216    pub payload: Value,
1217}
1218
1219#[derive(Debug, Clone, PartialEq, Eq)]
1220pub enum ModuleRouteError {
1221    UnloadedModule(String),
1222    ModuleRuntime(RuntimeBoundaryError),
1223    UnexpectedRouteResponse,
1224}
1225
1226impl std::fmt::Display for ModuleRouteError {
1227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1228        match self {
1229            Self::UnloadedModule(id) => write!(f, "unloaded module: {id}"),
1230            Self::ModuleRuntime(err) => write!(f, "module runtime: {err}"),
1231            Self::UnexpectedRouteResponse => write!(f, "unexpected route response"),
1232        }
1233    }
1234}
1235
1236impl std::error::Error for ModuleRouteError {
1237    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1238        match self {
1239            Self::ModuleRuntime(err) => Some(err),
1240            _ => None,
1241        }
1242    }
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq)]
1246pub enum RoutingResolveError {
1247    RouterModuleNotLoaded,
1248    DeliveryModuleNotLoaded,
1249    EmptyRecipient,
1250    InvalidChannel,
1251    InvalidRateLimitPerMinute,
1252    RetryMaxExceedsCap { provided: u32, cap: u32 },
1253    RouterBoundary(RuntimeBoundaryError),
1254}
1255
1256impl std::fmt::Display for RoutingResolveError {
1257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1258        match self {
1259            Self::RouterModuleNotLoaded => write!(f, "router module not loaded"),
1260            Self::DeliveryModuleNotLoaded => write!(f, "delivery module not loaded"),
1261            Self::EmptyRecipient => write!(f, "empty recipient"),
1262            Self::InvalidChannel => write!(f, "invalid channel"),
1263            Self::InvalidRateLimitPerMinute => write!(f, "invalid rate limit per minute"),
1264            Self::RetryMaxExceedsCap { provided, cap } => {
1265                write!(f, "retry max {provided} exceeds cap {cap}")
1266            }
1267            Self::RouterBoundary(err) => write!(f, "router boundary: {err}"),
1268        }
1269    }
1270}
1271
1272impl std::error::Error for RoutingResolveError {
1273    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1274        match self {
1275            Self::RouterBoundary(err) => Some(err),
1276            _ => None,
1277        }
1278    }
1279}
1280
1281#[derive(Debug, Clone, PartialEq, Eq)]
1282pub enum DeliverySendError {
1283    DeliveryModuleNotLoaded,
1284    InvalidRouteTarget(String),
1285    InvalidRouteId,
1286    UnknownRouteId(String),
1287    ForgedResolution,
1288    InvalidRecipient,
1289    InvalidSink,
1290    InvalidIdempotencyKey,
1291    IdempotencyPayloadMismatch,
1292    RateLimited {
1293        sink: String,
1294        window_start_ms: u64,
1295        limit: u32,
1296    },
1297    DeliveryBoundary(RuntimeBoundaryError),
1298}
1299
1300impl std::fmt::Display for DeliverySendError {
1301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1302        match self {
1303            Self::DeliveryModuleNotLoaded => write!(f, "delivery module not loaded"),
1304            Self::InvalidRouteTarget(target) => write!(f, "invalid route target: {target}"),
1305            Self::InvalidRouteId => write!(f, "invalid route id"),
1306            Self::UnknownRouteId(id) => write!(f, "unknown route id: {id}"),
1307            Self::ForgedResolution => write!(f, "forged resolution"),
1308            Self::InvalidRecipient => write!(f, "invalid recipient"),
1309            Self::InvalidSink => write!(f, "invalid sink"),
1310            Self::InvalidIdempotencyKey => write!(f, "invalid idempotency key"),
1311            Self::IdempotencyPayloadMismatch => write!(f, "idempotency payload mismatch"),
1312            Self::RateLimited {
1313                sink,
1314                window_start_ms,
1315                limit,
1316            } => {
1317                write!(
1318                    f,
1319                    "rate limited on sink {sink} (window {window_start_ms}ms, limit {limit})"
1320                )
1321            }
1322            Self::DeliveryBoundary(err) => write!(f, "delivery boundary: {err}"),
1323        }
1324    }
1325}
1326
1327impl std::error::Error for DeliverySendError {
1328    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1329        match self {
1330            Self::DeliveryBoundary(err) => Some(err),
1331            _ => None,
1332        }
1333    }
1334}
1335
1336#[derive(Debug, Clone, PartialEq, Eq)]
1337pub enum RuntimeRouteMutationError {
1338    EmptyRouteKey,
1339    EmptyRecipient,
1340    InvalidChannel,
1341    EmptySink,
1342    EmptyTargetModule,
1343    InvalidRateLimitPerMinute,
1344    RetryMaxExceedsCap { provided: u32, cap: u32 },
1345    RouteNotFound(String),
1346}
1347
1348impl std::fmt::Display for RuntimeRouteMutationError {
1349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1350        match self {
1351            Self::EmptyRouteKey => write!(f, "empty route key"),
1352            Self::EmptyRecipient => write!(f, "empty recipient"),
1353            Self::InvalidChannel => write!(f, "invalid channel"),
1354            Self::EmptySink => write!(f, "empty sink"),
1355            Self::EmptyTargetModule => write!(f, "empty target module"),
1356            Self::InvalidRateLimitPerMinute => write!(f, "invalid rate limit per minute"),
1357            Self::RetryMaxExceedsCap { provided, cap } => {
1358                write!(f, "retry max {provided} exceeds cap {cap}")
1359            }
1360            Self::RouteNotFound(key) => write!(f, "route not found: {key}"),
1361        }
1362    }
1363}
1364
1365impl std::error::Error for RuntimeRouteMutationError {}
1366
1367#[derive(Debug, Clone, PartialEq, Eq)]
1368pub enum RpcRouteError {
1369    InvalidRequest,
1370    BoundaryProcess(ProcessBoundaryError),
1371    Route(ModuleRouteError),
1372    InvalidResponse,
1373}
1374
1375impl std::fmt::Display for RpcRouteError {
1376    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1377        match self {
1378            Self::InvalidRequest => write!(f, "invalid request"),
1379            Self::BoundaryProcess(err) => write!(f, "boundary process: {err}"),
1380            Self::Route(err) => write!(f, "route: {err}"),
1381            Self::InvalidResponse => write!(f, "invalid response"),
1382        }
1383    }
1384}
1385
1386impl std::error::Error for RpcRouteError {
1387    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1388        match self {
1389            Self::BoundaryProcess(err) => Some(err),
1390            Self::Route(err) => Some(err),
1391            _ => None,
1392        }
1393    }
1394}
1395
1396#[derive(Debug, Clone, PartialEq, Eq)]
1397pub enum RuntimeMutationError {
1398    Config(ConfigResolutionError),
1399    Runtime(RuntimeBoundaryError),
1400}
1401
1402impl std::fmt::Display for RuntimeMutationError {
1403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1404        match self {
1405            Self::Config(err) => write!(f, "config resolution: {err}"),
1406            Self::Runtime(err) => write!(f, "runtime boundary: {err}"),
1407        }
1408    }
1409}
1410
1411impl std::error::Error for RuntimeMutationError {
1412    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1413        match self {
1414            Self::Config(err) => Some(err),
1415            Self::Runtime(err) => Some(err),
1416        }
1417    }
1418}
1419
1420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1421#[serde(rename_all = "snake_case")]
1422pub enum SubscribeScope {
1423    Mob,
1424    Agent,
1425    Interaction,
1426}
1427
1428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1429pub struct SubscribeRequest {
1430    pub scope: SubscribeScope,
1431    pub last_event_id: Option<String>,
1432    pub agent_id: Option<String>,
1433}
1434
1435impl Default for SubscribeRequest {
1436    fn default() -> Self {
1437        Self {
1438            scope: SubscribeScope::Mob,
1439            last_event_id: None,
1440            agent_id: None,
1441        }
1442    }
1443}
1444
1445#[derive(Debug, Clone, PartialEq, Eq)]
1446pub enum SubscribeError {
1447    EmptyCheckpoint,
1448    UnknownCheckpoint(String),
1449    MissingAgentId,
1450    InvalidAgentId,
1451}
1452
1453impl std::fmt::Display for SubscribeError {
1454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1455        match self {
1456            Self::EmptyCheckpoint => write!(f, "empty checkpoint"),
1457            Self::UnknownCheckpoint(id) => write!(f, "unknown checkpoint: {id}"),
1458            Self::MissingAgentId => write!(f, "missing agent id"),
1459            Self::InvalidAgentId => write!(f, "invalid agent id"),
1460        }
1461    }
1462}
1463
1464impl std::error::Error for SubscribeError {}
1465
1466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1467pub struct SubscribeKeepAlive {
1468    pub interval_ms: u64,
1469    pub event: String,
1470}
1471
1472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1473pub struct SubscribeResponse {
1474    pub scope: SubscribeScope,
1475    pub replay_from_event_id: Option<String>,
1476    pub keep_alive: SubscribeKeepAlive,
1477    pub keep_alive_comment: String,
1478    pub event_frames: Vec<String>,
1479    pub events: Vec<EventEnvelope<UnifiedEvent>>,
1480}
1481
1482const SSE_KEEP_ALIVE_INTERVAL_MS: u64 = 15_000;
1483const SSE_KEEP_ALIVE_EVENT_NAME: &str = "keep-alive";
1484const SSE_KEEP_ALIVE_COMMENT_FRAME: &str = ": keep-alive\n\n";
1485const SUBSCRIBE_REPLAY_EVENT_CAP: usize = 3;
1486const SCHEDULING_CLAIM_RETENTION_WINDOW_MS: u64 = 86_400_000;
1487const SCHEDULING_CLAIMS_MAX_RETAINED: usize = 4_096;
1488const SCHEDULING_LAST_DUE_MAX_RETAINED: usize = 4_096;
1489const DELIVERY_HISTORY_LIMIT_DEFAULT: usize = 20;
1490const DELIVERY_HISTORY_LIMIT_MAX: usize = 200;
1491const ROUTING_RESOLUTION_LIMIT_MAX: usize = 512;
1492pub const ROUTING_RETRY_MAX_CAP: u32 = 10;
1493const DELIVERY_RATE_WINDOW_MS: u64 = 60_000;
1494const DELIVERY_RATE_WINDOWS_RETAINED: u64 = 2;
1495const DELIVERY_CLOCK_STEP_MS: u64 = 1_000;
1496const GATING_APPROVAL_TIMEOUT_DEFAULT_MS: u64 = 60_000;
1497/// Lower bound on a caller-supplied R3 approval timeout. Without it a value of
1498/// `0` (or a few ms) makes `deadline_at_ms == created_at_ms`, so the pending
1499/// entry is treated as already-expired by `refresh_gating_timeouts` on the very
1500/// next gating RPC and is replaced with a `timeout_fallback` SafeDraft before
1501/// any approver can act — R3 approval can never complete. 1 second is the floor
1502/// that keeps a `PendingApproval` outcome actually approvable.
1503const GATING_APPROVAL_TIMEOUT_MIN_MS: u64 = 1_000;
1504/// Upper bound on a caller-supplied R3 approval timeout. Without it a huge
1505/// `approval_timeout_ms` makes `created_at_ms.saturating_add(timeout_ms)`
1506/// saturate to `u64::MAX`, so `now_ms >= deadline_at_ms` is never true and the
1507/// pending approval can never time out to a safe draft. 7 days is far beyond
1508/// any real human-approval window.
1509const GATING_APPROVAL_TIMEOUT_MAX_MS: u64 = 7 * 24 * 60 * 60 * 1_000;
1510const GATING_AUDIT_MAX_RETAINED: usize = 512;
1511const GATING_PENDING_MAX_RETAINED: usize = 512;
1512const MEMORY_ASSERTIONS_MAX_RETAINED: usize = 4_096;
1513const MEMORY_SUPPORTED_STORES: [&str; 5] = [
1514    "knowledge_graph",
1515    "vector",
1516    "timeline",
1517    "todo",
1518    "top_of_mind",
1519];
1520const MEMORY_LEDGER_HEALTHCHECK_TIMEOUT: Duration = Duration::from_secs(2);
1521// Multi-year bounded lookback so sparse valid cron schedules (for example leap-day)
1522// are not silently skipped when polling cadence is coarse.
1523const CRON_LOOKBACK_MINUTES: u64 = 5_270_400;
1524// Per-request ceiling on TOTAL cron lookback iterations across all schedules in
1525// one evaluate/dispatch call. Each schedule may still walk back the full
1526// `CRON_LOOKBACK_MINUTES` (≈10y) for a legitimately-sparse cron such as
1527// `0 0 29 2 *` (Feb 29, ≈1.5M iterations), but a request packed with many such
1528// crons cannot multiply that cost without bound. The ceiling allows several
1529// worst-case sparse crons per request (well above any real schedule set) while
1530// capping the adversarial `MAX_SCHEDULES_PER_REQUEST * ~1.5M` (~380M) blow-up
1531// that would otherwise stall the tokio worker and starve the module mutex.
1532const CRON_LOOKBACK_BUDGET_PER_REQUEST: u64 = CRON_LOOKBACK_MINUTES.saturating_mul(8);
1533const CONSOLE_EXPERIENCE_ROUTE: &str = "/console/experience";
1534const CONSOLE_MODULES_ROUTE: &str = "/console/modules";
1535const EVENTS_SUBSCRIBE_METHOD: &str = "mobkit/events/subscribe";
1536
1537fn default_delivery_history_limit() -> usize {
1538    DELIVERY_HISTORY_LIMIT_DEFAULT
1539}
1540
1541pub fn run_meerkat_baseline_verification_once(
1542    command: &str,
1543    args: &[String],
1544    env: &[(String, String)],
1545    timeout: Duration,
1546) -> Result<BaselineVerificationReport, BaselineRuntimeError> {
1547    let line = run_process_json_line(command, args, env, timeout)
1548        .map_err(BaselineRuntimeError::Process)?;
1549    let value: Value =
1550        serde_json::from_str(&line).map_err(|_| BaselineRuntimeError::InvalidRepoPathJson)?;
1551    let repo = value
1552        .as_object()
1553        .and_then(|obj| obj.get("repo_root"))
1554        .ok_or(BaselineRuntimeError::MissingRepoRoot)?
1555        .as_str()
1556        .ok_or(BaselineRuntimeError::InvalidRepoRoot)?;
1557    if repo.trim().is_empty() {
1558        return Err(BaselineRuntimeError::InvalidRepoRoot);
1559    }
1560    verify_meerkat_baseline_symbols(Some(std::path::Path::new(repo)))
1561        .map_err(BaselineRuntimeError::Baseline)
1562}
1563
1564pub fn build_runtime_decision_state(
1565    input: RuntimeDecisionInputs,
1566) -> Result<RuntimeDecisionState, DecisionRuntimeError> {
1567    validate_bigquery_naming(&input.bigquery).map_err(DecisionRuntimeError::Policy)?;
1568    let modules = load_trusted_mobkit_modules_from_toml(&input.trusted_mobkit_toml)
1569        .map_err(DecisionRuntimeError::Policy)?;
1570    validate_runtime_ops_policy(&input.ops).map_err(DecisionRuntimeError::Policy)?;
1571    let release_metadata = parse_release_metadata_json(&input.release_metadata_json)
1572        .map_err(DecisionRuntimeError::Policy)?;
1573    validate_release_metadata(&release_metadata).map_err(DecisionRuntimeError::Policy)?;
1574    if input.trusted_oidc.audience.trim().is_empty() {
1575        return Err(DecisionRuntimeError::Policy(
1576            DecisionPolicyError::InvalidTrustedAuthConfig(
1577                "trusted OIDC audience must not be empty".to_string(),
1578            ),
1579        ));
1580    }
1581    parse_oidc_discovery_json(&input.trusted_oidc.discovery_json).map_err(|err| {
1582        DecisionRuntimeError::Policy(DecisionPolicyError::InvalidTrustedAuthConfig(format!(
1583            "invalid trusted OIDC discovery: {err:?}"
1584        )))
1585    })?;
1586    parse_jwks_json(&input.trusted_oidc.jwks_json).map_err(|err| {
1587        DecisionRuntimeError::Policy(DecisionPolicyError::InvalidTrustedAuthConfig(format!(
1588            "invalid trusted JWKS: {err:?}"
1589        )))
1590    })?;
1591
1592    Ok(RuntimeDecisionState {
1593        bigquery: input.bigquery,
1594        modules,
1595        auth: input.auth,
1596        trusted_oidc: input.trusted_oidc,
1597        console: input.console,
1598        ops: input.ops,
1599        release_metadata,
1600    })
1601}
1602
1603fn current_time_ms() -> u64 {
1604    SystemTime::now()
1605        .duration_since(UNIX_EPOCH)
1606        .unwrap_or_default()
1607        .as_millis() as u64
1608}