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