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;
9
10use std::sync::{Arc, OnceLock};
11
12use systemprompt_analytics::{AnalyticsService, FingerprintRepository};
13use systemprompt_config::ProfileBootstrap;
14use systemprompt_database::{Database, MigrationConfig, install_extension_schemas_full};
15use systemprompt_extension::ExtensionRegistry;
16use systemprompt_marketplace::MarketplaceFilter;
17use systemprompt_mcp::services::registry::RegistryService;
18use systemprompt_models::{AppPaths, Config};
19use systemprompt_security::authz::{AuthzDecisionHook, SharedAuthzHook};
20use systemprompt_users::UserService;
21
22use crate::context::{AppContext, ConfigPlane, DataPlane, Plugins, Subsystems};
23use crate::error::{RuntimeError, RuntimeResult};
24use crate::registry::ModuleApiRegistry;
25
26/// Assembles an [`AppContext`], owning the bootstrap order described on the
27/// module.
28///
29/// All fields default to a no-op build: extensions are discovered via
30/// inventory, schema installation is off, and the marketplace filter falls
31/// back to the inventory-registered implementation (or an allow-all filter).
32/// Override these with the `with_*` methods before calling
33/// [`build`](Self::build).
34#[derive(Default)]
35pub struct AppContextBuilder {
36    extension_registry: Option<ExtensionRegistry>,
37    show_startup_warnings: bool,
38    marketplace_filter: Option<Arc<dyn MarketplaceFilter>>,
39    authz_hook: Option<SharedAuthzHook>,
40    install_schemas: bool,
41    migration_config: MigrationConfig,
42}
43
44impl std::fmt::Debug for AppContextBuilder {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("AppContextBuilder")
47            .field("extension_registry", &self.extension_registry.is_some())
48            .field("show_startup_warnings", &self.show_startup_warnings)
49            .field("marketplace_filter", &self.marketplace_filter.is_some())
50            .field("authz_hook", &self.authz_hook.is_some())
51            .field("install_schemas", &self.install_schemas)
52            .field("migration_config", &self.migration_config)
53            .finish()
54    }
55}
56
57struct CoreLayer {
58    config: Arc<Config>,
59    app_paths: Arc<AppPaths>,
60    database: Arc<Database>,
61    authz_hook: SharedAuthzHook,
62}
63
64impl AppContextBuilder {
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Supplies an explicit extension registry. When unset, `build()`
71    /// discovers extensions via inventory ([`ExtensionRegistry::discover`]).
72    #[must_use]
73    pub fn with_extensions(mut self, registry: ExtensionRegistry) -> Self {
74        self.extension_registry = Some(registry);
75        self
76    }
77
78    #[must_use]
79    pub const fn with_startup_warnings(mut self, show: bool) -> Self {
80        self.show_startup_warnings = show;
81        self
82    }
83
84    /// Supplies an explicit marketplace filter. When unset, `build()` selects
85    /// the highest-priority inventory-registered filter, falling back to an
86    /// allow-all filter when none succeeds.
87    #[must_use]
88    pub fn with_marketplace_filter(mut self, filter: Arc<dyn MarketplaceFilter>) -> Self {
89        self.marketplace_filter = Some(filter);
90        self
91    }
92
93    /// Install / migrate extension schemas as part of `build()`. Off by
94    /// default so admin tools (`db doctor`, repair scripts) can open a
95    /// connection without mutating the schema. `serve` turns this on.
96    #[must_use]
97    pub const fn with_migrations(mut self, install: bool) -> Self {
98        self.install_schemas = install;
99        self
100    }
101
102    /// Supplies an extension-built authz decision hook. The hook is wired
103    /// only when `profile.governance.authz.hook.mode = extension`; pairing
104    /// this call with any other mode is a bootstrap error.
105    #[must_use]
106    pub fn with_authz_hook<H>(mut self, hook: H) -> Self
107    where
108        H: AuthzDecisionHook + 'static,
109    {
110        self.authz_hook = Some(Arc::new(hook));
111        self
112    }
113
114    /// Variant of [`Self::with_authz_hook`] for callers that already hold an
115    /// `Arc<dyn AuthzDecisionHook>` (e.g. a pre-built [`CompositeAuthzHook`]
116    /// shared across consumers).
117    ///
118    /// [`CompositeAuthzHook`]: systemprompt_security::authz::CompositeAuthzHook
119    #[must_use]
120    pub fn with_shared_authz_hook(mut self, hook: SharedAuthzHook) -> Self {
121        self.authz_hook = Some(hook);
122        self
123    }
124
125    #[must_use]
126    pub const fn with_migration_config(mut self, config: MigrationConfig) -> Self {
127        self.migration_config = config;
128        self
129    }
130
131    pub async fn build(self) -> RuntimeResult<AppContext> {
132        let CoreLayer {
133            config,
134            app_paths,
135            database,
136            authz_hook,
137        } = init_core(self.authz_hook).await?;
138
139        let api_registry = Arc::new(ModuleApiRegistry::new());
140        let extension_registry = init_extensions(
141            self.extension_registry,
142            self.install_schemas,
143            self.migration_config,
144            &database,
145        )
146        .await?;
147
148        let geoip_reader = AppContext::load_geoip_database(&config, self.show_startup_warnings);
149        let content_config = AppContext::load_content_config(&config, &app_paths);
150        let content_routing = assembly::content_routing_from(content_config.as_ref());
151        let route_classifier = Arc::new(systemprompt_models::RouteClassifier::new(
152            content_routing.clone(),
153        ));
154        let analytics_service = Arc::new(AnalyticsService::new(
155            &database,
156            geoip_reader.clone(),
157            content_routing,
158        )?);
159
160        let fingerprint_repo = match FingerprintRepository::new(&database) {
161            Ok(repo) => Some(Arc::new(repo)),
162            Err(e) => {
163                tracing::warn!(error = %e, "Failed to initialize fingerprint repository");
164                None
165            },
166        };
167
168        // UserService is a mandatory dependency: the system admin cannot be
169        // resolved without it, so a construction failure is fatal here rather
170        // than a warning that re-surfaces as a less specific error downstream.
171        let user_service = Arc::new(UserService::new(&database)?);
172
173        let system_admin =
174            assembly::resolve_and_install_system_admin(&config, &user_service).await?;
175        let mcp_registry = RegistryService::new(system_admin.id().clone());
176
177        let marketplace_filter = self
178            .marketplace_filter
179            .unwrap_or_else(|| assembly::build_marketplace_filter(&database));
180
181        let event_bridge = Arc::new(OnceLock::new());
182
183        Ok(AppContext::from_parts(
184            DataPlane {
185                database,
186                analytics_service,
187                fingerprint_repo,
188                user_service: Some(user_service),
189            },
190            ConfigPlane {
191                config,
192                app_paths,
193                content_config,
194                route_classifier,
195            },
196            Plugins {
197                extension_registry,
198                api_registry,
199                mcp_registry,
200                marketplace_filter,
201            },
202            Subsystems {
203                system_admin,
204                authz_hook,
205                event_bridge,
206                geoip_reader,
207            },
208        ))
209    }
210}
211
212/// Bootstraps profile, paths, files, config, database, authz, and logging.
213///
214/// The path/files/config inits are idempotent `OnceLock` guards, so a non-CLI
215/// entry (API, tests) can build a context self-sufficiently while a CLI that
216/// already ran them sees a no-op.
217async fn init_core(authz_hook_override: Option<SharedAuthzHook>) -> RuntimeResult<CoreLayer> {
218    let profile = ProfileBootstrap::get()?;
219    let app_paths = Arc::new(AppPaths::from_profile(&profile.paths)?);
220    systemprompt_files::FilesConfig::init(&app_paths)?;
221    systemprompt_config::try_init_config()
222        .map_err(|err| RuntimeError::Internal(format!("config init: {err}")))?;
223    let config = Arc::new(Config::get()?.clone());
224
225    let database = Arc::new(
226        Database::from_config_with_write(
227            &config.database_type,
228            &config.database_url,
229            config.database_write_url.as_deref(),
230        )
231        .await?,
232    );
233
234    let authz_audit_pool = database.write_pool_arc().ok();
235    let authz_hook = systemprompt_security::authz::build_authz_hook(
236        profile.governance.as_ref(),
237        authz_audit_pool,
238        authz_hook_override,
239    )
240    .map_err(|err| RuntimeError::Internal(format!("authz bootstrap: {err}")))?;
241
242    systemprompt_logging::init_logging(Arc::clone(&database));
243
244    if config.database_write_url.is_some() {
245        tracing::debug!(
246            "Database read/write separation enabled: reads from replica, writes to primary"
247        );
248    }
249
250    Ok(CoreLayer {
251        config,
252        app_paths,
253        database,
254        authz_hook,
255    })
256}
257
258async fn init_extensions(
259    extension_registry: Option<ExtensionRegistry>,
260    install_schemas: bool,
261    migration_config: MigrationConfig,
262    database: &Arc<Database>,
263) -> RuntimeResult<Arc<ExtensionRegistry>> {
264    let registry = match extension_registry {
265        Some(registry) => registry,
266        None => ExtensionRegistry::discover()?,
267    };
268    registry.validate()?;
269
270    if install_schemas {
271        install_extension_schemas_full(&registry, database.write(), &[], migration_config).await?;
272    }
273
274    Ok(Arc::new(registry))
275}