Skip to main content

lenso_contracts/
manifest.rs

1//! A module's pure-data contract: serializable metadata describable without
2//! behavior. Owned + serde so every loading source produces the same shape.
3
4use crate::StoryDisplayDescriptor;
5use crate::admin::{
6    AdminDeclarativeComponent, AdminDeclarativeSurface, AdminEmbeddedEntry, AdminEmbeddedRuntime,
7    AdminEmbeddedSurface, AdminPermission, AdminSurface,
8};
9use crate::admin_schema::AdminSchema;
10use crate::console::{
11    ConsoleActionInputValue, ConsoleContribution, ConsoleContributionAction, ConsoleModuleManifest,
12    ConsoleModuleSurface, ConsoleSlot, ConsoleSurface, ConsoleSurfacePresentation,
13};
14use crate::events::{EventHandlerDeclaration, EventSurface};
15use crate::http::{ModuleHttpMethod, ModuleHttpRoute, lint_module_http_routes};
16use crate::lifecycle::{
17    LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration, LifecycleStartupCheckKind,
18    LifecycleSurface,
19};
20use crate::runtime::{
21    RuntimeFunctionDeclaration, RuntimeSurface, ScheduledFunctionDeclaration,
22    WORKFLOW_DEFINITION_PROTOCOL, WorkflowDataContract, WorkflowDefinition,
23    WorkflowStepDeclaration,
24};
25use crate::validate_cron_expression;
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29use std::collections::HashSet;
30use utoipa::ToSchema;
31
32pub const MODULE_MANIFEST_PROTOCOL: &str = "lenso.module-manifest.v1";
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
35#[serde(deny_unknown_fields)]
36pub struct ModuleRequirement {
37    pub module_id: String,
38    pub version_requirement: String,
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub capabilities: Vec<String>,
41    #[serde(default)]
42    pub optional: bool,
43}
44
45impl ModuleRequirement {
46    pub fn new(
47        module_id: impl Into<String>,
48        version_requirement: impl AsRef<str>,
49    ) -> Result<Self, String> {
50        let version_requirement = semver::VersionReq::parse(version_requirement.as_ref())
51            .map_err(|error| format!("invalid Module version requirement: {error}"))?
52            .to_string();
53        Ok(Self {
54            module_id: module_id.into(),
55            version_requirement,
56            capabilities: Vec::new(),
57            optional: false,
58        })
59    }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
63#[serde(rename_all = "snake_case")]
64pub enum ModuleConfigFieldType {
65    String,
66    Integer,
67    Boolean,
68    Json,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
72#[serde(rename_all = "snake_case")]
73pub enum ModuleConfigScope {
74    Module,
75    Service,
76    Environment,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
80#[serde(rename_all = "snake_case")]
81pub enum ModuleConfigMutability {
82    Static,
83    Reloadable,
84    Runtime,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
88#[serde(rename_all = "snake_case")]
89pub enum ModuleConfigActivation {
90    None,
91    Build,
92    Restart,
93    ServiceRestart,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97#[serde(deny_unknown_fields)]
98pub struct ModuleConfigValidation {
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub pattern: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub minimum: Option<i64>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub maximum: Option<i64>,
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub allowed_values: Vec<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
110#[serde(deny_unknown_fields)]
111pub struct ModuleConfigField {
112    pub key: String,
113    pub field_type: ModuleConfigFieldType,
114    pub required: bool,
115    pub scope: ModuleConfigScope,
116    pub sensitive: bool,
117    pub secret_reference: bool,
118    pub mutability: ModuleConfigMutability,
119    pub activation: ModuleConfigActivation,
120    /// Capability required to read this field through the Managed Service
121    /// System Plane operation. Sensitive fields are write-only and must leave
122    /// this unset.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub read_capability: Option<String>,
125    /// Capability required to write this field through the Managed Service
126    /// System Plane operation.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub write_capability: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub default: Option<Value>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub validation: Option<ModuleConfigValidation>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
136#[serde(deny_unknown_fields)]
137pub struct ModuleConfigContract {
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub fields: Vec<ModuleConfigField>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
143#[serde(rename_all = "snake_case")]
144pub enum ModuleMigrationActivation {
145    BeforeActivation,
146    AfterActivation,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
150#[serde(deny_unknown_fields)]
151pub struct ModuleMigrationDeclaration {
152    pub migration_id: String,
153    pub order: u32,
154    pub store: String,
155    pub destructive: bool,
156    pub reversible: bool,
157    pub activation: ModuleMigrationActivation,
158}
159
160/// The serializable metadata a module exposes. Runtime configuration is part
161/// of this contract so a Managed Service can enforce a descriptor-bound
162/// System Plane operation without opening a generic key/value seam.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165#[non_exhaustive]
166pub struct ModuleManifest {
167    pub protocol: String,
168
169    /// Stable fully qualified ModuleId, e.g. `"lenso/identity"`.
170    pub module_id: String,
171
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub summary: Option<String>,
174
175    /// Console story-display metadata.
176    #[serde(default)]
177    pub story_display: Vec<StoryDisplayDescriptor>,
178
179    /// Admin surface: `Some(AdminSurface::Schema(_))` for schema-driven CRUD,
180    /// future custom surfaces for richer module admin UI, or `None` for modules
181    /// with no admin surface (e.g. notifications).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub admin: Option<AdminSurface>,
184
185    /// Declared module-owned HTTP routes. These are metadata only until a
186    /// loading-source-specific mount/proxy protocol exists.
187    #[serde(default)]
188    pub http_routes: Vec<ModuleHttpRoute>,
189
190    /// Declared runtime behavior. These entries are manifest data only; source
191    /// bindings decide how to register executable behavior.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub runtime: Option<RuntimeSurface>,
194
195    /// Declared event subscriptions. These entries are manifest data only;
196    /// source bindings decide how to register executable behavior.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub events: Option<EventSurface>,
199
200    /// Declared lifecycle work. The host validates and schedules these entries;
201    /// modules do not receive arbitrary startup callbacks.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub lifecycle: Option<LifecycleSurface>,
204
205    /// Declared Console surfaces provided by trusted frontend packages.
206    #[serde(default)]
207    pub console: Vec<ConsoleSurface>,
208
209    /// Declared Console extension slots owned by host or module surfaces.
210    #[serde(default)]
211    pub console_slots: Vec<ConsoleSlot>,
212
213    /// Declared Console slot contributions attached to host or module-owned surfaces.
214    #[serde(default)]
215    pub console_contributions: Vec<ConsoleContribution>,
216
217    /// RESERVED SEAM — capabilities the module declares (perms/tenancy).
218    #[serde(default)]
219    pub capabilities: Vec<String>,
220
221    /// Other Modules required by this business capability.
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub requires: Vec<ModuleRequirement>,
224
225    #[serde(default, skip_serializing_if = "ModuleConfigContract::is_empty")]
226    pub config: ModuleConfigContract,
227
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub migrations: Vec<ModuleMigrationDeclaration>,
230}
231
232impl ModuleManifest {
233    /// Start building a manifest for a fully qualified ModuleId.
234    #[must_use]
235    pub fn builder(module_id: impl Into<String>) -> ModuleManifestBuilder {
236        ModuleManifestBuilder {
237            manifest: ModuleManifest {
238                protocol: MODULE_MANIFEST_PROTOCOL.to_owned(),
239                module_id: module_id.into(),
240                summary: None,
241                story_display: Vec::new(),
242                admin: None,
243                http_routes: Vec::new(),
244                runtime: None,
245                events: None,
246                lifecycle: None,
247                console: Vec::new(),
248                console_slots: Vec::new(),
249                console_contributions: Vec::new(),
250                capabilities: Vec::new(),
251                requires: Vec::new(),
252                config: ModuleConfigContract::default(),
253                migrations: Vec::new(),
254            },
255        }
256    }
257
258    /// Derive the Console SDK manifest from the typed Module declaration.
259    /// Declarative Shell surfaces are intentionally excluded because they do
260    /// not load executable ESM; the owning Release artifact carries only the
261    /// executable surface set.
262    #[must_use]
263    pub fn console_module_manifest(
264        &self,
265        host_api: impl Into<String>,
266        console_ui: impl Into<String>,
267    ) -> ConsoleModuleManifest {
268        let mut surfaces = self
269            .console
270            .iter()
271            .filter_map(|surface| {
272                if !matches!(surface.presentation, ConsoleSurfacePresentation::Esm { .. }) {
273                    return None;
274                }
275                Some(ConsoleModuleSurface {
276                    id: surface.name.clone(),
277                    path: surface.route.clone(),
278                    label: surface.label.clone(),
279                    area: console_surface_area(&surface.route),
280                    required_capabilities: surface.required_capabilities.clone(),
281                    icon: surface.icon.clone(),
282                    navigation: surface.navigation.clone(),
283                })
284            })
285            .collect::<Vec<_>>();
286        surfaces.sort_by(|left, right| left.id.cmp(&right.id));
287        ConsoleModuleManifest {
288            protocol: crate::CONSOLE_MODULE_PROTOCOL.to_owned(),
289            module_id: self.module_id.clone(),
290            host_api: host_api.into(),
291            console_ui: console_ui.into(),
292            surfaces,
293        }
294    }
295}
296
297impl ModuleConfigContract {
298    fn is_empty(&self) -> bool {
299        self.fields.is_empty()
300    }
301}
302
303fn console_surface_area(route: &str) -> crate::ConsoleSurfaceArea {
304    match route.split('/').nth(1) {
305        Some("operations") => crate::ConsoleSurfaceArea::Operations,
306        Some("data") => crate::ConsoleSurfaceArea::Data,
307        Some("configuration") => crate::ConsoleSurfaceArea::Configuration,
308        _ => crate::ConsoleSurfaceArea::Runtime,
309    }
310}
311
312/// Validates one System Plane configuration write against its immutable
313/// Module descriptor. This is deliberately independent of the storage
314/// implementation so every Managed Service applies the same type rules.
315pub fn validate_module_config_value(
316    field: &ModuleConfigField,
317    value: &Value,
318) -> Result<(), String> {
319    let type_matches = match field.field_type {
320        ModuleConfigFieldType::String => value.is_string(),
321        ModuleConfigFieldType::Integer => value.as_i64().is_some(),
322        ModuleConfigFieldType::Boolean => value.is_boolean(),
323        ModuleConfigFieldType::Json => true,
324    };
325    if !type_matches {
326        return Err(format!("value for `{}` has the wrong type", field.key));
327    }
328
329    let Some(validation) = &field.validation else {
330        return Ok(());
331    };
332    if let Some(minimum) = validation.minimum
333        && value.as_i64().is_some_and(|number| number < minimum)
334    {
335        return Err(format!("value for `{}` is below the minimum", field.key));
336    }
337    if let Some(maximum) = validation.maximum
338        && value.as_i64().is_some_and(|number| number > maximum)
339    {
340        return Err(format!("value for `{}` is above the maximum", field.key));
341    }
342    if !validation.allowed_values.is_empty()
343        && !value.as_str().is_some_and(|candidate| {
344            validation
345                .allowed_values
346                .iter()
347                .any(|allowed| allowed == candidate)
348        })
349    {
350        return Err(format!("value for `{}` is not an allowed value", field.key));
351    }
352    if let Some(pattern) = &validation.pattern {
353        let regex = regex::Regex::new(pattern)
354            .map_err(|_| format!("validation pattern for `{}` is invalid", field.key))?;
355        if !value
356            .as_str()
357            .is_some_and(|candidate| regex.is_match(candidate))
358        {
359            return Err(format!(
360                "value for `{}` does not match its pattern",
361                field.key
362            ));
363        }
364    }
365    Ok(())
366}
367
368#[derive(
369    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema,
370)]
371#[serde(rename_all = "snake_case")]
372pub enum ModuleManifestLintSeverity {
373    Ok,
374    Warning,
375    Error,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
379pub struct ModuleManifestLint {
380    pub severity: ModuleManifestLintSeverity,
381    pub subject: String,
382    pub message: String,
383    pub suggestion: String,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct ModuleCapabilityReference {
388    pub capability: String,
389    pub subject: String,
390}
391
392pub fn lint_module_manifest(manifest: &ModuleManifest) -> Vec<ModuleManifestLint> {
393    let mut lints = lint_module_manifest_parts(
394        &manifest.module_id,
395        manifest.admin.as_ref(),
396        &manifest.http_routes,
397        manifest.runtime.as_ref(),
398        manifest.events.as_ref(),
399        manifest.lifecycle.as_ref(),
400        &manifest.console,
401        &manifest.console_slots,
402        &manifest.console_contributions,
403        &manifest.capabilities,
404        &manifest.requires,
405    );
406    lint_manifest_contract(manifest, &mut lints);
407    lints
408}
409
410fn lint_manifest_contract(manifest: &ModuleManifest, lints: &mut Vec<ModuleManifestLint>) {
411    if manifest.protocol != MODULE_MANIFEST_PROTOCOL {
412        push_contract_error(
413            lints,
414            "module.protocol",
415            format!("Protocol must be {MODULE_MANIFEST_PROTOCOL}."),
416            "Use the canonical Module Manifest protocol discriminator.",
417        );
418    }
419    if !sorted_unique(&manifest.capabilities) {
420        push_contract_error(
421            lints,
422            "module.capabilities",
423            "Capabilities must be non-empty, sorted, and unique.",
424            "Sort and deduplicate ModuleManifest.capabilities.",
425        );
426    }
427
428    let mut requirement_ids = HashSet::new();
429    for requirement in &manifest.requires {
430        if !requirement_ids.insert(requirement.module_id.as_str()) {
431            push_contract_error(
432                lints,
433                format!("requirement {}", requirement.module_id),
434                "Module requirements must have unique ModuleIds.",
435                "Merge duplicate requirements into one canonical declaration.",
436            );
437        }
438        if !matches!(
439            semver::VersionReq::parse(&requirement.version_requirement),
440            Ok(version) if version.to_string() == requirement.version_requirement
441        ) {
442            push_contract_error(
443                lints,
444                format!("requirement {}", requirement.module_id),
445                "Module version requirement must be normalized SemVer.",
446                "Parse and serialize the version requirement before publishing.",
447            );
448        }
449        if !sorted_unique(&requirement.capabilities) {
450            push_contract_error(
451                lints,
452                format!("requirement {} capabilities", requirement.module_id),
453                "Required capabilities must be non-empty, sorted, and unique.",
454                "Sort and deduplicate the requirement capabilities.",
455            );
456        }
457    }
458
459    let mut config_keys = HashSet::new();
460    for field in &manifest.config.fields {
461        if field.key.trim().is_empty() || !config_keys.insert(field.key.as_str()) {
462            push_contract_error(
463                lints,
464                format!("config {}", field.key),
465                "Config keys must be non-empty and unique.",
466                "Give each config field one stable key.",
467            );
468        }
469        let secret_named = field
470            .key
471            .split(['.', '-', '_'])
472            .any(|part| matches!(part, "secret" | "password" | "token" | "credential"));
473        if (field.sensitive || field.secret_reference || secret_named) && field.default.is_some() {
474            push_contract_error(
475                lints,
476                format!("config {} default", field.key),
477                "Secret-bearing config fields must not embed default values.",
478                "Resolve secret values outside the Module contract.",
479            );
480        }
481        if field.sensitive && !field.secret_reference {
482            push_contract_error(
483                lints,
484                format!("config {} secret_reference", field.key),
485                "Sensitive config must be declared as a secret reference.",
486                "Set secret_reference and keep the value outside the Manifest.",
487            );
488        }
489        if field.sensitive && field.read_capability.is_some() {
490            push_contract_error(
491                lints,
492                format!("config {} read_capability", field.key),
493                "Sensitive config fields are write-only and must not declare a read capability.",
494                "Remove read_capability and use a write capability for secret rotation.",
495            );
496        }
497        if !field.sensitive
498            && field
499                .read_capability
500                .as_deref()
501                .is_none_or(|value| value.trim().is_empty())
502        {
503            push_contract_error(
504                lints,
505                format!("config {} read_capability", field.key),
506                "Readable config fields require a non-empty read capability.",
507                "Declare the least-privilege capability that can read this field.",
508            );
509        }
510        if field.mutability != ModuleConfigMutability::Static
511            && field
512                .write_capability
513                .as_deref()
514                .is_none_or(|value| value.trim().is_empty())
515        {
516            push_contract_error(
517                lints,
518                format!("config {} write_capability", field.key),
519                "Mutable config fields require a non-empty write capability.",
520                "Declare the least-privilege capability that can update this field.",
521            );
522        }
523        if field
524            .read_capability
525            .as_deref()
526            .is_some_and(|value| value.trim().is_empty())
527            || field
528                .write_capability
529                .as_deref()
530                .is_some_and(|value| value.trim().is_empty())
531        {
532            push_contract_error(
533                lints,
534                format!("config {} capabilities", field.key),
535                "Config capability identifiers must be non-empty when present.",
536                "Remove empty capability values or replace them with stable identifiers.",
537            );
538        }
539    }
540
541    let mut migration_ids = HashSet::new();
542    let mut migration_orders = HashSet::new();
543    for migration in &manifest.migrations {
544        if migration.migration_id.trim().is_empty()
545            || migration.store.trim().is_empty()
546            || !migration_ids.insert(migration.migration_id.as_str())
547            || !migration_orders.insert(migration.order)
548        {
549            push_contract_error(
550                lints,
551                format!("migration {}", migration.migration_id),
552                "Migrations require non-empty identities and stores with unique order values.",
553                "Declare one deterministic order for each migration.",
554            );
555        }
556    }
557}
558
559fn sorted_unique(values: &[String]) -> bool {
560    values.iter().all(|value| !value.trim().is_empty())
561        && values.windows(2).all(|pair| pair[0] < pair[1])
562}
563
564fn push_contract_error(
565    lints: &mut Vec<ModuleManifestLint>,
566    subject: impl Into<String>,
567    message: impl Into<String>,
568    suggestion: impl Into<String>,
569) {
570    lints.push(ModuleManifestLint {
571        severity: ModuleManifestLintSeverity::Error,
572        subject: subject.into(),
573        message: message.into(),
574        suggestion: suggestion.into(),
575    });
576}
577
578pub fn lint_module_manifest_parts(
579    module_id: &str,
580    admin: Option<&AdminSurface>,
581    http_routes: &[ModuleHttpRoute],
582    runtime: Option<&RuntimeSurface>,
583    events: Option<&EventSurface>,
584    lifecycle: Option<&LifecycleSurface>,
585    console: &[ConsoleSurface],
586    console_slots: &[ConsoleSlot],
587    console_contributions: &[ConsoleContribution],
588    capabilities: &[String],
589    requirements: &[ModuleRequirement],
590) -> Vec<ModuleManifestLint> {
591    let mut lints = Vec::new();
592    let module_name = module_id.rsplit('/').next().unwrap_or(module_id);
593
594    if !valid_module_id(module_id) {
595        lints.push(ModuleManifestLint {
596            severity: ModuleManifestLintSeverity::Error,
597            subject: "module.module_id".to_owned(),
598            message: "ModuleId must use the fully qualified namespace/name form.".to_owned(),
599            suggestion: "Set ModuleManifest.module_id to a stable value such as lenso/auth."
600                .to_owned(),
601        });
602    }
603
604    for capability in capabilities {
605        if !valid_capability(capability) {
606            lints.push(ModuleManifestLint {
607                severity: ModuleManifestLintSeverity::Warning,
608                subject: format!("capability {capability}"),
609                message: "Capability name should use dot-separated lowercase identifiers."
610                    .to_owned(),
611                suggestion: "Use a stable capability name such as module.entity.read.".to_owned(),
612            });
613        }
614    }
615    for requirement in requirements {
616        if !valid_module_id(&requirement.module_id)
617            || semver::VersionReq::parse(&requirement.version_requirement).is_err()
618        {
619            lints.push(ModuleManifestLint {
620                severity: ModuleManifestLintSeverity::Error,
621                subject: format!("requirement {}", requirement.module_id),
622                message: "Module requirement identity and version range must be valid.".to_owned(),
623                suggestion: "Use a fully qualified ModuleId and normalized SemVer requirement."
624                    .to_owned(),
625            });
626        } else if requirement.module_id == module_id {
627            lints.push(ModuleManifestLint {
628                severity: ModuleManifestLintSeverity::Error,
629                subject: format!("requirement {}", requirement.module_id),
630                message: "Module must not depend on itself.".to_owned(),
631                suggestion: "Remove the self requirement from ModuleManifest.requires.".to_owned(),
632            });
633        }
634    }
635
636    for route_lint in lint_module_http_routes(http_routes) {
637        lints.push(ModuleManifestLint {
638            severity: match route_lint.severity {
639                crate::http::ModuleRouteLintSeverity::Ok => ModuleManifestLintSeverity::Ok,
640                crate::http::ModuleRouteLintSeverity::Warning => {
641                    ModuleManifestLintSeverity::Warning
642                }
643                crate::http::ModuleRouteLintSeverity::Error => ModuleManifestLintSeverity::Error,
644            },
645            subject: route_lint.subject,
646            message: route_lint.message,
647            suggestion: route_lint.suggestion,
648        });
649    }
650    lint_capability_references(
651        admin,
652        http_routes,
653        lifecycle,
654        console,
655        console_contributions,
656        capabilities,
657        &mut lints,
658    );
659
660    if let Some(admin) = admin {
661        lint_admin_surface(admin, &mut lints);
662    }
663    let mut runtime_lints = Vec::new();
664    if let Some(runtime) = runtime {
665        lint_runtime_surface(module_name, runtime, &mut runtime_lints);
666    }
667    if let Some(events) = events {
668        lint_event_surface(events, &mut lints);
669    }
670    if let Some(lifecycle) = lifecycle {
671        lint_lifecycle_surface(lifecycle, runtime, capabilities, &mut lints);
672    }
673    lint_console_surfaces(console, &mut lints);
674    lint_console_slots(console_slots, &mut lints);
675    lint_console_contributions(console_contributions, &mut lints);
676    lints.extend(runtime_lints);
677
678    if lints.is_empty() {
679        lints.push(ModuleManifestLint {
680            severity: ModuleManifestLintSeverity::Ok,
681            subject: "manifest".to_owned(),
682            message: "Module manifest metadata is complete.".to_owned(),
683            suggestion: "No action needed.".to_owned(),
684        });
685    }
686
687    lints
688}
689
690pub fn module_capability_references(
691    admin: Option<&AdminSurface>,
692    http_routes: &[ModuleHttpRoute],
693    lifecycle: Option<&LifecycleSurface>,
694    console: &[ConsoleSurface],
695    console_contributions: &[ConsoleContribution],
696) -> Vec<ModuleCapabilityReference> {
697    let mut references = Vec::new();
698
699    for route in http_routes {
700        if let Some(capability) = route.capability.as_deref()
701            && present(capability)
702        {
703            references.push(ModuleCapabilityReference {
704                capability: capability.to_owned(),
705                subject: format!("http_route.{}", route_identity(route)),
706            });
707        }
708    }
709
710    if let Some(admin) = admin {
711        collect_admin_capability_references(admin, &mut references);
712    }
713
714    if let Some(lifecycle) = lifecycle {
715        for check in &lifecycle.startup_checks {
716            if let LifecycleStartupCheckKind::CapabilityDeclared { capability } = &check.check
717                && present(capability)
718            {
719                references.push(ModuleCapabilityReference {
720                    capability: capability.to_owned(),
721                    subject: format!("lifecycle.startup_check.capability.{capability}"),
722                });
723            }
724        }
725    }
726
727    for surface in console {
728        let subject = if present(&surface.name) {
729            format!("console.surface.{}", surface.name)
730        } else {
731            "console.surface".to_owned()
732        };
733        for capability in &surface.required_capabilities {
734            if present(capability) {
735                references.push(ModuleCapabilityReference {
736                    capability: capability.clone(),
737                    subject: subject.clone(),
738                });
739            }
740        }
741    }
742
743    for contribution in console_contributions {
744        let subject = if present(&contribution.target) {
745            format!("console.contribution.{}", contribution.target)
746        } else {
747            "console.contribution".to_owned()
748        };
749        for capability in &contribution.required_capabilities {
750            if present(capability) {
751                references.push(ModuleCapabilityReference {
752                    capability: capability.clone(),
753                    subject: subject.clone(),
754                });
755            }
756        }
757    }
758
759    references
760}
761
762fn lint_capability_references(
763    admin: Option<&AdminSurface>,
764    http_routes: &[ModuleHttpRoute],
765    lifecycle: Option<&LifecycleSurface>,
766    console: &[ConsoleSurface],
767    console_contributions: &[ConsoleContribution],
768    capabilities: &[String],
769    lints: &mut Vec<ModuleManifestLint>,
770) {
771    let declared = capabilities
772        .iter()
773        .map(String::as_str)
774        .collect::<HashSet<_>>();
775
776    for reference in module_capability_references(
777        admin,
778        http_routes,
779        lifecycle,
780        console,
781        console_contributions,
782    ) {
783        // Lifecycle startup checks already produce a lifecycle-specific lint with
784        // the check context and required/optional semantics.
785        if reference.subject.starts_with("lifecycle.") {
786            continue;
787        }
788        if declared.contains(reference.capability.as_str()) {
789            continue;
790        }
791        lints.push(ModuleManifestLint {
792            severity: ModuleManifestLintSeverity::Warning,
793            subject: format!("capability.reference.{}", reference.subject),
794            message: "Capability reference is not declared by the module.".to_owned(),
795            suggestion: format!(
796                "Add `{}` to ModuleManifest.capabilities or update the reference.",
797                reference.capability
798            ),
799        });
800    }
801}
802
803fn collect_admin_capability_references(
804    admin: &AdminSurface,
805    references: &mut Vec<ModuleCapabilityReference>,
806) {
807    match admin {
808        AdminSurface::Schema(schema) => {
809            collect_schema_capability_references("admin.schema", schema, references);
810        }
811        AdminSurface::DeclarativeCustom(surface) => {
812            collect_declarative_query_capability_references(surface, references);
813            for action in &surface.actions {
814                if present(&action.capability) {
815                    let action_subject = if present(&action.name) {
816                        format!("admin.declarative.action.{}", action.name)
817                    } else {
818                        "admin.declarative.action".to_owned()
819                    };
820                    references.push(ModuleCapabilityReference {
821                        capability: action.capability.clone(),
822                        subject: action_subject,
823                    });
824                }
825            }
826            if let Some(schema) = &surface.fallback_schema {
827                collect_schema_capability_references(
828                    "admin.declarative.fallback_schema",
829                    schema,
830                    references,
831                );
832            }
833        }
834        AdminSurface::EmbeddedCustom(surface) => {
835            if let Some(schema) = &surface.fallback_schema {
836                collect_schema_capability_references(
837                    "admin.embedded.fallback_schema",
838                    schema,
839                    references,
840                );
841            }
842        }
843    }
844}
845
846fn collect_schema_capability_references(
847    prefix: &str,
848    schema: &AdminSchema,
849    references: &mut Vec<ModuleCapabilityReference>,
850) {
851    for entity in &schema.entities {
852        if present(&entity.read_capability) {
853            references.push(ModuleCapabilityReference {
854                capability: entity.read_capability.clone(),
855                subject: format!("{prefix}.{}", entity.name),
856            });
857        }
858    }
859}
860
861fn lint_runtime_surface(
862    module_name: &str,
863    runtime: &RuntimeSurface,
864    lints: &mut Vec<ModuleManifestLint>,
865) {
866    if runtime.functions.is_empty() && runtime.schedules.is_empty() && runtime.workflows.is_empty()
867    {
868        lints.push(ModuleManifestLint {
869            severity: ModuleManifestLintSeverity::Warning,
870            subject: "runtime".to_owned(),
871            message: "Runtime surface declares no functions, schedules, or workflows.".to_owned(),
872            suggestion: "Add at least one runtime declaration or omit the runtime surface."
873                .to_owned(),
874        });
875        return;
876    }
877
878    let mut names = HashSet::new();
879    for function in &runtime.functions {
880        lint_runtime_function(function, &mut names, lints);
881    }
882    let function_names = runtime_function_names(Some(runtime));
883    let mut schedule_names = HashSet::new();
884    for schedule in &runtime.schedules {
885        lint_scheduled_function(schedule, &function_names, &mut schedule_names, lints);
886    }
887    let mut workflow_identities = HashSet::new();
888    for workflow in &runtime.workflows {
889        lint_workflow_definition(module_name, workflow, &mut workflow_identities, lints);
890    }
891}
892
893fn lint_workflow_definition(
894    module_name: &str,
895    workflow: &WorkflowDefinition,
896    identities: &mut HashSet<(String, String, String)>,
897    lints: &mut Vec<ModuleManifestLint>,
898) {
899    let subject = if present(&workflow.name) && present(&workflow.version) {
900        format!("runtime.workflow.{}.{}", workflow.name, workflow.version)
901    } else {
902        "runtime.workflow".to_owned()
903    };
904
905    if workflow.protocol != WORKFLOW_DEFINITION_PROTOCOL {
906        lints.push(ModuleManifestLint {
907            severity: ModuleManifestLintSeverity::Error,
908            subject: format!("{subject}.protocol"),
909            message: "Durable Workflow definition uses an unsupported protocol.".to_owned(),
910            suggestion: format!("Set protocol to {WORKFLOW_DEFINITION_PROTOCOL}."),
911        });
912    }
913    if !present(&workflow.owner) || workflow.owner != module_name {
914        lints.push(ModuleManifestLint {
915            severity: ModuleManifestLintSeverity::Error,
916            subject: format!("{subject}.owner"),
917            message: "Durable Workflow owner must match the declaring Module.".to_owned(),
918            suggestion: format!("Set owner to the Module name `{module_name}`."),
919        });
920    }
921    if !present(&workflow.name) {
922        lints.push(ModuleManifestLint {
923            severity: ModuleManifestLintSeverity::Error,
924            subject: subject.clone(),
925            message: "Durable Workflow definition is missing a stable name.".to_owned(),
926            suggestion: "Set a path-safe workflow name such as support_sla.".to_owned(),
927        });
928    } else if !valid_runtime_function_name(&workflow.name) {
929        lints.push(ModuleManifestLint {
930            severity: ModuleManifestLintSeverity::Error,
931            subject: subject.clone(),
932            message: "Durable Workflow name must be path-safe.".to_owned(),
933            suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
934        });
935    }
936    if !present(&workflow.version) {
937        lints.push(ModuleManifestLint {
938            severity: ModuleManifestLintSeverity::Error,
939            subject: format!("{subject}.version"),
940            message: "Durable Workflow definition is missing a version.".to_owned(),
941            suggestion: "Set a stable definition version such as v1.".to_owned(),
942        });
943    } else if !valid_runtime_function_name(&workflow.version) {
944        lints.push(ModuleManifestLint {
945            severity: ModuleManifestLintSeverity::Error,
946            subject: format!("{subject}.version"),
947            message: "Durable Workflow version must be path-safe.".to_owned(),
948            suggestion: "Use a stable path-safe version such as v1 or 1.0.0.".to_owned(),
949        });
950    }
951    if present(&workflow.owner)
952        && present(&workflow.name)
953        && present(&workflow.version)
954        && !identities.insert((
955            workflow.owner.clone(),
956            workflow.name.clone(),
957            workflow.version.clone(),
958        ))
959    {
960        lints.push(ModuleManifestLint {
961            severity: ModuleManifestLintSeverity::Error,
962            subject: subject.clone(),
963            message: "Duplicate Durable Workflow definition identity.".to_owned(),
964            suggestion: "Keep one declaration per owner, name, and version.".to_owned(),
965        });
966    }
967
968    lint_workflow_data_contract(
969        &format!("{subject}.input_contract"),
970        &workflow.input_contract,
971        lints,
972    );
973    lint_workflow_data_contract(
974        &format!("{subject}.result_contract"),
975        &workflow.result_contract,
976        lints,
977    );
978
979    if workflow.steps.is_empty() {
980        lints.push(ModuleManifestLint {
981            severity: ModuleManifestLintSeverity::Error,
982            subject: format!("{subject}.steps"),
983            message: "Durable Workflow definition must declare an ordered first step.".to_owned(),
984            suggestion: "Add at least one stable step declaration.".to_owned(),
985        });
986    }
987    let mut step_names = HashSet::new();
988    let mut compensation_names = HashSet::new();
989    let mut compensation_orders = HashSet::new();
990    for step in &workflow.steps {
991        let step_subject = if present(&step.name) {
992            format!("{subject}.step.{}", step.name)
993        } else {
994            format!("{subject}.step")
995        };
996        if !present(&step.name) || !valid_runtime_function_name(&step.name) {
997            lints.push(ModuleManifestLint {
998                severity: ModuleManifestLintSeverity::Error,
999                subject: step_subject.clone(),
1000                message: "Durable Workflow step name must be a non-empty path-safe identifier."
1001                    .to_owned(),
1002                suggestion: "Use a stable step name such as acknowledge_ticket.".to_owned(),
1003            });
1004        } else if !step_names.insert(step.name.clone()) {
1005            lints.push(ModuleManifestLint {
1006                severity: ModuleManifestLintSeverity::Error,
1007                subject: step_subject.clone(),
1008                message: "Durable Workflow step name is declared more than once.".to_owned(),
1009                suggestion: "Keep one ordered declaration per stable step name.".to_owned(),
1010            });
1011        }
1012        lint_workflow_step_recovery(
1013            step,
1014            &step_subject,
1015            &mut compensation_names,
1016            &mut compensation_orders,
1017            lints,
1018        );
1019    }
1020}
1021
1022fn lint_workflow_step_recovery(
1023    step: &WorkflowStepDeclaration,
1024    subject: &str,
1025    compensation_names: &mut HashSet<String>,
1026    compensation_orders: &mut HashSet<u32>,
1027    lints: &mut Vec<ModuleManifestLint>,
1028) {
1029    if let Some(retry_policy) = &step.retry_policy
1030        && (retry_policy.max_attempts == 0
1031            || i32::try_from(retry_policy.max_attempts).is_err()
1032            || retry_policy.delays_ms.len()
1033                != usize::try_from(retry_policy.max_attempts.saturating_sub(1))
1034                    .unwrap_or(usize::MAX)
1035            || retry_policy
1036                .delays_ms
1037                .iter()
1038                .any(|delay| i64::try_from(*delay).is_err()))
1039    {
1040        lints.push(ModuleManifestLint {
1041            severity: ModuleManifestLintSeverity::Error,
1042            subject: format!("{subject}.retry_policy"),
1043            message: "Durable Workflow retry schedule must use supported attempt and delay values."
1044                .to_owned(),
1045            suggestion: "Set maxAttempts to 1..=2147483647 and provide maxAttempts - 1 delaysMs entries within the signed 64-bit range."
1046                .to_owned(),
1047        });
1048    }
1049    if step
1050        .timeout_ms
1051        .is_some_and(|timeout| timeout == 0 || i64::try_from(timeout).is_err())
1052    {
1053        lints.push(ModuleManifestLint {
1054            severity: ModuleManifestLintSeverity::Error,
1055            subject: format!("{subject}.timeout_ms"),
1056            message: "Durable Workflow timeout must use a supported positive value.".to_owned(),
1057            suggestion: "Set timeoutMs within the positive signed 64-bit range or omit it."
1058                .to_owned(),
1059        });
1060    }
1061    if let Some(compensation) = &step.compensation {
1062        let compensation_subject = format!("{subject}.compensation");
1063        if !present(&compensation.name) || !valid_runtime_function_name(&compensation.name) {
1064            lints.push(ModuleManifestLint {
1065                severity: ModuleManifestLintSeverity::Error,
1066                subject: format!("{compensation_subject}.name"),
1067                message:
1068                    "Durable Workflow compensation name must be a non-empty path-safe identifier."
1069                        .to_owned(),
1070                suggestion: "Use a stable compensation name such as release_sla_reservation."
1071                    .to_owned(),
1072            });
1073        } else if !compensation_names.insert(compensation.name.clone()) {
1074            lints.push(ModuleManifestLint {
1075                severity: ModuleManifestLintSeverity::Error,
1076                subject: format!("{compensation_subject}.name"),
1077                message: "Durable Workflow compensation name is declared more than once."
1078                    .to_owned(),
1079                suggestion: "Keep one stable compensation name per Workflow Definition.".to_owned(),
1080            });
1081        }
1082        if compensation.order == 0 || i32::try_from(compensation.order).is_err() {
1083            lints.push(ModuleManifestLint {
1084                severity: ModuleManifestLintSeverity::Error,
1085                subject: format!("{compensation_subject}.order"),
1086                message: "Durable Workflow compensation order must use a supported positive value."
1087                    .to_owned(),
1088                suggestion: "Set order to a unique value within 1..=2147483647.".to_owned(),
1089            });
1090        } else if !compensation_orders.insert(compensation.order) {
1091            lints.push(ModuleManifestLint {
1092                severity: ModuleManifestLintSeverity::Error,
1093                subject: format!("{compensation_subject}.order"),
1094                message: "Durable Workflow compensation order is declared more than once."
1095                    .to_owned(),
1096                suggestion: "Assign one deterministic order to each compensation.".to_owned(),
1097            });
1098        }
1099        lint_workflow_data_contract(
1100            &format!("{compensation_subject}.contract"),
1101            &compensation.contract,
1102            lints,
1103        );
1104        lint_workflow_data_contract(
1105            &format!("{compensation_subject}.completion_contract"),
1106            &compensation.completion_contract,
1107            lints,
1108        );
1109    }
1110}
1111
1112fn lint_workflow_data_contract(
1113    subject: &str,
1114    contract: &WorkflowDataContract,
1115    lints: &mut Vec<ModuleManifestLint>,
1116) {
1117    if !present(&contract.contract_id) || !present(&contract.version) {
1118        lints.push(ModuleManifestLint {
1119            severity: ModuleManifestLintSeverity::Error,
1120            subject: subject.to_owned(),
1121            message: "Durable Workflow data contract requires a stable identity and version."
1122                .to_owned(),
1123            suggestion: "Set contractId and version to stable contract identifiers.".to_owned(),
1124        });
1125    }
1126}
1127
1128fn lint_runtime_function(
1129    function: &RuntimeFunctionDeclaration,
1130    names: &mut HashSet<String>,
1131    lints: &mut Vec<ModuleManifestLint>,
1132) {
1133    let subject = if present(&function.name) {
1134        format!("runtime.function.{}", function.name)
1135    } else {
1136        "runtime.function".to_owned()
1137    };
1138
1139    if !present(&function.name) {
1140        lints.push(ModuleManifestLint {
1141            severity: ModuleManifestLintSeverity::Error,
1142            subject: subject.clone(),
1143            message: "Runtime function declaration is missing a name.".to_owned(),
1144            suggestion: "Set a stable versioned function name such as module.action.v1.".to_owned(),
1145        });
1146    } else if !valid_runtime_function_name(&function.name) {
1147        lints.push(ModuleManifestLint {
1148            severity: ModuleManifestLintSeverity::Warning,
1149            subject: subject.clone(),
1150            message: "Runtime function name should be a stable path-safe identifier.".to_owned(),
1151            suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1152        });
1153    } else if !names.insert(function.name.clone()) {
1154        lints.push(ModuleManifestLint {
1155            severity: ModuleManifestLintSeverity::Error,
1156            subject: subject.clone(),
1157            message: "Duplicate runtime function declaration.".to_owned(),
1158            suggestion: "Keep one declaration per runtime function name.".to_owned(),
1159        });
1160    }
1161
1162    if !present(&function.queue) {
1163        lints.push(ModuleManifestLint {
1164            severity: ModuleManifestLintSeverity::Warning,
1165            subject: subject.clone(),
1166            message: "Runtime function declaration is missing a queue.".to_owned(),
1167            suggestion: "Set the host queue used to claim this function.".to_owned(),
1168        });
1169    }
1170
1171    if let Some(input_schema) = &function.input_schema
1172        && input_schema != &function.name
1173    {
1174        lints.push(ModuleManifestLint {
1175            severity: ModuleManifestLintSeverity::Warning,
1176            subject: format!("{subject}.input_schema"),
1177            message: "Runtime function input schema does not match the function name.".to_owned(),
1178            suggestion: "Use the versioned function name as the input_schema contract identifier."
1179                .to_owned(),
1180        });
1181    }
1182
1183    if let Some(retry_policy) = &function.retry_policy
1184        && retry_policy.max_attempts == 0
1185    {
1186        lints.push(ModuleManifestLint {
1187            severity: ModuleManifestLintSeverity::Warning,
1188            subject: format!("{subject}.retry_policy"),
1189            message: "Runtime function retry policy declares zero attempts.".to_owned(),
1190            suggestion: "Set max_attempts to at least 1 or omit the retry policy.".to_owned(),
1191        });
1192    }
1193}
1194
1195fn lint_scheduled_function(
1196    schedule: &ScheduledFunctionDeclaration,
1197    runtime_functions: &HashSet<String>,
1198    names: &mut HashSet<String>,
1199    lints: &mut Vec<ModuleManifestLint>,
1200) {
1201    let subject = if present(&schedule.name) {
1202        format!("runtime.schedule.{}", schedule.name)
1203    } else {
1204        "runtime.schedule".to_owned()
1205    };
1206
1207    if !present(&schedule.name) {
1208        lints.push(ModuleManifestLint {
1209            severity: ModuleManifestLintSeverity::Error,
1210            subject: subject.clone(),
1211            message: "Scheduled runtime function is missing a name.".to_owned(),
1212            suggestion: "Set a stable schedule name such as sync_contacts_hourly.".to_owned(),
1213        });
1214    } else if !valid_runtime_function_name(&schedule.name) {
1215        lints.push(ModuleManifestLint {
1216            severity: ModuleManifestLintSeverity::Warning,
1217            subject: subject.clone(),
1218            message: "Scheduled runtime function name should be path-safe.".to_owned(),
1219            suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1220        });
1221    } else if !names.insert(schedule.name.clone()) {
1222        lints.push(ModuleManifestLint {
1223            severity: ModuleManifestLintSeverity::Error,
1224            subject: subject.clone(),
1225            message: "Duplicate scheduled runtime function declaration.".to_owned(),
1226            suggestion: "Keep one schedule declaration per schedule name.".to_owned(),
1227        });
1228    }
1229
1230    if !present(&schedule.cron) {
1231        lints.push(ModuleManifestLint {
1232            severity: ModuleManifestLintSeverity::Error,
1233            subject: format!("{subject}.cron"),
1234            message: "Scheduled runtime function is missing a cron expression.".to_owned(),
1235            suggestion: "Set cron to a standard 5-field UTC cron expression.".to_owned(),
1236        });
1237    } else if validate_cron_expression(&schedule.cron).is_err() {
1238        lints.push(ModuleManifestLint {
1239            severity: ModuleManifestLintSeverity::Error,
1240            subject: format!("{subject}.cron"),
1241            message: "Scheduled runtime function cron expression is invalid.".to_owned(),
1242            suggestion: "Use a standard 5-field expression such as */15 * * * *.".to_owned(),
1243        });
1244    }
1245
1246    if !present(&schedule.function_name) {
1247        lints.push(ModuleManifestLint {
1248            severity: ModuleManifestLintSeverity::Error,
1249            subject,
1250            message: "Scheduled runtime function is missing a function name.".to_owned(),
1251            suggestion: "Set function_name to a declared runtime function.".to_owned(),
1252        });
1253    } else if !runtime_functions.contains(&schedule.function_name) {
1254        lints.push(ModuleManifestLint {
1255            severity: ModuleManifestLintSeverity::Error,
1256            subject,
1257            message: "Scheduled runtime function references an unknown runtime function."
1258                .to_owned(),
1259            suggestion:
1260                "Declare the function in ModuleManifest.runtime.functions or remove the schedule."
1261                    .to_owned(),
1262        });
1263    }
1264}
1265
1266fn lint_event_surface(events: &EventSurface, lints: &mut Vec<ModuleManifestLint>) {
1267    if events.handlers.is_empty() {
1268        lints.push(ModuleManifestLint {
1269            severity: ModuleManifestLintSeverity::Warning,
1270            subject: "events.handlers".to_owned(),
1271            message: "Event surface declares no handlers.".to_owned(),
1272            suggestion: "Add at least one event handler declaration or omit the events surface."
1273                .to_owned(),
1274        });
1275        return;
1276    }
1277
1278    let mut names = HashSet::new();
1279    for handler in &events.handlers {
1280        lint_event_handler(handler, &mut names, lints);
1281    }
1282}
1283
1284fn lint_event_handler(
1285    handler: &EventHandlerDeclaration,
1286    names: &mut HashSet<String>,
1287    lints: &mut Vec<ModuleManifestLint>,
1288) {
1289    let subject = if present(&handler.name) {
1290        format!("events.handler.{}", handler.name)
1291    } else {
1292        "events.handler".to_owned()
1293    };
1294
1295    if !present(&handler.name) {
1296        lints.push(ModuleManifestLint {
1297            severity: ModuleManifestLintSeverity::Error,
1298            subject: subject.clone(),
1299            message: "Event handler declaration is missing a name.".to_owned(),
1300            suggestion: "Set a stable handler name such as sync_contact_on_user_registered."
1301                .to_owned(),
1302        });
1303    } else if !valid_runtime_function_name(&handler.name) {
1304        lints.push(ModuleManifestLint {
1305            severity: ModuleManifestLintSeverity::Warning,
1306            subject: subject.clone(),
1307            message: "Event handler name should be a stable path-safe identifier.".to_owned(),
1308            suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1309        });
1310    } else if !names.insert(handler.name.clone()) {
1311        lints.push(ModuleManifestLint {
1312            severity: ModuleManifestLintSeverity::Error,
1313            subject: subject.clone(),
1314            message: "Duplicate event handler declaration.".to_owned(),
1315            suggestion: "Keep one declaration per event handler name.".to_owned(),
1316        });
1317    }
1318
1319    if !present(&handler.event_name) {
1320        lints.push(ModuleManifestLint {
1321            severity: ModuleManifestLintSeverity::Error,
1322            subject: format!("{subject}.event_name"),
1323            message: "Event handler declaration is missing an event_name.".to_owned(),
1324            suggestion: "Set the stable outbox event name this handler consumes.".to_owned(),
1325        });
1326    } else if !valid_runtime_function_name(&handler.event_name) {
1327        lints.push(ModuleManifestLint {
1328            severity: ModuleManifestLintSeverity::Warning,
1329            subject: format!("{subject}.event_name"),
1330            message: "Event name should be a stable path-safe identifier.".to_owned(),
1331            suggestion: "Use the versioned event name such as identity.user_registered.v1."
1332                .to_owned(),
1333        });
1334    }
1335}
1336
1337fn lint_lifecycle_surface(
1338    lifecycle: &LifecycleSurface,
1339    runtime: Option<&RuntimeSurface>,
1340    capabilities: &[String],
1341    lints: &mut Vec<ModuleManifestLint>,
1342) {
1343    if lifecycle.startup_checks.is_empty() && lifecycle.activation_jobs.is_empty() {
1344        lints.push(ModuleManifestLint {
1345            severity: ModuleManifestLintSeverity::Warning,
1346            subject: "lifecycle".to_owned(),
1347            message: "Lifecycle surface declares no startup checks or activation jobs.".to_owned(),
1348            suggestion: "Add lifecycle entries or omit the lifecycle surface.".to_owned(),
1349        });
1350        return;
1351    }
1352
1353    let runtime_functions = runtime_function_names(runtime);
1354    let capability_names = capabilities.iter().cloned().collect::<HashSet<_>>();
1355
1356    for check in &lifecycle.startup_checks {
1357        lint_lifecycle_startup_check(check, &runtime_functions, &capability_names, lints);
1358    }
1359
1360    for job in &lifecycle.activation_jobs {
1361        lint_lifecycle_activation_job(job, &runtime_functions, lints);
1362    }
1363}
1364
1365fn lint_lifecycle_startup_check(
1366    check: &LifecycleStartupCheckDeclaration,
1367    runtime_functions: &HashSet<String>,
1368    capabilities: &HashSet<String>,
1369    lints: &mut Vec<ModuleManifestLint>,
1370) {
1371    if !present(&check.name) {
1372        lints.push(ModuleManifestLint {
1373            severity: ModuleManifestLintSeverity::Warning,
1374            subject: "lifecycle.startup_check".to_owned(),
1375            message: "Lifecycle startup check is missing a name.".to_owned(),
1376            suggestion: "Set a short operator-facing check name.".to_owned(),
1377        });
1378    }
1379
1380    match &check.check {
1381        LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
1382            if !runtime_functions.contains(function_name) {
1383                lints.push(ModuleManifestLint {
1384                    severity: ModuleManifestLintSeverity::Error,
1385                    subject: format!(
1386                        "lifecycle.startup_check.function_registered.{function_name}"
1387                    ),
1388                    message: "Lifecycle startup check references an unknown runtime function."
1389                        .to_owned(),
1390                    suggestion:
1391                        "Declare the function in ModuleManifest.runtime.functions or remove the check."
1392                            .to_owned(),
1393                });
1394            }
1395        }
1396        LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
1397            if !capabilities.contains(capability) {
1398                lints.push(ModuleManifestLint {
1399                    severity: ModuleManifestLintSeverity::Warning,
1400                    subject: format!("lifecycle.startup_check.capability.{capability}"),
1401                    message: "Lifecycle startup check references an undeclared capability."
1402                        .to_owned(),
1403                    suggestion:
1404                        "Add the capability to ModuleManifest.capabilities or update the check."
1405                            .to_owned(),
1406                });
1407            }
1408        }
1409    }
1410}
1411
1412fn lint_lifecycle_activation_job(
1413    job: &LifecycleActivationJobDeclaration,
1414    runtime_functions: &HashSet<String>,
1415    lints: &mut Vec<ModuleManifestLint>,
1416) {
1417    let subject = if present(&job.name) {
1418        format!("lifecycle.activation_job.{}", job.name)
1419    } else {
1420        "lifecycle.activation_job".to_owned()
1421    };
1422
1423    if !present(&job.name) {
1424        lints.push(ModuleManifestLint {
1425            severity: ModuleManifestLintSeverity::Warning,
1426            subject: subject.clone(),
1427            message: "Lifecycle activation job is missing a name.".to_owned(),
1428            suggestion: "Set a short operator-facing activation job name.".to_owned(),
1429        });
1430    }
1431
1432    if !present(&job.function_name) {
1433        lints.push(ModuleManifestLint {
1434            severity: ModuleManifestLintSeverity::Error,
1435            subject,
1436            message: "Lifecycle activation job is missing a function name.".to_owned(),
1437            suggestion: "Set function_name to a declared runtime function.".to_owned(),
1438        });
1439    } else if !runtime_functions.contains(&job.function_name) {
1440        lints.push(ModuleManifestLint {
1441            severity: ModuleManifestLintSeverity::Error,
1442            subject,
1443            message: "Lifecycle activation job references an unknown runtime function.".to_owned(),
1444            suggestion:
1445                "Declare the function in ModuleManifest.runtime.functions or remove the activation job."
1446                    .to_owned(),
1447        });
1448    }
1449}
1450
1451fn runtime_function_names(runtime: Option<&RuntimeSurface>) -> HashSet<String> {
1452    runtime
1453        .into_iter()
1454        .flat_map(|surface| surface.functions.iter())
1455        .map(|function| function.name.clone())
1456        .collect()
1457}
1458
1459fn lint_console_surfaces(console: &[ConsoleSurface], lints: &mut Vec<ModuleManifestLint>) {
1460    let mut names = HashSet::new();
1461    let mut routes = HashSet::new();
1462
1463    for surface in console {
1464        let subject = if present(&surface.name) {
1465            format!("console.surface.{}", surface.name)
1466        } else {
1467            "console.surface".to_owned()
1468        };
1469
1470        if !present(&surface.name) {
1471            lints.push(ModuleManifestLint {
1472                severity: ModuleManifestLintSeverity::Error,
1473                subject: subject.clone(),
1474                message: "Console surface is missing a name.".to_owned(),
1475                suggestion: "Set a stable surface name such as stories.".to_owned(),
1476            });
1477        } else if !valid_console_surface_name(&surface.name) {
1478            lints.push(ModuleManifestLint {
1479                severity: ModuleManifestLintSeverity::Warning,
1480                subject: subject.clone(),
1481                message: "Console surface name should be a path-safe identifier.".to_owned(),
1482                suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1483            });
1484        } else if !names.insert(surface.name.clone()) {
1485            lints.push(ModuleManifestLint {
1486                severity: ModuleManifestLintSeverity::Error,
1487                subject: subject.clone(),
1488                message: "Duplicate console surface declaration.".to_owned(),
1489                suggestion: "Keep one console surface per surface name.".to_owned(),
1490            });
1491        }
1492
1493        if !present(&surface.label) {
1494            lints.push(ModuleManifestLint {
1495                severity: ModuleManifestLintSeverity::Warning,
1496                subject: format!("{subject}.label"),
1497                message: "Console surface is missing an operator-facing label.".to_owned(),
1498                suggestion: "Set a short navigation label such as Stories.".to_owned(),
1499            });
1500        }
1501
1502        if !surface.route.starts_with('/') || surface.route.contains('*') {
1503            lints.push(ModuleManifestLint {
1504                severity: ModuleManifestLintSeverity::Error,
1505                subject: format!("{subject}.route"),
1506                message: "Console surface route must be an absolute static route.".to_owned(),
1507                suggestion: "Use a Console route such as /runtime/stories.".to_owned(),
1508            });
1509        } else if !routes.insert(surface.route.clone()) {
1510            lints.push(ModuleManifestLint {
1511                severity: ModuleManifestLintSeverity::Error,
1512                subject: format!("{subject}.route"),
1513                message: "Duplicate console surface route declaration.".to_owned(),
1514                suggestion: "Keep one console surface per route.".to_owned(),
1515            });
1516        }
1517
1518        match &surface.presentation {
1519            crate::ConsoleSurfacePresentation::Declarative { schema } => {
1520                if !schema.is_object() {
1521                    lints.push(ModuleManifestLint {
1522                        severity: ModuleManifestLintSeverity::Error,
1523                        subject: format!("{subject}.presentation.schema"),
1524                        message: "Declarative Console surface schema must be an object.".to_owned(),
1525                        suggestion: "Provide a declarative surface schema object.".to_owned(),
1526                    });
1527                }
1528            }
1529            crate::ConsoleSurfacePresentation::Esm { entry } => {
1530                if !present(entry) {
1531                    lints.push(ModuleManifestLint {
1532                        severity: ModuleManifestLintSeverity::Error,
1533                        subject: format!("{subject}.presentation.entry"),
1534                        message: "ESM Console surface entry is missing.".to_owned(),
1535                        suggestion:
1536                            "Reference an entry from the Module Release Console UI artifact."
1537                                .to_owned(),
1538                    });
1539                }
1540            }
1541            crate::ConsoleSurfacePresentation::Isolated { .. } => {
1542                lints.push(ModuleManifestLint {
1543                    severity: ModuleManifestLintSeverity::Error,
1544                    subject: format!("{subject}.presentation"),
1545                    message: "Retired Console Bridge surfaces cannot be published.".to_owned(),
1546                    suggestion: "Publish a console_ui_esm Module Release artifact instead."
1547                        .to_owned(),
1548                });
1549            }
1550        }
1551
1552        if let Some(navigation) = &surface.navigation {
1553            lint_console_navigation(&subject, navigation, lints);
1554        }
1555    }
1556}
1557
1558fn lint_console_slots(console_slots: &[ConsoleSlot], lints: &mut Vec<ModuleManifestLint>) {
1559    let mut slots = HashSet::new();
1560
1561    for slot in console_slots {
1562        let subject = if present(&slot.id) {
1563            format!("console.slot.{}", slot.id)
1564        } else {
1565            "console.slot".to_owned()
1566        };
1567
1568        if !present(&slot.id) {
1569            lints.push(ModuleManifestLint {
1570                severity: ModuleManifestLintSeverity::Error,
1571                subject: subject.clone(),
1572                message: "Console slot is missing an id.".to_owned(),
1573                suggestion: "Set a stable dotted slot id such as auth.users.detail.actions."
1574                    .to_owned(),
1575            });
1576        } else if !valid_console_slot_target(&slot.id) {
1577            lints.push(ModuleManifestLint {
1578                severity: ModuleManifestLintSeverity::Warning,
1579                subject: subject.clone(),
1580                message: "Console slot id should be a path-safe dotted id.".to_owned(),
1581                suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1582            });
1583        } else if !slots.insert((slot.id.clone(), slot.version)) {
1584            lints.push(ModuleManifestLint {
1585                severity: ModuleManifestLintSeverity::Error,
1586                subject: subject.clone(),
1587                message: "Duplicate console slot declaration.".to_owned(),
1588                suggestion: "Keep one declaration per console slot id and version.".to_owned(),
1589            });
1590        }
1591
1592        if slot.version == 0 {
1593            lints.push(ModuleManifestLint {
1594                severity: ModuleManifestLintSeverity::Error,
1595                subject: format!("{subject}.version"),
1596                message: "Console slot version must be greater than zero.".to_owned(),
1597                suggestion: "Start slot contracts at version 1.".to_owned(),
1598            });
1599        }
1600
1601        if !present(&slot.label) {
1602            lints.push(ModuleManifestLint {
1603                severity: ModuleManifestLintSeverity::Warning,
1604                subject: format!("{subject}.label"),
1605                message: "Console slot is missing an operator-facing label.".to_owned(),
1606                suggestion: "Set a short label such as User detail actions.".to_owned(),
1607            });
1608        }
1609
1610        if slot.accepts.is_empty() {
1611            lints.push(ModuleManifestLint {
1612                severity: ModuleManifestLintSeverity::Warning,
1613                subject: format!("{subject}.accepts"),
1614                message: "Console slot declares no accepted contribution kinds.".to_owned(),
1615                suggestion: "Declare at least one accepted kind such as admin_action.".to_owned(),
1616            });
1617        }
1618
1619        let mut context_names = HashSet::new();
1620        for context in &slot.context {
1621            let context_subject = if present(&context.name) {
1622                format!("{subject}.context.{}", context.name)
1623            } else {
1624                format!("{subject}.context")
1625            };
1626            if !present(&context.name) {
1627                lints.push(ModuleManifestLint {
1628                    severity: ModuleManifestLintSeverity::Error,
1629                    subject: context_subject.clone(),
1630                    message: "Console slot context is missing a name.".to_owned(),
1631                    suggestion: "Set a stable context name such as selected_user.".to_owned(),
1632                });
1633            } else if !valid_slot_context_segment(&context.name) {
1634                lints.push(ModuleManifestLint {
1635                    severity: ModuleManifestLintSeverity::Warning,
1636                    subject: context_subject.clone(),
1637                    message: "Console slot context name should be path-safe.".to_owned(),
1638                    suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1639                });
1640            } else if !context_names.insert(context.name.clone()) {
1641                lints.push(ModuleManifestLint {
1642                    severity: ModuleManifestLintSeverity::Error,
1643                    subject: context_subject.clone(),
1644                    message: "Duplicate console slot context declaration.".to_owned(),
1645                    suggestion: "Keep one declaration per slot context name.".to_owned(),
1646                });
1647            }
1648
1649            let mut field_names = HashSet::new();
1650            for field in &context.fields {
1651                let field_subject = if present(&field.name) {
1652                    format!("{context_subject}.field.{}", field.name)
1653                } else {
1654                    format!("{context_subject}.field")
1655                };
1656                if !present(&field.name) {
1657                    lints.push(ModuleManifestLint {
1658                        severity: ModuleManifestLintSeverity::Error,
1659                        subject: field_subject.clone(),
1660                        message: "Console slot context field is missing a name.".to_owned(),
1661                        suggestion: "Set a stable field name such as id.".to_owned(),
1662                    });
1663                } else if !valid_slot_context_segment(&field.name) {
1664                    lints.push(ModuleManifestLint {
1665                        severity: ModuleManifestLintSeverity::Warning,
1666                        subject: field_subject.clone(),
1667                        message: "Console slot context field should be path-safe.".to_owned(),
1668                        suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1669                    });
1670                } else if !field_names.insert(field.name.clone()) {
1671                    lints.push(ModuleManifestLint {
1672                        severity: ModuleManifestLintSeverity::Error,
1673                        subject: field_subject,
1674                        message: "Duplicate console slot context field declaration.".to_owned(),
1675                        suggestion: "Keep one declaration per context field name.".to_owned(),
1676                    });
1677                }
1678            }
1679        }
1680    }
1681}
1682
1683fn lint_console_contributions(
1684    contributions: &[ConsoleContribution],
1685    lints: &mut Vec<ModuleManifestLint>,
1686) {
1687    for contribution in contributions {
1688        let subject = if present(&contribution.target) {
1689            format!("console.contribution.{}", contribution.target)
1690        } else {
1691            "console.contribution".to_owned()
1692        };
1693
1694        if !present(&contribution.target) {
1695            lints.push(ModuleManifestLint {
1696                severity: ModuleManifestLintSeverity::Error,
1697                subject: subject.clone(),
1698                message: "Console contribution is missing a target slot.".to_owned(),
1699                suggestion: "Set a stable slot target such as auth.users.detail.actions."
1700                    .to_owned(),
1701            });
1702        } else if !valid_console_slot_target(&contribution.target) {
1703            lints.push(ModuleManifestLint {
1704                severity: ModuleManifestLintSeverity::Warning,
1705                subject: subject.clone(),
1706                message: "Console contribution target should be a path-safe dotted slot id."
1707                    .to_owned(),
1708                suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1709            });
1710        }
1711
1712        if contribution.target_version == 0 {
1713            lints.push(ModuleManifestLint {
1714                severity: ModuleManifestLintSeverity::Error,
1715                subject: format!("{subject}.target_version"),
1716                message: "Console contribution target version must be greater than zero."
1717                    .to_owned(),
1718                suggestion: "Set target_version to the slot contract version, usually 1."
1719                    .to_owned(),
1720            });
1721        }
1722
1723        if !present(&contribution.label) {
1724            lints.push(ModuleManifestLint {
1725                severity: ModuleManifestLintSeverity::Warning,
1726                subject: format!("{subject}.label"),
1727                message: "Console contribution is missing an operator-facing label.".to_owned(),
1728                suggestion: "Set a short action label such as Reset password.".to_owned(),
1729            });
1730        }
1731
1732        match &contribution.action {
1733            ConsoleContributionAction::AdminAction {
1734                module,
1735                name,
1736                input_bindings,
1737            } => {
1738                if !present(module) {
1739                    lints.push(ModuleManifestLint {
1740                        severity: ModuleManifestLintSeverity::Error,
1741                        subject: format!("{subject}.action.module"),
1742                        message: "Console contribution action is missing a module name.".to_owned(),
1743                        suggestion: "Set the module that owns the admin action.".to_owned(),
1744                    });
1745                }
1746                if !present(name) {
1747                    lints.push(ModuleManifestLint {
1748                        severity: ModuleManifestLintSeverity::Error,
1749                        subject: format!("{subject}.action.name"),
1750                        message: "Console contribution action is missing an action name."
1751                            .to_owned(),
1752                        suggestion: "Set the admin action name declared by that module.".to_owned(),
1753                    });
1754                }
1755                for binding in input_bindings {
1756                    if !present(&binding.input) {
1757                        lints.push(ModuleManifestLint {
1758                            severity: ModuleManifestLintSeverity::Error,
1759                            subject: format!("{subject}.action.input_binding"),
1760                            message:
1761                                "Console contribution action binding is missing an input name."
1762                                    .to_owned(),
1763                            suggestion: "Set the input field that receives the bound value."
1764                                .to_owned(),
1765                        });
1766                    }
1767                    match &binding.value {
1768                        ConsoleActionInputValue::SlotContext { path } => {
1769                            if !present(path) {
1770                                lints.push(ModuleManifestLint {
1771                                    severity: ModuleManifestLintSeverity::Error,
1772                                    subject: format!("{subject}.action.input_binding.path"),
1773                                    message:
1774                                        "Console contribution slot-context binding is missing a path."
1775                                            .to_owned(),
1776                                    suggestion:
1777                                        "Set a slot context path such as selected_user.id."
1778                                            .to_owned(),
1779                                });
1780                            } else if !valid_slot_context_path(path) {
1781                                lints.push(ModuleManifestLint {
1782                                    severity: ModuleManifestLintSeverity::Warning,
1783                                    subject: format!("{subject}.action.input_binding.path"),
1784                                    message:
1785                                        "Console contribution slot-context path should be path-safe."
1786                                            .to_owned(),
1787                                    suggestion:
1788                                        "Use dot-separated context fields such as selected_user.id."
1789                                            .to_owned(),
1790                                });
1791                            }
1792                        }
1793                    }
1794                }
1795            }
1796        }
1797    }
1798}
1799
1800const HOST_SYSTEM_CONSOLE_WORKSPACE_ID: &str = "system";
1801
1802fn lint_console_navigation(
1803    subject: &str,
1804    navigation: &crate::ConsoleNavigation,
1805    lints: &mut Vec<ModuleManifestLint>,
1806) {
1807    let workspace_subject = format!("{subject}.navigation.workspace");
1808    if !valid_console_navigation_id(&navigation.workspace.id) {
1809        lints.push(ModuleManifestLint {
1810            severity: ModuleManifestLintSeverity::Warning,
1811            subject: format!("{workspace_subject}.id"),
1812            message: "Console workspace id should be a path-safe identifier.".to_owned(),
1813            suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1814        });
1815    } else if navigation.workspace.id == HOST_SYSTEM_CONSOLE_WORKSPACE_ID {
1816        lints.push(ModuleManifestLint {
1817            severity: ModuleManifestLintSeverity::Warning,
1818            subject: format!("{workspace_subject}.id"),
1819            message: "Console workspace id system is reserved for host-owned surfaces.".to_owned(),
1820            suggestion:
1821                "Omit navigation to use the host System workspace, or use a module-owned workspace id."
1822                    .to_owned(),
1823        });
1824    }
1825    if !present(&navigation.workspace.label) {
1826        lints.push(ModuleManifestLint {
1827            severity: ModuleManifestLintSeverity::Warning,
1828            subject: format!("{workspace_subject}.label"),
1829            message: "Console workspace is missing an operator-facing label.".to_owned(),
1830            suggestion: "Set a short workspace label such as CRM.".to_owned(),
1831        });
1832    }
1833    if let Some(group) = &navigation.group {
1834        let group_subject = format!("{subject}.navigation.group");
1835        if !valid_console_navigation_id(&group.id) {
1836            lints.push(ModuleManifestLint {
1837                severity: ModuleManifestLintSeverity::Warning,
1838                subject: format!("{group_subject}.id"),
1839                message: "Console navigation group id should be a path-safe identifier.".to_owned(),
1840                suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1841            });
1842        }
1843        if !present(&group.label) {
1844            lints.push(ModuleManifestLint {
1845                severity: ModuleManifestLintSeverity::Warning,
1846                subject: format!("{group_subject}.label"),
1847                message: "Console navigation group is missing an operator-facing label.".to_owned(),
1848                suggestion: "Set a short group label such as Customers.".to_owned(),
1849            });
1850        }
1851    }
1852}
1853
1854fn lint_admin_surface(admin: &AdminSurface, lints: &mut Vec<ModuleManifestLint>) {
1855    match admin {
1856        AdminSurface::Schema(schema) => lint_schema_entities("admin.schema", schema, lints),
1857        AdminSurface::DeclarativeCustom(surface) => {
1858            if surface.pages.is_empty() && surface.actions.is_empty() {
1859                lints.push(ModuleManifestLint {
1860                    severity: ModuleManifestLintSeverity::Warning,
1861                    subject: "admin.declarative.pages".to_owned(),
1862                    message: "Declarative admin surface declares no pages or actions.".to_owned(),
1863                    suggestion:
1864                        "Add at least one page/action or omit the declarative admin surface."
1865                            .to_owned(),
1866                });
1867            }
1868            if let Some(schema) = &surface.fallback_schema {
1869                lint_schema_entities("admin.declarative.fallback_schema", schema, lints);
1870            }
1871            let fallback_entities = surface
1872                .fallback_schema
1873                .as_ref()
1874                .map(schema_entity_names)
1875                .unwrap_or_default();
1876            for page in &surface.pages {
1877                for section in &page.sections {
1878                    match &section.component {
1879                        AdminDeclarativeComponent::EntityTable { entity }
1880                        | AdminDeclarativeComponent::EntityDetail { entity } => {
1881                            if !fallback_entities.contains(entity) {
1882                                lints.push(ModuleManifestLint {
1883                                    severity: ModuleManifestLintSeverity::Warning,
1884                                    subject: format!("admin.declarative.section.{}", section.name),
1885                                    message: format!(
1886                                        "Declarative section references unknown fallback entity `{entity}`."
1887                                    ),
1888                                    suggestion:
1889                                        "Declare the entity in fallback_schema or update the section binding."
1890                                            .to_owned(),
1891                                });
1892                            }
1893                        }
1894                        AdminDeclarativeComponent::QueryValue {
1895                            capability,
1896                            query,
1897                            value_path,
1898                        } => lint_query_value(
1899                            section.name.as_str(),
1900                            query,
1901                            capability,
1902                            value_path,
1903                            lints,
1904                        ),
1905                        AdminDeclarativeComponent::MetricStrip { .. } => {}
1906                    }
1907                }
1908            }
1909        }
1910        AdminSurface::EmbeddedCustom(surface) => {
1911            if surface.runtime != AdminEmbeddedRuntime::Iframe {
1912                lints.push(ModuleManifestLint {
1913                    severity: ModuleManifestLintSeverity::Warning,
1914                    subject: "admin.embedded.runtime".to_owned(),
1915                    message: "Embedded admin runtime is reserved for a future host policy."
1916                        .to_owned(),
1917                    suggestion: "Use iframe for the current embedded admin slice.".to_owned(),
1918                });
1919            }
1920            match &surface.entry {
1921                AdminEmbeddedEntry::Url {
1922                    url,
1923                    allowed_origins,
1924                } => {
1925                    if !url.starts_with("https://") && !url.starts_with("http://localhost") {
1926                        lints.push(ModuleManifestLint {
1927                            severity: ModuleManifestLintSeverity::Warning,
1928                            subject: "admin.embedded.entry.url".to_owned(),
1929                            message:
1930                                "Embedded admin URL should use HTTPS outside local development."
1931                                    .to_owned(),
1932                            suggestion: "Use an HTTPS URL and list its origin in allowed_origins."
1933                                .to_owned(),
1934                        });
1935                    }
1936                    if allowed_origins.is_empty() {
1937                        lints.push(ModuleManifestLint {
1938                            severity: ModuleManifestLintSeverity::Warning,
1939                            subject: "admin.embedded.entry.allowed_origins".to_owned(),
1940                            message: "Embedded admin surface declares no allowed origins."
1941                                .to_owned(),
1942                            suggestion:
1943                                "Declare the iframe origin allowlist before enabling the surface."
1944                                    .to_owned(),
1945                        });
1946                    }
1947                }
1948            }
1949            if let Some(schema) = &surface.fallback_schema {
1950                lint_schema_entities("admin.embedded.fallback_schema", schema, lints);
1951                let fallback_entities = schema_entity_names(schema);
1952                for permission in &surface.permissions {
1953                    if let AdminPermission::ReadEntity { entity } = permission
1954                        && !fallback_entities.contains(entity)
1955                    {
1956                        lints.push(ModuleManifestLint {
1957                            severity: ModuleManifestLintSeverity::Warning,
1958                            subject: format!("admin.embedded.permission.{entity}"),
1959                            message: format!(
1960                                "Embedded admin permission references unknown fallback entity `{entity}`."
1961                            ),
1962                            suggestion:
1963                                "Declare the entity in fallback_schema or remove the permission."
1964                                    .to_owned(),
1965                        });
1966                    }
1967                }
1968            }
1969        }
1970    }
1971}
1972
1973fn lint_schema_entities(prefix: &str, schema: &AdminSchema, lints: &mut Vec<ModuleManifestLint>) {
1974    if schema.entities.is_empty() {
1975        lints.push(ModuleManifestLint {
1976            severity: ModuleManifestLintSeverity::Warning,
1977            subject: prefix.to_owned(),
1978            message: "Admin schema declares no entities.".to_owned(),
1979            suggestion: "Add at least one entity or omit the admin schema surface.".to_owned(),
1980        });
1981    }
1982    for entity in &schema.entities {
1983        if !present(&entity.read_capability) {
1984            lints.push(ModuleManifestLint {
1985                severity: ModuleManifestLintSeverity::Warning,
1986                subject: format!("{prefix}.{}", entity.name),
1987                message: "Admin entity is missing read capability.".to_owned(),
1988                suggestion: "Declare the capability required to read this entity.".to_owned(),
1989            });
1990        }
1991    }
1992}
1993
1994fn collect_declarative_query_capability_references(
1995    surface: &AdminDeclarativeSurface,
1996    references: &mut Vec<ModuleCapabilityReference>,
1997) {
1998    for page in &surface.pages {
1999        for section in &page.sections {
2000            let AdminDeclarativeComponent::QueryValue {
2001                capability, query, ..
2002            } = &section.component
2003            else {
2004                continue;
2005            };
2006            if present(capability) {
2007                let subject = if present(query) {
2008                    format!("admin.declarative.query.{query}")
2009                } else {
2010                    format!("admin.declarative.section.{}", section.name)
2011                };
2012                references.push(ModuleCapabilityReference {
2013                    capability: capability.clone(),
2014                    subject,
2015                });
2016            }
2017        }
2018    }
2019}
2020
2021fn lint_query_value(
2022    section_name: &str,
2023    query: &str,
2024    capability: &str,
2025    value_path: &str,
2026    lints: &mut Vec<ModuleManifestLint>,
2027) {
2028    let subject = if present(query) {
2029        format!("admin.declarative.query.{query}")
2030    } else {
2031        format!("admin.declarative.section.{section_name}")
2032    };
2033    if !valid_runtime_function_name(query) {
2034        lints.push(ModuleManifestLint {
2035            severity: ModuleManifestLintSeverity::Warning,
2036            subject: subject.clone(),
2037            message: "Declarative query name should be a stable path-safe identifier.".to_owned(),
2038            suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
2039        });
2040    }
2041    if !present(value_path) {
2042        lints.push(ModuleManifestLint {
2043            severity: ModuleManifestLintSeverity::Warning,
2044            subject: subject.clone(),
2045            message: "Declarative query value is missing a value path.".to_owned(),
2046            suggestion: "Set value_path to the JSON field rendered by this section.".to_owned(),
2047        });
2048    }
2049    if !present(capability) {
2050        lints.push(ModuleManifestLint {
2051            severity: ModuleManifestLintSeverity::Warning,
2052            subject,
2053            message: "Declarative query is missing a read capability.".to_owned(),
2054            suggestion: "Declare the capability required to read this query.".to_owned(),
2055        });
2056    }
2057}
2058
2059fn schema_entity_names(schema: &AdminSchema) -> HashSet<String> {
2060    schema
2061        .entities
2062        .iter()
2063        .map(|entity| entity.name.clone())
2064        .collect()
2065}
2066
2067fn present(value: &str) -> bool {
2068    !value.trim().is_empty()
2069}
2070
2071fn valid_capability(value: &str) -> bool {
2072    let mut parts = value.split('.');
2073    let Some(first) = parts.next() else {
2074        return false;
2075    };
2076    present(first)
2077        && value.contains('.')
2078        && std::iter::once(first).chain(parts).all(|part| {
2079            present(part)
2080                && part.chars().all(|character| {
2081                    character.is_ascii_lowercase() || character == '_' || character.is_ascii_digit()
2082                })
2083        })
2084}
2085
2086fn valid_runtime_function_name(value: &str) -> bool {
2087    present(value)
2088        && value.chars().all(|character| {
2089            character.is_ascii_alphanumeric()
2090                || character == '.'
2091                || character == '_'
2092                || character == '-'
2093        })
2094}
2095
2096fn valid_console_surface_name(value: &str) -> bool {
2097    present(value)
2098        && value.chars().all(|character| {
2099            character.is_ascii_alphanumeric() || character == '_' || character == '-'
2100        })
2101}
2102
2103fn valid_console_slot_target(value: &str) -> bool {
2104    present(value)
2105        && value.contains('.')
2106        && value.chars().all(|character| {
2107            character.is_ascii_alphanumeric()
2108                || character == '.'
2109                || character == '_'
2110                || character == '-'
2111        })
2112}
2113
2114fn valid_slot_context_path(value: &str) -> bool {
2115    present(value) && value.split('.').all(valid_slot_context_segment)
2116}
2117
2118fn valid_slot_context_segment(value: &str) -> bool {
2119    present(value)
2120        && value.chars().all(|character| {
2121            character.is_ascii_alphanumeric() || character == '_' || character == '-'
2122        })
2123}
2124
2125fn valid_console_navigation_id(value: &str) -> bool {
2126    valid_console_surface_name(value)
2127}
2128
2129fn valid_module_id(value: &str) -> bool {
2130    let Some((namespace, name)) = value.split_once('/') else {
2131        return false;
2132    };
2133    !namespace.is_empty()
2134        && !name.is_empty()
2135        && !name.contains('/')
2136        && [namespace, name].into_iter().all(|segment| {
2137            segment.starts_with(|character: char| character.is_ascii_lowercase())
2138                && segment.chars().all(|character| {
2139                    character.is_ascii_lowercase()
2140                        || character.is_ascii_digit()
2141                        || character == '-'
2142                        || character == '_'
2143                })
2144        })
2145}
2146
2147fn route_identity(route: &ModuleHttpRoute) -> String {
2148    format!("{} {}", method_label(route.method), route.path)
2149}
2150
2151fn method_label(method: ModuleHttpMethod) -> &'static str {
2152    match method {
2153        ModuleHttpMethod::Get => "GET",
2154        ModuleHttpMethod::Post => "POST",
2155        ModuleHttpMethod::Put => "PUT",
2156        ModuleHttpMethod::Patch => "PATCH",
2157        ModuleHttpMethod::Delete => "DELETE",
2158    }
2159}
2160
2161/// Fluent builder for [`ModuleManifest`]. Reusable by every loading source.
2162#[derive(Debug)]
2163pub struct ModuleManifestBuilder {
2164    manifest: ModuleManifest,
2165}
2166
2167impl ModuleManifestBuilder {
2168    #[must_use]
2169    pub fn summary(mut self, summary: impl Into<String>) -> Self {
2170        self.manifest.summary = Some(summary.into());
2171        self
2172    }
2173
2174    /// Attach console story-display metadata.
2175    #[must_use]
2176    pub fn story_display(mut self, story_display: Vec<StoryDisplayDescriptor>) -> Self {
2177        self.manifest.story_display = story_display;
2178        self
2179    }
2180
2181    /// Attach declared capabilities.
2182    #[must_use]
2183    pub fn capabilities(mut self, mut capabilities: Vec<String>) -> Self {
2184        capabilities.sort();
2185        self.manifest.capabilities = capabilities;
2186        self
2187    }
2188
2189    /// Attach required Modules.
2190    #[must_use]
2191    pub fn requires(mut self, mut requirements: Vec<ModuleRequirement>) -> Self {
2192        for requirement in &mut requirements {
2193            requirement.capabilities.sort();
2194        }
2195        requirements.sort_by(|left, right| left.module_id.cmp(&right.module_id));
2196        self.manifest.requires = requirements;
2197        self
2198    }
2199
2200    /// Compatibility for currently published first-party authoring crates.
2201    /// New code must use [`Self::requires`]. This method does not preserve a
2202    /// legacy wire field; it produces canonical Module requirements.
2203    #[doc(hidden)]
2204    #[must_use]
2205    pub fn dependencies(mut self, dependencies: Vec<String>) -> Self {
2206        let mut requirements = dependencies
2207            .into_iter()
2208            .map(|dependency| ModuleRequirement {
2209                module_id: if dependency.contains('/') {
2210                    dependency
2211                } else {
2212                    format!("lenso/{dependency}")
2213                },
2214                version_requirement: "*".to_owned(),
2215                capabilities: Vec::new(),
2216                optional: false,
2217            })
2218            .collect::<Vec<_>>();
2219        requirements.sort_by(|left, right| left.module_id.cmp(&right.module_id));
2220        self.manifest.requires = requirements;
2221        self
2222    }
2223
2224    #[must_use]
2225    pub fn config(mut self, mut config: ModuleConfigContract) -> Self {
2226        config
2227            .fields
2228            .sort_by(|left, right| left.key.cmp(&right.key));
2229        self.manifest.config = config;
2230        self
2231    }
2232
2233    #[must_use]
2234    pub fn migrations(mut self, mut migrations: Vec<ModuleMigrationDeclaration>) -> Self {
2235        migrations.sort_by_key(|migration| migration.order);
2236        self.manifest.migrations = migrations;
2237        self
2238    }
2239
2240    /// Attach declared module-owned HTTP routes.
2241    #[must_use]
2242    pub fn http_routes(mut self, routes: Vec<ModuleHttpRoute>) -> Self {
2243        self.manifest.http_routes = routes;
2244        self
2245    }
2246
2247    /// Attach runtime declarations.
2248    #[must_use]
2249    pub fn runtime(mut self, runtime: RuntimeSurface) -> Self {
2250        self.manifest.runtime = Some(runtime);
2251        self
2252    }
2253
2254    /// Attach event handler declarations.
2255    #[must_use]
2256    pub fn events(mut self, events: EventSurface) -> Self {
2257        self.manifest.events = Some(events);
2258        self
2259    }
2260
2261    /// Attach a schema-driven admin surface.
2262    #[must_use]
2263    pub fn admin(mut self, schema: AdminSchema) -> Self {
2264        self.manifest.admin = Some(AdminSurface::Schema(schema));
2265        self
2266    }
2267
2268    /// Attach a host-rendered custom admin surface declaration.
2269    #[must_use]
2270    pub fn declarative_admin(mut self, surface: AdminDeclarativeSurface) -> Self {
2271        self.manifest.admin = Some(AdminSurface::DeclarativeCustom(surface));
2272        self
2273    }
2274
2275    /// Attach a sandboxed module-owned admin surface declaration.
2276    #[must_use]
2277    pub fn embedded_admin(mut self, surface: AdminEmbeddedSurface) -> Self {
2278        self.manifest.admin = Some(AdminSurface::EmbeddedCustom(surface));
2279        self
2280    }
2281
2282    /// Attach lifecycle declarations.
2283    #[must_use]
2284    pub fn lifecycle(mut self, lifecycle: LifecycleSurface) -> Self {
2285        self.manifest.lifecycle = Some(lifecycle);
2286        self
2287    }
2288
2289    /// Attach trusted Console frontend surface declarations.
2290    #[must_use]
2291    pub fn console(mut self, console: Vec<ConsoleSurface>) -> Self {
2292        self.manifest.console = console;
2293        self
2294    }
2295
2296    /// Attach Console extension slot declarations.
2297    #[must_use]
2298    pub fn console_slots(mut self, console_slots: Vec<ConsoleSlot>) -> Self {
2299        self.manifest.console_slots = console_slots;
2300        self
2301    }
2302
2303    /// Attach trusted Console slot contribution declarations.
2304    #[must_use]
2305    pub fn console_contributions(
2306        mut self,
2307        console_contributions: Vec<ConsoleContribution>,
2308    ) -> Self {
2309        self.manifest.console_contributions = console_contributions;
2310        self
2311    }
2312
2313    /// Finish building.
2314    #[must_use]
2315    pub fn build(mut self) -> ModuleManifest {
2316        // Published first-party authoring crates from the pre-reset workspace
2317        // still pass their local names to the builder. Canonicalize that
2318        // authoring input without accepting the removed legacy wire shape.
2319        if !self.manifest.module_id.contains('/') {
2320            self.manifest.module_id = format!("lenso/{}", self.manifest.module_id);
2321        }
2322        self.manifest
2323    }
2324}
2325
2326#[cfg(test)]
2327mod tests {
2328    use super::*;
2329    use crate::admin::{
2330        AdminDeclarativeComponent, AdminDeclarativePage, AdminDeclarativeSection,
2331        AdminDeclarativeSurface,
2332    };
2333    use crate::{
2334        AdminEmbeddedEntry, AdminEmbeddedRuntime, AdminEmbeddedSurface, AdminSandboxPolicy,
2335        ConsoleActionInputBinding, ConsoleActionInputValue, ConsoleContribution,
2336        ConsoleContributionAction, ConsoleContributionKind, ConsoleSlot, ConsoleSlotContext,
2337        ConsoleSlotContextField, ConsoleSlotContextFieldType, ConsoleSurface,
2338        ConsoleSurfacePresentation, EventHandlerDeclaration, EventSurface,
2339    };
2340    use crate::{
2341        LifecycleActivationJobDeclaration, LifecycleActivationRunPolicy,
2342        LifecycleStartupCheckDeclaration, LifecycleStartupCheckKind, LifecycleSurface,
2343    };
2344    use crate::{ModuleHttpMethod, ModuleHttpRoute};
2345    use crate::{
2346        RuntimeFunctionDeclaration, RuntimeRetryPolicyDeclaration, RuntimeSurface,
2347        WorkflowCompensationDeclaration, WorkflowDataContract, WorkflowDefinition,
2348        WorkflowRetryPolicyDeclaration, WorkflowStepDeclaration,
2349    };
2350    use crate::{StoryDisplayDescriptor, StoryDisplaySource};
2351
2352    #[test]
2353    fn manifest_round_trips_through_json() {
2354        let manifest = ModuleManifest::builder("lenso/identity")
2355            .story_display(vec![StoryDisplayDescriptor {
2356                source: StoryDisplaySource::ExecutionName {
2357                    name: "identity.create_user".to_owned(),
2358                },
2359                display_name: "Create User".to_owned(),
2360                story_title: Some("User Registration".to_owned()),
2361            }])
2362            .build();
2363
2364        let json = serde_json::to_string(&manifest).expect("serialize");
2365        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2366
2367        assert_eq!(manifest, back);
2368    }
2369
2370    #[test]
2371    fn manifest_with_console_surface_round_trips_through_json() {
2372        let manifest = ModuleManifest::builder("lenso/platform-story")
2373            .console(vec![ConsoleSurface {
2374                name: "stories".to_owned(),
2375                label: "Stories".to_owned(),
2376                route: "/runtime/stories".to_owned(),
2377                presentation: ConsoleSurfacePresentation::Esm {
2378                    entry: "storyConsoleModule".to_owned(),
2379                },
2380                icon: Some("workflow".to_owned()),
2381                required_capabilities: vec!["runtime.stories.read".to_owned()],
2382                navigation: None,
2383            }])
2384            .capabilities(vec!["runtime.stories.read".to_owned()])
2385            .build();
2386
2387        let json = serde_json::to_string(&manifest).expect("serialize");
2388        assert!(json.contains(r#""console""#), "got {json}");
2389        assert!(json.contains(r#""kind":"esm""#), "got {json}");
2390
2391        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2392
2393        assert_eq!(manifest, back);
2394    }
2395
2396    #[test]
2397    fn manifest_with_console_contribution_round_trips_through_json() {
2398        let contribution = ConsoleContribution {
2399            target: "auth.users.detail.actions".to_owned(),
2400            target_version: 1,
2401            label: "Reset password".to_owned(),
2402            action: ConsoleContributionAction::AdminAction {
2403                module: "auth-password".to_owned(),
2404                name: "reset_password".to_owned(),
2405                input_bindings: vec![ConsoleActionInputBinding {
2406                    input: "user_id".to_owned(),
2407                    value: ConsoleActionInputValue::SlotContext {
2408                        path: "selected_user.id".to_owned(),
2409                    },
2410                }],
2411            },
2412            icon: Some("key-round".to_owned()),
2413            required_capabilities: vec!["auth_password.credentials.write".to_owned()],
2414        };
2415        let manifest = ModuleManifest::builder("lenso/auth-password")
2416            .capabilities(vec!["auth_password.credentials.write".to_owned()])
2417            .console_contributions(vec![contribution.clone()])
2418            .build();
2419
2420        let json = serde_json::to_string(&manifest).expect("serialize");
2421        assert!(json.contains(r#""console_contributions""#), "got {json}");
2422        assert!(
2423            json.contains(r#""target":"auth.users.detail.actions""#),
2424            "got {json}"
2425        );
2426        assert!(json.contains(r#""target_version":1"#), "got {json}");
2427        assert!(json.contains(r#""kind":"admin_action""#), "got {json}");
2428        assert!(json.contains(r#""kind":"slot_context""#), "got {json}");
2429
2430        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2431
2432        assert_eq!(back.console_contributions, vec![contribution]);
2433    }
2434
2435    #[test]
2436    fn manifest_with_console_slot_round_trips_through_json() {
2437        let slot = ConsoleSlot {
2438            id: "auth.users.detail.actions".to_owned(),
2439            version: 1,
2440            label: "User detail actions".to_owned(),
2441            accepts: vec![ConsoleContributionKind::AdminAction],
2442            context: vec![ConsoleSlotContext {
2443                name: "selected_user".to_owned(),
2444                fields: vec![ConsoleSlotContextField {
2445                    name: "id".to_owned(),
2446                    field_type: ConsoleSlotContextFieldType::String,
2447                    required: true,
2448                }],
2449            }],
2450        };
2451        let manifest = ModuleManifest::builder("lenso/auth")
2452            .console_slots(vec![slot.clone()])
2453            .build();
2454
2455        let json = serde_json::to_string(&manifest).expect("serialize");
2456        assert!(json.contains(r#""console_slots""#), "got {json}");
2457        assert!(
2458            json.contains(r#""id":"auth.users.detail.actions""#),
2459            "got {json}"
2460        );
2461        assert!(json.contains(r#""accepts":["admin_action"]"#), "got {json}");
2462
2463        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2464
2465        assert_eq!(back.console_slots, vec![slot]);
2466    }
2467
2468    #[test]
2469    fn console_contribution_capability_references_are_linted() {
2470        let manifest = ModuleManifest::builder("lenso/auth-password")
2471            .console_contributions(vec![ConsoleContribution {
2472                target: "auth.users.detail.actions".to_owned(),
2473                target_version: 1,
2474                label: "Reset password".to_owned(),
2475                action: ConsoleContributionAction::AdminAction {
2476                    module: "auth-password".to_owned(),
2477                    name: "reset_password".to_owned(),
2478                    input_bindings: vec![ConsoleActionInputBinding {
2479                        input: "user_id".to_owned(),
2480                        value: ConsoleActionInputValue::SlotContext {
2481                            path: "selected_user.id".to_owned(),
2482                        },
2483                    }],
2484                },
2485                icon: None,
2486                required_capabilities: vec!["auth_password.credentials.write".to_owned()],
2487            }])
2488            .build();
2489
2490        let lints = lint_module_manifest(&manifest);
2491
2492        assert!(lints.iter().any(|lint| {
2493            lint.subject == "capability.reference.console.contribution.auth.users.detail.actions"
2494                && lint.message == "Capability reference is not declared by the module."
2495        }));
2496    }
2497
2498    #[test]
2499    fn console_surface_navigation_round_trips() {
2500        let surface = ConsoleSurface {
2501            name: "contacts".to_owned(),
2502            label: "Contacts".to_owned(),
2503            route: "/crm/contacts".to_owned(),
2504            presentation: ConsoleSurfacePresentation::Esm {
2505                entry: "crmConsoleModule".to_owned(),
2506            },
2507            icon: Some("users".to_owned()),
2508            required_capabilities: vec!["crm.contacts.read".to_owned()],
2509            navigation: Some(crate::ConsoleNavigation {
2510                workspace: crate::ConsoleWorkspaceRef {
2511                    id: "crm".to_owned(),
2512                    label: "CRM".to_owned(),
2513                    icon: Some("briefcase".to_owned()),
2514                },
2515                group: Some(crate::ConsoleNavigationGroup {
2516                    id: "customers".to_owned(),
2517                    label: "Customers".to_owned(),
2518                    icon: None,
2519                    order: Some(20),
2520                }),
2521                order: Some(10),
2522            }),
2523        };
2524
2525        let json = serde_json::to_string(&surface).expect("serialize");
2526        let back: ConsoleSurface = serde_json::from_str(&json).expect("deserialize");
2527
2528        assert_eq!(back, surface);
2529    }
2530
2531    #[test]
2532    fn console_navigation_lints_empty_workspace_label() {
2533        let manifest = ModuleManifest::builder("acme/crm")
2534            .capabilities(vec!["crm.contacts.read".to_owned()])
2535            .console(vec![ConsoleSurface {
2536                name: "contacts".to_owned(),
2537                label: "Contacts".to_owned(),
2538                route: "/crm/contacts".to_owned(),
2539                presentation: ConsoleSurfacePresentation::Esm {
2540                    entry: "crmConsoleModule".to_owned(),
2541                },
2542                icon: None,
2543                required_capabilities: vec!["crm.contacts.read".to_owned()],
2544                navigation: Some(crate::ConsoleNavigation {
2545                    workspace: crate::ConsoleWorkspaceRef {
2546                        id: "crm".to_owned(),
2547                        label: "".to_owned(),
2548                        icon: None,
2549                    },
2550                    group: None,
2551                    order: None,
2552                }),
2553            }])
2554            .build();
2555
2556        let subjects: Vec<_> = lint_module_manifest(&manifest)
2557            .into_iter()
2558            .map(|lint| lint.subject)
2559            .collect();
2560
2561        assert!(
2562            subjects.contains(&"console.surface.contacts.navigation.workspace.label".to_owned())
2563        );
2564    }
2565
2566    #[test]
2567    fn console_navigation_lints_reserved_system_workspace() {
2568        let manifest = ModuleManifest::builder("acme/crm")
2569            .capabilities(vec!["crm.contacts.read".to_owned()])
2570            .console(vec![ConsoleSurface {
2571                name: "contacts".to_owned(),
2572                label: "Contacts".to_owned(),
2573                route: "/crm/contacts".to_owned(),
2574                presentation: ConsoleSurfacePresentation::Esm {
2575                    entry: "crmConsoleModule".to_owned(),
2576                },
2577                icon: None,
2578                required_capabilities: vec!["crm.contacts.read".to_owned()],
2579                navigation: Some(crate::ConsoleNavigation {
2580                    workspace: crate::ConsoleWorkspaceRef {
2581                        id: "system".to_owned(),
2582                        label: "System".to_owned(),
2583                        icon: Some("settings".to_owned()),
2584                    },
2585                    group: None,
2586                    order: Some(10),
2587                }),
2588            }])
2589            .build();
2590
2591        let lints = lint_module_manifest(&manifest);
2592
2593        assert!(lints.iter().any(|lint| {
2594            lint.subject == "console.surface.contacts.navigation.workspace.id"
2595                && lint.severity == ModuleManifestLintSeverity::Warning
2596                && lint.message
2597                    == "Console workspace id system is reserved for host-owned surfaces."
2598        }));
2599    }
2600
2601    #[test]
2602    fn lints_invalid_console_surface_declarations() {
2603        let manifest = ModuleManifest::builder("lenso/platform-story")
2604            .console(vec![
2605                ConsoleSurface {
2606                    name: "stories".to_owned(),
2607                    label: "Stories".to_owned(),
2608                    route: "runtime/stories".to_owned(),
2609                    presentation: ConsoleSurfacePresentation::Esm {
2610                        entry: String::new(),
2611                    },
2612                    icon: None,
2613                    required_capabilities: vec!["runtime.stories.read".to_owned()],
2614                    navigation: None,
2615                },
2616                ConsoleSurface {
2617                    name: "stories".to_owned(),
2618                    label: "Stories duplicate".to_owned(),
2619                    route: "/runtime/stories".to_owned(),
2620                    presentation: ConsoleSurfacePresentation::Esm {
2621                        entry: "storyConsoleModule".to_owned(),
2622                    },
2623                    icon: None,
2624                    required_capabilities: vec![],
2625                    navigation: None,
2626                },
2627            ])
2628            .build();
2629
2630        let lints = lint_module_manifest(&manifest);
2631        let subjects = lints
2632            .iter()
2633            .map(|lint| lint.subject.as_str())
2634            .collect::<Vec<_>>();
2635
2636        assert!(subjects.contains(&"console.surface.stories.route"));
2637        assert!(subjects.contains(&"console.surface.stories.presentation.entry"));
2638        assert!(!subjects.contains(&"console.surface.stories.presentation.bridge_protocol"));
2639        assert!(subjects.contains(&"capability.reference.console.surface.stories"));
2640        assert!(lints.iter().any(|lint| {
2641            lint.subject == "console.surface.stories"
2642                && lint.message == "Duplicate console surface declaration."
2643        }));
2644    }
2645
2646    #[test]
2647    fn empty_admin_is_skipped_in_json() {
2648        let manifest = ModuleManifest::builder("lenso/notifications").build();
2649        let json = serde_json::to_string(&manifest).expect("serialize");
2650        assert!(
2651            !json.contains("admin"),
2652            "admin: None must be skipped, got {json}"
2653        );
2654    }
2655
2656    #[test]
2657    fn manifest_lints_self_dependency() {
2658        let manifest = ModuleManifest::builder("lenso/auth")
2659            .requires(vec![
2660                ModuleRequirement::new("lenso/auth", "*").expect("valid requirement"),
2661            ])
2662            .build();
2663
2664        let lints = lint_module_manifest(&manifest);
2665
2666        assert!(lints.iter().any(|lint| {
2667            lint.severity == ModuleManifestLintSeverity::Error
2668                && lint.subject == "requirement lenso/auth"
2669                && lint.message == "Module must not depend on itself."
2670        }));
2671    }
2672
2673    #[test]
2674    fn manifest_with_admin_serializes_schema_kind() {
2675        use crate::admin_schema::{AdminSchema, EntitySchema, FieldSchema, FieldType};
2676        let schema = AdminSchema {
2677            entities: vec![EntitySchema {
2678                name: "users".to_owned(),
2679                label: "Users".to_owned(),
2680                read_capability: "identity.users.read".to_owned(),
2681                fields: vec![FieldSchema {
2682                    name: "email".into(),
2683                    label: "Email".into(),
2684                    field_type: FieldType::String,
2685                    nullable: false,
2686                }],
2687            }],
2688        };
2689        let manifest = ModuleManifest::builder("lenso/identity")
2690            .admin(schema)
2691            .build();
2692        let json = serde_json::to_string(&manifest).expect("serialize");
2693        assert!(json.contains(r#""kind":"schema""#), "got {json}");
2694    }
2695
2696    #[test]
2697    fn manifest_with_declarative_admin_serializes_kind() {
2698        use crate::admin::AdminDeclarativeSurface;
2699
2700        let manifest = ModuleManifest::builder("acme/remote-crm")
2701            .declarative_admin(AdminDeclarativeSurface {
2702                pages: vec![],
2703                actions: vec![],
2704                fallback_schema: None,
2705            })
2706            .build();
2707        let json = serde_json::to_string(&manifest).expect("serialize");
2708        assert!(
2709            json.contains(r#""kind":"declarative_custom""#),
2710            "got {json}"
2711        );
2712    }
2713
2714    #[test]
2715    fn manifest_with_embedded_admin_serializes_kind() {
2716        use crate::admin::{
2717            AdminEmbeddedEntry, AdminEmbeddedRuntime, AdminEmbeddedSurface, AdminSandboxPolicy,
2718        };
2719
2720        let manifest = ModuleManifest::builder("acme/remote-crm")
2721            .embedded_admin(AdminEmbeddedSurface {
2722                runtime: AdminEmbeddedRuntime::Iframe,
2723                entry: AdminEmbeddedEntry::Url {
2724                    url: "https://crm.example.test/admin".to_owned(),
2725                    allowed_origins: vec!["https://crm.example.test".to_owned()],
2726                },
2727                sandbox: AdminSandboxPolicy {
2728                    allow_scripts: true,
2729                    allow_forms: false,
2730                    allow_popups: false,
2731                    allow_same_origin: false,
2732                },
2733                permissions: vec![],
2734                fallback_schema: None,
2735            })
2736            .build();
2737        let json = serde_json::to_string(&manifest).expect("serialize");
2738        assert!(json.contains(r#""kind":"embedded_custom""#), "got {json}");
2739    }
2740
2741    #[test]
2742    fn manifest_with_http_routes_round_trips_through_json() {
2743        let manifest = ModuleManifest::builder("acme/remote-crm")
2744            .http_routes(vec![
2745                ModuleHttpRoute {
2746                    method: ModuleHttpMethod::Get,
2747                    path: "/contacts".to_owned(),
2748                    capability: Some("remote_crm.contacts.read".to_owned()),
2749                    display_name: Some("List Contacts".to_owned()),
2750                    story_title: Some("List Contacts".to_owned()),
2751                    operation: None,
2752                },
2753                ModuleHttpRoute {
2754                    method: ModuleHttpMethod::Post,
2755                    path: "/contacts".to_owned(),
2756                    capability: Some("remote_crm.contacts.write".to_owned()),
2757                    display_name: None,
2758                    story_title: None,
2759                    operation: None,
2760                },
2761            ])
2762            .build();
2763
2764        let json = serde_json::to_string(&manifest).expect("serialize");
2765        assert!(json.contains(r#""http_routes""#), "got {json}");
2766        assert!(json.contains(r#""method":"GET""#), "got {json}");
2767        assert!(
2768            json.contains(r#""display_name":"List Contacts""#),
2769            "got {json}"
2770        );
2771        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2772        assert_eq!(manifest, back);
2773    }
2774
2775    #[test]
2776    fn manifest_with_runtime_functions_round_trips_through_json() {
2777        let manifest = ModuleManifest::builder("acme/remote-crm")
2778            .runtime(RuntimeSurface {
2779                functions: vec![RuntimeFunctionDeclaration {
2780                    name: "remote_crm.sync_contact.v1".to_owned(),
2781                    version: 1,
2782                    queue: "remote-crm".to_owned(),
2783                    input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
2784                    retry_policy: Some(RuntimeRetryPolicyDeclaration {
2785                        max_attempts: 3,
2786                        initial_delay_ms: 1000,
2787                    }),
2788                    operation: None,
2789                }],
2790                schedules: vec![ScheduledFunctionDeclaration {
2791                    name: "sync_contacts_hourly".to_owned(),
2792                    function_name: "remote_crm.sync_contact.v1".to_owned(),
2793                    cron: "0 * * * *".to_owned(),
2794                    input: serde_json::json!({ "reason": "schedule" }),
2795                }],
2796                workflows: vec![],
2797            })
2798            .build();
2799
2800        let json = serde_json::to_string(&manifest).expect("serialize");
2801
2802        assert!(json.contains(r#""runtime""#), "got {json}");
2803        assert!(
2804            json.contains(r#""name":"remote_crm.sync_contact.v1""#),
2805            "got {json}"
2806        );
2807        assert!(json.contains(r#""queue":"remote-crm""#), "got {json}");
2808        assert!(json.contains(r#""schedules""#), "got {json}");
2809        assert!(
2810            !json.contains(r#""workflows""#),
2811            "empty workflow declarations must not change existing Runtime Function manifests: {json}"
2812        );
2813        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2814        assert_eq!(manifest, back);
2815    }
2816
2817    #[test]
2818    fn manifest_with_versioned_workflow_round_trips_and_lints_cleanly() {
2819        let manifest = ModuleManifest::builder("acme/support-sla")
2820            .runtime(RuntimeSurface {
2821                functions: vec![],
2822                schedules: vec![],
2823                workflows: vec![WorkflowDefinition::new(
2824                    "support-sla",
2825                    "ticket_sla",
2826                    "v1",
2827                    WorkflowDataContract::new("support.sla.start", "v1"),
2828                    WorkflowDataContract::new("support.sla.result", "v1"),
2829                    vec![
2830                        WorkflowStepDeclaration::new("acknowledge_ticket")
2831                            .with_display_name("Acknowledge ticket")
2832                            .with_retry_policy(WorkflowRetryPolicyDeclaration::new(
2833                                3,
2834                                vec![1_000, 5_000],
2835                            ))
2836                            .with_timeout_ms(30_000)
2837                            .with_compensation(
2838                                WorkflowCompensationDeclaration::new(
2839                                    "withdraw_sla_acknowledgement",
2840                                    1,
2841                                    WorkflowDataContract::new("sla-compensation-requested", "v1"),
2842                                )
2843                                .with_completion_contract(
2844                                    WorkflowDataContract::new("sla-compensated", "v1"),
2845                                ),
2846                            ),
2847                        WorkflowStepDeclaration::new("await_resolution"),
2848                    ],
2849                )],
2850            })
2851            .build();
2852
2853        let json = serde_json::to_string(&manifest).expect("serialize");
2854        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2855        let lints = lint_module_manifest(&back);
2856        let workflow_schema = crate::workflow_definition_schema();
2857        let compensation_schema = &workflow_schema["$defs"]["compensation"];
2858
2859        assert_eq!(manifest, back);
2860        assert!(json.contains(r#""protocol":"lenso.workflow-definition.v1""#));
2861        assert!(json.contains(r#""inputContract""#));
2862        assert!(json.contains(r#""maxAttempts":3"#));
2863        assert!(json.contains(r#""delaysMs":[1000,5000]"#));
2864        assert!(json.contains(r#""timeoutMs":30000"#));
2865        assert!(json.contains(r#""name":"withdraw_sla_acknowledgement""#));
2866        assert!(json.contains(r#""order":1"#));
2867        assert!(json.contains(r#""contract":{"contractId":"sla-compensation-requested""#));
2868        assert!(json.contains(r#""completionContract":{"contractId":"sla-compensated""#));
2869        assert_eq!(
2870            compensation_schema["properties"]["completionContract"]["$ref"],
2871            "#/$defs/dataContract"
2872        );
2873        assert!(
2874            compensation_schema["required"]
2875                .as_array()
2876                .unwrap()
2877                .iter()
2878                .any(|field| field == "completionContract")
2879        );
2880        assert!(lints.iter().all(|lint| {
2881            lint.severity != ModuleManifestLintSeverity::Error
2882                && !lint.subject.starts_with("runtime.workflow")
2883        }));
2884    }
2885
2886    #[test]
2887    fn manifest_lint_rejects_unowned_or_ambiguous_workflows() {
2888        let invalid = WorkflowDefinition::new(
2889            "another-module",
2890            "ticket_sla",
2891            "v1",
2892            WorkflowDataContract::new("", "v1"),
2893            WorkflowDataContract::new("support.sla.result", "v1"),
2894            vec![
2895                WorkflowStepDeclaration::new("acknowledge_ticket")
2896                    .with_retry_policy(WorkflowRetryPolicyDeclaration::new(3, vec![1_000]))
2897                    .with_timeout_ms(0)
2898                    .with_compensation(WorkflowCompensationDeclaration::new(
2899                        "invalid compensation",
2900                        0,
2901                        WorkflowDataContract::new("", ""),
2902                    )),
2903                WorkflowStepDeclaration::new("acknowledge_ticket").with_compensation(
2904                    WorkflowCompensationDeclaration::new(
2905                        "invalid compensation",
2906                        0,
2907                        WorkflowDataContract::new("", ""),
2908                    ),
2909                ),
2910            ],
2911        );
2912        let manifest = ModuleManifest::builder("acme/support-sla")
2913            .runtime(RuntimeSurface {
2914                functions: vec![],
2915                schedules: vec![],
2916                workflows: vec![invalid.clone(), invalid],
2917            })
2918            .build();
2919
2920        let lints = lint_module_manifest(&manifest);
2921
2922        assert!(
2923            lints
2924                .iter()
2925                .any(|lint| lint.subject == "runtime.workflow.ticket_sla.v1.owner")
2926        );
2927        assert!(lints.iter().any(|lint| {
2928            lint.subject == "runtime.workflow.ticket_sla.v1"
2929                && lint.message == "Duplicate Durable Workflow definition identity."
2930        }));
2931        assert!(lints.iter().any(|lint| {
2932            lint.subject == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket"
2933                && lint.message == "Durable Workflow step name is declared more than once."
2934        }));
2935        assert!(lints.iter().any(|lint| {
2936            lint.subject == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.retry_policy"
2937        }));
2938        assert!(lints.iter().any(|lint| {
2939            lint.subject == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.timeout_ms"
2940        }));
2941        assert!(lints.iter().any(|lint| {
2942            lint.subject
2943                == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.compensation.name"
2944        }));
2945        assert!(lints.iter().any(|lint| {
2946            lint.subject
2947                == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.compensation.order"
2948        }));
2949        assert!(lints.iter().any(|lint| {
2950            lint.subject
2951                == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.compensation.contract"
2952        }));
2953    }
2954
2955    #[test]
2956    fn manifest_with_event_handlers_round_trips_through_json() {
2957        let manifest = ModuleManifest::builder("acme/remote-crm")
2958            .events(EventSurface {
2959                handlers: vec![EventHandlerDeclaration {
2960                    name: "sync_contact_on_user_registered".to_owned(),
2961                    event_name: "identity.user_registered.v1".to_owned(),
2962                    operation: None,
2963                }],
2964            })
2965            .build();
2966
2967        let json = serde_json::to_string(&manifest).expect("serialize");
2968
2969        assert!(json.contains(r#""events""#), "got {json}");
2970        assert!(
2971            json.contains(r#""name":"sync_contact_on_user_registered""#),
2972            "got {json}"
2973        );
2974        assert!(
2975            json.contains(r#""event_name":"identity.user_registered.v1""#),
2976            "got {json}"
2977        );
2978        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2979        assert_eq!(manifest, back);
2980    }
2981
2982    #[test]
2983    fn manifest_lint_warns_for_invalid_capability_names() {
2984        let manifest = ModuleManifest::builder("acme/remote-crm")
2985            .capabilities(vec!["RemoteCRM Contacts Read".to_owned()])
2986            .build();
2987
2988        assert!(
2989            lint_module_manifest(&manifest)
2990                .iter()
2991                .any(|lint| lint.subject == "capability RemoteCRM Contacts Read"
2992                    && lint.severity == ModuleManifestLintSeverity::Warning)
2993        );
2994    }
2995
2996    #[test]
2997    fn manifest_lint_warns_for_unknown_declarative_fallback_entities() {
2998        let manifest = ModuleManifest::builder("acme/remote-crm")
2999            .declarative_admin(AdminDeclarativeSurface {
3000                pages: vec![AdminDeclarativePage {
3001                    name: "dashboard".to_owned(),
3002                    label: "Dashboard".to_owned(),
3003                    sections: vec![AdminDeclarativeSection {
3004                        name: "missing".to_owned(),
3005                        label: "Missing".to_owned(),
3006                        component: AdminDeclarativeComponent::EntityTable {
3007                            entity: "contacts".to_owned(),
3008                        },
3009                    }],
3010                }],
3011                actions: vec![],
3012                fallback_schema: None,
3013            })
3014            .build();
3015
3016        assert!(
3017            lint_module_manifest(&manifest)
3018                .iter()
3019                .any(|lint| lint.subject == "admin.declarative.section.missing"
3020                    && lint.severity == ModuleManifestLintSeverity::Warning)
3021        );
3022    }
3023
3024    #[test]
3025    fn manifest_lint_warns_for_embedded_origin_policy() {
3026        let manifest = ModuleManifest::builder("acme/remote-crm")
3027            .embedded_admin(AdminEmbeddedSurface {
3028                runtime: AdminEmbeddedRuntime::Iframe,
3029                entry: AdminEmbeddedEntry::Url {
3030                    url: "http://crm.example.test/admin".to_owned(),
3031                    allowed_origins: vec![],
3032                },
3033                sandbox: AdminSandboxPolicy {
3034                    allow_scripts: true,
3035                    allow_forms: false,
3036                    allow_popups: false,
3037                    allow_same_origin: false,
3038                },
3039                permissions: vec![],
3040                fallback_schema: None,
3041            })
3042            .build();
3043
3044        let lints = lint_module_manifest(&manifest);
3045
3046        assert!(
3047            lints
3048                .iter()
3049                .any(|lint| lint.subject == "admin.embedded.entry.url")
3050        );
3051        assert!(
3052            lints
3053                .iter()
3054                .any(|lint| lint.subject == "admin.embedded.entry.allowed_origins")
3055        );
3056    }
3057
3058    #[test]
3059    fn manifest_lint_warns_for_runtime_function_declarations() {
3060        let manifest = ModuleManifest::builder("acme/remote-crm")
3061            .runtime(RuntimeSurface {
3062                functions: vec![
3063                    RuntimeFunctionDeclaration {
3064                        name: "remote_crm/sync_contact.v1".to_owned(),
3065                        version: 1,
3066                        queue: "".to_owned(),
3067                        input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
3068                        retry_policy: Some(RuntimeRetryPolicyDeclaration {
3069                            max_attempts: 0,
3070                            initial_delay_ms: 1000,
3071                        }),
3072                        operation: None,
3073                    },
3074                    RuntimeFunctionDeclaration {
3075                        name: "remote_crm.sync_contact.v1".to_owned(),
3076                        version: 1,
3077                        queue: "remote-crm".to_owned(),
3078                        input_schema: Some("remote_crm.sync_contact.input.v1".to_owned()),
3079                        retry_policy: None,
3080                        operation: None,
3081                    },
3082                    RuntimeFunctionDeclaration {
3083                        name: "remote_crm.sync_contact.v1".to_owned(),
3084                        version: 1,
3085                        queue: "remote-crm".to_owned(),
3086                        input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
3087                        retry_policy: None,
3088                        operation: None,
3089                    },
3090                ],
3091                schedules: vec![],
3092                workflows: vec![],
3093            })
3094            .build();
3095
3096        let lints = lint_module_manifest(&manifest);
3097
3098        assert!(lints.iter().any(|lint| {
3099            lint.subject == "runtime.function.remote_crm/sync_contact.v1"
3100                && lint.severity == ModuleManifestLintSeverity::Warning
3101        }));
3102        assert!(lints.iter().any(|lint| {
3103            lint.subject == "runtime.function.remote_crm/sync_contact.v1.retry_policy"
3104                && lint.severity == ModuleManifestLintSeverity::Warning
3105        }));
3106        assert!(lints.iter().any(|lint| {
3107            lint.subject == "runtime.function.remote_crm.sync_contact.v1.input_schema"
3108                && lint.severity == ModuleManifestLintSeverity::Warning
3109        }));
3110        assert!(lints.iter().any(|lint| {
3111            lint.subject == "runtime.function.remote_crm.sync_contact.v1"
3112                && lint.severity == ModuleManifestLintSeverity::Error
3113        }));
3114    }
3115
3116    #[test]
3117    fn manifest_with_lifecycle_round_trips_through_json() {
3118        let manifest = ModuleManifest::builder("acme/remote-crm")
3119            .runtime(RuntimeSurface {
3120                functions: vec![RuntimeFunctionDeclaration {
3121                    name: "remote_crm.warm_contact_cache.v1".to_owned(),
3122                    version: 1,
3123                    queue: "remote-crm".to_owned(),
3124                    input_schema: Some("remote_crm.warm_contact_cache.v1".to_owned()),
3125                    retry_policy: Some(RuntimeRetryPolicyDeclaration {
3126                        max_attempts: 2,
3127                        initial_delay_ms: 500,
3128                    }),
3129                    operation: None,
3130                }],
3131                schedules: vec![],
3132                workflows: vec![],
3133            })
3134            .lifecycle(LifecycleSurface {
3135                startup_checks: vec![LifecycleStartupCheckDeclaration {
3136                    name: "warm cache function is registered".to_owned(),
3137                    required: true,
3138                    check: LifecycleStartupCheckKind::FunctionRegistered {
3139                        function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
3140                    },
3141                }],
3142                activation_jobs: vec![LifecycleActivationJobDeclaration {
3143                    name: "warm contact cache".to_owned(),
3144                    function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
3145                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
3146                    input: serde_json::json!({ "reason": "worker_startup" }),
3147                    required: true,
3148                }],
3149            })
3150            .build();
3151
3152        let json = serde_json::to_string(&manifest).expect("serialize");
3153
3154        assert!(json.contains(r#""lifecycle""#), "got {json}");
3155        assert!(
3156            json.contains(r#""kind":"function_registered""#),
3157            "got {json}"
3158        );
3159        assert!(
3160            json.contains(r#""run_policy":"every_startup""#),
3161            "got {json}"
3162        );
3163        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
3164        assert_eq!(manifest, back);
3165    }
3166
3167    #[test]
3168    fn manifest_lint_flags_lifecycle_declarations_that_cannot_run() {
3169        let manifest = ModuleManifest::builder("acme/remote-crm")
3170            .runtime(RuntimeSurface {
3171                functions: vec![],
3172                schedules: vec![],
3173                workflows: vec![],
3174            })
3175            .lifecycle(LifecycleSurface {
3176                startup_checks: vec![
3177                    LifecycleStartupCheckDeclaration {
3178                        name: "".to_owned(),
3179                        required: true,
3180                        check: LifecycleStartupCheckKind::FunctionRegistered {
3181                            function_name: "remote_crm.missing.v1".to_owned(),
3182                        },
3183                    },
3184                    LifecycleStartupCheckDeclaration {
3185                        name: "missing capability".to_owned(),
3186                        required: true,
3187                        check: LifecycleStartupCheckKind::CapabilityDeclared {
3188                            capability: "remote_crm.contacts.read".to_owned(),
3189                        },
3190                    },
3191                ],
3192                activation_jobs: vec![LifecycleActivationJobDeclaration {
3193                    name: "warm contact cache".to_owned(),
3194                    function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
3195                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
3196                    input: serde_json::json!({}),
3197                    required: true,
3198                }],
3199            })
3200            .build();
3201
3202        let lints = lint_module_manifest(&manifest);
3203
3204        assert!(lints.iter().any(|lint| {
3205            lint.subject == "lifecycle.startup_check"
3206                && lint.severity == ModuleManifestLintSeverity::Warning
3207                && lint.message == "Lifecycle startup check is missing a name."
3208        }));
3209        assert!(lints.iter().any(|lint| {
3210            lint.subject == "lifecycle.startup_check.function_registered.remote_crm.missing.v1"
3211                && lint.severity == ModuleManifestLintSeverity::Error
3212        }));
3213        assert!(lints.iter().any(|lint| {
3214            lint.subject == "lifecycle.startup_check.capability.remote_crm.contacts.read"
3215                && lint.severity == ModuleManifestLintSeverity::Warning
3216        }));
3217        assert!(lints.iter().any(|lint| {
3218            lint.subject == "lifecycle.activation_job.warm contact cache"
3219                && lint.severity == ModuleManifestLintSeverity::Error
3220        }));
3221    }
3222
3223    #[test]
3224    fn manifest_lint_warns_for_empty_lifecycle_surface() {
3225        let manifest = ModuleManifest::builder("acme/remote-crm")
3226            .lifecycle(LifecycleSurface {
3227                startup_checks: vec![],
3228                activation_jobs: vec![],
3229            })
3230            .build();
3231
3232        let lints = lint_module_manifest(&manifest);
3233
3234        assert!(lints.iter().any(|lint| {
3235            lint.subject == "lifecycle"
3236                && lint.severity == ModuleManifestLintSeverity::Warning
3237                && lint.message
3238                    == "Lifecycle surface declares no startup checks or activation jobs."
3239        }));
3240    }
3241
3242    #[test]
3243    fn manifest_lint_warns_for_activation_job_missing_name() {
3244        let manifest = ModuleManifest::builder("acme/remote-crm")
3245            .runtime(RuntimeSurface {
3246                functions: vec![RuntimeFunctionDeclaration {
3247                    name: "remote_crm.warm_contact_cache.v1".to_owned(),
3248                    version: 1,
3249                    queue: "remote-crm".to_owned(),
3250                    input_schema: Some("remote_crm.warm_contact_cache.v1".to_owned()),
3251                    retry_policy: None,
3252                    operation: None,
3253                }],
3254                schedules: vec![],
3255                workflows: vec![],
3256            })
3257            .lifecycle(LifecycleSurface {
3258                startup_checks: vec![],
3259                activation_jobs: vec![LifecycleActivationJobDeclaration {
3260                    name: "".to_owned(),
3261                    function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
3262                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
3263                    input: serde_json::json!({}),
3264                    required: true,
3265                }],
3266            })
3267            .build();
3268
3269        let lints = lint_module_manifest(&manifest);
3270
3271        assert!(lints.iter().any(|lint| {
3272            lint.subject == "lifecycle.activation_job"
3273                && lint.severity == ModuleManifestLintSeverity::Warning
3274                && lint.message == "Lifecycle activation job is missing a name."
3275        }));
3276    }
3277
3278    #[test]
3279    fn manifest_lint_errors_for_activation_job_missing_function_name() {
3280        let manifest = ModuleManifest::builder("acme/remote-crm")
3281            .lifecycle(LifecycleSurface {
3282                startup_checks: vec![],
3283                activation_jobs: vec![LifecycleActivationJobDeclaration {
3284                    name: "".to_owned(),
3285                    function_name: "".to_owned(),
3286                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
3287                    input: serde_json::json!({}),
3288                    required: true,
3289                }],
3290            })
3291            .build();
3292
3293        let lints = lint_module_manifest(&manifest);
3294
3295        assert!(lints.iter().any(|lint| {
3296            lint.subject == "lifecycle.activation_job"
3297                && lint.severity == ModuleManifestLintSeverity::Error
3298                && lint.message == "Lifecycle activation job is missing a function name."
3299        }));
3300    }
3301
3302    #[test]
3303    fn manifest_lint_warns_for_undeclared_capability_references() {
3304        use crate::admin::{AdminAction, AdminActionDangerLevel};
3305
3306        let manifest = ModuleManifest::builder("acme/remote-crm")
3307            .capabilities(vec!["remote_crm.contacts.write".to_owned()])
3308            .http_routes(vec![ModuleHttpRoute {
3309                method: ModuleHttpMethod::Get,
3310                path: "/contacts/{id}".to_owned(),
3311                capability: Some("remote_crm.contacts.read".to_owned()),
3312                display_name: Some("Fetch Contact".to_owned()),
3313                story_title: Some("Fetch Contact".to_owned()),
3314                operation: None,
3315            }])
3316            .declarative_admin(AdminDeclarativeSurface {
3317                pages: vec![AdminDeclarativePage {
3318                    name: "contacts".to_owned(),
3319                    label: "Contacts".to_owned(),
3320                    sections: vec![AdminDeclarativeSection {
3321                        name: "contacts".to_owned(),
3322                        label: "Contacts".to_owned(),
3323                        component: AdminDeclarativeComponent::EntityTable {
3324                            entity: "contacts".to_owned(),
3325                        },
3326                    }],
3327                }],
3328                actions: vec![AdminAction {
3329                    name: "sync_contacts".to_owned(),
3330                    label: "Sync Contacts".to_owned(),
3331                    capability: "remote_crm.contacts.sync".to_owned(),
3332                    input_schema: None,
3333                    confirmation: None,
3334                    danger_level: AdminActionDangerLevel::Low,
3335                    operation: None,
3336                }],
3337                fallback_schema: Some(AdminSchema {
3338                    entities: vec![crate::EntitySchema {
3339                        name: "contacts".to_owned(),
3340                        label: "Contacts".to_owned(),
3341                        fields: vec![],
3342                        read_capability: "remote_crm.contacts.read".to_owned(),
3343                    }],
3344                }),
3345            })
3346            .build();
3347
3348        let lints = lint_module_manifest(&manifest);
3349
3350        assert!(lints.iter().any(|lint| {
3351            lint.severity == ModuleManifestLintSeverity::Warning
3352                && lint.subject == "capability.reference.http_route.GET /contacts/{id}"
3353                && lint.message == "Capability reference is not declared by the module."
3354        }));
3355        assert!(lints.iter().any(|lint| {
3356            lint.severity == ModuleManifestLintSeverity::Warning
3357                && lint.subject == "capability.reference.admin.declarative.action.sync_contacts"
3358                && lint.message == "Capability reference is not declared by the module."
3359        }));
3360        assert!(lints.iter().any(|lint| {
3361            lint.severity == ModuleManifestLintSeverity::Warning
3362                && lint.subject == "capability.reference.admin.declarative.fallback_schema.contacts"
3363                && lint.message == "Capability reference is not declared by the module."
3364        }));
3365    }
3366
3367    #[test]
3368    fn manifest_lint_catalog_covers_current_subjects() {
3369        let schema = AdminSchema {
3370            entities: vec![crate::EntitySchema {
3371                name: "contacts".to_owned(),
3372                label: "Contacts".to_owned(),
3373                fields: vec![],
3374                read_capability: "".to_owned(),
3375            }],
3376        };
3377        let manifest = ModuleManifest::builder("")
3378            .capabilities(vec!["RemoteCRM Contacts Read".to_owned()])
3379            .http_routes(vec![
3380                ModuleHttpRoute {
3381                    method: ModuleHttpMethod::Get,
3382                    path: "/contacts/{id}".to_owned(),
3383                    capability: None,
3384                    display_name: None,
3385                    story_title: None,
3386                    operation: None,
3387                },
3388                ModuleHttpRoute {
3389                    method: ModuleHttpMethod::Get,
3390                    path: "/contacts/{id}".to_owned(),
3391                    capability: None,
3392                    display_name: None,
3393                    story_title: None,
3394                    operation: None,
3395                },
3396            ])
3397            .embedded_admin(AdminEmbeddedSurface {
3398                runtime: AdminEmbeddedRuntime::Wasm,
3399                entry: AdminEmbeddedEntry::Url {
3400                    url: "http://crm.example.test/admin".to_owned(),
3401                    allowed_origins: vec![],
3402                },
3403                sandbox: AdminSandboxPolicy {
3404                    allow_scripts: true,
3405                    allow_forms: false,
3406                    allow_popups: false,
3407                    allow_same_origin: false,
3408                },
3409                permissions: vec![AdminPermission::ReadEntity {
3410                    entity: "missing".to_owned(),
3411                }],
3412                fallback_schema: Some(schema),
3413            })
3414            .runtime(RuntimeSurface {
3415                functions: vec![RuntimeFunctionDeclaration {
3416                    name: "remote_crm.sync_contact.v1".to_owned(),
3417                    version: 1,
3418                    queue: "".to_owned(),
3419                    input_schema: Some("remote_crm.sync_contact.input.v1".to_owned()),
3420                    retry_policy: Some(RuntimeRetryPolicyDeclaration {
3421                        max_attempts: 0,
3422                        initial_delay_ms: 1000,
3423                    }),
3424                    operation: None,
3425                }],
3426                schedules: vec![ScheduledFunctionDeclaration {
3427                    name: "sync_contacts_hourly".to_owned(),
3428                    function_name: "remote_crm.missing.v1".to_owned(),
3429                    cron: "bad cron".to_owned(),
3430                    input: serde_json::json!({}),
3431                }],
3432                workflows: vec![],
3433            })
3434            .lifecycle(LifecycleSurface {
3435                startup_checks: vec![LifecycleStartupCheckDeclaration {
3436                    name: "missing function".to_owned(),
3437                    required: true,
3438                    check: LifecycleStartupCheckKind::FunctionRegistered {
3439                        function_name: "remote_crm.missing.v1".to_owned(),
3440                    },
3441                }],
3442                activation_jobs: vec![LifecycleActivationJobDeclaration {
3443                    name: "missing activation".to_owned(),
3444                    function_name: "remote_crm.missing.v1".to_owned(),
3445                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
3446                    input: serde_json::json!({}),
3447                    required: true,
3448                }],
3449            })
3450            .console(vec![ConsoleSurface {
3451                name: "contacts".to_owned(),
3452                label: "Contacts".to_owned(),
3453                route: "/remote-crm/contacts".to_owned(),
3454                presentation: ConsoleSurfacePresentation::Esm {
3455                    entry: "remoteCrmConsoleModule".to_owned(),
3456                },
3457                icon: None,
3458                required_capabilities: Vec::new(),
3459                navigation: Some(crate::ConsoleNavigation {
3460                    workspace: crate::ConsoleWorkspaceRef {
3461                        id: "system".to_owned(),
3462                        label: "System".to_owned(),
3463                        icon: None,
3464                    },
3465                    group: None,
3466                    order: None,
3467                }),
3468            }])
3469            .build();
3470
3471        let catalog: Vec<_> = lint_module_manifest(&manifest)
3472            .into_iter()
3473            .map(|lint| (lint.severity, lint.subject))
3474            .collect();
3475
3476        assert_eq!(
3477            catalog,
3478            vec![
3479                (
3480                    ModuleManifestLintSeverity::Error,
3481                    "module.module_id".to_owned(),
3482                ),
3483                (
3484                    ModuleManifestLintSeverity::Warning,
3485                    "capability RemoteCRM Contacts Read".to_owned(),
3486                ),
3487                (
3488                    ModuleManifestLintSeverity::Error,
3489                    "GET /contacts/{id}".to_owned(),
3490                ),
3491                (
3492                    ModuleManifestLintSeverity::Warning,
3493                    "GET /contacts/{id}".to_owned(),
3494                ),
3495                (
3496                    ModuleManifestLintSeverity::Warning,
3497                    "GET /contacts/{id}".to_owned(),
3498                ),
3499                (
3500                    ModuleManifestLintSeverity::Warning,
3501                    "GET /contacts/{id}".to_owned(),
3502                ),
3503                (
3504                    ModuleManifestLintSeverity::Warning,
3505                    "GET /contacts/{id}".to_owned(),
3506                ),
3507                (
3508                    ModuleManifestLintSeverity::Warning,
3509                    "admin.embedded.runtime".to_owned(),
3510                ),
3511                (
3512                    ModuleManifestLintSeverity::Warning,
3513                    "admin.embedded.entry.url".to_owned(),
3514                ),
3515                (
3516                    ModuleManifestLintSeverity::Warning,
3517                    "admin.embedded.entry.allowed_origins".to_owned(),
3518                ),
3519                (
3520                    ModuleManifestLintSeverity::Warning,
3521                    "admin.embedded.fallback_schema.contacts".to_owned(),
3522                ),
3523                (
3524                    ModuleManifestLintSeverity::Warning,
3525                    "admin.embedded.permission.missing".to_owned(),
3526                ),
3527                (
3528                    ModuleManifestLintSeverity::Error,
3529                    "lifecycle.startup_check.function_registered.remote_crm.missing.v1".to_owned(),
3530                ),
3531                (
3532                    ModuleManifestLintSeverity::Error,
3533                    "lifecycle.activation_job.missing activation".to_owned(),
3534                ),
3535                (
3536                    ModuleManifestLintSeverity::Warning,
3537                    "console.surface.contacts.navigation.workspace.id".to_owned(),
3538                ),
3539                (
3540                    ModuleManifestLintSeverity::Warning,
3541                    "runtime.function.remote_crm.sync_contact.v1".to_owned(),
3542                ),
3543                (
3544                    ModuleManifestLintSeverity::Warning,
3545                    "runtime.function.remote_crm.sync_contact.v1.input_schema".to_owned(),
3546                ),
3547                (
3548                    ModuleManifestLintSeverity::Warning,
3549                    "runtime.function.remote_crm.sync_contact.v1.retry_policy".to_owned(),
3550                ),
3551                (
3552                    ModuleManifestLintSeverity::Error,
3553                    "runtime.schedule.sync_contacts_hourly.cron".to_owned(),
3554                ),
3555                (
3556                    ModuleManifestLintSeverity::Error,
3557                    "runtime.schedule.sync_contacts_hourly".to_owned(),
3558                ),
3559            ],
3560        );
3561    }
3562}