Skip to main content

systemprompt_runtime/builder/
mod.rs

1//! Builder that assembles an [`AppContext`] from profile + config state.
2//!
3//! The builder owns the bootstrap order: profile -> paths -> files ->
4//! database -> logging -> extensions -> ancillary services. Failures at
5//! any step propagate as [`RuntimeError`](crate::error::RuntimeError).
6//! Subsystem resolution helpers live in [`assembly`].
7
8mod assembly;
9mod core_layer;
10
11use std::sync::{Arc, OnceLock};
12
13use systemprompt_database::MigrationConfig;
14use systemprompt_extension::ExtensionRegistry;
15use systemprompt_marketplace::MarketplaceFilter;
16use systemprompt_mcp::services::registry::RegistryService;
17use systemprompt_security::authz::{AuthzDecisionHook, SharedAuthzHook};
18use systemprompt_users::UserService;
19
20use crate::context::{AppContext, ConfigPlane, DataPlane, Plugins, Subsystems};
21use crate::error::RuntimeResult;
22use crate::registry::ModuleApiRegistry;
23use core_layer::{CoreLayer, init_core, init_extensions};
24
25/// Assembles an [`AppContext`], owning the bootstrap order described on the
26/// module.
27///
28/// All fields default to a no-op build: extensions are discovered via
29/// inventory, schema installation is off, and the marketplace filter falls
30/// back to the inventory-registered implementation (or an allow-all filter).
31/// Override these with the `with_*` methods before calling
32/// [`build`](Self::build).
33#[derive(Default)]
34pub struct AppContextBuilder {
35    extension_registry: Option<ExtensionRegistry>,
36    show_startup_warnings: bool,
37    marketplace_filter: Option<Arc<dyn MarketplaceFilter>>,
38    authz_hook: Option<SharedAuthzHook>,
39    install_schemas: bool,
40    migration_config: MigrationConfig,
41}
42
43impl std::fmt::Debug for AppContextBuilder {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("AppContextBuilder")
46            .field("extension_registry", &self.extension_registry.is_some())
47            .field("show_startup_warnings", &self.show_startup_warnings)
48            .field("marketplace_filter", &self.marketplace_filter.is_some())
49            .field("authz_hook", &self.authz_hook.is_some())
50            .field("install_schemas", &self.install_schemas)
51            .field("migration_config", &self.migration_config)
52            .finish()
53    }
54}
55
56impl AppContextBuilder {
57    #[must_use]
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Supplies an explicit extension registry. When unset, `build()`
63    /// discovers extensions via inventory ([`ExtensionRegistry::discover`]).
64    #[must_use]
65    pub fn with_extensions(mut self, registry: ExtensionRegistry) -> Self {
66        self.extension_registry = Some(registry);
67        self
68    }
69
70    #[must_use]
71    pub const fn with_startup_warnings(mut self, show: bool) -> Self {
72        self.show_startup_warnings = show;
73        self
74    }
75
76    /// Supplies an explicit marketplace filter. When unset, `build()` selects
77    /// the highest-priority inventory-registered filter, falling back to an
78    /// allow-all filter when none succeeds.
79    #[must_use]
80    pub fn with_marketplace_filter(mut self, filter: Arc<dyn MarketplaceFilter>) -> Self {
81        self.marketplace_filter = Some(filter);
82        self
83    }
84
85    /// Install / migrate extension schemas as part of `build()`. Off by
86    /// default so admin tools (`db doctor`, repair scripts) can open a
87    /// connection without mutating the schema. `serve` turns this on.
88    #[must_use]
89    pub const fn with_migrations(mut self, install: bool) -> Self {
90        self.install_schemas = install;
91        self
92    }
93
94    /// Supplies an extension-built authz decision hook. The hook is wired
95    /// only when `profile.governance.authz.hook.mode = extension`; pairing
96    /// this call with any other mode is a bootstrap error.
97    #[must_use]
98    pub fn with_authz_hook<H>(mut self, hook: H) -> Self
99    where
100        H: AuthzDecisionHook + 'static,
101    {
102        self.authz_hook = Some(Arc::new(hook));
103        self
104    }
105
106    /// Variant of [`Self::with_authz_hook`] for callers that already hold an
107    /// `Arc<dyn AuthzDecisionHook>` (e.g. a pre-built [`CompositeAuthzHook`]
108    /// shared across consumers).
109    ///
110    /// [`CompositeAuthzHook`]: systemprompt_security::authz::CompositeAuthzHook
111    #[must_use]
112    pub fn with_shared_authz_hook(mut self, hook: SharedAuthzHook) -> Self {
113        self.authz_hook = Some(hook);
114        self
115    }
116
117    #[must_use]
118    pub const fn with_migration_config(mut self, config: MigrationConfig) -> Self {
119        self.migration_config = config;
120        self
121    }
122
123    pub async fn build(self) -> RuntimeResult<AppContext> {
124        let CoreLayer {
125            config,
126            app_paths,
127            database,
128            authz_hook,
129        } = init_core(self.authz_hook).await?;
130
131        let api_registry = Arc::new(ModuleApiRegistry::new());
132        let extension_registry = init_extensions(
133            self.extension_registry,
134            self.install_schemas,
135            self.migration_config,
136            &database,
137        )
138        .await?;
139
140        let assembly::ContentAnalytics {
141            geoip_reader,
142            content_config,
143            route_classifier,
144            analytics_service,
145            fingerprint_repo,
146        } = assembly::assemble_content_analytics(
147            &config,
148            &app_paths,
149            &database,
150            self.show_startup_warnings,
151        )?;
152
153        // UserService is a mandatory dependency: the system admin cannot be
154        // resolved without it, so a construction failure is fatal here rather
155        // than a warning that re-surfaces as a less specific error downstream.
156        let user_service = Arc::new(UserService::new(&database)?);
157
158        let system_admin =
159            assembly::resolve_and_install_system_admin(&config, &user_service).await?;
160        let mcp_registry = RegistryService::new(system_admin.id().clone());
161
162        let marketplace_filter = self
163            .marketplace_filter
164            .unwrap_or_else(|| assembly::build_marketplace_filter(&database));
165
166        let event_bridge = Arc::new(OnceLock::new());
167
168        Ok(AppContext::from_parts(
169            DataPlane {
170                database,
171                analytics_service,
172                fingerprint_repo,
173                user_service: Some(user_service),
174            },
175            ConfigPlane {
176                config,
177                app_paths,
178                content_config,
179                route_classifier,
180            },
181            Plugins {
182                extension_registry,
183                api_registry,
184                mcp_registry,
185                marketplace_filter,
186            },
187            Subsystems {
188                system_admin,
189                authz_hook,
190                event_bridge,
191                geoip_reader,
192            },
193        ))
194    }
195}