Skip to main content

lenso_bootstrap/
lib.rs

1//! Composition root: the single place that knows which modules exist.
2//!
3//! Both the API and the worker assemble their module wiring from this crate, so
4//! a module is registered here once rather than in scattered per-app edits.
5//!
6//! A module's contributions are split by how they are consumed:
7//! - [`modules`]: context-bound bindings (runtime functions + event handlers)
8//!   and runtime config (API + worker), demo-default for context-local callers.
9//! - [`modules_for_config`]: config-aware Linked Module loader that honors the
10//!   selected composition profile.
11//! - [`module_manifests`]: context-free manifest data (no [`AppContext`]) for
12//!   read-only / `OpenAPI` paths, with profile-aware variants for runtime use.
13//! - [`merge_linked_http`]: context-free HTTP routes and their OpenAPI docs
14//!   (API only), assembled without a live [`AppContext`].
15//! - [`story_display_descriptors`]: console display metadata, sourced from the
16//!   context-free [`module_manifests`].
17//!
18//! When adding a module, register it in the appropriate profile entry lists and
19//! expose its config-aware loader contributions from this crate.
20
21use platform_core::error::ErrorDetail;
22use platform_core::{
23    ActorContext, AppContext, AppError, CorrelationId, ErrorCode, EventHandlerRegistry, Migration,
24    PLATFORM_MIGRATIONS, RuntimeConfigDescriptor, RuntimeConfigGroupDescriptor, RuntimeConfigScope,
25    RuntimeConfigType, StoryDisplayDescriptor, StoryDisplaySource, TraceContext,
26};
27use platform_http::ApiOpenApiRouter;
28use platform_module::CronSchedule;
29pub use platform_module::HostLinkedModule;
30use platform_module::{
31    EventHandlerRegistrationContext, LifecycleActivationRunPolicy, LifecycleStartupCheckKind,
32    LinkedBinding, Module, ModuleHttpMethod, ModuleLoadStatus, ModuleManifest, ModuleSource,
33};
34use platform_provider::{ProviderRuntimeAdapter, ProviderRuntimeAdapters};
35use platform_runtime::{
36    EnqueueFunctionRequest, FunctionRegistry, RUNTIME_MIGRATIONS, RuntimeClient,
37    ScheduledFunctionDefinition,
38};
39use std::path::Path;
40use std::sync::Arc;
41
42#[derive(Clone)]
43pub struct HostSystemPlaneConfig {
44    pub service_id: String,
45    pub service_principal: String,
46    pub service_revision: String,
47    pub audience: String,
48    pub workspace_root: std::path::PathBuf,
49    pub workload_identity: Arc<dyn lenso_service::WorkloadIdentityProvider>,
50    pub enrollment_authorizer: Arc<dyn platform_system_plane::EnrollmentAuthorizer>,
51    pub runtime_observability:
52        Option<Arc<platform_runtime_observability::RuntimeObservabilityProvider>>,
53    pub runtime_operations: Option<Arc<platform_runtime_operations::RuntimeOperationsProvider>>,
54}
55
56impl std::fmt::Debug for HostSystemPlaneConfig {
57    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        formatter
59            .debug_struct("HostSystemPlaneConfig")
60            .field("service_id", &self.service_id)
61            .field("service_principal", &self.service_principal)
62            .field("service_revision", &self.service_revision)
63            .field("audience", &self.audience)
64            .field("workspace_root", &self.workspace_root)
65            .field("workload_identity", &self.workload_identity)
66            .field("enrollment_authorizer", &self.enrollment_authorizer)
67            .field("runtime_observability", &self.runtime_observability)
68            .field("runtime_operations", &self.runtime_operations)
69            .finish()
70    }
71}
72
73#[derive(Debug, Clone)]
74pub struct HostSystemPlaneRuntime {
75    pub core: Arc<platform_system_plane::SystemPlaneRuntime>,
76    pub service_installations: Arc<platform_module_management::ServiceInstallationsProvider>,
77    pub runtime_observability:
78        Option<Arc<platform_runtime_observability::RuntimeObservabilityProvider>>,
79    pub runtime_operations: Option<Arc<platform_runtime_operations::RuntimeOperationsProvider>>,
80}
81
82pub fn compose_host_system_plane_runtime(
83    config: HostSystemPlaneConfig,
84) -> platform_core::AppResult<HostSystemPlaneRuntime> {
85    let service_installations = Arc::new(
86        platform_module_management::ServiceInstallationsProvider::new(config.workspace_root),
87    );
88    let mut registry = platform_system_plane::SystemPlaneRegistryBuilder::new(
89        &config.service_id,
90        &config.service_principal,
91        &config.service_revision,
92    )
93    .register(platform_module_management::ServiceInstallationsProvider::advertisement());
94    if config.runtime_observability.is_some() {
95        registry = registry.register(
96            platform_runtime_observability::RuntimeObservabilityProvider::advertisement(),
97        );
98    }
99    if config.runtime_operations.is_some() {
100        registry = registry
101            .register(platform_runtime_operations::RuntimeOperationsProvider::advertisement());
102    }
103    let registry = registry.build().map_err(|issues| {
104        AppError::new(
105            ErrorCode::Validation,
106            format!("Host System Plane registry is invalid: {issues:?}"),
107        )
108    })?;
109    let access = platform_system_plane::SystemPlaneAccess::new(
110        config.workload_identity,
111        config.audience,
112        config.enrollment_authorizer,
113    );
114    Ok(HostSystemPlaneRuntime {
115        core: Arc::new(platform_system_plane::SystemPlaneRuntime::new(
116            registry, access,
117        )),
118        service_installations,
119        runtime_observability: config.runtime_observability,
120        runtime_operations: config.runtime_operations,
121    })
122}
123
124struct LinkedModuleEntry {
125    module_name: &'static str,
126    manifest: fn() -> ModuleManifest,
127    load: fn(&AppContext) -> Module,
128    http_binding: Option<fn() -> LinkedBinding>,
129}
130
131const MODULES_CONFIG_GROUP: RuntimeConfigGroupDescriptor = RuntimeConfigGroupDescriptor {
132    id: "modules",
133    label: "Modules",
134    description: "Module load toggles applied on service startup.",
135    order: 10,
136};
137
138#[derive(Debug, Clone)]
139pub struct HostComposition {
140    linked_modules: Vec<HostLinkedModule>,
141    provider_runtime_adapters: ProviderRuntimeAdapters,
142}
143
144impl Default for HostComposition {
145    fn default() -> Self {
146        Self {
147            linked_modules: Vec::new(),
148            provider_runtime_adapters: ProviderRuntimeAdapters::production_defaults(),
149        }
150    }
151}
152
153impl HostComposition {
154    #[must_use]
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    #[must_use]
160    pub fn with_linked_module(mut self, module: HostLinkedModule) -> Self {
161        self.add_linked_module(module);
162        self
163    }
164
165    pub fn add_linked_module(&mut self, module: HostLinkedModule) {
166        self.linked_modules.push(module);
167    }
168
169    #[must_use]
170    pub fn linked_modules(&self) -> &[HostLinkedModule] {
171        &self.linked_modules
172    }
173
174    #[must_use]
175    pub fn with_provider_runtime_adapters(mut self, adapters: ProviderRuntimeAdapters) -> Self {
176        self.provider_runtime_adapters = adapters;
177        self
178    }
179
180    #[must_use]
181    pub fn provider_runtime_adapters(&self) -> &ProviderRuntimeAdapters {
182        &self.provider_runtime_adapters
183    }
184}
185
186#[derive(Debug, Clone)]
187pub struct HostWiring {
188    auth_session_policy: auth::session_policy::AuthSessionPolicyHandle,
189}
190
191impl HostWiring {
192    #[must_use]
193    pub fn auth_session_policy(&self) -> auth::session_policy::AuthSessionPolicyHandle {
194        self.auth_session_policy.clone()
195    }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum CompositionProfile {
200    Core,
201    Demo,
202}
203
204impl CompositionProfile {
205    pub fn parse(value: &str) -> platform_core::AppResult<Self> {
206        match value.trim().to_ascii_lowercase().as_str() {
207            "core" => Ok(Self::Core),
208            "demo" => Ok(Self::Demo),
209            other => Err(AppError::validation(
210                "Invalid Lenso composition profile",
211                vec![ErrorDetail {
212                    field: Some("module_sources.linked_profile".to_owned()),
213                    reason: format!("expected `core` or `demo`, got `{other}`"),
214                }],
215            )),
216        }
217    }
218
219    pub fn from_config(config: &platform_core::AppConfig) -> platform_core::AppResult<Self> {
220        Self::parse(&config.module_sources.linked_profile)
221    }
222}
223
224impl Default for CompositionProfile {
225    fn default() -> Self {
226        Self::Demo
227    }
228}
229
230const CORE_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[];
231
232const DEMO_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[
233    LinkedModuleEntry {
234        module_name: "auth",
235        manifest: auth::module::manifest,
236        load: auth::module::module,
237        http_binding: Some(auth::module::binding),
238    },
239    LinkedModuleEntry {
240        module_name: "auth-anonymous",
241        manifest: auth_anonymous::module::manifest,
242        load: auth_anonymous::module::module,
243        http_binding: Some(auth_anonymous::module::binding),
244    },
245    LinkedModuleEntry {
246        module_name: "auth-oauth",
247        manifest: auth_oauth::module::manifest,
248        load: auth_oauth::module::module,
249        http_binding: None,
250    },
251    LinkedModuleEntry {
252        module_name: "auth-password",
253        manifest: auth_password::module::manifest,
254        load: auth_password::module::module,
255        http_binding: Some(auth_password::module::binding),
256    },
257    LinkedModuleEntry {
258        module_name: "auth-phone",
259        manifest: auth_phone::module::manifest,
260        load: auth_phone::module::module,
261        http_binding: Some(auth_phone::module::binding),
262    },
263    LinkedModuleEntry {
264        module_name: "auth-github",
265        manifest: auth_github::module::manifest,
266        load: auth_github::module::module,
267        http_binding: Some(auth_github::module::binding),
268    },
269    LinkedModuleEntry {
270        module_name: "auth-google",
271        manifest: auth_google::module::manifest,
272        load: auth_google::module::module,
273        http_binding: Some(auth_google::module::binding),
274    },
275    LinkedModuleEntry {
276        module_name: "auth-oidc",
277        manifest: auth_oidc::module::manifest,
278        load: auth_oidc::module::module,
279        http_binding: Some(auth_oidc::module::binding),
280    },
281];
282
283fn linked_module_entries(profile: CompositionProfile) -> &'static [LinkedModuleEntry] {
284    match profile {
285        CompositionProfile::Core => CORE_LINKED_MODULE_ENTRIES,
286        CompositionProfile::Demo => DEMO_LINKED_MODULE_ENTRIES,
287    }
288}
289
290#[must_use]
291pub fn auth_linked_module() -> HostLinkedModule {
292    HostLinkedModule::linked(
293        auth::module::MODULE_NAME,
294        auth::module::manifest,
295        auth::module::module,
296        auth::migrations::AUTH_MIGRATIONS,
297    )
298    .with_http_binding(auth::module::binding)
299}
300
301#[must_use]
302pub fn auth_anonymous_linked_module() -> HostLinkedModule {
303    HostLinkedModule::linked(
304        auth_anonymous::module::MODULE_NAME,
305        auth_anonymous::module::manifest,
306        auth_anonymous::module::module,
307        auth_anonymous::migrations::AUTH_ANONYMOUS_MIGRATIONS,
308    )
309    .with_http_binding(auth_anonymous::module::binding)
310}
311
312#[must_use]
313pub fn auth_password_linked_module() -> HostLinkedModule {
314    HostLinkedModule::linked(
315        auth_password::module::MODULE_NAME,
316        auth_password::module::manifest,
317        auth_password::module::module,
318        auth_password::migrations::AUTH_PASSWORD_MIGRATIONS,
319    )
320    .with_http_binding(auth_password::module::binding)
321}
322
323#[must_use]
324pub fn auth_phone_linked_module() -> HostLinkedModule {
325    HostLinkedModule::linked(
326        auth_phone::module::MODULE_NAME,
327        auth_phone::module::manifest,
328        auth_phone::module::module,
329        auth_phone::migrations::AUTH_PHONE_MIGRATIONS,
330    )
331    .with_http_binding(auth_phone::module::binding)
332}
333
334#[must_use]
335pub fn auth_oauth_linked_module() -> HostLinkedModule {
336    HostLinkedModule::linked(
337        auth_oauth::module::MODULE_NAME,
338        auth_oauth::module::manifest,
339        auth_oauth::module::module,
340        auth_oauth::migrations::AUTH_OAUTH_MIGRATIONS,
341    )
342}
343
344#[must_use]
345pub fn auth_github_linked_module() -> HostLinkedModule {
346    HostLinkedModule::linked(
347        auth_github::module::MODULE_NAME,
348        auth_github::module::manifest,
349        auth_github::module::module,
350        auth_github::migrations::AUTH_GITHUB_MIGRATIONS,
351    )
352    .with_http_binding(auth_github::module::binding)
353}
354
355#[must_use]
356pub fn auth_google_linked_module() -> HostLinkedModule {
357    HostLinkedModule::linked(
358        auth_google::module::MODULE_NAME,
359        auth_google::module::manifest,
360        auth_google::module::module,
361        auth_google::migrations::AUTH_GOOGLE_MIGRATIONS,
362    )
363    .with_http_binding(auth_google::module::binding)
364}
365
366#[must_use]
367pub fn auth_oidc_linked_module() -> HostLinkedModule {
368    HostLinkedModule::linked(
369        auth_oidc::module::MODULE_NAME,
370        auth_oidc::module::manifest,
371        auth_oidc::module::module,
372        auth_oidc::migrations::AUTH_OIDC_MIGRATIONS,
373    )
374    .with_http_binding(auth_oidc::module::binding)
375}
376
377fn linked_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
378    config
379        .modules
380        .get(module_name)
381        .is_none_or(platform_core::ModuleConfig::is_enabled)
382}
383
384fn module_enabled_config_key(module_name: &str) -> String {
385    format!("modules.{module_name}.enabled")
386}
387
388fn linked_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
389    ctx.runtime_config
390        .snapshot()
391        .raw(&module_enabled_config_key(module_name))
392        .and_then(serde_json::Value::as_bool)
393        .unwrap_or_else(|| linked_module_enabled_from_config(&ctx.config, module_name))
394}
395
396fn first_disabled_dependency(ctx: &AppContext, manifest: fn() -> ModuleManifest) -> Option<String> {
397    (manifest)()
398        .requires
399        .into_iter()
400        .map(|requirement| requirement.module_id)
401        .find(|module_id| {
402            !linked_module_enabled(ctx, module_id.rsplit('/').next().unwrap_or(module_id))
403        })
404}
405
406fn first_disabled_dependency_from_config(
407    config: &platform_core::AppConfig,
408    manifest: fn() -> ModuleManifest,
409) -> Option<String> {
410    (manifest)()
411        .requires
412        .into_iter()
413        .map(|requirement| requirement.module_id)
414        .find(|module_id| {
415            !linked_module_enabled_from_config(
416                config,
417                module_id.rsplit('/').next().unwrap_or(module_id),
418            )
419        })
420}
421
422fn linked_module_with_dependencies_enabled(
423    ctx: &AppContext,
424    module_name: &str,
425    manifest: fn() -> ModuleManifest,
426) -> bool {
427    linked_module_enabled(ctx, module_name) && first_disabled_dependency(ctx, manifest).is_none()
428}
429
430fn linked_module_with_dependencies_enabled_from_config(
431    config: &platform_core::AppConfig,
432    module_name: &str,
433    manifest: fn() -> ModuleManifest,
434) -> bool {
435    linked_module_enabled_from_config(config, module_name)
436        && first_disabled_dependency_from_config(config, manifest).is_none()
437}
438
439pub fn auth_actor_resolver_for_context(
440    ctx: &AppContext,
441) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
442    auth_actor_resolver_for_context_with_composition(ctx, &HostComposition::default())
443}
444
445pub fn auth_actor_resolver_for_context_with_composition(
446    ctx: &AppContext,
447    composition: &HostComposition,
448) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
449    let profile = CompositionProfile::from_config(&ctx.config)?;
450    let auth_in_profile = linked_module_entries(profile)
451        .iter()
452        .any(|entry| entry.module_name == auth::module::MODULE_NAME);
453    let auth_in_composition = composition
454        .linked_modules()
455        .iter()
456        .any(|entry| entry.module_name == auth::module::MODULE_NAME);
457    if (!auth_in_profile && !auth_in_composition)
458        || !linked_module_enabled(ctx, auth::module::MODULE_NAME)
459    {
460        return Ok(None);
461    }
462
463    let auth_config = auth::config::AuthRuntimeConfig::from_context(ctx);
464    if auth_config.session_cache == auth::config::SessionCacheMode::Redis && ctx.redis.is_none() {
465        return Err(AppError::validation(
466            "Redis auth session cache is not configured",
467            vec![ErrorDetail {
468                field: Some("auth.session_cache".to_owned()),
469                reason: "set REDIS_URL when auth.session_cache is redis".to_owned(),
470            }],
471        ));
472    }
473    let auth_resolver: Arc<dyn platform_core::ActorResolver> = Arc::new(
474        auth::resolver::AuthActorResolver::new_with_session_cache(
475            ctx.db.clone(),
476            ctx.actor_resolver.clone(),
477            auth::redis_cache::session_cache_from_context(ctx),
478        )
479        .with_user_scopes(auth_config.console_admin_user_scopes),
480    );
481
482    let auth_password_enabled = linked_module_with_dependencies_enabled(
483        ctx,
484        auth_password::module::MODULE_NAME,
485        auth_password::module::manifest,
486    );
487    if auth_password_enabled {
488        if let Some(jwt_resolver) =
489            auth_password::module::jwt_actor_resolver(ctx, auth_resolver.clone())?
490        {
491            return Ok(Some(jwt_resolver));
492        }
493    }
494
495    Ok(Some(auth_resolver))
496}
497fn linked_module_entries_for_context(
498    ctx: &AppContext,
499) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
500    Ok(
501        linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
502            .iter()
503            .filter(|entry| {
504                linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
505            })
506            .collect(),
507    )
508}
509
510fn linked_module_entries_for_config(
511    config: &platform_core::AppConfig,
512) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
513    Ok(
514        linked_module_entries(CompositionProfile::from_config(config)?)
515            .iter()
516            .filter(|entry| {
517                linked_module_with_dependencies_enabled_from_config(
518                    config,
519                    entry.module_name,
520                    entry.manifest,
521                )
522            })
523            .collect(),
524    )
525}
526
527fn linked_profile_has_module(profile: CompositionProfile, module_name: &str) -> bool {
528    linked_module_entries(profile)
529        .iter()
530        .any(|entry| entry.module_name == module_name)
531}
532
533fn host_linked_modules_not_in_profile(
534    composition: &HostComposition,
535    profile: CompositionProfile,
536) -> impl Iterator<Item = HostLinkedModule> + '_ {
537    composition
538        .linked_modules()
539        .iter()
540        .cloned()
541        .filter(move |entry| !linked_profile_has_module(profile, entry.module_name))
542}
543
544fn host_linked_modules_for_config(
545    config: &platform_core::AppConfig,
546    composition: &HostComposition,
547    profile: CompositionProfile,
548) -> Vec<HostLinkedModule> {
549    host_linked_modules_not_in_profile(composition, profile)
550        .filter(|entry| {
551            linked_module_with_dependencies_enabled_from_config(
552                config,
553                entry.module_name,
554                entry.manifest,
555            )
556        })
557        .collect()
558}
559
560fn host_linked_modules_for_context(
561    ctx: &AppContext,
562    composition: &HostComposition,
563    profile: CompositionProfile,
564) -> Vec<HostLinkedModule> {
565    host_linked_modules_not_in_profile(composition, profile)
566        .filter(|entry| {
567            linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
568        })
569        .collect()
570}
571
572pub fn host_wiring_for_context(ctx: &AppContext) -> platform_core::AppResult<HostWiring> {
573    host_wiring_for_context_with_composition(ctx, &HostComposition::default())
574}
575
576pub fn host_wiring_for_context_with_composition(
577    ctx: &AppContext,
578    composition: &HostComposition,
579) -> platform_core::AppResult<HostWiring> {
580    let profile = CompositionProfile::from_config(&ctx.config)?;
581    let mut session_policies = Vec::new();
582    for module in host_linked_modules_for_context(ctx, composition, profile) {
583        for extension in module.contributions::<auth::session_policy::AuthHostExtension>() {
584            if let Some(factory) = extension.session_policy_factory() {
585                session_policies.push(factory(ctx));
586            }
587        }
588    }
589
590    Ok(HostWiring {
591        auth_session_policy: auth::session_policy::AuthSessionPolicyChain::handle(session_policies),
592    })
593}
594
595fn load_host_linked_module(ctx: &AppContext, entry: HostLinkedModule) -> Module {
596    match entry.load {
597        Some(load) => load(ctx),
598        None => Module::linked((entry.manifest)(), LinkedBinding::builder().build()),
599    }
600}
601
602/// Demo-default linked modules helper (context-bound: builds bindings).
603///
604/// Startup and config-aware paths should use [`modules_for_config`] or
605/// [`load_modules`] so `module_sources.linked_profile` is honored.
606#[must_use]
607pub fn modules(ctx: &AppContext) -> Vec<Module> {
608    modules_for_profile(ctx, CompositionProfile::default())
609}
610
611pub fn modules_for_config(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
612    Ok(linked_module_entries_for_context(ctx)?
613        .into_iter()
614        .map(|entry| (entry.load)(ctx))
615        .collect())
616}
617
618pub fn modules_for_config_with_composition(
619    ctx: &AppContext,
620    composition: &HostComposition,
621) -> platform_core::AppResult<Vec<Module>> {
622    let profile = CompositionProfile::from_config(&ctx.config)?;
623    let mut modules = modules_for_config(ctx)?;
624    modules.extend(
625        host_linked_modules_for_context(ctx, composition, profile)
626            .into_iter()
627            .map(|entry| load_host_linked_module(ctx, entry)),
628    );
629    Ok(modules)
630}
631
632#[must_use]
633pub fn modules_for_profile(ctx: &AppContext, profile: CompositionProfile) -> Vec<Module> {
634    linked_module_entries(profile)
635        .iter()
636        .map(|entry| (entry.load)(ctx))
637        .collect()
638}
639
640/// Loads a target-owned Provider Runtime Plan when Module management artifacts
641/// exist. A workspace with neither artifact is a Linked-only Host; a partial
642/// or inconsistent management state fails closed.
643pub fn provider_runtime_plan_from_workspace(
644    root: impl AsRef<Path>,
645) -> platform_core::AppResult<Option<lenso_module_management::ProviderRuntimePlan>> {
646    let root = root.as_ref();
647    let lock = root.join("lenso.modules.lock.json");
648    let planning = root.join(".lenso/module-planning-context.json");
649    if !lock.exists() && !planning.exists() {
650        return Ok(None);
651    }
652    lenso_module_management::WorkspaceModuleManagement::new(root)
653        .provider_runtime_plan()
654        .map(Some)
655        .map_err(|error| {
656            AppError::new(
657                ErrorCode::Validation,
658                format!("Provider runtime workspace is invalid: {error}"),
659            )
660        })
661}
662
663pub async fn load_modules_with_composition_and_provider_plan(
664    ctx: &AppContext,
665    composition: &HostComposition,
666    plan: Option<&lenso_module_management::ProviderRuntimePlan>,
667) -> platform_core::AppResult<Vec<Module>> {
668    let mut loaded = modules_for_config_with_composition(ctx, composition)?;
669    if let Some(runtime) = load_provider_runtime_with_composition(ctx, composition, plan).await? {
670        loaded.extend(runtime.into_modules());
671    }
672    Ok(loaded)
673}
674
675pub async fn load_provider_runtime_with_composition(
676    ctx: &AppContext,
677    composition: &HostComposition,
678    plan: Option<&lenso_module_management::ProviderRuntimePlan>,
679) -> platform_core::AppResult<Option<platform_provider::LoadedProviderRuntime>> {
680    let Some(plan) = plan else {
681        return Ok(None);
682    };
683    ProviderRuntimeAdapter::with_adapters(
684        plan.clone(),
685        composition.provider_runtime_adapters.clone(),
686    )?
687    .with_effect_coordinator(platform_provider::ProviderHostEffectCoordinator::new(
688        ctx.db.clone(),
689    ))
690    .load_verified()
691    .await
692    .map(Some)
693}
694
695pub fn migrations_for_config(
696    config: &platform_core::AppConfig,
697) -> platform_core::AppResult<Vec<Migration>> {
698    migrations_for_config_with_composition(config, &HostComposition::default())
699}
700
701pub fn migrations_for_config_with_composition(
702    config: &platform_core::AppConfig,
703    composition: &HostComposition,
704) -> platform_core::AppResult<Vec<Migration>> {
705    let mut migrations = PLATFORM_MIGRATIONS
706        .iter()
707        .chain(RUNTIME_MIGRATIONS)
708        .chain(platform_system_plane::SYSTEM_PLANE_MIGRATIONS)
709        .chain(platform_runtime_observability::RUNTIME_OBSERVABILITY_MIGRATIONS)
710        .chain(platform_runtime_operations::RUNTIME_OPERATIONS_MIGRATIONS)
711        .copied()
712        .collect::<Vec<_>>();
713
714    let profile = CompositionProfile::from_config(config)?;
715    if profile == CompositionProfile::Demo {
716        if linked_module_enabled_from_config(config, "auth") {
717            migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
718        }
719        if linked_module_with_dependencies_enabled_from_config(
720            config,
721            "auth-oauth",
722            auth_oauth::module::manifest,
723        ) {
724            migrations.extend(
725                auth_oauth::migrations::AUTH_OAUTH_MIGRATIONS
726                    .iter()
727                    .copied(),
728            );
729        }
730        if linked_module_with_dependencies_enabled_from_config(
731            config,
732            "auth-password",
733            auth_password::module::manifest,
734        ) {
735            migrations.extend(
736                auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
737                    .iter()
738                    .copied(),
739            );
740        }
741        if linked_module_with_dependencies_enabled_from_config(
742            config,
743            "auth-phone",
744            auth_phone::module::manifest,
745        ) {
746            migrations.extend(
747                auth_phone::migrations::AUTH_PHONE_MIGRATIONS
748                    .iter()
749                    .copied(),
750            );
751        }
752        if linked_module_with_dependencies_enabled_from_config(
753            config,
754            "auth-github",
755            auth_github::module::manifest,
756        ) {
757            migrations.extend(
758                auth_github::migrations::AUTH_GITHUB_MIGRATIONS
759                    .iter()
760                    .copied(),
761            );
762        }
763        if linked_module_with_dependencies_enabled_from_config(
764            config,
765            "auth-google",
766            auth_google::module::manifest,
767        ) {
768            migrations.extend(
769                auth_google::migrations::AUTH_GOOGLE_MIGRATIONS
770                    .iter()
771                    .copied(),
772            );
773        }
774        if linked_module_with_dependencies_enabled_from_config(
775            config,
776            "auth-oidc",
777            auth_oidc::module::manifest,
778        ) {
779            migrations.extend(auth_oidc::migrations::AUTH_OIDC_MIGRATIONS.iter().copied());
780        }
781    }
782
783    for module in host_linked_modules_for_config(config, composition, profile) {
784        migrations.extend(module.migrations.iter().copied());
785    }
786
787    Ok(migrations)
788}
789
790#[must_use]
791pub fn migrations_for_profile(profile: CompositionProfile) -> Vec<Migration> {
792    let mut migrations = PLATFORM_MIGRATIONS
793        .iter()
794        .chain(RUNTIME_MIGRATIONS)
795        .copied()
796        .collect::<Vec<_>>();
797
798    if profile == CompositionProfile::Demo {
799        migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
800        migrations.extend(
801            auth_oauth::migrations::AUTH_OAUTH_MIGRATIONS
802                .iter()
803                .copied(),
804        );
805        migrations.extend(
806            auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
807                .iter()
808                .copied(),
809        );
810        migrations.extend(
811            auth_phone::migrations::AUTH_PHONE_MIGRATIONS
812                .iter()
813                .copied(),
814        );
815        migrations.extend(
816            auth_github::migrations::AUTH_GITHUB_MIGRATIONS
817                .iter()
818                .copied(),
819        );
820        migrations.extend(
821            auth_google::migrations::AUTH_GOOGLE_MIGRATIONS
822                .iter()
823                .copied(),
824        );
825        migrations.extend(auth_oidc::migrations::AUTH_OIDC_MIGRATIONS.iter().copied());
826    }
827
828    migrations
829}
830
831/// Context-free module manifests for read-only / OpenAPI paths that have no
832/// [`AppContext`]. Kept in sync with [`modules`] by listing the same modules.
833#[must_use]
834pub fn module_manifests() -> Vec<ModuleManifest> {
835    module_manifests_for_profile(CompositionProfile::default())
836}
837
838#[must_use]
839pub fn module_manifests_for_profile(profile: CompositionProfile) -> Vec<ModuleManifest> {
840    linked_module_entries(profile)
841        .iter()
842        .map(|entry| (entry.manifest)())
843        .collect()
844}
845
846/// Runtime function declaration sources for context-free linked modules.
847#[must_use]
848pub fn linked_runtime_function_declaration_sources() -> Vec<(
849    String,
850    ModuleSource,
851    Option<platform_module::RuntimeSurface>,
852)> {
853    linked_runtime_function_declaration_sources_for_profile(CompositionProfile::default())
854}
855
856#[must_use]
857pub fn linked_runtime_function_declaration_sources_for_profile(
858    profile: CompositionProfile,
859) -> Vec<(
860    String,
861    ModuleSource,
862    Option<platform_module::RuntimeSurface>,
863)> {
864    module_manifests_for_profile(profile)
865        .into_iter()
866        .map(|manifest| (manifest.module_id, ModuleSource::Linked, manifest.runtime))
867        .collect()
868}
869
870pub fn linked_runtime_function_declaration_sources_for_config(
871    config: &platform_core::AppConfig,
872) -> platform_core::AppResult<
873    Vec<(
874        String,
875        ModuleSource,
876        Option<platform_module::RuntimeSurface>,
877    )>,
878> {
879    Ok(linked_module_entries_for_config(config)?
880        .into_iter()
881        .map(|entry| {
882            let manifest = (entry.manifest)();
883            (manifest.module_id, ModuleSource::Linked, manifest.runtime)
884        })
885        .collect())
886}
887
888pub fn linked_runtime_function_declaration_sources_for_context(
889    ctx: &AppContext,
890) -> platform_core::AppResult<
891    Vec<(
892        String,
893        ModuleSource,
894        Option<platform_module::RuntimeSurface>,
895    )>,
896> {
897    Ok(linked_module_entries_for_context(ctx)?
898        .into_iter()
899        .map(|entry| {
900            let manifest = (entry.manifest)();
901            (manifest.module_id, ModuleSource::Linked, manifest.runtime)
902        })
903        .collect())
904}
905
906pub fn linked_runtime_function_declaration_sources_for_context_with_composition(
907    ctx: &AppContext,
908    composition: &HostComposition,
909) -> platform_core::AppResult<
910    Vec<(
911        String,
912        ModuleSource,
913        Option<platform_module::RuntimeSurface>,
914    )>,
915> {
916    let profile = CompositionProfile::from_config(&ctx.config)?;
917    let mut sources = linked_runtime_function_declaration_sources_for_context(ctx)?;
918    sources.extend(
919        host_linked_modules_for_context(ctx, composition, profile)
920            .into_iter()
921            .map(|entry| {
922                let manifest = (entry.manifest)();
923                (manifest.module_id, ModuleSource::Linked, manifest.runtime)
924            }),
925    );
926    Ok(sources)
927}
928
929/// Public HTTP path ownership for linked modules.
930///
931/// Projected from context-free linked modules so OpenAPI guards and router
932/// assembly consume the same source-specific binding data.
933#[derive(Debug, Clone, PartialEq, Eq)]
934pub struct LinkedHttpRouteOwner {
935    pub module_name: String,
936    pub public_prefixes: &'static [&'static str],
937}
938
939#[must_use]
940pub fn linked_http_route_owners() -> Vec<LinkedHttpRouteOwner> {
941    linked_http_route_owners_for_profile(CompositionProfile::default())
942}
943
944#[must_use]
945pub fn linked_http_route_owners_for_profile(
946    profile: CompositionProfile,
947) -> Vec<LinkedHttpRouteOwner> {
948    linked_module_entries(profile)
949        .iter()
950        .filter_map(|entry| {
951            let http = entry.http_binding?().http?;
952            Some(LinkedHttpRouteOwner {
953                module_name: (entry.manifest)().module_id,
954                public_prefixes: http.public_prefixes,
955            })
956        })
957        .collect()
958}
959
960/// Context-free linked modules that contribute Axum/OpenAPI HTTP routers.
961#[must_use]
962pub fn linked_http_modules() -> Vec<Module> {
963    linked_http_modules_for_profile(CompositionProfile::default())
964}
965
966#[must_use]
967pub fn linked_http_modules_for_profile(profile: CompositionProfile) -> Vec<Module> {
968    linked_module_entries(profile)
969        .iter()
970        .filter_map(|entry| {
971            let http_binding = entry.http_binding?;
972            Some(Module::linked((entry.manifest)(), http_binding()))
973        })
974        .collect()
975}
976
977pub fn linked_http_modules_for_config(
978    config: &platform_core::AppConfig,
979) -> platform_core::AppResult<Vec<Module>> {
980    Ok(linked_module_entries_for_config(config)?
981        .into_iter()
982        .filter_map(|entry| {
983            let http_binding = entry.http_binding?;
984            Some(Module::linked((entry.manifest)(), http_binding()))
985        })
986        .collect())
987}
988
989pub fn linked_http_modules_for_context(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
990    Ok(linked_module_entries_for_context(ctx)?
991        .into_iter()
992        .filter_map(|entry| {
993            let http_binding = entry.http_binding?;
994            Some(Module::linked((entry.manifest)(), http_binding()))
995        })
996        .collect())
997}
998
999pub fn linked_http_modules_for_context_with_composition(
1000    ctx: &AppContext,
1001    composition: &HostComposition,
1002) -> platform_core::AppResult<Vec<Module>> {
1003    let profile = CompositionProfile::from_config(&ctx.config)?;
1004    let mut modules = linked_http_modules_for_context(ctx)?;
1005    modules.extend(
1006        host_linked_modules_for_context(ctx, composition, profile)
1007            .into_iter()
1008            .filter_map(|entry| {
1009                let http_binding = entry.http_binding?;
1010                Some(Module::linked((entry.manifest)(), http_binding()))
1011            }),
1012    );
1013    Ok(modules)
1014}
1015
1016/// Build a [`FunctionRegistry`] from every module's binding.
1017#[must_use]
1018pub fn function_registry(modules: &[Module]) -> FunctionRegistry {
1019    let mut registry = FunctionRegistry::default();
1020    for module in modules {
1021        module.binding.register_functions(&mut registry);
1022    }
1023    registry
1024}
1025
1026/// Validate and enqueue every startup activation job declared by loaded modules.
1027///
1028/// Lifecycle activation is host-owned: module manifests declare the work, and
1029/// the Lenso bootstrap validates those declarations against the runtime registry
1030/// before scheduling function runs.
1031pub async fn enqueue_lifecycle_activation_jobs(
1032    ctx: &AppContext,
1033    modules: &[Module],
1034    registry: &FunctionRegistry,
1035) -> platform_core::AppResult<Vec<String>> {
1036    validate_lifecycle_activation_jobs(modules, registry)?;
1037
1038    let client = RuntimeClient::new(ctx.db.clone());
1039    let mut run_ids = Vec::new();
1040
1041    for module in modules {
1042        let Some(lifecycle) = &module.manifest.lifecycle else {
1043            continue;
1044        };
1045
1046        for job in &lifecycle.activation_jobs {
1047            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1048                continue;
1049            }
1050            if !module_declares_runtime_function(module, &job.function_name) {
1051                continue;
1052            }
1053
1054            let Some(definition) = registry.get(&job.function_name) else {
1055                continue;
1056            };
1057
1058            let enqueue_result = client
1059                .enqueue_function(EnqueueFunctionRequest {
1060                    function_name: job.function_name.clone(),
1061                    input_json: job.input.clone(),
1062                    correlation_id: CorrelationId::new(ctx.ids.new_id("corr_lifecycle")),
1063                    actor: ActorContext::Service {
1064                        service_id: "worker".to_owned(),
1065                        scopes: vec!["runtime.functions.enqueue".to_owned()],
1066                    },
1067                    tenant_id: None,
1068                    tenancy_mode: platform_runtime::FunctionTenancyMode::None,
1069                    trace: TraceContext::default(),
1070                    causation_id: Some(format!(
1071                        "module_lifecycle:{}:{}",
1072                        module.manifest.module_id, job.name
1073                    )),
1074                    max_attempts: Some(runtime_max_attempts_for_enqueue(
1075                        definition.retry_policy.max_attempts,
1076                    )),
1077                })
1078                .await;
1079
1080            match enqueue_result {
1081                Ok(run_id) => run_ids.push(run_id),
1082                Err(error) if job.required => return Err(error),
1083                Err(error) => warn_optional_lifecycle_enqueue_failure(
1084                    &module.manifest.module_id,
1085                    &job.name,
1086                    &job.function_name,
1087                    &error,
1088                ),
1089            }
1090        }
1091    }
1092
1093    Ok(run_ids)
1094}
1095
1096fn validate_lifecycle_activation_jobs(
1097    modules: &[Module],
1098    registry: &FunctionRegistry,
1099) -> platform_core::AppResult<()> {
1100    for module in modules {
1101        let Some(lifecycle) = &module.manifest.lifecycle else {
1102            continue;
1103        };
1104
1105        for check in &lifecycle.startup_checks {
1106            match &check.check {
1107                LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
1108                    if !module_declares_runtime_function(module, function_name) {
1109                        let reason = format!(
1110                            "startup check `{}` references function `{}` not declared by module `{}`",
1111                            check.name, function_name, module.manifest.module_id
1112                        );
1113                        if !check.required {
1114                            warn_optional_lifecycle_skip(
1115                                &module.manifest.module_id,
1116                                "startup_checks",
1117                                &check.name,
1118                                &reason,
1119                            );
1120                            continue;
1121                        }
1122                        return Err(lifecycle_validation_error(
1123                            &module.manifest.module_id,
1124                            "startup_checks",
1125                            &check.name,
1126                            format!("required {reason}"),
1127                        ));
1128                    }
1129                    if registry.get(function_name).is_none() {
1130                        let reason = format!(
1131                            "startup check `{}` references missing function `{}`",
1132                            check.name, function_name
1133                        );
1134                        if !check.required {
1135                            warn_optional_lifecycle_skip(
1136                                &module.manifest.module_id,
1137                                "startup_checks",
1138                                &check.name,
1139                                &reason,
1140                            );
1141                            continue;
1142                        }
1143                        return Err(lifecycle_validation_error(
1144                            &module.manifest.module_id,
1145                            "startup_checks",
1146                            &check.name,
1147                            format!("required {reason}"),
1148                        ));
1149                    }
1150                }
1151                LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
1152                    if !module.manifest.capabilities.contains(capability) {
1153                        let reason = format!(
1154                            "startup check `{}` references missing capability `{}`",
1155                            check.name, capability
1156                        );
1157                        if !check.required {
1158                            warn_optional_lifecycle_skip(
1159                                &module.manifest.module_id,
1160                                "startup_checks",
1161                                &check.name,
1162                                &reason,
1163                            );
1164                            continue;
1165                        }
1166                        return Err(lifecycle_validation_error(
1167                            &module.manifest.module_id,
1168                            "startup_checks",
1169                            &check.name,
1170                            format!("required {reason}"),
1171                        ));
1172                    }
1173                }
1174                _ => {
1175                    let reason = format!(
1176                        "startup check `{}` uses an unsupported lifecycle check kind",
1177                        check.name
1178                    );
1179                    if !check.required {
1180                        warn_optional_lifecycle_skip(
1181                            &module.manifest.module_id,
1182                            "startup_checks",
1183                            &check.name,
1184                            &reason,
1185                        );
1186                        continue;
1187                    }
1188                    return Err(lifecycle_validation_error(
1189                        &module.manifest.module_id,
1190                        "startup_checks",
1191                        &check.name,
1192                        format!("required {reason}"),
1193                    ));
1194                }
1195            }
1196        }
1197
1198        for job in &lifecycle.activation_jobs {
1199            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1200                continue;
1201            }
1202
1203            if !module_declares_runtime_function(module, &job.function_name) {
1204                let reason = format!(
1205                    "activation job `{}` references function `{}` not declared by module `{}`",
1206                    job.name, job.function_name, module.manifest.module_id
1207                );
1208                if !job.required {
1209                    warn_optional_lifecycle_skip(
1210                        &module.manifest.module_id,
1211                        "activation_jobs",
1212                        &job.name,
1213                        &reason,
1214                    );
1215                    continue;
1216                }
1217                return Err(lifecycle_validation_error(
1218                    &module.manifest.module_id,
1219                    "activation_jobs",
1220                    &job.name,
1221                    format!("required {reason}"),
1222                ));
1223            }
1224            if registry.get(&job.function_name).is_none() {
1225                let reason = format!(
1226                    "activation job `{}` references missing function `{}`",
1227                    job.name, job.function_name
1228                );
1229                if !job.required {
1230                    warn_optional_lifecycle_skip(
1231                        &module.manifest.module_id,
1232                        "activation_jobs",
1233                        &job.name,
1234                        &reason,
1235                    );
1236                    continue;
1237                }
1238                return Err(lifecycle_validation_error(
1239                    &module.manifest.module_id,
1240                    "activation_jobs",
1241                    &job.name,
1242                    format!("required {reason}"),
1243                ));
1244            }
1245        }
1246    }
1247
1248    Ok(())
1249}
1250
1251fn module_declares_runtime_function(module: &Module, function_name: &str) -> bool {
1252    module.manifest.runtime.as_ref().is_some_and(|runtime| {
1253        runtime
1254            .functions
1255            .iter()
1256            .any(|function| function.name == function_name)
1257    })
1258}
1259
1260fn lifecycle_validation_error(
1261    module_name: &str,
1262    collection: &str,
1263    item_name: &str,
1264    reason: String,
1265) -> AppError {
1266    AppError::validation(
1267        "Module lifecycle declaration failed validation",
1268        vec![ErrorDetail {
1269            field: Some(format!(
1270                "module.{module_name}.lifecycle.{collection}.{item_name}"
1271            )),
1272            reason,
1273        }],
1274    )
1275}
1276
1277fn warn_optional_lifecycle_skip(
1278    module_name: &str,
1279    collection: &str,
1280    item_name: &str,
1281    reason: &str,
1282) {
1283    tracing::warn!(
1284        module_name = %module_name,
1285        lifecycle_collection = %collection,
1286        lifecycle_item = %item_name,
1287        reason = %reason,
1288        "optional module lifecycle declaration skipped"
1289    );
1290}
1291
1292fn warn_optional_lifecycle_enqueue_failure(
1293    module_name: &str,
1294    job_name: &str,
1295    function_name: &str,
1296    error: &AppError,
1297) {
1298    tracing::warn!(
1299        module_name = %module_name,
1300        lifecycle_collection = "activation_jobs",
1301        lifecycle_item = %job_name,
1302        function_name = %function_name,
1303        error_code = %error.code.as_str(),
1304        error_message = %error.public_message,
1305        "optional module lifecycle activation enqueue failed"
1306    );
1307}
1308
1309fn runtime_max_attempts_for_enqueue(max_attempts: u32) -> i32 {
1310    i32::try_from(max_attempts).unwrap_or(i32::MAX)
1311}
1312
1313/// Build host-owned runtime schedules declared by loaded modules.
1314pub fn scheduled_functions(
1315    modules: &[Module],
1316    registry: &FunctionRegistry,
1317) -> platform_core::AppResult<Vec<ScheduledFunctionDefinition>> {
1318    let mut schedules = Vec::new();
1319
1320    for module in modules {
1321        if !matches!(module.load_status, ModuleLoadStatus::Loaded) {
1322            continue;
1323        }
1324        let Some(runtime) = &module.manifest.runtime else {
1325            continue;
1326        };
1327
1328        for schedule in &runtime.schedules {
1329            if schedule.name.trim().is_empty() {
1330                return Err(AppError::new(
1331                    ErrorCode::Validation,
1332                    format!(
1333                        "scheduled runtime function for module {} is missing a name",
1334                        module.manifest.module_id
1335                    ),
1336                ));
1337            }
1338            if !module_declares_runtime_function(module, &schedule.function_name) {
1339                return Err(AppError::new(
1340                    ErrorCode::Validation,
1341                    format!(
1342                        "scheduled runtime function {}:{} references function {} not declared by module {}",
1343                        module.manifest.module_id,
1344                        schedule.name,
1345                        schedule.function_name,
1346                        module.manifest.module_id
1347                    ),
1348                ));
1349            }
1350            let Some(function) = registry.get(&schedule.function_name) else {
1351                return Err(AppError::new(
1352                    ErrorCode::Validation,
1353                    format!(
1354                        "scheduled runtime function {}:{} references missing function {}",
1355                        module.manifest.module_id, schedule.name, schedule.function_name
1356                    ),
1357                ));
1358            };
1359            let parsed_schedule = CronSchedule::parse(&schedule.cron).map_err(|error| {
1360                AppError::new(
1361                    ErrorCode::Validation,
1362                    format!(
1363                        "scheduled runtime function {}:{} has invalid cron expression: {error}",
1364                        module.manifest.module_id, schedule.name
1365                    ),
1366                )
1367            })?;
1368            schedules.push(ScheduledFunctionDefinition {
1369                schedule_key: format!("{}:{}", module.manifest.module_id, schedule.name),
1370                module_name: module.manifest.module_id.clone(),
1371                schedule_name: schedule.name.clone(),
1372                function_name: schedule.function_name.clone(),
1373                cron: schedule.cron.clone(),
1374                schedule: parsed_schedule,
1375                input_json: schedule.input.clone(),
1376                max_attempts: runtime_max_attempts_for_enqueue(function.retry_policy.max_attempts),
1377            });
1378        }
1379    }
1380
1381    Ok(schedules)
1382}
1383
1384/// Build an [`EventHandlerRegistry`] from every module's binding.
1385#[must_use]
1386pub fn event_handlers(modules: &[Module]) -> EventHandlerRegistry {
1387    event_handlers_with_context(modules, &EventHandlerRegistrationContext::empty())
1388}
1389
1390/// Build an [`EventHandlerRegistry`] with host runtime actions enabled for
1391/// provider event-handler result actions.
1392#[must_use]
1393pub fn event_handlers_with_runtime_actions(
1394    ctx: &AppContext,
1395    modules: &[Module],
1396    function_registry: Arc<FunctionRegistry>,
1397) -> EventHandlerRegistry {
1398    let context = EventHandlerRegistrationContext::with_runtime(
1399        RuntimeClient::new(ctx.db.clone()),
1400        function_registry,
1401    );
1402    event_handlers_with_context(modules, &context)
1403}
1404
1405fn event_handlers_with_context(
1406    modules: &[Module],
1407    context: &EventHandlerRegistrationContext,
1408) -> EventHandlerRegistry {
1409    let mut registry = EventHandlerRegistry::new();
1410    for module in modules {
1411        module
1412            .binding
1413            .register_event_handlers(&mut registry, context);
1414    }
1415    registry
1416}
1417
1418/// Merge every linked module's HTTP routes (and their `OpenAPI` docs) onto `base`.
1419///
1420/// Linked route builders are context-free, so this assembles the HTTP surface
1421/// without constructing the full module set (which requires an [`AppContext`])
1422/// — usable both for serving and for standalone `OpenAPI` document assembly.
1423/// This is the single source for linked API routes until HTTP joins the
1424/// [`platform_module::ModuleBinding`] seam.
1425pub fn merge_linked_http(base: ApiOpenApiRouter) -> ApiOpenApiRouter {
1426    merge_linked_http_for_profile(base, CompositionProfile::default())
1427}
1428
1429pub fn merge_linked_http_for_profile(
1430    base: ApiOpenApiRouter,
1431    profile: CompositionProfile,
1432) -> ApiOpenApiRouter {
1433    linked_http_modules_for_profile(profile)
1434        .into_iter()
1435        .filter_map(|module| module.linked_http)
1436        .fold(base, |router, contribution| (contribution.merge)(router))
1437}
1438
1439pub fn merge_linked_http_for_config(
1440    base: ApiOpenApiRouter,
1441    config: &platform_core::AppConfig,
1442) -> platform_core::AppResult<ApiOpenApiRouter> {
1443    Ok(linked_http_modules_for_config(config)?
1444        .into_iter()
1445        .filter_map(|module| module.linked_http)
1446        .fold(base, |router, contribution| (contribution.merge)(router)))
1447}
1448
1449pub fn merge_linked_http_for_context(
1450    base: ApiOpenApiRouter,
1451    ctx: &AppContext,
1452) -> platform_core::AppResult<ApiOpenApiRouter> {
1453    Ok(linked_http_modules_for_context(ctx)?
1454        .into_iter()
1455        .filter_map(|module| module.linked_http)
1456        .fold(base, |router, contribution| (contribution.merge)(router)))
1457}
1458
1459pub fn merge_linked_http_for_context_with_composition(
1460    base: ApiOpenApiRouter,
1461    ctx: &AppContext,
1462    composition: &HostComposition,
1463) -> platform_core::AppResult<ApiOpenApiRouter> {
1464    Ok(
1465        linked_http_modules_for_context_with_composition(ctx, composition)?
1466            .into_iter()
1467            .filter_map(|module| module.linked_http)
1468            .fold(base, |router, contribution| (contribution.merge)(router)),
1469    )
1470}
1471
1472/// Story-display descriptors for every module. Sourced from context-free
1473/// manifests so the `OpenAPI` path stays pure (no [`AppContext`]).
1474#[must_use]
1475pub fn story_display_descriptors() -> Vec<StoryDisplayDescriptor> {
1476    story_display_descriptors_for_profile(CompositionProfile::default())
1477}
1478
1479#[must_use]
1480pub fn story_display_descriptors_for_profile(
1481    profile: CompositionProfile,
1482) -> Vec<StoryDisplayDescriptor> {
1483    module_manifests_for_profile(profile)
1484        .into_iter()
1485        .flat_map(story_display_descriptors_from_manifest)
1486        .collect()
1487}
1488
1489pub fn story_display_descriptors_for_config(
1490    config: &platform_core::AppConfig,
1491) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
1492    Ok(linked_module_entries_for_config(config)?
1493        .into_iter()
1494        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
1495        .collect())
1496}
1497
1498pub fn story_display_descriptors_for_context(
1499    ctx: &AppContext,
1500) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
1501    Ok(linked_module_entries_for_context(ctx)?
1502        .into_iter()
1503        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
1504        .collect())
1505}
1506
1507fn story_display_descriptors_from_manifest(
1508    manifest: ModuleManifest,
1509) -> Vec<StoryDisplayDescriptor> {
1510    let mut descriptors = manifest.story_display;
1511    let existing_http = descriptors
1512        .iter()
1513        .filter_map(|descriptor| match &descriptor.source {
1514            StoryDisplaySource::HttpRequest { method, path } => {
1515                Some((method.clone(), path.clone()))
1516            }
1517            StoryDisplaySource::ExecutionName { .. } => None,
1518        })
1519        .collect::<Vec<_>>();
1520
1521    descriptors.extend(manifest.http_routes.into_iter().filter_map(|route| {
1522        let display_name = route.display_name?;
1523        let method = http_method_label(route.method)?;
1524        if existing_http
1525            .iter()
1526            .any(|(existing_method, existing_path)| {
1527                existing_method == method && existing_path == &route.path
1528            })
1529        {
1530            return None;
1531        }
1532        Some(StoryDisplayDescriptor {
1533            source: StoryDisplaySource::HttpRequest {
1534                method: method.to_owned(),
1535                path: route.path,
1536            },
1537            display_name,
1538            story_title: route.story_title,
1539        })
1540    }));
1541    descriptors
1542}
1543
1544fn http_method_label(method: ModuleHttpMethod) -> Option<&'static str> {
1545    Some(match method {
1546        ModuleHttpMethod::Get => "GET",
1547        ModuleHttpMethod::Post => "POST",
1548        ModuleHttpMethod::Put => "PUT",
1549        ModuleHttpMethod::Patch => "PATCH",
1550        ModuleHttpMethod::Delete => "DELETE",
1551        _ => return None,
1552    })
1553}
1554
1555/// Every module's setting descriptors.
1556///
1557/// The single source for the editable configuration registry. Apps build a
1558/// `RuntimeConfigRegistry` from this list at startup.
1559pub fn runtime_config_descriptors(
1560    ctx: &AppContext,
1561) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
1562    runtime_config_descriptors_with_composition(ctx, &HostComposition::default())
1563}
1564
1565pub fn runtime_config_descriptors_with_composition(
1566    ctx: &AppContext,
1567    composition: &HostComposition,
1568) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
1569    let profile = CompositionProfile::from_config(&ctx.config)?;
1570    let module_enabled_descriptors =
1571        linked_module_entries(profile)
1572            .iter()
1573            .map(|entry| RuntimeConfigDescriptor {
1574                key: module_enabled_config_key(entry.module_name),
1575                scope: RuntimeConfigScope::Shared,
1576                group: Some("modules"),
1577                section: None,
1578                order: 10,
1579                visible_when: None,
1580                generated: None,
1581                value_type: RuntimeConfigType::Bool,
1582                default: serde_json::json!(linked_module_enabled_from_config(
1583                    &ctx.config,
1584                    entry.module_name
1585                )),
1586                editable: true,
1587                restart_only: true,
1588                description: "Whether this linked module is loaded on service startup.",
1589            });
1590    let host_module_enabled_descriptors = host_linked_modules_not_in_profile(composition, profile)
1591        .map(|entry| RuntimeConfigDescriptor {
1592            key: module_enabled_config_key(entry.module_name),
1593            scope: RuntimeConfigScope::Shared,
1594            group: Some("modules"),
1595            section: None,
1596            order: 10,
1597            visible_when: None,
1598            generated: None,
1599            value_type: RuntimeConfigType::Bool,
1600            default: serde_json::json!(linked_module_enabled_from_config(
1601                &ctx.config,
1602                entry.module_name
1603            )),
1604            editable: true,
1605            restart_only: true,
1606            description: "Whether this host linked module is loaded on service startup.",
1607        });
1608    let module_descriptors = linked_module_entries(profile)
1609        .iter()
1610        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
1611        .map(|entry| (entry.load)(ctx))
1612        .chain(
1613            host_linked_modules_for_config(&ctx.config, composition, profile)
1614                .into_iter()
1615                .map(|entry| load_host_linked_module(ctx, entry)),
1616        )
1617        .flat_map(|module| module.runtime_config.iter().cloned())
1618        .collect::<Vec<_>>();
1619    // Platform-owned descriptors (e.g. worker knobs) plus every module's; keys
1620    // are globally unique, so chain order is presentation-only.
1621    Ok(platform_core::worker_runtime_config::RUNTIME_CONFIG
1622        .iter()
1623        .cloned()
1624        .chain(module_enabled_descriptors)
1625        .chain(host_module_enabled_descriptors)
1626        .chain(module_descriptors)
1627        .collect())
1628}
1629
1630/// Every config presentation group known to the current composition.
1631pub fn runtime_config_group_descriptors(
1632    ctx: &AppContext,
1633) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
1634    runtime_config_group_descriptors_with_composition(ctx, &HostComposition::default())
1635}
1636
1637pub fn runtime_config_group_descriptors_with_composition(
1638    ctx: &AppContext,
1639    composition: &HostComposition,
1640) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
1641    let profile = CompositionProfile::from_config(&ctx.config)?;
1642    let module_groups = linked_module_entries(profile)
1643        .iter()
1644        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
1645        .map(|entry| (entry.load)(ctx))
1646        .chain(
1647            host_linked_modules_for_config(&ctx.config, composition, profile)
1648                .into_iter()
1649                .map(|entry| load_host_linked_module(ctx, entry)),
1650        )
1651        .flat_map(|module| module.runtime_config_groups.iter().cloned())
1652        .collect::<Vec<_>>();
1653
1654    Ok(std::iter::once(MODULES_CONFIG_GROUP.clone())
1655        .chain(
1656            platform_core::worker_runtime_config::RUNTIME_CONFIG_GROUPS
1657                .iter()
1658                .cloned(),
1659        )
1660        .chain(module_groups)
1661        .collect())
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666    use super::*;
1667    use async_trait::async_trait;
1668    use auth::models::AuthUserId;
1669    use auth::session_policy::{
1670        AuthHostExtension, AuthSessionPolicy, SessionCreateDecision, SessionCreateInput,
1671    };
1672    use platform_core::{
1673        AppConfig, AuthConfig, DatabaseConfig, ErrorCode, ExecutionContext, HttpConfig,
1674        LoggingEventPublisher, ModuleConfig, ModuleSourcesConfig, PLATFORM_MIGRATIONS, RedisConfig,
1675        RuntimeConfigProvider, RuntimeConfigRegistry, RuntimeConfigSnapshot, ServiceConfig,
1676        TelemetryConfig, apply_migrations,
1677    };
1678    use platform_module::{
1679        LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration, LifecycleSurface,
1680        RuntimeFunctionDeclaration, RuntimeSurface,
1681    };
1682    use platform_runtime::{FunctionDefinition, FunctionHandler, RUNTIME_MIGRATIONS, RetryPolicy};
1683    use platform_testing::{SequentialIdGenerator, TestDatabase};
1684    use serde_json::{Value, json};
1685    use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
1686    use std::collections::BTreeMap;
1687    use std::sync::Arc;
1688    use std::time::Duration;
1689
1690    #[derive(Debug)]
1691    struct TestRuntimeConfigProvider {
1692        snapshot: Arc<RuntimeConfigSnapshot>,
1693    }
1694
1695    impl RuntimeConfigProvider for TestRuntimeConfigProvider {
1696        fn snapshot(&self) -> Arc<RuntimeConfigSnapshot> {
1697            Arc::clone(&self.snapshot)
1698        }
1699    }
1700
1701    #[test]
1702    fn linked_module_entry_names_match_manifests() {
1703        for profile in [CompositionProfile::Core, CompositionProfile::Demo] {
1704            for entry in linked_module_entries(profile) {
1705                assert_eq!(
1706                    Some(entry.module_name),
1707                    (entry.manifest)().module_id.rsplit('/').next(),
1708                    "linked module entry slug must match the local ModuleManifest ID segment"
1709                );
1710            }
1711        }
1712    }
1713
1714    #[test]
1715    fn core_profile_excludes_demo_linked_modules() {
1716        let names = module_manifests_for_profile(CompositionProfile::Core)
1717            .into_iter()
1718            .map(|manifest| manifest.module_id)
1719            .collect::<Vec<_>>();
1720
1721        assert!(
1722            names.is_empty(),
1723            "framework core must not implicitly install Console-owned modules"
1724        );
1725    }
1726
1727    #[test]
1728    fn demo_profile_includes_fixture_linked_modules() {
1729        let names = module_manifests_for_profile(CompositionProfile::Demo)
1730            .into_iter()
1731            .map(|manifest| manifest.module_id)
1732            .collect::<Vec<_>>();
1733
1734        assert_eq!(
1735            names,
1736            vec![
1737                "lenso/auth",
1738                "lenso/auth-anonymous",
1739                "lenso/auth-oauth",
1740                "lenso/auth-password",
1741                "lenso/auth-phone",
1742                "lenso/auth-github",
1743                "lenso/auth-google",
1744                "lenso/auth-oidc",
1745            ]
1746        );
1747    }
1748
1749    #[test]
1750    fn http_route_metadata_contributes_story_display_descriptors() {
1751        let descriptors = story_display_descriptors_for_profile(CompositionProfile::Demo);
1752
1753        assert!(descriptors.iter().any(|descriptor| {
1754            matches!(
1755                &descriptor.source,
1756                StoryDisplaySource::HttpRequest { method, path }
1757                    if method == "POST" && path == "/v1/auth/dev/sessions"
1758            ) && descriptor.display_name == "Create Development Session"
1759        }));
1760    }
1761
1762    #[test]
1763    fn core_profile_migrations_exclude_demo_module_migrations() {
1764        let names = migrations_for_profile(CompositionProfile::Core)
1765            .into_iter()
1766            .map(|migration| migration.name)
1767            .collect::<Vec<_>>();
1768
1769        assert!(names.iter().any(|name| name.starts_with("platform/")));
1770        assert!(names.iter().any(|name| name.starts_with("runtime/")));
1771        assert!(!names.iter().any(|name| name.starts_with("story/")));
1772        assert!(!names.iter().any(|name| name.starts_with("auth/")));
1773        assert!(!names.iter().any(|name| name.starts_with("auth-oauth/")));
1774        assert!(!names.iter().any(|name| name.starts_with("auth-github/")));
1775        assert!(!names.iter().any(|name| name.starts_with("auth-google/")));
1776        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
1777        assert!(!names.iter().any(|name| name.starts_with("auth-phone/")));
1778    }
1779
1780    #[test]
1781    fn demo_profile_migrations_include_fixture_module_migrations() {
1782        let names = migrations_for_profile(CompositionProfile::Demo)
1783            .into_iter()
1784            .map(|migration| migration.name)
1785            .collect::<Vec<_>>();
1786
1787        assert!(
1788            names
1789                .iter()
1790                .any(|name| name == &"auth/0001_create_auth_schema")
1791        );
1792        assert!(
1793            names
1794                .iter()
1795                .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
1796        );
1797        assert!(
1798            names
1799                .iter()
1800                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
1801        );
1802        assert!(
1803            names
1804                .iter()
1805                .any(|name| name == &"auth-phone/0001_create_auth_phone_schema")
1806        );
1807        assert!(
1808            names
1809                .iter()
1810                .any(|name| name == &"auth-github/0001_create_auth_github_schema")
1811        );
1812        assert!(
1813            names
1814                .iter()
1815                .any(|name| name == &"auth-google/0001_create_auth_google_schema")
1816        );
1817        assert!(
1818            names
1819                .iter()
1820                .any(|name| name == &"auth-oidc/0001_create_auth_oidc_schema")
1821        );
1822    }
1823
1824    #[test]
1825    fn host_composition_migrations_include_enabled_host_linked_modules() {
1826        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1827        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
1828
1829        let names = migrations_for_config_with_composition(&config, &composition)
1830            .expect("host composition migrations should load")
1831            .into_iter()
1832            .map(|migration| migration.name)
1833            .collect::<Vec<_>>();
1834
1835        assert!(names.iter().any(|name| name == &"billing/0001_init"));
1836    }
1837
1838    #[test]
1839    fn host_composition_can_install_auth_modules() {
1840        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
1841        config.module_sources.linked_profile = "core".to_owned();
1842        let composition = HostComposition::new()
1843            .with_linked_module(auth_linked_module())
1844            .with_linked_module(auth_oauth_linked_module())
1845            .with_linked_module(auth_password_linked_module())
1846            .with_linked_module(auth_phone_linked_module())
1847            .with_linked_module(auth_github_linked_module())
1848            .with_linked_module(auth_google_linked_module())
1849            .with_linked_module(auth_oidc_linked_module());
1850
1851        let names = migrations_for_config_with_composition(&config, &composition)
1852            .expect("host composition migrations should load")
1853            .into_iter()
1854            .map(|migration| migration.name)
1855            .collect::<Vec<_>>();
1856
1857        assert!(
1858            names
1859                .iter()
1860                .any(|name| name == &"auth/0001_create_auth_schema")
1861        );
1862        assert!(
1863            names
1864                .iter()
1865                .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
1866        );
1867        assert!(
1868            names
1869                .iter()
1870                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
1871        );
1872        assert!(
1873            names
1874                .iter()
1875                .any(|name| name == &"auth-phone/0001_create_auth_phone_schema")
1876        );
1877        assert!(
1878            names
1879                .iter()
1880                .any(|name| name == &"auth-github/0001_create_auth_github_schema")
1881        );
1882        assert!(
1883            names
1884                .iter()
1885                .any(|name| name == &"auth-google/0001_create_auth_google_schema")
1886        );
1887        assert!(
1888            names
1889                .iter()
1890                .any(|name| name == &"auth-oidc/0001_create_auth_oidc_schema")
1891        );
1892    }
1893
1894    #[tokio::test]
1895    async fn auth_phone_linked_module_declares_routes_runtime_config_and_migrations() {
1896        let linked = auth_phone_linked_module();
1897        let manifest = (linked.manifest)();
1898        let binding = linked
1899            .http_binding
1900            .expect("auth-phone should expose HTTP binding")();
1901        let module =
1902            (linked
1903                .load
1904                .expect("auth-phone should load as linked module"))(&AppContext::new(
1905                test_config_with_database_url("postgres://localhost/lenso_test"),
1906                platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1907                    .expect("lazy pool should build"),
1908                Arc::new(LoggingEventPublisher),
1909            ));
1910
1911        assert_eq!(linked.module_name, auth_phone::module::MODULE_NAME);
1912        assert_eq!(manifest.module_id, "lenso/auth-phone");
1913        assert_eq!(
1914            manifest
1915                .requires
1916                .iter()
1917                .map(|requirement| requirement.module_id.as_str())
1918                .collect::<Vec<_>>(),
1919            vec!["lenso/auth", "lenso/auth-password",]
1920        );
1921        assert!(
1922            manifest
1923                .http_routes
1924                .iter()
1925                .any(|route| route.path == "/v1/auth/phone/otp/start")
1926        );
1927        assert!(
1928            manifest
1929                .http_routes
1930                .iter()
1931                .any(|route| route.path == "/v1/auth/phone/password/login")
1932        );
1933        assert_eq!(
1934            binding
1935                .http
1936                .expect("auth-phone HTTP contribution")
1937                .public_prefixes,
1938            &["/v1/auth/phone/"]
1939        );
1940        assert!(
1941            linked
1942                .migrations
1943                .iter()
1944                .any(|migration| migration.name == "auth-phone/0001_create_auth_phone_schema")
1945        );
1946        assert!(
1947            module
1948                .runtime_config
1949                .iter()
1950                .any(|descriptor| descriptor.key == "auth-phone.otp_code_length")
1951        );
1952        assert!(
1953            module
1954                .runtime_config_groups
1955                .iter()
1956                .any(|group| group.id == "auth-phone.otp")
1957        );
1958    }
1959
1960    #[tokio::test]
1961    async fn host_composition_runtime_config_includes_host_module_toggle() {
1962        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1963            .expect("lazy pool should build");
1964        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1965        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
1966        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
1967
1968        let keys = runtime_config_descriptors_with_composition(&ctx, &composition)
1969            .expect("host composition descriptors should load")
1970            .into_iter()
1971            .map(|descriptor| descriptor.key)
1972            .collect::<Vec<_>>();
1973
1974        assert!(keys.iter().any(|key| key == "modules.billing.enabled"));
1975    }
1976
1977    #[tokio::test]
1978    async fn host_composition_skips_modules_already_in_linked_profile() {
1979        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1980            .expect("lazy pool should build");
1981        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1982        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
1983        let composition = HostComposition::new().with_linked_module(auth_linked_module());
1984
1985        let descriptors = runtime_config_descriptors_with_composition(&ctx, &composition)
1986            .expect("host composition descriptors should load");
1987        let auth_toggle_count = descriptors
1988            .iter()
1989            .filter(|descriptor| descriptor.key == "modules.auth.enabled")
1990            .count();
1991
1992        assert_eq!(auth_toggle_count, 1);
1993        RuntimeConfigRegistry::try_new(descriptors).expect("descriptors should be unique");
1994    }
1995
1996    #[tokio::test]
1997    async fn host_composition_modules_include_manifest_only_modules() {
1998        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1999            .expect("lazy pool should build");
2000        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2001        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2002        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2003
2004        let names = modules_for_config_with_composition(&ctx, &composition)
2005            .expect("host composition modules should load")
2006            .into_iter()
2007            .map(|module| module.manifest.module_id)
2008            .collect::<Vec<_>>();
2009
2010        assert!(names.iter().any(|name| name == "fixture/billing"));
2011    }
2012
2013    #[tokio::test]
2014    async fn host_wiring_collects_auth_session_policy_contributions() {
2015        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2016            .expect("lazy pool should build");
2017        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2018        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2019        let composition = HostComposition::new().with_linked_module(
2020            test_host_linked_module()
2021                .with_contribution(AuthHostExtension::session_policy(test_session_policy)),
2022        );
2023
2024        let wiring = host_wiring_for_context_with_composition(&ctx, &composition)
2025            .expect("host wiring should compose");
2026        let now = ctx.clock.now();
2027        let decision = wiring
2028            .auth_session_policy()
2029            .policy()
2030            .before_session_create(&SessionCreateInput {
2031                user_id: AuthUserId("usr_wiring".to_owned()),
2032                session_id: "sess_wiring".to_owned(),
2033                proposed_device_id: Some("device_wiring".to_owned()),
2034                created_at: now,
2035                expires_at: now,
2036                client: Default::default(),
2037            })
2038            .await
2039            .expect("wired policy should allow session");
2040
2041        assert_eq!(decision.device_id.as_deref(), Some("device_from_wiring"));
2042    }
2043
2044    fn test_session_policy(_ctx: &AppContext) -> Arc<dyn AuthSessionPolicy> {
2045        Arc::new(TestSessionPolicy)
2046    }
2047
2048    #[derive(Debug)]
2049    struct TestSessionPolicy;
2050
2051    #[async_trait]
2052    impl AuthSessionPolicy for TestSessionPolicy {
2053        async fn before_session_create(
2054            &self,
2055            input: &SessionCreateInput,
2056        ) -> platform_core::AppResult<SessionCreateDecision> {
2057            assert_eq!(input.proposed_device_id.as_deref(), Some("device_wiring"));
2058            Ok(SessionCreateDecision {
2059                device_id: Some("device_from_wiring".to_owned()),
2060            })
2061        }
2062    }
2063
2064    #[test]
2065    fn demo_profile_includes_every_core_entry() {
2066        let demo_names = linked_module_entries(CompositionProfile::Demo)
2067            .iter()
2068            .map(|entry| entry.module_name)
2069            .collect::<Vec<_>>();
2070
2071        for core_entry in linked_module_entries(CompositionProfile::Core) {
2072            assert!(
2073                demo_names.contains(&core_entry.module_name),
2074                "demo profile should include core linked module `{}`",
2075                core_entry.module_name
2076            );
2077        }
2078    }
2079
2080    #[test]
2081    fn default_module_manifests_use_demo_profile() {
2082        let names = module_manifests()
2083            .into_iter()
2084            .map(|manifest| manifest.module_id)
2085            .collect::<Vec<_>>();
2086
2087        assert_eq!(
2088            names,
2089            vec![
2090                "lenso/auth",
2091                "lenso/auth-anonymous",
2092                "lenso/auth-oauth",
2093                "lenso/auth-password",
2094                "lenso/auth-phone",
2095                "lenso/auth-github",
2096                "lenso/auth-google",
2097                "lenso/auth-oidc",
2098            ]
2099        );
2100    }
2101
2102    #[test]
2103    fn linked_http_route_owners_are_profile_aware() {
2104        assert!(linked_http_route_owners_for_profile(CompositionProfile::Core).is_empty());
2105        assert_eq!(
2106            linked_http_route_owners_for_profile(CompositionProfile::Demo),
2107            vec![
2108                LinkedHttpRouteOwner {
2109                    module_name: "lenso/auth".to_owned(),
2110                    public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
2111                },
2112                LinkedHttpRouteOwner {
2113                    module_name: "lenso/auth-anonymous".to_owned(),
2114                    public_prefixes: &["/v1/auth/anonymous/"],
2115                },
2116                LinkedHttpRouteOwner {
2117                    module_name: "lenso/auth-password".to_owned(),
2118                    public_prefixes: &["/v1/auth/password/"],
2119                },
2120                LinkedHttpRouteOwner {
2121                    module_name: "lenso/auth-phone".to_owned(),
2122                    public_prefixes: &["/v1/auth/phone/"],
2123                },
2124                LinkedHttpRouteOwner {
2125                    module_name: "lenso/auth-github".to_owned(),
2126                    public_prefixes: &["/v1/auth/github/"],
2127                },
2128                LinkedHttpRouteOwner {
2129                    module_name: "lenso/auth-google".to_owned(),
2130                    public_prefixes: &["/v1/auth/google/"],
2131                },
2132                LinkedHttpRouteOwner {
2133                    module_name: "lenso/auth-oidc".to_owned(),
2134                    public_prefixes: &["/.well-known/", "/oauth/"],
2135                },
2136            ]
2137        );
2138    }
2139
2140    #[tokio::test]
2141    async fn modules_for_config_uses_core_linked_profile() {
2142        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2143            .expect("lazy pool should build");
2144        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2145        config.module_sources.linked_profile = "core".to_owned();
2146        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2147
2148        let names = modules_for_config(&ctx)
2149            .expect("core linked profile should parse")
2150            .into_iter()
2151            .map(|module| module.manifest.module_id)
2152            .collect::<Vec<_>>();
2153
2154        assert!(names.is_empty());
2155    }
2156
2157    #[tokio::test]
2158    async fn auth_actor_resolver_is_profile_and_composition_aware() {
2159        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2160            .expect("lazy pool should build");
2161        let demo_ctx = AppContext::new(
2162            test_config_with_database_url("postgres://localhost/lenso_test"),
2163            db.clone(),
2164            Arc::new(LoggingEventPublisher),
2165        );
2166        assert!(
2167            auth_actor_resolver_for_context(&demo_ctx)
2168                .expect("demo profile")
2169                .is_some()
2170        );
2171
2172        let mut composition_config =
2173            test_config_with_database_url("postgres://localhost/lenso_test");
2174        composition_config.module_sources.linked_profile = "core".to_owned();
2175        let composition_ctx = AppContext::new(
2176            composition_config,
2177            db.clone(),
2178            Arc::new(LoggingEventPublisher),
2179        );
2180        let composition = HostComposition::new().with_linked_module(auth_linked_module());
2181        assert!(
2182            auth_actor_resolver_for_context_with_composition(&composition_ctx, &composition)
2183                .expect("auth composition")
2184                .is_some()
2185        );
2186
2187        let mut core_config = test_config_with_database_url("postgres://localhost/lenso_test");
2188        core_config.module_sources.linked_profile = "core".to_owned();
2189        let core_ctx = AppContext::new(core_config, db, Arc::new(LoggingEventPublisher));
2190        assert!(
2191            auth_actor_resolver_for_context(&core_ctx)
2192                .expect("core profile")
2193                .is_none()
2194        );
2195    }
2196
2197    #[tokio::test]
2198    async fn auth_actor_resolver_respects_disabled_auth_module() {
2199        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2200            .expect("lazy pool should build");
2201        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2202        config.modules.insert(
2203            auth::module::MODULE_NAME.to_owned(),
2204            ModuleConfig {
2205                enabled: Some(false),
2206                values: BTreeMap::new(),
2207            },
2208        );
2209        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2210
2211        assert!(
2212            auth_actor_resolver_for_context(&ctx)
2213                .expect("demo profile")
2214                .is_none()
2215        );
2216    }
2217
2218    #[tokio::test]
2219    async fn auth_linked_providers_require_auth_module() {
2220        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2221            .expect("lazy pool should build");
2222        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2223        config.modules.insert(
2224            auth::module::MODULE_NAME.to_owned(),
2225            ModuleConfig {
2226                enabled: Some(false),
2227                values: BTreeMap::new(),
2228            },
2229        );
2230        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2231
2232        let names = modules_for_config(&ctx)
2233            .expect("demo profile")
2234            .into_iter()
2235            .map(|module| module.manifest.module_id)
2236            .collect::<Vec<_>>();
2237
2238        assert!(!names.iter().any(|name| name == "lenso/auth-oauth"));
2239        assert!(!names.iter().any(|name| name == "lenso/auth-anonymous"));
2240        assert!(!names.iter().any(|name| name == "lenso/auth-password"));
2241        assert!(!names.iter().any(|name| name == "lenso/auth-phone"));
2242        assert!(!names.iter().any(|name| name == "lenso/auth-github"));
2243        assert!(!names.iter().any(|name| name == "lenso/auth-google"));
2244        assert!(!names.iter().any(|name| name == "lenso/auth-oidc"));
2245    }
2246
2247    #[tokio::test]
2248    async fn auth_github_requires_oauth_substrate() {
2249        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2250            .expect("lazy pool should build");
2251        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2252        config.modules.insert(
2253            auth_oauth::module::MODULE_NAME.to_owned(),
2254            ModuleConfig {
2255                enabled: Some(false),
2256                values: BTreeMap::new(),
2257            },
2258        );
2259        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2260
2261        let names = modules_for_config(&ctx)
2262            .expect("demo profile")
2263            .into_iter()
2264            .map(|module| module.manifest.module_id)
2265            .collect::<Vec<_>>();
2266
2267        assert!(!names.iter().any(|name| name == "lenso/auth-oauth"));
2268        assert!(!names.iter().any(|name| name == "lenso/auth-github"));
2269        assert!(!names.iter().any(|name| name == "lenso/auth-google"));
2270        assert!(names.iter().any(|name| name == "lenso/auth-password"));
2271        assert!(names.iter().any(|name| name == "lenso/auth-phone"));
2272        assert!(names.iter().any(|name| name == "lenso/auth-oidc"));
2273    }
2274
2275    #[tokio::test]
2276    async fn auth_google_requires_oauth_substrate() {
2277        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2278            .expect("lazy pool should build");
2279        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2280        config.modules.insert(
2281            auth_oauth::module::MODULE_NAME.to_owned(),
2282            ModuleConfig {
2283                enabled: Some(false),
2284                values: BTreeMap::new(),
2285            },
2286        );
2287        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2288
2289        let names = modules_for_config(&ctx)
2290            .expect("demo profile")
2291            .into_iter()
2292            .map(|module| module.manifest.module_id)
2293            .collect::<Vec<_>>();
2294
2295        assert!(!names.iter().any(|name| name == "lenso/auth-oauth"));
2296        assert!(!names.iter().any(|name| name == "lenso/auth-github"));
2297        assert!(!names.iter().any(|name| name == "lenso/auth-google"));
2298        assert!(names.iter().any(|name| name == "lenso/auth-password"));
2299        assert!(names.iter().any(|name| name == "lenso/auth-phone"));
2300        assert!(names.iter().any(|name| name == "lenso/auth-oidc"));
2301    }
2302
2303    #[tokio::test]
2304    async fn auth_actor_resolver_allows_jwt_strategy_without_secret() {
2305        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2306            .expect("lazy pool should build");
2307        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2308        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2309        let registry =
2310            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2311                .expect("registry");
2312        let mut stored = BTreeMap::new();
2313        stored.insert(
2314            ("*".to_owned(), "auth-password.token_strategy".to_owned()),
2315            json!("jwt"),
2316        );
2317        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2318        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2319            snapshot: Arc::new(snapshot),
2320        }));
2321
2322        assert!(
2323            auth_actor_resolver_for_context(&ctx)
2324                .expect("JWT resolver should be skipped until jwt_secret is configured")
2325                .is_some()
2326        );
2327    }
2328
2329    #[tokio::test]
2330    async fn auth_actor_resolver_requires_redis_when_session_cache_is_redis() {
2331        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2332            .expect("lazy pool should build");
2333        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2334        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2335        let registry =
2336            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2337                .expect("registry");
2338        let mut stored = BTreeMap::new();
2339        stored.insert(
2340            ("*".to_owned(), "auth.session_cache".to_owned()),
2341            json!("redis"),
2342        );
2343        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2344        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2345            snapshot: Arc::new(snapshot),
2346        }));
2347
2348        let error =
2349            auth_actor_resolver_for_context(&ctx).expect_err("redis cache should require Redis");
2350
2351        assert_eq!(error.code, ErrorCode::Validation);
2352    }
2353
2354    #[tokio::test]
2355    async fn auth_session_cache_factory_returns_no_cache_in_database_mode() {
2356        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2357            .expect("lazy pool should build");
2358        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2359        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2360
2361        assert!(auth::redis_cache::session_cache_from_context(&ctx).is_none());
2362    }
2363
2364    #[tokio::test]
2365    async fn modules_for_config_skips_disabled_linked_modules() {
2366        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2367            .expect("lazy pool should build");
2368        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2369        config.modules.insert(
2370            "auth-password".to_owned(),
2371            ModuleConfig {
2372                enabled: Some(false),
2373                values: BTreeMap::new(),
2374            },
2375        );
2376        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2377
2378        let names = modules_for_config(&ctx)
2379            .expect("demo linked profile should parse")
2380            .into_iter()
2381            .map(|module| module.manifest.module_id)
2382            .collect::<Vec<_>>();
2383
2384        assert_eq!(
2385            names,
2386            vec![
2387                "lenso/auth",
2388                "lenso/auth-anonymous",
2389                "lenso/auth-oauth",
2390                "lenso/auth-github",
2391                "lenso/auth-google",
2392                "lenso/auth-oidc"
2393            ]
2394        );
2395    }
2396
2397    #[tokio::test]
2398    async fn modules_for_config_uses_runtime_config_enabled_flag() {
2399        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2400            .expect("lazy pool should build");
2401        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2402        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2403        let registry =
2404            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2405                .expect("registry");
2406        let mut stored = BTreeMap::new();
2407        stored.insert(
2408            ("*".to_owned(), "modules.auth-password.enabled".to_owned()),
2409            json!(false),
2410        );
2411        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2412        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2413            snapshot: Arc::new(snapshot),
2414        }));
2415
2416        let names = modules_for_config(&ctx)
2417            .expect("demo linked profile should parse")
2418            .into_iter()
2419            .map(|module| module.manifest.module_id)
2420            .collect::<Vec<_>>();
2421
2422        assert_eq!(
2423            names,
2424            vec![
2425                "lenso/auth",
2426                "lenso/auth-anonymous",
2427                "lenso/auth-oauth",
2428                "lenso/auth-github",
2429                "lenso/auth-google",
2430                "lenso/auth-oidc"
2431            ]
2432        );
2433        let linked_http_names = linked_http_modules_for_context(&ctx)
2434            .expect("linked HTTP modules should load")
2435            .into_iter()
2436            .map(|module| module.manifest.module_id)
2437            .collect::<Vec<_>>();
2438
2439        assert_eq!(
2440            linked_http_names,
2441            vec![
2442                "lenso/auth",
2443                "lenso/auth-anonymous",
2444                "lenso/auth-github",
2445                "lenso/auth-google",
2446                "lenso/auth-oidc"
2447            ]
2448        );
2449    }
2450
2451    #[tokio::test]
2452    async fn runtime_config_descriptors_include_module_enabled_flags() {
2453        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2454            .expect("lazy pool should build");
2455        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2456        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2457
2458        let keys = runtime_config_descriptors(&ctx)
2459            .expect("descriptors should load")
2460            .into_iter()
2461            .map(|descriptor| {
2462                (
2463                    descriptor.key,
2464                    descriptor.group,
2465                    descriptor.restart_only,
2466                    descriptor.default,
2467                )
2468            })
2469            .collect::<Vec<_>>();
2470
2471        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2472            key == "modules.auth.enabled"
2473                && *group == Some("modules")
2474                && *restart_only
2475                && default == &json!(true)
2476        }));
2477        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2478            key == "modules.auth-anonymous.enabled"
2479                && *group == Some("modules")
2480                && *restart_only
2481                && default == &json!(true)
2482        }));
2483        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2484            key == "modules.auth-password.enabled"
2485                && *group == Some("modules")
2486                && *restart_only
2487                && default == &json!(true)
2488        }));
2489        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2490            key == "modules.auth-phone.enabled"
2491                && *group == Some("modules")
2492                && *restart_only
2493                && default == &json!(true)
2494        }));
2495        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2496            key == "modules.auth-oauth.enabled"
2497                && *group == Some("modules")
2498                && *restart_only
2499                && default == &json!(true)
2500        }));
2501        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2502            key == "modules.auth-github.enabled"
2503                && *group == Some("modules")
2504                && *restart_only
2505                && default == &json!(true)
2506        }));
2507        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2508            key == "modules.auth-google.enabled"
2509                && *group == Some("modules")
2510                && *restart_only
2511                && default == &json!(true)
2512        }));
2513        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2514            key == "modules.auth-oidc.enabled"
2515                && *group == Some("modules")
2516                && *restart_only
2517                && default == &json!(true)
2518        }));
2519    }
2520
2521    #[tokio::test]
2522    async fn runtime_config_groups_include_module_owned_groups() {
2523        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2524            .expect("lazy pool should build");
2525        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2526        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2527
2528        let groups = runtime_config_group_descriptors(&ctx)
2529            .expect("groups should load")
2530            .into_iter()
2531            .map(|group| (group.id, group.label))
2532            .collect::<Vec<_>>();
2533
2534        assert!(groups.contains(&("modules", "Modules")));
2535        assert!(groups.contains(&("auth-password.hashing", "Password Hashing")));
2536        assert!(groups.contains(&("auth-password.tokens", "Tokens")));
2537        assert!(!groups.iter().any(|(id, _)| *id == "auth-password.jwt"));
2538        assert!(groups.contains(&("auth-phone.otp", "Phone OTP")));
2539        assert!(!groups.iter().any(|(id, _)| *id == "auth-phone.password"));
2540    }
2541
2542    #[test]
2543    fn migrations_for_config_skip_disabled_linked_module_migrations() {
2544        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2545        config.modules.insert(
2546            "auth-password".to_owned(),
2547            ModuleConfig {
2548                enabled: Some(false),
2549                values: BTreeMap::new(),
2550            },
2551        );
2552
2553        let names = migrations_for_config(&config)
2554            .expect("demo linked profile should parse")
2555            .into_iter()
2556            .map(|migration| migration.name)
2557            .collect::<Vec<_>>();
2558
2559        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
2560        assert!(!names.iter().any(|name| name.starts_with("auth-phone/")));
2561        assert!(
2562            names
2563                .iter()
2564                .any(|name| name == &"auth/0001_create_auth_schema")
2565        );
2566        assert!(
2567            names
2568                .iter()
2569                .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
2570        );
2571        assert!(
2572            names
2573                .iter()
2574                .any(|name| name == &"auth-github/0001_create_auth_github_schema")
2575        );
2576        assert!(
2577            names
2578                .iter()
2579                .any(|name| name == &"auth-google/0001_create_auth_google_schema")
2580        );
2581    }
2582
2583    #[test]
2584    fn linked_http_modules_for_config_skip_disabled_linked_routes() {
2585        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2586        config.modules.insert(
2587            "auth-password".to_owned(),
2588            ModuleConfig {
2589                enabled: Some(false),
2590                values: BTreeMap::new(),
2591            },
2592        );
2593
2594        let names = linked_http_modules_for_config(&config)
2595            .expect("demo linked profile should parse")
2596            .into_iter()
2597            .map(|module| module.manifest.module_id)
2598            .collect::<Vec<_>>();
2599
2600        assert_eq!(
2601            names,
2602            vec![
2603                "lenso/auth",
2604                "lenso/auth-anonymous",
2605                "lenso/auth-github",
2606                "lenso/auth-google",
2607                "lenso/auth-oidc"
2608            ]
2609        );
2610    }
2611
2612    #[test]
2613    fn composition_profile_rejects_unknown_values() {
2614        let error = CompositionProfile::parse("fixture")
2615            .expect_err("fixture is not a supported linked module profile");
2616
2617        assert_eq!(error.code, ErrorCode::Validation);
2618        assert!(
2619            error
2620                .details
2621                .iter()
2622                .any(|detail| detail.field.as_deref() == Some("module_sources.linked_profile"))
2623        );
2624    }
2625
2626    #[test]
2627    fn linked_http_route_owners_are_projected_from_modules() {
2628        assert_eq!(
2629            linked_http_route_owners(),
2630            vec![
2631                LinkedHttpRouteOwner {
2632                    module_name: "lenso/auth".to_owned(),
2633                    public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
2634                },
2635                LinkedHttpRouteOwner {
2636                    module_name: "lenso/auth-anonymous".to_owned(),
2637                    public_prefixes: &["/v1/auth/anonymous/"],
2638                },
2639                LinkedHttpRouteOwner {
2640                    module_name: "lenso/auth-password".to_owned(),
2641                    public_prefixes: &["/v1/auth/password/"],
2642                },
2643                LinkedHttpRouteOwner {
2644                    module_name: "lenso/auth-phone".to_owned(),
2645                    public_prefixes: &["/v1/auth/phone/"],
2646                },
2647                LinkedHttpRouteOwner {
2648                    module_name: "lenso/auth-github".to_owned(),
2649                    public_prefixes: &["/v1/auth/github/"],
2650                },
2651                LinkedHttpRouteOwner {
2652                    module_name: "lenso/auth-google".to_owned(),
2653                    public_prefixes: &["/v1/auth/google/"],
2654                },
2655                LinkedHttpRouteOwner {
2656                    module_name: "lenso/auth-oidc".to_owned(),
2657                    public_prefixes: &["/.well-known/", "/oauth/"],
2658                },
2659            ]
2660        );
2661    }
2662
2663    #[test]
2664    fn linked_http_bindings_are_declared_in_manifests() {
2665        for module in linked_http_modules() {
2666            let http = module
2667                .linked_http
2668                .expect("linked HTTP module should carry HTTP contribution");
2669            assert!(
2670                !module.manifest.http_routes.is_empty(),
2671                "linked HTTP module `{}` must declare ModuleManifest::http_routes",
2672                module.manifest.module_id
2673            );
2674            for route in &module.manifest.http_routes {
2675                assert!(
2676                    http.public_prefixes
2677                        .iter()
2678                        .any(|prefix| route.path.starts_with(prefix)),
2679                    "linked HTTP module `{}` declares manifest route `{}` outside its public prefixes",
2680                    module.manifest.module_id,
2681                    route.path
2682                );
2683            }
2684        }
2685    }
2686
2687    #[test]
2688    fn linked_http_modules_are_registered_modules() {
2689        let manifests = module_manifests();
2690
2691        for module in linked_http_modules() {
2692            let registered_manifest = manifests
2693                .iter()
2694                .find(|manifest| manifest.module_id == module.manifest.module_id)
2695                .unwrap_or_else(|| {
2696                    panic!(
2697                        "linked HTTP module `{}` is missing from module_manifests",
2698                        module.manifest.module_id
2699                    )
2700                });
2701            assert_eq!(
2702                registered_manifest, &module.manifest,
2703                "linked HTTP module `{}` must use the registered ModuleManifest",
2704                module.manifest.module_id
2705            );
2706        }
2707    }
2708
2709    #[tokio::test]
2710    async fn lifecycle_activation_enqueue_creates_function_run() {
2711        let Some(db) = TestDatabase::create().await else {
2712            return;
2713        };
2714        apply_runtime_stack_migrations(&db).await;
2715
2716        let mut ctx = AppContext::new(
2717            test_config(&db),
2718            db.pool.clone(),
2719            Arc::new(LoggingEventPublisher),
2720        );
2721        ctx.ids = Arc::new(SequentialIdGenerator::default());
2722        let modules = vec![
2723            test_lifecycle_module(lifecycle_activation_job(true, json!({ "warm": "cache" })))
2724                .into(),
2725        ];
2726        let registry = registry_with_lifecycle_function(7);
2727
2728        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2729            .await
2730            .expect("lifecycle activation job should enqueue");
2731
2732        assert_eq!(run_ids.len(), 1);
2733        let row = sqlx::query_as::<_, (String, Value, i32, String, Value)>(
2734            r#"
2735            select function_name, input_json, max_attempts, correlation_id, actor
2736            from runtime.function_runs
2737            where id = $1
2738            "#,
2739        )
2740        .bind(&run_ids[0])
2741        .fetch_one(&db.pool)
2742        .await
2743        .expect("function run should exist");
2744
2745        assert_eq!(row.0, LIFECYCLE_FUNCTION_NAME);
2746        assert_eq!(row.1["warm"], "cache");
2747        assert_eq!(
2748            row.1["_lenso_runtime"]["correlation_id"],
2749            "corr_lifecycle_1"
2750        );
2751        assert_eq!(
2752            row.1["_lenso_runtime"]["causation_id"],
2753            "module_lifecycle:fixture/test-module:warm cache"
2754        );
2755        assert_eq!(row.2, 7);
2756        assert_eq!(row.3, "corr_lifecycle_1");
2757        assert_eq!(row.4["kind"], "service");
2758        assert_eq!(row.4["service_id"], "worker");
2759        assert_eq!(row.4["scopes"][0], "runtime.functions.enqueue");
2760
2761        db.cleanup().await;
2762    }
2763
2764    #[test]
2765    fn lifecycle_activation_validation_rejects_required_missing_function() {
2766        let modules =
2767            vec![test_lifecycle_module(lifecycle_activation_job(true, Value::Null)).into()];
2768        let registry = FunctionRegistry::default();
2769
2770        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2771            .expect_err("required missing activation function should fail validation");
2772
2773        assert_eq!(error.code, ErrorCode::Validation);
2774        assert_eq!(
2775            error.details[0].field.as_deref(),
2776            Some("module.fixture/test-module.lifecycle.activation_jobs.warm cache")
2777        );
2778        assert!(
2779            error.details[0].reason.contains("missing function"),
2780            "validation detail should name the missing registry function"
2781        );
2782    }
2783
2784    #[test]
2785    fn lifecycle_activation_validation_rejects_required_startup_check_missing_function() {
2786        let modules = vec![test_lifecycle_module_with_lifecycle(
2787            LifecycleSurface {
2788                startup_checks: vec![LifecycleStartupCheckDeclaration {
2789                    name: "function registered".to_owned(),
2790                    required: true,
2791                    check: LifecycleStartupCheckKind::FunctionRegistered {
2792                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
2793                    },
2794                }],
2795                activation_jobs: Vec::new(),
2796            },
2797            true,
2798            Vec::new(),
2799        )];
2800        let registry = FunctionRegistry::default();
2801
2802        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2803            .expect_err("required startup check should fail when function is missing");
2804
2805        assert_eq!(error.code, ErrorCode::Validation);
2806        assert_eq!(
2807            error.details[0].field.as_deref(),
2808            Some("module.fixture/test-module.lifecycle.startup_checks.function registered")
2809        );
2810        assert!(
2811            error.details[0].reason.contains("missing function"),
2812            "validation detail should name the missing registry function"
2813        );
2814    }
2815
2816    #[test]
2817    fn lifecycle_activation_validation_rejects_required_startup_check_function_not_declared() {
2818        let modules = vec![test_lifecycle_module_with_lifecycle(
2819            LifecycleSurface {
2820                startup_checks: vec![LifecycleStartupCheckDeclaration {
2821                    name: "function registered".to_owned(),
2822                    required: true,
2823                    check: LifecycleStartupCheckKind::FunctionRegistered {
2824                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
2825                    },
2826                }],
2827                activation_jobs: Vec::new(),
2828            },
2829            false,
2830            Vec::new(),
2831        )];
2832        let registry = registry_with_lifecycle_function(3);
2833
2834        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2835            .expect_err("required startup check should fail when manifest does not declare it");
2836
2837        assert_eq!(error.code, ErrorCode::Validation);
2838        assert_eq!(
2839            error.details[0].field.as_deref(),
2840            Some("module.fixture/test-module.lifecycle.startup_checks.function registered")
2841        );
2842        assert!(
2843            error.details[0].reason.contains("not declared"),
2844            "validation detail should name the missing module runtime declaration"
2845        );
2846    }
2847
2848    #[test]
2849    fn lifecycle_activation_validation_rejects_required_startup_check_missing_capability() {
2850        let modules = vec![test_lifecycle_module_with_lifecycle(
2851            LifecycleSurface {
2852                startup_checks: vec![LifecycleStartupCheckDeclaration {
2853                    name: "capability declared".to_owned(),
2854                    required: true,
2855                    check: LifecycleStartupCheckKind::CapabilityDeclared {
2856                        capability: "test.cache.warm".to_owned(),
2857                    },
2858                }],
2859                activation_jobs: Vec::new(),
2860            },
2861            false,
2862            Vec::new(),
2863        )];
2864        let registry = FunctionRegistry::default();
2865
2866        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2867            .expect_err("required startup check should fail when capability is missing");
2868
2869        assert_eq!(error.code, ErrorCode::Validation);
2870        assert_eq!(
2871            error.details[0].field.as_deref(),
2872            Some("module.fixture/test-module.lifecycle.startup_checks.capability declared")
2873        );
2874        assert!(
2875            error.details[0].reason.contains("missing capability"),
2876            "validation detail should name the missing capability"
2877        );
2878    }
2879
2880    #[test]
2881    fn lifecycle_activation_optional_startup_checks_do_not_fail_validation() {
2882        let modules = vec![test_lifecycle_module_with_lifecycle(
2883            LifecycleSurface {
2884                startup_checks: vec![
2885                    LifecycleStartupCheckDeclaration {
2886                        name: "optional function".to_owned(),
2887                        required: false,
2888                        check: LifecycleStartupCheckKind::FunctionRegistered {
2889                            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
2890                        },
2891                    },
2892                    LifecycleStartupCheckDeclaration {
2893                        name: "optional capability".to_owned(),
2894                        required: false,
2895                        check: LifecycleStartupCheckKind::CapabilityDeclared {
2896                            capability: "test.cache.warm".to_owned(),
2897                        },
2898                    },
2899                ],
2900                activation_jobs: Vec::new(),
2901            },
2902            false,
2903            Vec::new(),
2904        )];
2905        let registry = FunctionRegistry::default();
2906
2907        validate_lifecycle_activation_jobs(&modules, &registry)
2908            .expect("optional startup checks should not fail validation");
2909    }
2910
2911    #[test]
2912    fn lifecycle_activation_validation_rejects_required_job_not_declared_by_module() {
2913        let modules = vec![
2914            test_lifecycle_module(lifecycle_activation_job(true, Value::Null))
2915                .without_runtime_declaration()
2916                .into(),
2917        ];
2918        let registry = registry_with_lifecycle_function(3);
2919
2920        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2921            .expect_err("required activation job should fail when manifest does not declare it");
2922
2923        assert_eq!(error.code, ErrorCode::Validation);
2924        assert_eq!(
2925            error.details[0].field.as_deref(),
2926            Some("module.fixture/test-module.lifecycle.activation_jobs.warm cache")
2927        );
2928        assert!(
2929            error.details[0].reason.contains("not declared"),
2930            "validation detail should name the missing module runtime declaration"
2931        );
2932    }
2933
2934    #[tokio::test]
2935    async fn optional_missing_lifecycle_activation_is_skipped() {
2936        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2937            .expect("lazy pool should build");
2938        let ctx = AppContext::new(
2939            test_config_with_database_url("postgres://localhost/lenso_test"),
2940            db,
2941            Arc::new(LoggingEventPublisher),
2942        );
2943        let modules =
2944            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
2945        let registry = FunctionRegistry::default();
2946
2947        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2948            .await
2949            .expect("optional missing activation function should be skipped");
2950
2951        assert!(run_ids.is_empty());
2952    }
2953
2954    #[tokio::test]
2955    async fn lifecycle_activation_optional_job_not_declared_is_skipped() {
2956        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2957            .expect("lazy pool should build");
2958        let ctx = AppContext::new(
2959            test_config_with_database_url("postgres://localhost/lenso_test"),
2960            db,
2961            Arc::new(LoggingEventPublisher),
2962        );
2963        let modules = vec![
2964            test_lifecycle_module(lifecycle_activation_job(false, Value::Null))
2965                .without_runtime_declaration()
2966                .into(),
2967        ];
2968        let registry = registry_with_lifecycle_function(3);
2969
2970        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2971            .await
2972            .expect("optional undeclared activation function should be skipped");
2973
2974        assert!(run_ids.is_empty());
2975    }
2976
2977    #[tokio::test]
2978    async fn lifecycle_activation_optional_enqueue_failure_is_skipped() {
2979        let db = PgPoolOptions::new()
2980            .max_connections(1)
2981            .acquire_timeout(Duration::from_millis(50))
2982            .connect_lazy_with(
2983                PgConnectOptions::new()
2984                    .host("127.0.0.1")
2985                    .port(1)
2986                    .username("postgres")
2987                    .database("lenso_test"),
2988            );
2989        let ctx = AppContext::new(
2990            test_config_with_database_url("postgres://localhost:1/lenso_test"),
2991            db,
2992            Arc::new(LoggingEventPublisher),
2993        );
2994        let modules =
2995            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
2996        let registry = registry_with_lifecycle_function(3);
2997
2998        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2999            .await
3000            .expect("optional enqueue failure should be skipped");
3001
3002        assert!(run_ids.is_empty());
3003    }
3004
3005    #[test]
3006    fn lifecycle_activation_max_attempts_conversion_saturates() {
3007        assert_eq!(runtime_max_attempts_for_enqueue(7), 7);
3008        assert_eq!(runtime_max_attempts_for_enqueue(u32::MAX), i32::MAX);
3009    }
3010
3011    const LIFECYCLE_FUNCTION_NAME: &str = "test.warm_cache.v1";
3012
3013    #[derive(Debug)]
3014    struct NoopFunctionHandler;
3015
3016    #[async_trait]
3017    impl FunctionHandler for NoopFunctionHandler {
3018        async fn call(
3019            &self,
3020            _ctx: ExecutionContext,
3021            _input: Value,
3022        ) -> platform_core::AppResult<Value> {
3023            Ok(Value::Null)
3024        }
3025    }
3026
3027    fn lifecycle_activation_job(required: bool, input: Value) -> LifecycleActivationJobDeclaration {
3028        LifecycleActivationJobDeclaration {
3029            name: "warm cache".to_owned(),
3030            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3031            run_policy: LifecycleActivationRunPolicy::EveryStartup,
3032            input,
3033            required,
3034        }
3035    }
3036
3037    struct TestLifecycleModuleBuilder {
3038        lifecycle: LifecycleSurface,
3039        declare_runtime_function: bool,
3040        capabilities: Vec<String>,
3041    }
3042
3043    impl TestLifecycleModuleBuilder {
3044        fn without_runtime_declaration(mut self) -> Self {
3045            self.declare_runtime_function = false;
3046            self
3047        }
3048    }
3049
3050    impl From<TestLifecycleModuleBuilder> for Module {
3051        fn from(builder: TestLifecycleModuleBuilder) -> Self {
3052            let mut manifest =
3053                ModuleManifest::builder("fixture/test-module").lifecycle(builder.lifecycle);
3054            if builder.declare_runtime_function {
3055                manifest = manifest.runtime(RuntimeSurface {
3056                    functions: vec![RuntimeFunctionDeclaration {
3057                        name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3058                        version: 1,
3059                        queue: "test".to_owned(),
3060                        input_schema: None,
3061                        retry_policy: None,
3062                        operation: None,
3063                    }],
3064                    schedules: vec![],
3065                    workflows: vec![],
3066                });
3067            }
3068            if !builder.capabilities.is_empty() {
3069                manifest = manifest.capabilities(builder.capabilities);
3070            }
3071            Module::linked(manifest.build(), LinkedBinding::builder().build())
3072        }
3073    }
3074
3075    fn test_lifecycle_module(job: LifecycleActivationJobDeclaration) -> TestLifecycleModuleBuilder {
3076        TestLifecycleModuleBuilder {
3077            lifecycle: LifecycleSurface {
3078                startup_checks: Vec::new(),
3079                activation_jobs: vec![job],
3080            },
3081            declare_runtime_function: true,
3082            capabilities: Vec::new(),
3083        }
3084    }
3085
3086    fn test_lifecycle_module_with_lifecycle(
3087        lifecycle: LifecycleSurface,
3088        declare_runtime_function: bool,
3089        capabilities: Vec<String>,
3090    ) -> Module {
3091        TestLifecycleModuleBuilder {
3092            lifecycle,
3093            declare_runtime_function,
3094            capabilities,
3095        }
3096        .into()
3097    }
3098
3099    fn registry_with_lifecycle_function(max_attempts: u32) -> FunctionRegistry {
3100        let mut registry = FunctionRegistry::default();
3101        registry.register(FunctionDefinition {
3102            name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3103            version: 1,
3104            queue: "test".to_owned(),
3105            retry_policy: RetryPolicy::fixed(max_attempts, Duration::ZERO),
3106            handler: Arc::new(NoopFunctionHandler),
3107        });
3108        registry
3109    }
3110
3111    const TEST_HOST_MIGRATIONS: &[Migration] = &[Migration {
3112        name: "billing/0001_init",
3113        sql: "select 1;",
3114    }];
3115
3116    fn test_host_manifest() -> ModuleManifest {
3117        ModuleManifest::builder("fixture/billing").build()
3118    }
3119
3120    fn test_host_linked_module() -> HostLinkedModule {
3121        HostLinkedModule::manifest_only("billing", test_host_manifest, TEST_HOST_MIGRATIONS)
3122    }
3123
3124    fn test_config(db: &TestDatabase) -> AppConfig {
3125        test_config_with_database_url(db.url.clone())
3126    }
3127
3128    fn test_config_with_database_url(database_url: impl Into<String>) -> AppConfig {
3129        AppConfig {
3130            service: ServiceConfig::default(),
3131            database: DatabaseConfig {
3132                url: database_url.into(),
3133                max_connections: 5,
3134            },
3135            redis: RedisConfig::default(),
3136            http: HttpConfig::default(),
3137            telemetry: TelemetryConfig::default(),
3138            auth: AuthConfig::default(),
3139            module_sources: ModuleSourcesConfig::default(),
3140            modules: BTreeMap::new(),
3141        }
3142    }
3143
3144    async fn apply_runtime_stack_migrations(db: &TestDatabase) {
3145        let migrations = PLATFORM_MIGRATIONS
3146            .iter()
3147            .chain(RUNTIME_MIGRATIONS)
3148            .copied()
3149            .collect::<Vec<_>>();
3150        apply_migrations(&db.pool, &migrations)
3151            .await
3152            .expect("platform and runtime migrations should apply");
3153    }
3154}