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, ConsoleSlot,
12    ConsoleSurface,
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::module_source::ModuleSource;
21use crate::runtime::{RuntimeFunctionDeclaration, RuntimeSurface, ScheduledFunctionDeclaration};
22use crate::validate_cron_expression;
23use serde::{Deserialize, Serialize};
24use std::collections::HashSet;
25use utoipa::ToSchema;
26
27/// The serializable metadata a module exposes. Runtime config is deliberately
28/// NOT here — it stays an internal `&'static` field on [`crate::Module`]
29/// because the config registry needs the real (non-serde) `RuntimeConfigType`
30/// to validate. Only round-trippable fields belong here.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[non_exhaustive]
33pub struct ModuleManifest {
34    /// Stable module name, e.g. `"identity"`.
35    pub name: String,
36
37    /// Console story-display metadata.
38    #[serde(default)]
39    pub story_display: Vec<StoryDisplayDescriptor>,
40
41    /// Admin surface: `Some(AdminSurface::Schema(_))` for schema-driven CRUD,
42    /// future custom surfaces for richer module admin UI, or `None` for modules
43    /// with no admin surface (e.g. notifications).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub admin: Option<AdminSurface>,
46
47    /// Declared module-owned HTTP routes. These are metadata only until a
48    /// loading-source-specific mount/proxy protocol exists.
49    #[serde(default)]
50    pub http_routes: Vec<ModuleHttpRoute>,
51
52    /// Declared runtime behavior. These entries are manifest data only; source
53    /// bindings decide how to register executable behavior.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub runtime: Option<RuntimeSurface>,
56
57    /// Declared event subscriptions. These entries are manifest data only;
58    /// source bindings decide how to register executable behavior.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub events: Option<EventSurface>,
61
62    /// Declared lifecycle work. The host validates and schedules these entries;
63    /// modules do not receive arbitrary startup callbacks.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub lifecycle: Option<LifecycleSurface>,
66
67    /// Declared Runtime Console surfaces provided by trusted frontend packages.
68    #[serde(default)]
69    pub console: Vec<ConsoleSurface>,
70
71    /// Declared Runtime Console extension slots owned by host or module surfaces.
72    #[serde(default)]
73    pub console_slots: Vec<ConsoleSlot>,
74
75    /// Declared Runtime Console slot contributions attached to host or module-owned surfaces.
76    #[serde(default)]
77    pub console_contributions: Vec<ConsoleContribution>,
78
79    /// RESERVED SEAM — capabilities the module declares (perms/tenancy).
80    #[serde(default)]
81    pub capabilities: Vec<String>,
82
83    /// Other modules this module requires to be installed first.
84    #[serde(default)]
85    pub dependencies: Vec<String>,
86}
87
88impl ModuleManifest {
89    /// Start building a manifest for `name`.
90    #[must_use]
91    pub fn builder(name: impl Into<String>) -> ModuleManifestBuilder {
92        ModuleManifestBuilder {
93            manifest: ModuleManifest {
94                name: name.into(),
95                story_display: Vec::new(),
96                admin: None,
97                http_routes: Vec::new(),
98                runtime: None,
99                events: None,
100                lifecycle: None,
101                console: Vec::new(),
102                console_slots: Vec::new(),
103                console_contributions: Vec::new(),
104                capabilities: Vec::new(),
105                dependencies: Vec::new(),
106            },
107        }
108    }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
112#[serde(rename_all = "snake_case")]
113pub enum ModuleManifestLintSeverity {
114    Ok,
115    Warning,
116    Error,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
120pub struct ModuleManifestLint {
121    pub severity: ModuleManifestLintSeverity,
122    pub subject: String,
123    pub message: String,
124    pub suggestion: String,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ModuleCapabilityReference {
129    pub capability: String,
130    pub subject: String,
131}
132
133pub fn lint_module_manifest(
134    source: ModuleSource,
135    manifest: &ModuleManifest,
136) -> Vec<ModuleManifestLint> {
137    lint_module_manifest_parts(
138        source,
139        &manifest.name,
140        manifest.admin.as_ref(),
141        &manifest.http_routes,
142        manifest.runtime.as_ref(),
143        manifest.events.as_ref(),
144        manifest.lifecycle.as_ref(),
145        &manifest.console,
146        &manifest.console_slots,
147        &manifest.console_contributions,
148        &manifest.capabilities,
149        &manifest.dependencies,
150    )
151}
152
153pub fn lint_module_manifest_parts(
154    source: ModuleSource,
155    name: &str,
156    admin: Option<&AdminSurface>,
157    http_routes: &[ModuleHttpRoute],
158    runtime: Option<&RuntimeSurface>,
159    events: Option<&EventSurface>,
160    lifecycle: Option<&LifecycleSurface>,
161    console: &[ConsoleSurface],
162    console_slots: &[ConsoleSlot],
163    console_contributions: &[ConsoleContribution],
164    capabilities: &[String],
165    dependencies: &[String],
166) -> Vec<ModuleManifestLint> {
167    let mut lints = Vec::new();
168
169    if !present(name) {
170        lints.push(ModuleManifestLint {
171            severity: ModuleManifestLintSeverity::Error,
172            subject: "module.name".to_owned(),
173            message: "Missing module manifest name.".to_owned(),
174            suggestion: "Set ModuleManifest.name to the stable module identifier.".to_owned(),
175        });
176    }
177
178    for capability in capabilities {
179        if !valid_capability(capability) {
180            lints.push(ModuleManifestLint {
181                severity: ModuleManifestLintSeverity::Warning,
182                subject: format!("capability {capability}"),
183                message: "Capability name should use dot-separated lowercase identifiers."
184                    .to_owned(),
185                suggestion: "Use a stable capability name such as module.entity.read.".to_owned(),
186            });
187        }
188    }
189    for dependency in dependencies {
190        if !present(dependency) {
191            lints.push(ModuleManifestLint {
192                severity: ModuleManifestLintSeverity::Error,
193                subject: "dependency".to_owned(),
194                message: "Module dependency name must not be empty.".to_owned(),
195                suggestion: "Remove the empty dependency or set it to a stable module name."
196                    .to_owned(),
197            });
198        } else if dependency == name {
199            lints.push(ModuleManifestLint {
200                severity: ModuleManifestLintSeverity::Error,
201                subject: format!("dependency {dependency}"),
202                message: "Module must not depend on itself.".to_owned(),
203                suggestion: "Remove the self dependency from ModuleManifest.dependencies."
204                    .to_owned(),
205            });
206        }
207    }
208
209    for route_lint in lint_module_http_routes(source, http_routes) {
210        lints.push(ModuleManifestLint {
211            severity: match route_lint.severity {
212                crate::http::ModuleRouteLintSeverity::Ok => ModuleManifestLintSeverity::Ok,
213                crate::http::ModuleRouteLintSeverity::Warning => {
214                    ModuleManifestLintSeverity::Warning
215                }
216                crate::http::ModuleRouteLintSeverity::Error => ModuleManifestLintSeverity::Error,
217            },
218            subject: route_lint.subject,
219            message: route_lint.message,
220            suggestion: route_lint.suggestion,
221        });
222    }
223    lint_capability_references(
224        admin,
225        http_routes,
226        lifecycle,
227        console,
228        console_contributions,
229        capabilities,
230        &mut lints,
231    );
232
233    if let Some(admin) = admin {
234        lint_admin_surface(admin, &mut lints);
235    }
236    let mut runtime_lints = Vec::new();
237    if let Some(runtime) = runtime {
238        lint_runtime_surface(runtime, &mut runtime_lints);
239    }
240    if let Some(events) = events {
241        lint_event_surface(events, &mut lints);
242    }
243    if let Some(lifecycle) = lifecycle {
244        lint_lifecycle_surface(lifecycle, runtime, capabilities, &mut lints);
245    }
246    lint_console_surfaces(console, &mut lints);
247    lint_console_slots(console_slots, &mut lints);
248    lint_console_contributions(console_contributions, &mut lints);
249    lints.extend(runtime_lints);
250
251    if lints.is_empty() {
252        lints.push(ModuleManifestLint {
253            severity: ModuleManifestLintSeverity::Ok,
254            subject: "manifest".to_owned(),
255            message: "Module manifest metadata is complete.".to_owned(),
256            suggestion: "No action needed.".to_owned(),
257        });
258    }
259
260    lints
261}
262
263pub fn module_capability_references(
264    admin: Option<&AdminSurface>,
265    http_routes: &[ModuleHttpRoute],
266    lifecycle: Option<&LifecycleSurface>,
267    console: &[ConsoleSurface],
268    console_contributions: &[ConsoleContribution],
269) -> Vec<ModuleCapabilityReference> {
270    let mut references = Vec::new();
271
272    for route in http_routes {
273        if let Some(capability) = route.capability.as_deref()
274            && present(capability)
275        {
276            references.push(ModuleCapabilityReference {
277                capability: capability.to_owned(),
278                subject: format!("http_route.{}", route_identity(route)),
279            });
280        }
281    }
282
283    if let Some(admin) = admin {
284        collect_admin_capability_references(admin, &mut references);
285    }
286
287    if let Some(lifecycle) = lifecycle {
288        for check in &lifecycle.startup_checks {
289            if let LifecycleStartupCheckKind::CapabilityDeclared { capability } = &check.check
290                && present(capability)
291            {
292                references.push(ModuleCapabilityReference {
293                    capability: capability.to_owned(),
294                    subject: format!("lifecycle.startup_check.capability.{capability}"),
295                });
296            }
297        }
298    }
299
300    for surface in console {
301        let subject = if present(&surface.name) {
302            format!("console.surface.{}", surface.name)
303        } else {
304            "console.surface".to_owned()
305        };
306        for capability in &surface.required_capabilities {
307            if present(capability) {
308                references.push(ModuleCapabilityReference {
309                    capability: capability.clone(),
310                    subject: subject.clone(),
311                });
312            }
313        }
314    }
315
316    for contribution in console_contributions {
317        let subject = if present(&contribution.target) {
318            format!("console.contribution.{}", contribution.target)
319        } else {
320            "console.contribution".to_owned()
321        };
322        for capability in &contribution.required_capabilities {
323            if present(capability) {
324                references.push(ModuleCapabilityReference {
325                    capability: capability.clone(),
326                    subject: subject.clone(),
327                });
328            }
329        }
330    }
331
332    references
333}
334
335fn lint_capability_references(
336    admin: Option<&AdminSurface>,
337    http_routes: &[ModuleHttpRoute],
338    lifecycle: Option<&LifecycleSurface>,
339    console: &[ConsoleSurface],
340    console_contributions: &[ConsoleContribution],
341    capabilities: &[String],
342    lints: &mut Vec<ModuleManifestLint>,
343) {
344    let declared = capabilities
345        .iter()
346        .map(String::as_str)
347        .collect::<HashSet<_>>();
348
349    for reference in module_capability_references(
350        admin,
351        http_routes,
352        lifecycle,
353        console,
354        console_contributions,
355    ) {
356        // Lifecycle startup checks already produce a lifecycle-specific lint with
357        // the check context and required/optional semantics.
358        if reference.subject.starts_with("lifecycle.") {
359            continue;
360        }
361        if declared.contains(reference.capability.as_str()) {
362            continue;
363        }
364        lints.push(ModuleManifestLint {
365            severity: ModuleManifestLintSeverity::Warning,
366            subject: format!("capability.reference.{}", reference.subject),
367            message: "Capability reference is not declared by the module.".to_owned(),
368            suggestion: format!(
369                "Add `{}` to ModuleManifest.capabilities or update the reference.",
370                reference.capability
371            ),
372        });
373    }
374}
375
376fn collect_admin_capability_references(
377    admin: &AdminSurface,
378    references: &mut Vec<ModuleCapabilityReference>,
379) {
380    match admin {
381        AdminSurface::Schema(schema) => {
382            collect_schema_capability_references("admin.schema", schema, references);
383        }
384        AdminSurface::DeclarativeCustom(surface) => {
385            collect_declarative_query_capability_references(surface, references);
386            for action in &surface.actions {
387                if present(&action.capability) {
388                    let action_subject = if present(&action.name) {
389                        format!("admin.declarative.action.{}", action.name)
390                    } else {
391                        "admin.declarative.action".to_owned()
392                    };
393                    references.push(ModuleCapabilityReference {
394                        capability: action.capability.clone(),
395                        subject: action_subject,
396                    });
397                }
398            }
399            if let Some(schema) = &surface.fallback_schema {
400                collect_schema_capability_references(
401                    "admin.declarative.fallback_schema",
402                    schema,
403                    references,
404                );
405            }
406        }
407        AdminSurface::EmbeddedCustom(surface) => {
408            if let Some(schema) = &surface.fallback_schema {
409                collect_schema_capability_references(
410                    "admin.embedded.fallback_schema",
411                    schema,
412                    references,
413                );
414            }
415        }
416    }
417}
418
419fn collect_schema_capability_references(
420    prefix: &str,
421    schema: &AdminSchema,
422    references: &mut Vec<ModuleCapabilityReference>,
423) {
424    for entity in &schema.entities {
425        if present(&entity.read_capability) {
426            references.push(ModuleCapabilityReference {
427                capability: entity.read_capability.clone(),
428                subject: format!("{prefix}.{}", entity.name),
429            });
430        }
431    }
432}
433
434fn lint_runtime_surface(runtime: &RuntimeSurface, lints: &mut Vec<ModuleManifestLint>) {
435    if runtime.functions.is_empty() && runtime.schedules.is_empty() {
436        lints.push(ModuleManifestLint {
437            severity: ModuleManifestLintSeverity::Warning,
438            subject: "runtime".to_owned(),
439            message: "Runtime surface declares no functions or schedules.".to_owned(),
440            suggestion: "Add at least one runtime declaration or omit the runtime surface."
441                .to_owned(),
442        });
443        return;
444    }
445
446    let mut names = HashSet::new();
447    for function in &runtime.functions {
448        lint_runtime_function(function, &mut names, lints);
449    }
450    let function_names = runtime_function_names(Some(runtime));
451    let mut schedule_names = HashSet::new();
452    for schedule in &runtime.schedules {
453        lint_scheduled_function(schedule, &function_names, &mut schedule_names, lints);
454    }
455}
456
457fn lint_runtime_function(
458    function: &RuntimeFunctionDeclaration,
459    names: &mut HashSet<String>,
460    lints: &mut Vec<ModuleManifestLint>,
461) {
462    let subject = if present(&function.name) {
463        format!("runtime.function.{}", function.name)
464    } else {
465        "runtime.function".to_owned()
466    };
467
468    if !present(&function.name) {
469        lints.push(ModuleManifestLint {
470            severity: ModuleManifestLintSeverity::Error,
471            subject: subject.clone(),
472            message: "Runtime function declaration is missing a name.".to_owned(),
473            suggestion: "Set a stable versioned function name such as module.action.v1.".to_owned(),
474        });
475    } else if !valid_runtime_function_name(&function.name) {
476        lints.push(ModuleManifestLint {
477            severity: ModuleManifestLintSeverity::Warning,
478            subject: subject.clone(),
479            message: "Runtime function name should be a stable path-safe identifier.".to_owned(),
480            suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
481        });
482    } else if !names.insert(function.name.clone()) {
483        lints.push(ModuleManifestLint {
484            severity: ModuleManifestLintSeverity::Error,
485            subject: subject.clone(),
486            message: "Duplicate runtime function declaration.".to_owned(),
487            suggestion: "Keep one declaration per runtime function name.".to_owned(),
488        });
489    }
490
491    if !present(&function.queue) {
492        lints.push(ModuleManifestLint {
493            severity: ModuleManifestLintSeverity::Warning,
494            subject: subject.clone(),
495            message: "Runtime function declaration is missing a queue.".to_owned(),
496            suggestion: "Set the host queue used to claim this function.".to_owned(),
497        });
498    }
499
500    if let Some(input_schema) = &function.input_schema
501        && input_schema != &function.name
502    {
503        lints.push(ModuleManifestLint {
504            severity: ModuleManifestLintSeverity::Warning,
505            subject: format!("{subject}.input_schema"),
506            message: "Runtime function input schema does not match the function name.".to_owned(),
507            suggestion: "Use the versioned function name as the input_schema contract identifier."
508                .to_owned(),
509        });
510    }
511
512    if let Some(retry_policy) = &function.retry_policy
513        && retry_policy.max_attempts == 0
514    {
515        lints.push(ModuleManifestLint {
516            severity: ModuleManifestLintSeverity::Warning,
517            subject: format!("{subject}.retry_policy"),
518            message: "Runtime function retry policy declares zero attempts.".to_owned(),
519            suggestion: "Set max_attempts to at least 1 or omit the retry policy.".to_owned(),
520        });
521    }
522}
523
524fn lint_scheduled_function(
525    schedule: &ScheduledFunctionDeclaration,
526    runtime_functions: &HashSet<String>,
527    names: &mut HashSet<String>,
528    lints: &mut Vec<ModuleManifestLint>,
529) {
530    let subject = if present(&schedule.name) {
531        format!("runtime.schedule.{}", schedule.name)
532    } else {
533        "runtime.schedule".to_owned()
534    };
535
536    if !present(&schedule.name) {
537        lints.push(ModuleManifestLint {
538            severity: ModuleManifestLintSeverity::Error,
539            subject: subject.clone(),
540            message: "Scheduled runtime function is missing a name.".to_owned(),
541            suggestion: "Set a stable schedule name such as sync_contacts_hourly.".to_owned(),
542        });
543    } else if !valid_runtime_function_name(&schedule.name) {
544        lints.push(ModuleManifestLint {
545            severity: ModuleManifestLintSeverity::Warning,
546            subject: subject.clone(),
547            message: "Scheduled runtime function name should be path-safe.".to_owned(),
548            suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
549        });
550    } else if !names.insert(schedule.name.clone()) {
551        lints.push(ModuleManifestLint {
552            severity: ModuleManifestLintSeverity::Error,
553            subject: subject.clone(),
554            message: "Duplicate scheduled runtime function declaration.".to_owned(),
555            suggestion: "Keep one schedule declaration per schedule name.".to_owned(),
556        });
557    }
558
559    if !present(&schedule.cron) {
560        lints.push(ModuleManifestLint {
561            severity: ModuleManifestLintSeverity::Error,
562            subject: format!("{subject}.cron"),
563            message: "Scheduled runtime function is missing a cron expression.".to_owned(),
564            suggestion: "Set cron to a standard 5-field UTC cron expression.".to_owned(),
565        });
566    } else if validate_cron_expression(&schedule.cron).is_err() {
567        lints.push(ModuleManifestLint {
568            severity: ModuleManifestLintSeverity::Error,
569            subject: format!("{subject}.cron"),
570            message: "Scheduled runtime function cron expression is invalid.".to_owned(),
571            suggestion: "Use a standard 5-field expression such as */15 * * * *.".to_owned(),
572        });
573    }
574
575    if !present(&schedule.function_name) {
576        lints.push(ModuleManifestLint {
577            severity: ModuleManifestLintSeverity::Error,
578            subject,
579            message: "Scheduled runtime function is missing a function name.".to_owned(),
580            suggestion: "Set function_name to a declared runtime function.".to_owned(),
581        });
582    } else if !runtime_functions.contains(&schedule.function_name) {
583        lints.push(ModuleManifestLint {
584            severity: ModuleManifestLintSeverity::Error,
585            subject,
586            message: "Scheduled runtime function references an unknown runtime function."
587                .to_owned(),
588            suggestion:
589                "Declare the function in ModuleManifest.runtime.functions or remove the schedule."
590                    .to_owned(),
591        });
592    }
593}
594
595fn lint_event_surface(events: &EventSurface, lints: &mut Vec<ModuleManifestLint>) {
596    if events.handlers.is_empty() {
597        lints.push(ModuleManifestLint {
598            severity: ModuleManifestLintSeverity::Warning,
599            subject: "events.handlers".to_owned(),
600            message: "Event surface declares no handlers.".to_owned(),
601            suggestion: "Add at least one event handler declaration or omit the events surface."
602                .to_owned(),
603        });
604        return;
605    }
606
607    let mut names = HashSet::new();
608    for handler in &events.handlers {
609        lint_event_handler(handler, &mut names, lints);
610    }
611}
612
613fn lint_event_handler(
614    handler: &EventHandlerDeclaration,
615    names: &mut HashSet<String>,
616    lints: &mut Vec<ModuleManifestLint>,
617) {
618    let subject = if present(&handler.name) {
619        format!("events.handler.{}", handler.name)
620    } else {
621        "events.handler".to_owned()
622    };
623
624    if !present(&handler.name) {
625        lints.push(ModuleManifestLint {
626            severity: ModuleManifestLintSeverity::Error,
627            subject: subject.clone(),
628            message: "Event handler declaration is missing a name.".to_owned(),
629            suggestion: "Set a stable handler name such as sync_contact_on_user_registered."
630                .to_owned(),
631        });
632    } else if !valid_runtime_function_name(&handler.name) {
633        lints.push(ModuleManifestLint {
634            severity: ModuleManifestLintSeverity::Warning,
635            subject: subject.clone(),
636            message: "Event handler name should be a stable path-safe identifier.".to_owned(),
637            suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
638        });
639    } else if !names.insert(handler.name.clone()) {
640        lints.push(ModuleManifestLint {
641            severity: ModuleManifestLintSeverity::Error,
642            subject: subject.clone(),
643            message: "Duplicate event handler declaration.".to_owned(),
644            suggestion: "Keep one declaration per event handler name.".to_owned(),
645        });
646    }
647
648    if !present(&handler.event_name) {
649        lints.push(ModuleManifestLint {
650            severity: ModuleManifestLintSeverity::Error,
651            subject: format!("{subject}.event_name"),
652            message: "Event handler declaration is missing an event_name.".to_owned(),
653            suggestion: "Set the stable outbox event name this handler consumes.".to_owned(),
654        });
655    } else if !valid_runtime_function_name(&handler.event_name) {
656        lints.push(ModuleManifestLint {
657            severity: ModuleManifestLintSeverity::Warning,
658            subject: format!("{subject}.event_name"),
659            message: "Event name should be a stable path-safe identifier.".to_owned(),
660            suggestion: "Use the versioned event name such as identity.user_registered.v1."
661                .to_owned(),
662        });
663    }
664}
665
666fn lint_lifecycle_surface(
667    lifecycle: &LifecycleSurface,
668    runtime: Option<&RuntimeSurface>,
669    capabilities: &[String],
670    lints: &mut Vec<ModuleManifestLint>,
671) {
672    if lifecycle.startup_checks.is_empty() && lifecycle.activation_jobs.is_empty() {
673        lints.push(ModuleManifestLint {
674            severity: ModuleManifestLintSeverity::Warning,
675            subject: "lifecycle".to_owned(),
676            message: "Lifecycle surface declares no startup checks or activation jobs.".to_owned(),
677            suggestion: "Add lifecycle entries or omit the lifecycle surface.".to_owned(),
678        });
679        return;
680    }
681
682    let runtime_functions = runtime_function_names(runtime);
683    let capability_names = capabilities.iter().cloned().collect::<HashSet<_>>();
684
685    for check in &lifecycle.startup_checks {
686        lint_lifecycle_startup_check(check, &runtime_functions, &capability_names, lints);
687    }
688
689    for job in &lifecycle.activation_jobs {
690        lint_lifecycle_activation_job(job, &runtime_functions, lints);
691    }
692}
693
694fn lint_lifecycle_startup_check(
695    check: &LifecycleStartupCheckDeclaration,
696    runtime_functions: &HashSet<String>,
697    capabilities: &HashSet<String>,
698    lints: &mut Vec<ModuleManifestLint>,
699) {
700    if !present(&check.name) {
701        lints.push(ModuleManifestLint {
702            severity: ModuleManifestLintSeverity::Warning,
703            subject: "lifecycle.startup_check".to_owned(),
704            message: "Lifecycle startup check is missing a name.".to_owned(),
705            suggestion: "Set a short operator-facing check name.".to_owned(),
706        });
707    }
708
709    match &check.check {
710        LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
711            if !runtime_functions.contains(function_name) {
712                lints.push(ModuleManifestLint {
713                    severity: ModuleManifestLintSeverity::Error,
714                    subject: format!(
715                        "lifecycle.startup_check.function_registered.{function_name}"
716                    ),
717                    message: "Lifecycle startup check references an unknown runtime function."
718                        .to_owned(),
719                    suggestion:
720                        "Declare the function in ModuleManifest.runtime.functions or remove the check."
721                            .to_owned(),
722                });
723            }
724        }
725        LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
726            if !capabilities.contains(capability) {
727                lints.push(ModuleManifestLint {
728                    severity: ModuleManifestLintSeverity::Warning,
729                    subject: format!("lifecycle.startup_check.capability.{capability}"),
730                    message: "Lifecycle startup check references an undeclared capability."
731                        .to_owned(),
732                    suggestion:
733                        "Add the capability to ModuleManifest.capabilities or update the check."
734                            .to_owned(),
735                });
736            }
737        }
738    }
739}
740
741fn lint_lifecycle_activation_job(
742    job: &LifecycleActivationJobDeclaration,
743    runtime_functions: &HashSet<String>,
744    lints: &mut Vec<ModuleManifestLint>,
745) {
746    let subject = if present(&job.name) {
747        format!("lifecycle.activation_job.{}", job.name)
748    } else {
749        "lifecycle.activation_job".to_owned()
750    };
751
752    if !present(&job.name) {
753        lints.push(ModuleManifestLint {
754            severity: ModuleManifestLintSeverity::Warning,
755            subject: subject.clone(),
756            message: "Lifecycle activation job is missing a name.".to_owned(),
757            suggestion: "Set a short operator-facing activation job name.".to_owned(),
758        });
759    }
760
761    if !present(&job.function_name) {
762        lints.push(ModuleManifestLint {
763            severity: ModuleManifestLintSeverity::Error,
764            subject,
765            message: "Lifecycle activation job is missing a function name.".to_owned(),
766            suggestion: "Set function_name to a declared runtime function.".to_owned(),
767        });
768    } else if !runtime_functions.contains(&job.function_name) {
769        lints.push(ModuleManifestLint {
770            severity: ModuleManifestLintSeverity::Error,
771            subject,
772            message: "Lifecycle activation job references an unknown runtime function.".to_owned(),
773            suggestion:
774                "Declare the function in ModuleManifest.runtime.functions or remove the activation job."
775                    .to_owned(),
776        });
777    }
778}
779
780fn runtime_function_names(runtime: Option<&RuntimeSurface>) -> HashSet<String> {
781    runtime
782        .into_iter()
783        .flat_map(|surface| surface.functions.iter())
784        .map(|function| function.name.clone())
785        .collect()
786}
787
788fn lint_console_surfaces(console: &[ConsoleSurface], lints: &mut Vec<ModuleManifestLint>) {
789    let mut names = HashSet::new();
790    let mut routes = HashSet::new();
791
792    for surface in console {
793        let subject = if present(&surface.name) {
794            format!("console.surface.{}", surface.name)
795        } else {
796            "console.surface".to_owned()
797        };
798
799        if !present(&surface.name) {
800            lints.push(ModuleManifestLint {
801                severity: ModuleManifestLintSeverity::Error,
802                subject: subject.clone(),
803                message: "Console surface is missing a name.".to_owned(),
804                suggestion: "Set a stable surface name such as stories.".to_owned(),
805            });
806        } else if !valid_console_surface_name(&surface.name) {
807            lints.push(ModuleManifestLint {
808                severity: ModuleManifestLintSeverity::Warning,
809                subject: subject.clone(),
810                message: "Console surface name should be a path-safe identifier.".to_owned(),
811                suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
812            });
813        } else if !names.insert(surface.name.clone()) {
814            lints.push(ModuleManifestLint {
815                severity: ModuleManifestLintSeverity::Error,
816                subject: subject.clone(),
817                message: "Duplicate console surface declaration.".to_owned(),
818                suggestion: "Keep one console surface per surface name.".to_owned(),
819            });
820        }
821
822        if !present(&surface.label) {
823            lints.push(ModuleManifestLint {
824                severity: ModuleManifestLintSeverity::Warning,
825                subject: format!("{subject}.label"),
826                message: "Console surface is missing an operator-facing label.".to_owned(),
827                suggestion: "Set a short navigation label such as Stories.".to_owned(),
828            });
829        }
830
831        if !surface.route.starts_with('/') || surface.route.contains('*') {
832            lints.push(ModuleManifestLint {
833                severity: ModuleManifestLintSeverity::Error,
834                subject: format!("{subject}.route"),
835                message: "Console surface route must be an absolute static route.".to_owned(),
836                suggestion: "Use a Console route such as /runtime/stories.".to_owned(),
837            });
838        } else if !routes.insert(surface.route.clone()) {
839            lints.push(ModuleManifestLint {
840                severity: ModuleManifestLintSeverity::Error,
841                subject: format!("{subject}.route"),
842                message: "Duplicate console surface route declaration.".to_owned(),
843                suggestion: "Keep one console surface per route.".to_owned(),
844            });
845        }
846
847        if !valid_console_package_name(&surface.package.name) {
848            lints.push(ModuleManifestLint {
849                severity: ModuleManifestLintSeverity::Warning,
850                subject: format!("{subject}.package"),
851                message: "Console surface package should be an npm package name.".to_owned(),
852                suggestion: "Use a build-time package name such as @lenso/story-console."
853                    .to_owned(),
854            });
855        }
856
857        if !present(&surface.package.export) {
858            lints.push(ModuleManifestLint {
859                severity: ModuleManifestLintSeverity::Warning,
860                subject: format!("{subject}.package.export"),
861                message: "Console surface package export is missing.".to_owned(),
862                suggestion: "Set the named export registered by the Runtime Console build."
863                    .to_owned(),
864            });
865        }
866
867        if let Some(navigation) = &surface.navigation {
868            lint_console_navigation(&subject, navigation, lints);
869        }
870    }
871}
872
873fn lint_console_slots(console_slots: &[ConsoleSlot], lints: &mut Vec<ModuleManifestLint>) {
874    let mut slots = HashSet::new();
875
876    for slot in console_slots {
877        let subject = if present(&slot.id) {
878            format!("console.slot.{}", slot.id)
879        } else {
880            "console.slot".to_owned()
881        };
882
883        if !present(&slot.id) {
884            lints.push(ModuleManifestLint {
885                severity: ModuleManifestLintSeverity::Error,
886                subject: subject.clone(),
887                message: "Console slot is missing an id.".to_owned(),
888                suggestion: "Set a stable dotted slot id such as auth.users.detail.actions."
889                    .to_owned(),
890            });
891        } else if !valid_console_slot_target(&slot.id) {
892            lints.push(ModuleManifestLint {
893                severity: ModuleManifestLintSeverity::Warning,
894                subject: subject.clone(),
895                message: "Console slot id should be a path-safe dotted id.".to_owned(),
896                suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
897            });
898        } else if !slots.insert((slot.id.clone(), slot.version)) {
899            lints.push(ModuleManifestLint {
900                severity: ModuleManifestLintSeverity::Error,
901                subject: subject.clone(),
902                message: "Duplicate console slot declaration.".to_owned(),
903                suggestion: "Keep one declaration per console slot id and version.".to_owned(),
904            });
905        }
906
907        if slot.version == 0 {
908            lints.push(ModuleManifestLint {
909                severity: ModuleManifestLintSeverity::Error,
910                subject: format!("{subject}.version"),
911                message: "Console slot version must be greater than zero.".to_owned(),
912                suggestion: "Start slot contracts at version 1.".to_owned(),
913            });
914        }
915
916        if !present(&slot.label) {
917            lints.push(ModuleManifestLint {
918                severity: ModuleManifestLintSeverity::Warning,
919                subject: format!("{subject}.label"),
920                message: "Console slot is missing an operator-facing label.".to_owned(),
921                suggestion: "Set a short label such as User detail actions.".to_owned(),
922            });
923        }
924
925        if slot.accepts.is_empty() {
926            lints.push(ModuleManifestLint {
927                severity: ModuleManifestLintSeverity::Warning,
928                subject: format!("{subject}.accepts"),
929                message: "Console slot declares no accepted contribution kinds.".to_owned(),
930                suggestion: "Declare at least one accepted kind such as admin_action.".to_owned(),
931            });
932        }
933
934        let mut context_names = HashSet::new();
935        for context in &slot.context {
936            let context_subject = if present(&context.name) {
937                format!("{subject}.context.{}", context.name)
938            } else {
939                format!("{subject}.context")
940            };
941            if !present(&context.name) {
942                lints.push(ModuleManifestLint {
943                    severity: ModuleManifestLintSeverity::Error,
944                    subject: context_subject.clone(),
945                    message: "Console slot context is missing a name.".to_owned(),
946                    suggestion: "Set a stable context name such as selected_user.".to_owned(),
947                });
948            } else if !valid_slot_context_segment(&context.name) {
949                lints.push(ModuleManifestLint {
950                    severity: ModuleManifestLintSeverity::Warning,
951                    subject: context_subject.clone(),
952                    message: "Console slot context name should be path-safe.".to_owned(),
953                    suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
954                });
955            } else if !context_names.insert(context.name.clone()) {
956                lints.push(ModuleManifestLint {
957                    severity: ModuleManifestLintSeverity::Error,
958                    subject: context_subject.clone(),
959                    message: "Duplicate console slot context declaration.".to_owned(),
960                    suggestion: "Keep one declaration per slot context name.".to_owned(),
961                });
962            }
963
964            let mut field_names = HashSet::new();
965            for field in &context.fields {
966                let field_subject = if present(&field.name) {
967                    format!("{context_subject}.field.{}", field.name)
968                } else {
969                    format!("{context_subject}.field")
970                };
971                if !present(&field.name) {
972                    lints.push(ModuleManifestLint {
973                        severity: ModuleManifestLintSeverity::Error,
974                        subject: field_subject.clone(),
975                        message: "Console slot context field is missing a name.".to_owned(),
976                        suggestion: "Set a stable field name such as id.".to_owned(),
977                    });
978                } else if !valid_slot_context_segment(&field.name) {
979                    lints.push(ModuleManifestLint {
980                        severity: ModuleManifestLintSeverity::Warning,
981                        subject: field_subject.clone(),
982                        message: "Console slot context field should be path-safe.".to_owned(),
983                        suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
984                    });
985                } else if !field_names.insert(field.name.clone()) {
986                    lints.push(ModuleManifestLint {
987                        severity: ModuleManifestLintSeverity::Error,
988                        subject: field_subject,
989                        message: "Duplicate console slot context field declaration.".to_owned(),
990                        suggestion: "Keep one declaration per context field name.".to_owned(),
991                    });
992                }
993            }
994        }
995    }
996}
997
998fn lint_console_contributions(
999    contributions: &[ConsoleContribution],
1000    lints: &mut Vec<ModuleManifestLint>,
1001) {
1002    for contribution in contributions {
1003        let subject = if present(&contribution.target) {
1004            format!("console.contribution.{}", contribution.target)
1005        } else {
1006            "console.contribution".to_owned()
1007        };
1008
1009        if !present(&contribution.target) {
1010            lints.push(ModuleManifestLint {
1011                severity: ModuleManifestLintSeverity::Error,
1012                subject: subject.clone(),
1013                message: "Console contribution is missing a target slot.".to_owned(),
1014                suggestion: "Set a stable slot target such as auth.users.detail.actions."
1015                    .to_owned(),
1016            });
1017        } else if !valid_console_slot_target(&contribution.target) {
1018            lints.push(ModuleManifestLint {
1019                severity: ModuleManifestLintSeverity::Warning,
1020                subject: subject.clone(),
1021                message: "Console contribution target should be a path-safe dotted slot id."
1022                    .to_owned(),
1023                suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1024            });
1025        }
1026
1027        if contribution.target_version == 0 {
1028            lints.push(ModuleManifestLint {
1029                severity: ModuleManifestLintSeverity::Error,
1030                subject: format!("{subject}.target_version"),
1031                message: "Console contribution target version must be greater than zero."
1032                    .to_owned(),
1033                suggestion: "Set target_version to the slot contract version, usually 1."
1034                    .to_owned(),
1035            });
1036        }
1037
1038        if !present(&contribution.label) {
1039            lints.push(ModuleManifestLint {
1040                severity: ModuleManifestLintSeverity::Warning,
1041                subject: format!("{subject}.label"),
1042                message: "Console contribution is missing an operator-facing label.".to_owned(),
1043                suggestion: "Set a short action label such as Reset password.".to_owned(),
1044            });
1045        }
1046
1047        match &contribution.action {
1048            ConsoleContributionAction::AdminAction {
1049                module,
1050                name,
1051                input_bindings,
1052            } => {
1053                if !present(module) {
1054                    lints.push(ModuleManifestLint {
1055                        severity: ModuleManifestLintSeverity::Error,
1056                        subject: format!("{subject}.action.module"),
1057                        message: "Console contribution action is missing a module name.".to_owned(),
1058                        suggestion: "Set the module that owns the admin action.".to_owned(),
1059                    });
1060                }
1061                if !present(name) {
1062                    lints.push(ModuleManifestLint {
1063                        severity: ModuleManifestLintSeverity::Error,
1064                        subject: format!("{subject}.action.name"),
1065                        message: "Console contribution action is missing an action name."
1066                            .to_owned(),
1067                        suggestion: "Set the admin action name declared by that module.".to_owned(),
1068                    });
1069                }
1070                for binding in input_bindings {
1071                    if !present(&binding.input) {
1072                        lints.push(ModuleManifestLint {
1073                            severity: ModuleManifestLintSeverity::Error,
1074                            subject: format!("{subject}.action.input_binding"),
1075                            message:
1076                                "Console contribution action binding is missing an input name."
1077                                    .to_owned(),
1078                            suggestion: "Set the input field that receives the bound value."
1079                                .to_owned(),
1080                        });
1081                    }
1082                    match &binding.value {
1083                        ConsoleActionInputValue::SlotContext { path } => {
1084                            if !present(path) {
1085                                lints.push(ModuleManifestLint {
1086                                    severity: ModuleManifestLintSeverity::Error,
1087                                    subject: format!("{subject}.action.input_binding.path"),
1088                                    message:
1089                                        "Console contribution slot-context binding is missing a path."
1090                                            .to_owned(),
1091                                    suggestion:
1092                                        "Set a slot context path such as selected_user.id."
1093                                            .to_owned(),
1094                                });
1095                            } else if !valid_slot_context_path(path) {
1096                                lints.push(ModuleManifestLint {
1097                                    severity: ModuleManifestLintSeverity::Warning,
1098                                    subject: format!("{subject}.action.input_binding.path"),
1099                                    message:
1100                                        "Console contribution slot-context path should be path-safe."
1101                                            .to_owned(),
1102                                    suggestion:
1103                                        "Use dot-separated context fields such as selected_user.id."
1104                                            .to_owned(),
1105                                });
1106                            }
1107                        }
1108                    }
1109                }
1110            }
1111        }
1112    }
1113}
1114
1115const HOST_SYSTEM_CONSOLE_WORKSPACE_ID: &str = "system";
1116
1117fn lint_console_navigation(
1118    subject: &str,
1119    navigation: &crate::ConsoleNavigation,
1120    lints: &mut Vec<ModuleManifestLint>,
1121) {
1122    let workspace_subject = format!("{subject}.navigation.workspace");
1123    if !valid_console_navigation_id(&navigation.workspace.id) {
1124        lints.push(ModuleManifestLint {
1125            severity: ModuleManifestLintSeverity::Warning,
1126            subject: format!("{workspace_subject}.id"),
1127            message: "Console workspace id should be a path-safe identifier.".to_owned(),
1128            suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1129        });
1130    } else if navigation.workspace.id == HOST_SYSTEM_CONSOLE_WORKSPACE_ID {
1131        lints.push(ModuleManifestLint {
1132            severity: ModuleManifestLintSeverity::Warning,
1133            subject: format!("{workspace_subject}.id"),
1134            message: "Console workspace id system is reserved for host-owned surfaces.".to_owned(),
1135            suggestion:
1136                "Omit navigation to use the host System workspace, or use a module-owned workspace id."
1137                    .to_owned(),
1138        });
1139    }
1140    if !present(&navigation.workspace.label) {
1141        lints.push(ModuleManifestLint {
1142            severity: ModuleManifestLintSeverity::Warning,
1143            subject: format!("{workspace_subject}.label"),
1144            message: "Console workspace is missing an operator-facing label.".to_owned(),
1145            suggestion: "Set a short workspace label such as CRM.".to_owned(),
1146        });
1147    }
1148    if let Some(group) = &navigation.group {
1149        let group_subject = format!("{subject}.navigation.group");
1150        if !valid_console_navigation_id(&group.id) {
1151            lints.push(ModuleManifestLint {
1152                severity: ModuleManifestLintSeverity::Warning,
1153                subject: format!("{group_subject}.id"),
1154                message: "Console navigation group id should be a path-safe identifier.".to_owned(),
1155                suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1156            });
1157        }
1158        if !present(&group.label) {
1159            lints.push(ModuleManifestLint {
1160                severity: ModuleManifestLintSeverity::Warning,
1161                subject: format!("{group_subject}.label"),
1162                message: "Console navigation group is missing an operator-facing label.".to_owned(),
1163                suggestion: "Set a short group label such as Customers.".to_owned(),
1164            });
1165        }
1166    }
1167}
1168
1169fn lint_admin_surface(admin: &AdminSurface, lints: &mut Vec<ModuleManifestLint>) {
1170    match admin {
1171        AdminSurface::Schema(schema) => lint_schema_entities("admin.schema", schema, lints),
1172        AdminSurface::DeclarativeCustom(surface) => {
1173            if surface.pages.is_empty() && surface.actions.is_empty() {
1174                lints.push(ModuleManifestLint {
1175                    severity: ModuleManifestLintSeverity::Warning,
1176                    subject: "admin.declarative.pages".to_owned(),
1177                    message: "Declarative admin surface declares no pages or actions.".to_owned(),
1178                    suggestion:
1179                        "Add at least one page/action or omit the declarative admin surface."
1180                            .to_owned(),
1181                });
1182            }
1183            if let Some(schema) = &surface.fallback_schema {
1184                lint_schema_entities("admin.declarative.fallback_schema", schema, lints);
1185            }
1186            let fallback_entities = surface
1187                .fallback_schema
1188                .as_ref()
1189                .map(schema_entity_names)
1190                .unwrap_or_default();
1191            for page in &surface.pages {
1192                for section in &page.sections {
1193                    match &section.component {
1194                        AdminDeclarativeComponent::EntityTable { entity }
1195                        | AdminDeclarativeComponent::EntityDetail { entity } => {
1196                            if !fallback_entities.contains(entity) {
1197                                lints.push(ModuleManifestLint {
1198                                    severity: ModuleManifestLintSeverity::Warning,
1199                                    subject: format!("admin.declarative.section.{}", section.name),
1200                                    message: format!(
1201                                        "Declarative section references unknown fallback entity `{entity}`."
1202                                    ),
1203                                    suggestion:
1204                                        "Declare the entity in fallback_schema or update the section binding."
1205                                            .to_owned(),
1206                                });
1207                            }
1208                        }
1209                        AdminDeclarativeComponent::QueryValue {
1210                            capability,
1211                            query,
1212                            value_path,
1213                        } => lint_query_value(
1214                            section.name.as_str(),
1215                            query,
1216                            capability,
1217                            value_path,
1218                            lints,
1219                        ),
1220                        AdminDeclarativeComponent::MetricStrip { .. } => {}
1221                    }
1222                }
1223            }
1224        }
1225        AdminSurface::EmbeddedCustom(surface) => {
1226            if surface.runtime != AdminEmbeddedRuntime::Iframe {
1227                lints.push(ModuleManifestLint {
1228                    severity: ModuleManifestLintSeverity::Warning,
1229                    subject: "admin.embedded.runtime".to_owned(),
1230                    message: "Embedded admin runtime is reserved for a future host policy."
1231                        .to_owned(),
1232                    suggestion: "Use iframe for the current embedded admin slice.".to_owned(),
1233                });
1234            }
1235            match &surface.entry {
1236                AdminEmbeddedEntry::Url {
1237                    url,
1238                    allowed_origins,
1239                } => {
1240                    if !url.starts_with("https://") && !url.starts_with("http://localhost") {
1241                        lints.push(ModuleManifestLint {
1242                            severity: ModuleManifestLintSeverity::Warning,
1243                            subject: "admin.embedded.entry.url".to_owned(),
1244                            message:
1245                                "Embedded admin URL should use HTTPS outside local development."
1246                                    .to_owned(),
1247                            suggestion: "Use an HTTPS URL and list its origin in allowed_origins."
1248                                .to_owned(),
1249                        });
1250                    }
1251                    if allowed_origins.is_empty() {
1252                        lints.push(ModuleManifestLint {
1253                            severity: ModuleManifestLintSeverity::Warning,
1254                            subject: "admin.embedded.entry.allowed_origins".to_owned(),
1255                            message: "Embedded admin surface declares no allowed origins."
1256                                .to_owned(),
1257                            suggestion:
1258                                "Declare the iframe origin allowlist before enabling the surface."
1259                                    .to_owned(),
1260                        });
1261                    }
1262                }
1263            }
1264            if let Some(schema) = &surface.fallback_schema {
1265                lint_schema_entities("admin.embedded.fallback_schema", schema, lints);
1266                let fallback_entities = schema_entity_names(schema);
1267                for permission in &surface.permissions {
1268                    if let AdminPermission::ReadEntity { entity } = permission
1269                        && !fallback_entities.contains(entity)
1270                    {
1271                        lints.push(ModuleManifestLint {
1272                            severity: ModuleManifestLintSeverity::Warning,
1273                            subject: format!("admin.embedded.permission.{entity}"),
1274                            message: format!(
1275                                "Embedded admin permission references unknown fallback entity `{entity}`."
1276                            ),
1277                            suggestion:
1278                                "Declare the entity in fallback_schema or remove the permission."
1279                                    .to_owned(),
1280                        });
1281                    }
1282                }
1283            }
1284        }
1285    }
1286}
1287
1288fn lint_schema_entities(prefix: &str, schema: &AdminSchema, lints: &mut Vec<ModuleManifestLint>) {
1289    if schema.entities.is_empty() {
1290        lints.push(ModuleManifestLint {
1291            severity: ModuleManifestLintSeverity::Warning,
1292            subject: prefix.to_owned(),
1293            message: "Admin schema declares no entities.".to_owned(),
1294            suggestion: "Add at least one entity or omit the admin schema surface.".to_owned(),
1295        });
1296    }
1297    for entity in &schema.entities {
1298        if !present(&entity.read_capability) {
1299            lints.push(ModuleManifestLint {
1300                severity: ModuleManifestLintSeverity::Warning,
1301                subject: format!("{prefix}.{}", entity.name),
1302                message: "Admin entity is missing read capability.".to_owned(),
1303                suggestion: "Declare the capability required to read this entity.".to_owned(),
1304            });
1305        }
1306    }
1307}
1308
1309fn collect_declarative_query_capability_references(
1310    surface: &AdminDeclarativeSurface,
1311    references: &mut Vec<ModuleCapabilityReference>,
1312) {
1313    for page in &surface.pages {
1314        for section in &page.sections {
1315            let AdminDeclarativeComponent::QueryValue {
1316                capability, query, ..
1317            } = &section.component
1318            else {
1319                continue;
1320            };
1321            if present(capability) {
1322                let subject = if present(query) {
1323                    format!("admin.declarative.query.{query}")
1324                } else {
1325                    format!("admin.declarative.section.{}", section.name)
1326                };
1327                references.push(ModuleCapabilityReference {
1328                    capability: capability.clone(),
1329                    subject,
1330                });
1331            }
1332        }
1333    }
1334}
1335
1336fn lint_query_value(
1337    section_name: &str,
1338    query: &str,
1339    capability: &str,
1340    value_path: &str,
1341    lints: &mut Vec<ModuleManifestLint>,
1342) {
1343    let subject = if present(query) {
1344        format!("admin.declarative.query.{query}")
1345    } else {
1346        format!("admin.declarative.section.{section_name}")
1347    };
1348    if !valid_runtime_function_name(query) {
1349        lints.push(ModuleManifestLint {
1350            severity: ModuleManifestLintSeverity::Warning,
1351            subject: subject.clone(),
1352            message: "Declarative query name should be a stable path-safe identifier.".to_owned(),
1353            suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1354        });
1355    }
1356    if !present(value_path) {
1357        lints.push(ModuleManifestLint {
1358            severity: ModuleManifestLintSeverity::Warning,
1359            subject: subject.clone(),
1360            message: "Declarative query value is missing a value path.".to_owned(),
1361            suggestion: "Set value_path to the JSON field rendered by this section.".to_owned(),
1362        });
1363    }
1364    if !present(capability) {
1365        lints.push(ModuleManifestLint {
1366            severity: ModuleManifestLintSeverity::Warning,
1367            subject,
1368            message: "Declarative query is missing a read capability.".to_owned(),
1369            suggestion: "Declare the capability required to read this query.".to_owned(),
1370        });
1371    }
1372}
1373
1374fn schema_entity_names(schema: &AdminSchema) -> HashSet<String> {
1375    schema
1376        .entities
1377        .iter()
1378        .map(|entity| entity.name.clone())
1379        .collect()
1380}
1381
1382fn present(value: &str) -> bool {
1383    !value.trim().is_empty()
1384}
1385
1386fn valid_capability(value: &str) -> bool {
1387    let mut parts = value.split('.');
1388    let Some(first) = parts.next() else {
1389        return false;
1390    };
1391    present(first)
1392        && value.contains('.')
1393        && std::iter::once(first).chain(parts).all(|part| {
1394            present(part)
1395                && part.chars().all(|character| {
1396                    character.is_ascii_lowercase() || character == '_' || character.is_ascii_digit()
1397                })
1398        })
1399}
1400
1401fn valid_runtime_function_name(value: &str) -> bool {
1402    present(value)
1403        && value.chars().all(|character| {
1404            character.is_ascii_alphanumeric()
1405                || character == '.'
1406                || character == '_'
1407                || character == '-'
1408        })
1409}
1410
1411fn valid_console_surface_name(value: &str) -> bool {
1412    present(value)
1413        && value.chars().all(|character| {
1414            character.is_ascii_alphanumeric() || character == '_' || character == '-'
1415        })
1416}
1417
1418fn valid_console_slot_target(value: &str) -> bool {
1419    present(value)
1420        && value.contains('.')
1421        && value.chars().all(|character| {
1422            character.is_ascii_alphanumeric()
1423                || character == '.'
1424                || character == '_'
1425                || character == '-'
1426        })
1427}
1428
1429fn valid_slot_context_path(value: &str) -> bool {
1430    present(value) && value.split('.').all(valid_slot_context_segment)
1431}
1432
1433fn valid_slot_context_segment(value: &str) -> bool {
1434    present(value)
1435        && value.chars().all(|character| {
1436            character.is_ascii_alphanumeric() || character == '_' || character == '-'
1437        })
1438}
1439
1440fn valid_console_navigation_id(value: &str) -> bool {
1441    valid_console_surface_name(value)
1442}
1443
1444fn valid_console_package_name(value: &str) -> bool {
1445    present(value)
1446        && !value.contains(' ')
1447        && (value.starts_with('@') || value.chars().any(|character| character == '-'))
1448}
1449
1450fn route_identity(route: &ModuleHttpRoute) -> String {
1451    format!("{} {}", method_label(route.method), route.path)
1452}
1453
1454fn method_label(method: ModuleHttpMethod) -> &'static str {
1455    match method {
1456        ModuleHttpMethod::Get => "GET",
1457        ModuleHttpMethod::Post => "POST",
1458        ModuleHttpMethod::Put => "PUT",
1459        ModuleHttpMethod::Patch => "PATCH",
1460        ModuleHttpMethod::Delete => "DELETE",
1461    }
1462}
1463
1464/// Fluent builder for [`ModuleManifest`]. Reusable by every loading source.
1465#[derive(Debug)]
1466pub struct ModuleManifestBuilder {
1467    manifest: ModuleManifest,
1468}
1469
1470impl ModuleManifestBuilder {
1471    /// Attach console story-display metadata.
1472    #[must_use]
1473    pub fn story_display(mut self, story_display: Vec<StoryDisplayDescriptor>) -> Self {
1474        self.manifest.story_display = story_display;
1475        self
1476    }
1477
1478    /// Attach declared capabilities.
1479    #[must_use]
1480    pub fn capabilities(mut self, capabilities: Vec<String>) -> Self {
1481        self.manifest.capabilities = capabilities;
1482        self
1483    }
1484
1485    /// Attach required module dependencies.
1486    #[must_use]
1487    pub fn dependencies(mut self, dependencies: Vec<String>) -> Self {
1488        self.manifest.dependencies = dependencies;
1489        self
1490    }
1491
1492    /// Attach declared module-owned HTTP routes.
1493    #[must_use]
1494    pub fn http_routes(mut self, routes: Vec<ModuleHttpRoute>) -> Self {
1495        self.manifest.http_routes = routes;
1496        self
1497    }
1498
1499    /// Attach runtime declarations.
1500    #[must_use]
1501    pub fn runtime(mut self, runtime: RuntimeSurface) -> Self {
1502        self.manifest.runtime = Some(runtime);
1503        self
1504    }
1505
1506    /// Attach event handler declarations.
1507    #[must_use]
1508    pub fn events(mut self, events: EventSurface) -> Self {
1509        self.manifest.events = Some(events);
1510        self
1511    }
1512
1513    /// Attach a schema-driven admin surface.
1514    #[must_use]
1515    pub fn admin(mut self, schema: AdminSchema) -> Self {
1516        self.manifest.admin = Some(AdminSurface::Schema(schema));
1517        self
1518    }
1519
1520    /// Attach a host-rendered custom admin surface declaration.
1521    #[must_use]
1522    pub fn declarative_admin(mut self, surface: AdminDeclarativeSurface) -> Self {
1523        self.manifest.admin = Some(AdminSurface::DeclarativeCustom(surface));
1524        self
1525    }
1526
1527    /// Attach a sandboxed module-owned admin surface declaration.
1528    #[must_use]
1529    pub fn embedded_admin(mut self, surface: AdminEmbeddedSurface) -> Self {
1530        self.manifest.admin = Some(AdminSurface::EmbeddedCustom(surface));
1531        self
1532    }
1533
1534    /// Attach lifecycle declarations.
1535    #[must_use]
1536    pub fn lifecycle(mut self, lifecycle: LifecycleSurface) -> Self {
1537        self.manifest.lifecycle = Some(lifecycle);
1538        self
1539    }
1540
1541    /// Attach trusted Runtime Console frontend surface declarations.
1542    #[must_use]
1543    pub fn console(mut self, console: Vec<ConsoleSurface>) -> Self {
1544        self.manifest.console = console;
1545        self
1546    }
1547
1548    /// Attach Runtime Console extension slot declarations.
1549    #[must_use]
1550    pub fn console_slots(mut self, console_slots: Vec<ConsoleSlot>) -> Self {
1551        self.manifest.console_slots = console_slots;
1552        self
1553    }
1554
1555    /// Attach trusted Runtime Console slot contribution declarations.
1556    #[must_use]
1557    pub fn console_contributions(
1558        mut self,
1559        console_contributions: Vec<ConsoleContribution>,
1560    ) -> Self {
1561        self.manifest.console_contributions = console_contributions;
1562        self
1563    }
1564
1565    /// Finish building.
1566    #[must_use]
1567    pub fn build(self) -> ModuleManifest {
1568        self.manifest
1569    }
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574    use super::*;
1575    use crate::admin::{
1576        AdminDeclarativeComponent, AdminDeclarativePage, AdminDeclarativeSection,
1577        AdminDeclarativeSurface,
1578    };
1579    use crate::{
1580        AdminEmbeddedEntry, AdminEmbeddedRuntime, AdminEmbeddedSurface, AdminSandboxPolicy,
1581        ConsoleActionInputBinding, ConsoleActionInputValue, ConsoleArea, ConsoleContribution,
1582        ConsoleContributionAction, ConsoleContributionKind, ConsolePackage, ConsoleSlot,
1583        ConsoleSlotContext, ConsoleSlotContextField, ConsoleSlotContextFieldType, ConsoleSurface,
1584        EventHandlerDeclaration, EventSurface,
1585    };
1586    use crate::{
1587        LifecycleActivationJobDeclaration, LifecycleActivationRunPolicy,
1588        LifecycleStartupCheckDeclaration, LifecycleStartupCheckKind, LifecycleSurface,
1589    };
1590    use crate::{ModuleHttpMethod, ModuleHttpRoute};
1591    use crate::{RuntimeFunctionDeclaration, RuntimeRetryPolicyDeclaration, RuntimeSurface};
1592    use crate::{StoryDisplayDescriptor, StoryDisplaySource};
1593
1594    #[test]
1595    fn manifest_round_trips_through_json() {
1596        let manifest = ModuleManifest::builder("identity")
1597            .story_display(vec![StoryDisplayDescriptor {
1598                source: StoryDisplaySource::ExecutionName {
1599                    name: "identity.create_user".to_owned(),
1600                },
1601                display_name: "Create User".to_owned(),
1602                story_title: Some("User Registration".to_owned()),
1603            }])
1604            .build();
1605
1606        let json = serde_json::to_string(&manifest).expect("serialize");
1607        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1608
1609        assert_eq!(manifest, back);
1610    }
1611
1612    #[test]
1613    fn manifest_with_console_surface_round_trips_through_json() {
1614        let manifest = ModuleManifest::builder("platform-story")
1615            .console(vec![ConsoleSurface {
1616                name: "stories".to_owned(),
1617                label: "Stories".to_owned(),
1618                area: ConsoleArea::Runtime,
1619                route: "/runtime/stories".to_owned(),
1620                package: ConsolePackage {
1621                    name: "@lenso/story-console".to_owned(),
1622                    export: "storyConsoleModule".to_owned(),
1623                },
1624                icon: Some("workflow".to_owned()),
1625                required_capabilities: vec!["runtime.stories.read".to_owned()],
1626                navigation: None,
1627            }])
1628            .capabilities(vec!["runtime.stories.read".to_owned()])
1629            .build();
1630
1631        let json = serde_json::to_string(&manifest).expect("serialize");
1632        assert!(json.contains(r#""console""#), "got {json}");
1633        assert!(json.contains(r#""area":"runtime""#), "got {json}");
1634
1635        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1636
1637        assert_eq!(manifest, back);
1638    }
1639
1640    #[test]
1641    fn manifest_with_console_contribution_round_trips_through_json() {
1642        let contribution = ConsoleContribution {
1643            target: "auth.users.detail.actions".to_owned(),
1644            target_version: 1,
1645            label: "Reset password".to_owned(),
1646            action: ConsoleContributionAction::AdminAction {
1647                module: "auth-password".to_owned(),
1648                name: "reset_password".to_owned(),
1649                input_bindings: vec![ConsoleActionInputBinding {
1650                    input: "user_id".to_owned(),
1651                    value: ConsoleActionInputValue::SlotContext {
1652                        path: "selected_user.id".to_owned(),
1653                    },
1654                }],
1655            },
1656            icon: Some("key-round".to_owned()),
1657            required_capabilities: vec!["auth_password.credentials.write".to_owned()],
1658        };
1659        let manifest = ModuleManifest::builder("auth-password")
1660            .capabilities(vec!["auth_password.credentials.write".to_owned()])
1661            .console_contributions(vec![contribution.clone()])
1662            .build();
1663
1664        let json = serde_json::to_string(&manifest).expect("serialize");
1665        assert!(json.contains(r#""console_contributions""#), "got {json}");
1666        assert!(
1667            json.contains(r#""target":"auth.users.detail.actions""#),
1668            "got {json}"
1669        );
1670        assert!(json.contains(r#""target_version":1"#), "got {json}");
1671        assert!(json.contains(r#""kind":"admin_action""#), "got {json}");
1672        assert!(json.contains(r#""kind":"slot_context""#), "got {json}");
1673
1674        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1675
1676        assert_eq!(back.console_contributions, vec![contribution]);
1677    }
1678
1679    #[test]
1680    fn manifest_with_console_slot_round_trips_through_json() {
1681        let slot = ConsoleSlot {
1682            id: "auth.users.detail.actions".to_owned(),
1683            version: 1,
1684            label: "User detail actions".to_owned(),
1685            accepts: vec![ConsoleContributionKind::AdminAction],
1686            context: vec![ConsoleSlotContext {
1687                name: "selected_user".to_owned(),
1688                fields: vec![ConsoleSlotContextField {
1689                    name: "id".to_owned(),
1690                    field_type: ConsoleSlotContextFieldType::String,
1691                    required: true,
1692                }],
1693            }],
1694        };
1695        let manifest = ModuleManifest::builder("auth")
1696            .console_slots(vec![slot.clone()])
1697            .build();
1698
1699        let json = serde_json::to_string(&manifest).expect("serialize");
1700        assert!(json.contains(r#""console_slots""#), "got {json}");
1701        assert!(
1702            json.contains(r#""id":"auth.users.detail.actions""#),
1703            "got {json}"
1704        );
1705        assert!(json.contains(r#""accepts":["admin_action"]"#), "got {json}");
1706
1707        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1708
1709        assert_eq!(back.console_slots, vec![slot]);
1710    }
1711
1712    #[test]
1713    fn console_contribution_capability_references_are_linted() {
1714        let manifest = ModuleManifest::builder("auth-password")
1715            .console_contributions(vec![ConsoleContribution {
1716                target: "auth.users.detail.actions".to_owned(),
1717                target_version: 1,
1718                label: "Reset password".to_owned(),
1719                action: ConsoleContributionAction::AdminAction {
1720                    module: "auth-password".to_owned(),
1721                    name: "reset_password".to_owned(),
1722                    input_bindings: vec![ConsoleActionInputBinding {
1723                        input: "user_id".to_owned(),
1724                        value: ConsoleActionInputValue::SlotContext {
1725                            path: "selected_user.id".to_owned(),
1726                        },
1727                    }],
1728                },
1729                icon: None,
1730                required_capabilities: vec!["auth_password.credentials.write".to_owned()],
1731            }])
1732            .build();
1733
1734        let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
1735
1736        assert!(lints.iter().any(|lint| {
1737            lint.subject == "capability.reference.console.contribution.auth.users.detail.actions"
1738                && lint.message == "Capability reference is not declared by the module."
1739        }));
1740    }
1741
1742    #[test]
1743    fn console_surface_navigation_round_trips() {
1744        let surface = ConsoleSurface {
1745            name: "contacts".to_owned(),
1746            label: "Contacts".to_owned(),
1747            area: ConsoleArea::Data,
1748            route: "/crm/contacts".to_owned(),
1749            package: crate::ConsolePackage {
1750                name: "@lenso/crm-console".to_owned(),
1751                export: "crmConsoleModule".to_owned(),
1752            },
1753            icon: Some("users".to_owned()),
1754            required_capabilities: vec!["crm.contacts.read".to_owned()],
1755            navigation: Some(crate::ConsoleNavigation {
1756                workspace: crate::ConsoleWorkspaceRef {
1757                    id: "crm".to_owned(),
1758                    label: "CRM".to_owned(),
1759                    icon: Some("briefcase".to_owned()),
1760                },
1761                group: Some(crate::ConsoleNavigationGroup {
1762                    id: "customers".to_owned(),
1763                    label: "Customers".to_owned(),
1764                    icon: None,
1765                    order: Some(20),
1766                }),
1767                order: Some(10),
1768            }),
1769        };
1770
1771        let json = serde_json::to_string(&surface).expect("serialize");
1772        let back: ConsoleSurface = serde_json::from_str(&json).expect("deserialize");
1773
1774        assert_eq!(back, surface);
1775    }
1776
1777    #[test]
1778    fn console_navigation_lints_empty_workspace_label() {
1779        let manifest = ModuleManifest::builder("crm")
1780            .capabilities(vec!["crm.contacts.read".to_owned()])
1781            .console(vec![ConsoleSurface {
1782                name: "contacts".to_owned(),
1783                label: "Contacts".to_owned(),
1784                area: ConsoleArea::Data,
1785                route: "/crm/contacts".to_owned(),
1786                package: crate::ConsolePackage {
1787                    name: "@lenso/crm-console".to_owned(),
1788                    export: "crmConsoleModule".to_owned(),
1789                },
1790                icon: None,
1791                required_capabilities: vec!["crm.contacts.read".to_owned()],
1792                navigation: Some(crate::ConsoleNavigation {
1793                    workspace: crate::ConsoleWorkspaceRef {
1794                        id: "crm".to_owned(),
1795                        label: "".to_owned(),
1796                        icon: None,
1797                    },
1798                    group: None,
1799                    order: None,
1800                }),
1801            }])
1802            .build();
1803
1804        let subjects: Vec<_> = lint_module_manifest(ModuleSource::Linked, &manifest)
1805            .into_iter()
1806            .map(|lint| lint.subject)
1807            .collect();
1808
1809        assert!(
1810            subjects.contains(&"console.surface.contacts.navigation.workspace.label".to_owned())
1811        );
1812    }
1813
1814    #[test]
1815    fn console_navigation_lints_reserved_system_workspace() {
1816        let manifest = ModuleManifest::builder("crm")
1817            .capabilities(vec!["crm.contacts.read".to_owned()])
1818            .console(vec![ConsoleSurface {
1819                name: "contacts".to_owned(),
1820                label: "Contacts".to_owned(),
1821                area: ConsoleArea::Data,
1822                route: "/crm/contacts".to_owned(),
1823                package: crate::ConsolePackage {
1824                    name: "@lenso/crm-console".to_owned(),
1825                    export: "crmConsoleModule".to_owned(),
1826                },
1827                icon: None,
1828                required_capabilities: vec!["crm.contacts.read".to_owned()],
1829                navigation: Some(crate::ConsoleNavigation {
1830                    workspace: crate::ConsoleWorkspaceRef {
1831                        id: "system".to_owned(),
1832                        label: "System".to_owned(),
1833                        icon: Some("settings".to_owned()),
1834                    },
1835                    group: None,
1836                    order: Some(10),
1837                }),
1838            }])
1839            .build();
1840
1841        let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
1842
1843        assert!(lints.iter().any(|lint| {
1844            lint.subject == "console.surface.contacts.navigation.workspace.id"
1845                && lint.severity == ModuleManifestLintSeverity::Warning
1846                && lint.message
1847                    == "Console workspace id system is reserved for host-owned surfaces."
1848        }));
1849    }
1850
1851    #[test]
1852    fn lints_invalid_console_surface_declarations() {
1853        let manifest = ModuleManifest::builder("platform-story")
1854            .console(vec![
1855                ConsoleSurface {
1856                    name: "stories".to_owned(),
1857                    label: "Stories".to_owned(),
1858                    area: ConsoleArea::Runtime,
1859                    route: "runtime/stories".to_owned(),
1860                    package: ConsolePackage {
1861                        name: "story console".to_owned(),
1862                        export: String::new(),
1863                    },
1864                    icon: None,
1865                    required_capabilities: vec!["runtime.stories.read".to_owned()],
1866                    navigation: None,
1867                },
1868                ConsoleSurface {
1869                    name: "stories".to_owned(),
1870                    label: "Stories duplicate".to_owned(),
1871                    area: ConsoleArea::Runtime,
1872                    route: "/runtime/stories".to_owned(),
1873                    package: ConsolePackage {
1874                        name: "@lenso/story-console".to_owned(),
1875                        export: "storyConsoleModule".to_owned(),
1876                    },
1877                    icon: None,
1878                    required_capabilities: vec![],
1879                    navigation: None,
1880                },
1881            ])
1882            .build();
1883
1884        let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
1885        let subjects = lints
1886            .iter()
1887            .map(|lint| lint.subject.as_str())
1888            .collect::<Vec<_>>();
1889
1890        assert!(subjects.contains(&"console.surface.stories.route"));
1891        assert!(subjects.contains(&"console.surface.stories.package"));
1892        assert!(subjects.contains(&"console.surface.stories.package.export"));
1893        assert!(subjects.contains(&"capability.reference.console.surface.stories"));
1894        assert!(lints.iter().any(|lint| {
1895            lint.subject == "console.surface.stories"
1896                && lint.message == "Duplicate console surface declaration."
1897        }));
1898    }
1899
1900    #[test]
1901    fn empty_admin_is_skipped_in_json() {
1902        let manifest = ModuleManifest::builder("notifications").build();
1903        let json = serde_json::to_string(&manifest).expect("serialize");
1904        assert!(
1905            !json.contains("admin"),
1906            "admin: None must be skipped, got {json}"
1907        );
1908    }
1909
1910    #[test]
1911    fn manifest_lints_self_dependency() {
1912        let manifest = ModuleManifest::builder("auth")
1913            .dependencies(vec!["auth".to_owned()])
1914            .build();
1915
1916        let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
1917
1918        assert!(lints.iter().any(|lint| {
1919            lint.severity == ModuleManifestLintSeverity::Error
1920                && lint.subject == "dependency auth"
1921                && lint.message == "Module must not depend on itself."
1922        }));
1923    }
1924
1925    #[test]
1926    fn manifest_with_admin_serializes_schema_kind() {
1927        use crate::admin_schema::{AdminSchema, EntitySchema, FieldSchema, FieldType};
1928        let schema = AdminSchema {
1929            entities: vec![EntitySchema {
1930                name: "users".to_owned(),
1931                label: "Users".to_owned(),
1932                read_capability: "identity.users.read".to_owned(),
1933                fields: vec![FieldSchema {
1934                    name: "email".into(),
1935                    label: "Email".into(),
1936                    field_type: FieldType::String,
1937                    nullable: false,
1938                }],
1939            }],
1940        };
1941        let manifest = ModuleManifest::builder("identity").admin(schema).build();
1942        let json = serde_json::to_string(&manifest).expect("serialize");
1943        assert!(json.contains(r#""kind":"schema""#), "got {json}");
1944    }
1945
1946    #[test]
1947    fn manifest_with_declarative_admin_serializes_kind() {
1948        use crate::admin::AdminDeclarativeSurface;
1949
1950        let manifest = ModuleManifest::builder("remote-crm")
1951            .declarative_admin(AdminDeclarativeSurface {
1952                pages: vec![],
1953                actions: vec![],
1954                fallback_schema: None,
1955            })
1956            .build();
1957        let json = serde_json::to_string(&manifest).expect("serialize");
1958        assert!(
1959            json.contains(r#""kind":"declarative_custom""#),
1960            "got {json}"
1961        );
1962    }
1963
1964    #[test]
1965    fn manifest_with_embedded_admin_serializes_kind() {
1966        use crate::admin::{
1967            AdminEmbeddedEntry, AdminEmbeddedRuntime, AdminEmbeddedSurface, AdminSandboxPolicy,
1968        };
1969
1970        let manifest = ModuleManifest::builder("remote-crm")
1971            .embedded_admin(AdminEmbeddedSurface {
1972                runtime: AdminEmbeddedRuntime::Iframe,
1973                entry: AdminEmbeddedEntry::Url {
1974                    url: "https://crm.example.test/admin".to_owned(),
1975                    allowed_origins: vec!["https://crm.example.test".to_owned()],
1976                },
1977                sandbox: AdminSandboxPolicy {
1978                    allow_scripts: true,
1979                    allow_forms: false,
1980                    allow_popups: false,
1981                    allow_same_origin: false,
1982                },
1983                permissions: vec![],
1984                fallback_schema: None,
1985            })
1986            .build();
1987        let json = serde_json::to_string(&manifest).expect("serialize");
1988        assert!(json.contains(r#""kind":"embedded_custom""#), "got {json}");
1989    }
1990
1991    #[test]
1992    fn manifest_with_http_routes_round_trips_through_json() {
1993        let manifest = ModuleManifest::builder("remote-crm")
1994            .http_routes(vec![
1995                ModuleHttpRoute {
1996                    method: ModuleHttpMethod::Get,
1997                    path: "/contacts".to_owned(),
1998                    capability: Some("remote_crm.contacts.read".to_owned()),
1999                    display_name: Some("List Contacts".to_owned()),
2000                    story_title: Some("List Contacts".to_owned()),
2001                    operation: None,
2002                },
2003                ModuleHttpRoute {
2004                    method: ModuleHttpMethod::Post,
2005                    path: "/contacts".to_owned(),
2006                    capability: Some("remote_crm.contacts.write".to_owned()),
2007                    display_name: None,
2008                    story_title: None,
2009                    operation: None,
2010                },
2011            ])
2012            .build();
2013
2014        let json = serde_json::to_string(&manifest).expect("serialize");
2015        assert!(json.contains(r#""http_routes""#), "got {json}");
2016        assert!(json.contains(r#""method":"GET""#), "got {json}");
2017        assert!(
2018            json.contains(r#""display_name":"List Contacts""#),
2019            "got {json}"
2020        );
2021        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2022        assert_eq!(manifest, back);
2023    }
2024
2025    #[test]
2026    fn manifest_with_runtime_functions_round_trips_through_json() {
2027        let manifest = ModuleManifest::builder("remote-crm")
2028            .runtime(RuntimeSurface {
2029                functions: vec![RuntimeFunctionDeclaration {
2030                    name: "remote_crm.sync_contact.v1".to_owned(),
2031                    version: 1,
2032                    queue: "remote-crm".to_owned(),
2033                    input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
2034                    retry_policy: Some(RuntimeRetryPolicyDeclaration {
2035                        max_attempts: 3,
2036                        initial_delay_ms: 1000,
2037                    }),
2038                    operation: None,
2039                }],
2040                schedules: vec![ScheduledFunctionDeclaration {
2041                    name: "sync_contacts_hourly".to_owned(),
2042                    function_name: "remote_crm.sync_contact.v1".to_owned(),
2043                    cron: "0 * * * *".to_owned(),
2044                    input: serde_json::json!({ "reason": "schedule" }),
2045                }],
2046            })
2047            .build();
2048
2049        let json = serde_json::to_string(&manifest).expect("serialize");
2050
2051        assert!(json.contains(r#""runtime""#), "got {json}");
2052        assert!(
2053            json.contains(r#""name":"remote_crm.sync_contact.v1""#),
2054            "got {json}"
2055        );
2056        assert!(json.contains(r#""queue":"remote-crm""#), "got {json}");
2057        assert!(json.contains(r#""schedules""#), "got {json}");
2058        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2059        assert_eq!(manifest, back);
2060    }
2061
2062    #[test]
2063    fn manifest_with_event_handlers_round_trips_through_json() {
2064        let manifest = ModuleManifest::builder("remote-crm")
2065            .events(EventSurface {
2066                handlers: vec![EventHandlerDeclaration {
2067                    name: "sync_contact_on_user_registered".to_owned(),
2068                    event_name: "identity.user_registered.v1".to_owned(),
2069                    operation: None,
2070                }],
2071            })
2072            .build();
2073
2074        let json = serde_json::to_string(&manifest).expect("serialize");
2075
2076        assert!(json.contains(r#""events""#), "got {json}");
2077        assert!(
2078            json.contains(r#""name":"sync_contact_on_user_registered""#),
2079            "got {json}"
2080        );
2081        assert!(
2082            json.contains(r#""event_name":"identity.user_registered.v1""#),
2083            "got {json}"
2084        );
2085        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2086        assert_eq!(manifest, back);
2087    }
2088
2089    #[test]
2090    fn manifest_lint_warns_for_invalid_capability_names() {
2091        let manifest = ModuleManifest::builder("remote-crm")
2092            .capabilities(vec!["RemoteCRM Contacts Read".to_owned()])
2093            .build();
2094
2095        assert!(
2096            lint_module_manifest(ModuleSource::Remote, &manifest)
2097                .iter()
2098                .any(|lint| lint.subject == "capability RemoteCRM Contacts Read"
2099                    && lint.severity == ModuleManifestLintSeverity::Warning)
2100        );
2101    }
2102
2103    #[test]
2104    fn manifest_lint_warns_for_unknown_declarative_fallback_entities() {
2105        let manifest = ModuleManifest::builder("remote-crm")
2106            .declarative_admin(AdminDeclarativeSurface {
2107                pages: vec![AdminDeclarativePage {
2108                    name: "dashboard".to_owned(),
2109                    label: "Dashboard".to_owned(),
2110                    sections: vec![AdminDeclarativeSection {
2111                        name: "missing".to_owned(),
2112                        label: "Missing".to_owned(),
2113                        component: AdminDeclarativeComponent::EntityTable {
2114                            entity: "contacts".to_owned(),
2115                        },
2116                    }],
2117                }],
2118                actions: vec![],
2119                fallback_schema: None,
2120            })
2121            .build();
2122
2123        assert!(
2124            lint_module_manifest(ModuleSource::Remote, &manifest)
2125                .iter()
2126                .any(|lint| lint.subject == "admin.declarative.section.missing"
2127                    && lint.severity == ModuleManifestLintSeverity::Warning)
2128        );
2129    }
2130
2131    #[test]
2132    fn manifest_lint_warns_for_embedded_origin_policy() {
2133        let manifest = ModuleManifest::builder("remote-crm")
2134            .embedded_admin(AdminEmbeddedSurface {
2135                runtime: AdminEmbeddedRuntime::Iframe,
2136                entry: AdminEmbeddedEntry::Url {
2137                    url: "http://crm.example.test/admin".to_owned(),
2138                    allowed_origins: vec![],
2139                },
2140                sandbox: AdminSandboxPolicy {
2141                    allow_scripts: true,
2142                    allow_forms: false,
2143                    allow_popups: false,
2144                    allow_same_origin: false,
2145                },
2146                permissions: vec![],
2147                fallback_schema: None,
2148            })
2149            .build();
2150
2151        let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
2152
2153        assert!(
2154            lints
2155                .iter()
2156                .any(|lint| lint.subject == "admin.embedded.entry.url")
2157        );
2158        assert!(
2159            lints
2160                .iter()
2161                .any(|lint| lint.subject == "admin.embedded.entry.allowed_origins")
2162        );
2163    }
2164
2165    #[test]
2166    fn manifest_lint_warns_for_runtime_function_declarations() {
2167        let manifest = ModuleManifest::builder("remote-crm")
2168            .runtime(RuntimeSurface {
2169                functions: vec![
2170                    RuntimeFunctionDeclaration {
2171                        name: "remote_crm/sync_contact.v1".to_owned(),
2172                        version: 1,
2173                        queue: "".to_owned(),
2174                        input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
2175                        retry_policy: Some(RuntimeRetryPolicyDeclaration {
2176                            max_attempts: 0,
2177                            initial_delay_ms: 1000,
2178                        }),
2179                        operation: None,
2180                    },
2181                    RuntimeFunctionDeclaration {
2182                        name: "remote_crm.sync_contact.v1".to_owned(),
2183                        version: 1,
2184                        queue: "remote-crm".to_owned(),
2185                        input_schema: Some("remote_crm.sync_contact.input.v1".to_owned()),
2186                        retry_policy: None,
2187                        operation: None,
2188                    },
2189                    RuntimeFunctionDeclaration {
2190                        name: "remote_crm.sync_contact.v1".to_owned(),
2191                        version: 1,
2192                        queue: "remote-crm".to_owned(),
2193                        input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
2194                        retry_policy: None,
2195                        operation: None,
2196                    },
2197                ],
2198                schedules: vec![],
2199            })
2200            .build();
2201
2202        let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
2203
2204        assert!(lints.iter().any(|lint| {
2205            lint.subject == "runtime.function.remote_crm/sync_contact.v1"
2206                && lint.severity == ModuleManifestLintSeverity::Warning
2207        }));
2208        assert!(lints.iter().any(|lint| {
2209            lint.subject == "runtime.function.remote_crm/sync_contact.v1.retry_policy"
2210                && lint.severity == ModuleManifestLintSeverity::Warning
2211        }));
2212        assert!(lints.iter().any(|lint| {
2213            lint.subject == "runtime.function.remote_crm.sync_contact.v1.input_schema"
2214                && lint.severity == ModuleManifestLintSeverity::Warning
2215        }));
2216        assert!(lints.iter().any(|lint| {
2217            lint.subject == "runtime.function.remote_crm.sync_contact.v1"
2218                && lint.severity == ModuleManifestLintSeverity::Error
2219        }));
2220    }
2221
2222    #[test]
2223    fn manifest_with_lifecycle_round_trips_through_json() {
2224        let manifest = ModuleManifest::builder("remote-crm")
2225            .runtime(RuntimeSurface {
2226                functions: vec![RuntimeFunctionDeclaration {
2227                    name: "remote_crm.warm_contact_cache.v1".to_owned(),
2228                    version: 1,
2229                    queue: "remote-crm".to_owned(),
2230                    input_schema: Some("remote_crm.warm_contact_cache.v1".to_owned()),
2231                    retry_policy: Some(RuntimeRetryPolicyDeclaration {
2232                        max_attempts: 2,
2233                        initial_delay_ms: 500,
2234                    }),
2235                    operation: None,
2236                }],
2237                schedules: vec![],
2238            })
2239            .lifecycle(LifecycleSurface {
2240                startup_checks: vec![LifecycleStartupCheckDeclaration {
2241                    name: "warm cache function is registered".to_owned(),
2242                    required: true,
2243                    check: LifecycleStartupCheckKind::FunctionRegistered {
2244                        function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
2245                    },
2246                }],
2247                activation_jobs: vec![LifecycleActivationJobDeclaration {
2248                    name: "warm contact cache".to_owned(),
2249                    function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
2250                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
2251                    input: serde_json::json!({ "reason": "worker_startup" }),
2252                    required: true,
2253                }],
2254            })
2255            .build();
2256
2257        let json = serde_json::to_string(&manifest).expect("serialize");
2258
2259        assert!(json.contains(r#""lifecycle""#), "got {json}");
2260        assert!(
2261            json.contains(r#""kind":"function_registered""#),
2262            "got {json}"
2263        );
2264        assert!(
2265            json.contains(r#""run_policy":"every_startup""#),
2266            "got {json}"
2267        );
2268        let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2269        assert_eq!(manifest, back);
2270    }
2271
2272    #[test]
2273    fn manifest_lint_flags_lifecycle_declarations_that_cannot_run() {
2274        let manifest = ModuleManifest::builder("remote-crm")
2275            .runtime(RuntimeSurface {
2276                functions: vec![],
2277                schedules: vec![],
2278            })
2279            .lifecycle(LifecycleSurface {
2280                startup_checks: vec![
2281                    LifecycleStartupCheckDeclaration {
2282                        name: "".to_owned(),
2283                        required: true,
2284                        check: LifecycleStartupCheckKind::FunctionRegistered {
2285                            function_name: "remote_crm.missing.v1".to_owned(),
2286                        },
2287                    },
2288                    LifecycleStartupCheckDeclaration {
2289                        name: "missing capability".to_owned(),
2290                        required: true,
2291                        check: LifecycleStartupCheckKind::CapabilityDeclared {
2292                            capability: "remote_crm.contacts.read".to_owned(),
2293                        },
2294                    },
2295                ],
2296                activation_jobs: vec![LifecycleActivationJobDeclaration {
2297                    name: "warm contact cache".to_owned(),
2298                    function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
2299                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
2300                    input: serde_json::json!({}),
2301                    required: true,
2302                }],
2303            })
2304            .build();
2305
2306        let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
2307
2308        assert!(lints.iter().any(|lint| {
2309            lint.subject == "lifecycle.startup_check"
2310                && lint.severity == ModuleManifestLintSeverity::Warning
2311                && lint.message == "Lifecycle startup check is missing a name."
2312        }));
2313        assert!(lints.iter().any(|lint| {
2314            lint.subject == "lifecycle.startup_check.function_registered.remote_crm.missing.v1"
2315                && lint.severity == ModuleManifestLintSeverity::Error
2316        }));
2317        assert!(lints.iter().any(|lint| {
2318            lint.subject == "lifecycle.startup_check.capability.remote_crm.contacts.read"
2319                && lint.severity == ModuleManifestLintSeverity::Warning
2320        }));
2321        assert!(lints.iter().any(|lint| {
2322            lint.subject == "lifecycle.activation_job.warm contact cache"
2323                && lint.severity == ModuleManifestLintSeverity::Error
2324        }));
2325    }
2326
2327    #[test]
2328    fn manifest_lint_warns_for_empty_lifecycle_surface() {
2329        let manifest = ModuleManifest::builder("remote-crm")
2330            .lifecycle(LifecycleSurface {
2331                startup_checks: vec![],
2332                activation_jobs: vec![],
2333            })
2334            .build();
2335
2336        let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
2337
2338        assert!(lints.iter().any(|lint| {
2339            lint.subject == "lifecycle"
2340                && lint.severity == ModuleManifestLintSeverity::Warning
2341                && lint.message
2342                    == "Lifecycle surface declares no startup checks or activation jobs."
2343        }));
2344    }
2345
2346    #[test]
2347    fn manifest_lint_warns_for_activation_job_missing_name() {
2348        let manifest = ModuleManifest::builder("remote-crm")
2349            .runtime(RuntimeSurface {
2350                functions: vec![RuntimeFunctionDeclaration {
2351                    name: "remote_crm.warm_contact_cache.v1".to_owned(),
2352                    version: 1,
2353                    queue: "remote-crm".to_owned(),
2354                    input_schema: Some("remote_crm.warm_contact_cache.v1".to_owned()),
2355                    retry_policy: None,
2356                    operation: None,
2357                }],
2358                schedules: vec![],
2359            })
2360            .lifecycle(LifecycleSurface {
2361                startup_checks: vec![],
2362                activation_jobs: vec![LifecycleActivationJobDeclaration {
2363                    name: "".to_owned(),
2364                    function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
2365                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
2366                    input: serde_json::json!({}),
2367                    required: true,
2368                }],
2369            })
2370            .build();
2371
2372        let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
2373
2374        assert!(lints.iter().any(|lint| {
2375            lint.subject == "lifecycle.activation_job"
2376                && lint.severity == ModuleManifestLintSeverity::Warning
2377                && lint.message == "Lifecycle activation job is missing a name."
2378        }));
2379    }
2380
2381    #[test]
2382    fn manifest_lint_errors_for_activation_job_missing_function_name() {
2383        let manifest = ModuleManifest::builder("remote-crm")
2384            .lifecycle(LifecycleSurface {
2385                startup_checks: vec![],
2386                activation_jobs: vec![LifecycleActivationJobDeclaration {
2387                    name: "".to_owned(),
2388                    function_name: "".to_owned(),
2389                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
2390                    input: serde_json::json!({}),
2391                    required: true,
2392                }],
2393            })
2394            .build();
2395
2396        let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
2397
2398        assert!(lints.iter().any(|lint| {
2399            lint.subject == "lifecycle.activation_job"
2400                && lint.severity == ModuleManifestLintSeverity::Error
2401                && lint.message == "Lifecycle activation job is missing a function name."
2402        }));
2403    }
2404
2405    #[test]
2406    fn manifest_lint_warns_for_undeclared_capability_references() {
2407        use crate::admin::{AdminAction, AdminActionDangerLevel};
2408
2409        let manifest = ModuleManifest::builder("remote-crm")
2410            .capabilities(vec!["remote_crm.contacts.write".to_owned()])
2411            .http_routes(vec![ModuleHttpRoute {
2412                method: ModuleHttpMethod::Get,
2413                path: "/contacts/{id}".to_owned(),
2414                capability: Some("remote_crm.contacts.read".to_owned()),
2415                display_name: Some("Fetch Contact".to_owned()),
2416                story_title: Some("Fetch Contact".to_owned()),
2417                operation: None,
2418            }])
2419            .declarative_admin(AdminDeclarativeSurface {
2420                pages: vec![AdminDeclarativePage {
2421                    name: "contacts".to_owned(),
2422                    label: "Contacts".to_owned(),
2423                    sections: vec![AdminDeclarativeSection {
2424                        name: "contacts".to_owned(),
2425                        label: "Contacts".to_owned(),
2426                        component: AdminDeclarativeComponent::EntityTable {
2427                            entity: "contacts".to_owned(),
2428                        },
2429                    }],
2430                }],
2431                actions: vec![AdminAction {
2432                    name: "sync_contacts".to_owned(),
2433                    label: "Sync Contacts".to_owned(),
2434                    capability: "remote_crm.contacts.sync".to_owned(),
2435                    input_schema: None,
2436                    confirmation: None,
2437                    danger_level: AdminActionDangerLevel::Low,
2438                    operation: None,
2439                }],
2440                fallback_schema: Some(AdminSchema {
2441                    entities: vec![crate::EntitySchema {
2442                        name: "contacts".to_owned(),
2443                        label: "Contacts".to_owned(),
2444                        fields: vec![],
2445                        read_capability: "remote_crm.contacts.read".to_owned(),
2446                    }],
2447                }),
2448            })
2449            .build();
2450
2451        let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
2452
2453        assert!(lints.iter().any(|lint| {
2454            lint.severity == ModuleManifestLintSeverity::Warning
2455                && lint.subject == "capability.reference.http_route.GET /contacts/{id}"
2456                && lint.message == "Capability reference is not declared by the module."
2457        }));
2458        assert!(lints.iter().any(|lint| {
2459            lint.severity == ModuleManifestLintSeverity::Warning
2460                && lint.subject == "capability.reference.admin.declarative.action.sync_contacts"
2461                && lint.message == "Capability reference is not declared by the module."
2462        }));
2463        assert!(lints.iter().any(|lint| {
2464            lint.severity == ModuleManifestLintSeverity::Warning
2465                && lint.subject == "capability.reference.admin.declarative.fallback_schema.contacts"
2466                && lint.message == "Capability reference is not declared by the module."
2467        }));
2468    }
2469
2470    #[test]
2471    fn manifest_lint_catalog_covers_current_subjects() {
2472        let schema = AdminSchema {
2473            entities: vec![crate::EntitySchema {
2474                name: "contacts".to_owned(),
2475                label: "Contacts".to_owned(),
2476                fields: vec![],
2477                read_capability: "".to_owned(),
2478            }],
2479        };
2480        let manifest = ModuleManifest::builder("")
2481            .capabilities(vec!["RemoteCRM Contacts Read".to_owned()])
2482            .http_routes(vec![
2483                ModuleHttpRoute {
2484                    method: ModuleHttpMethod::Get,
2485                    path: "/contacts/{id}".to_owned(),
2486                    capability: None,
2487                    display_name: None,
2488                    story_title: None,
2489                    operation: None,
2490                },
2491                ModuleHttpRoute {
2492                    method: ModuleHttpMethod::Get,
2493                    path: "/contacts/{id}".to_owned(),
2494                    capability: None,
2495                    display_name: None,
2496                    story_title: None,
2497                    operation: None,
2498                },
2499            ])
2500            .embedded_admin(AdminEmbeddedSurface {
2501                runtime: AdminEmbeddedRuntime::Wasm,
2502                entry: AdminEmbeddedEntry::Url {
2503                    url: "http://crm.example.test/admin".to_owned(),
2504                    allowed_origins: vec![],
2505                },
2506                sandbox: AdminSandboxPolicy {
2507                    allow_scripts: true,
2508                    allow_forms: false,
2509                    allow_popups: false,
2510                    allow_same_origin: false,
2511                },
2512                permissions: vec![AdminPermission::ReadEntity {
2513                    entity: "missing".to_owned(),
2514                }],
2515                fallback_schema: Some(schema),
2516            })
2517            .runtime(RuntimeSurface {
2518                functions: vec![RuntimeFunctionDeclaration {
2519                    name: "remote_crm.sync_contact.v1".to_owned(),
2520                    version: 1,
2521                    queue: "".to_owned(),
2522                    input_schema: Some("remote_crm.sync_contact.input.v1".to_owned()),
2523                    retry_policy: Some(RuntimeRetryPolicyDeclaration {
2524                        max_attempts: 0,
2525                        initial_delay_ms: 1000,
2526                    }),
2527                    operation: None,
2528                }],
2529                schedules: vec![ScheduledFunctionDeclaration {
2530                    name: "sync_contacts_hourly".to_owned(),
2531                    function_name: "remote_crm.missing.v1".to_owned(),
2532                    cron: "bad cron".to_owned(),
2533                    input: serde_json::json!({}),
2534                }],
2535            })
2536            .lifecycle(LifecycleSurface {
2537                startup_checks: vec![LifecycleStartupCheckDeclaration {
2538                    name: "missing function".to_owned(),
2539                    required: true,
2540                    check: LifecycleStartupCheckKind::FunctionRegistered {
2541                        function_name: "remote_crm.missing.v1".to_owned(),
2542                    },
2543                }],
2544                activation_jobs: vec![LifecycleActivationJobDeclaration {
2545                    name: "missing activation".to_owned(),
2546                    function_name: "remote_crm.missing.v1".to_owned(),
2547                    run_policy: LifecycleActivationRunPolicy::EveryStartup,
2548                    input: serde_json::json!({}),
2549                    required: true,
2550                }],
2551            })
2552            .console(vec![ConsoleSurface {
2553                name: "contacts".to_owned(),
2554                label: "Contacts".to_owned(),
2555                area: ConsoleArea::Data,
2556                route: "/remote-crm/contacts".to_owned(),
2557                package: ConsolePackage {
2558                    name: "@lenso/remote-crm-console".to_owned(),
2559                    export: "remoteCrmConsoleModule".to_owned(),
2560                },
2561                icon: None,
2562                required_capabilities: Vec::new(),
2563                navigation: Some(crate::ConsoleNavigation {
2564                    workspace: crate::ConsoleWorkspaceRef {
2565                        id: "system".to_owned(),
2566                        label: "System".to_owned(),
2567                        icon: None,
2568                    },
2569                    group: None,
2570                    order: None,
2571                }),
2572            }])
2573            .build();
2574
2575        let catalog: Vec<_> = lint_module_manifest(ModuleSource::Remote, &manifest)
2576            .into_iter()
2577            .map(|lint| (lint.severity, lint.subject))
2578            .collect();
2579
2580        assert_eq!(
2581            catalog,
2582            vec![
2583                (ModuleManifestLintSeverity::Error, "module.name".to_owned()),
2584                (
2585                    ModuleManifestLintSeverity::Warning,
2586                    "capability RemoteCRM Contacts Read".to_owned(),
2587                ),
2588                (
2589                    ModuleManifestLintSeverity::Error,
2590                    "GET /contacts/{id}".to_owned(),
2591                ),
2592                (
2593                    ModuleManifestLintSeverity::Warning,
2594                    "GET /contacts/{id}".to_owned(),
2595                ),
2596                (
2597                    ModuleManifestLintSeverity::Warning,
2598                    "GET /contacts/{id}".to_owned(),
2599                ),
2600                (
2601                    ModuleManifestLintSeverity::Warning,
2602                    "GET /contacts/{id}".to_owned(),
2603                ),
2604                (
2605                    ModuleManifestLintSeverity::Warning,
2606                    "GET /contacts/{id}".to_owned(),
2607                ),
2608                (
2609                    ModuleManifestLintSeverity::Warning,
2610                    "GET /contacts/{id}".to_owned(),
2611                ),
2612                (
2613                    ModuleManifestLintSeverity::Warning,
2614                    "GET /contacts/{id}".to_owned(),
2615                ),
2616                (
2617                    ModuleManifestLintSeverity::Warning,
2618                    "admin.embedded.runtime".to_owned(),
2619                ),
2620                (
2621                    ModuleManifestLintSeverity::Warning,
2622                    "admin.embedded.entry.url".to_owned(),
2623                ),
2624                (
2625                    ModuleManifestLintSeverity::Warning,
2626                    "admin.embedded.entry.allowed_origins".to_owned(),
2627                ),
2628                (
2629                    ModuleManifestLintSeverity::Warning,
2630                    "admin.embedded.fallback_schema.contacts".to_owned(),
2631                ),
2632                (
2633                    ModuleManifestLintSeverity::Warning,
2634                    "admin.embedded.permission.missing".to_owned(),
2635                ),
2636                (
2637                    ModuleManifestLintSeverity::Error,
2638                    "lifecycle.startup_check.function_registered.remote_crm.missing.v1".to_owned(),
2639                ),
2640                (
2641                    ModuleManifestLintSeverity::Error,
2642                    "lifecycle.activation_job.missing activation".to_owned(),
2643                ),
2644                (
2645                    ModuleManifestLintSeverity::Warning,
2646                    "console.surface.contacts.navigation.workspace.id".to_owned(),
2647                ),
2648                (
2649                    ModuleManifestLintSeverity::Warning,
2650                    "runtime.function.remote_crm.sync_contact.v1".to_owned(),
2651                ),
2652                (
2653                    ModuleManifestLintSeverity::Warning,
2654                    "runtime.function.remote_crm.sync_contact.v1.input_schema".to_owned(),
2655                ),
2656                (
2657                    ModuleManifestLintSeverity::Warning,
2658                    "runtime.function.remote_crm.sync_contact.v1.retry_policy".to_owned(),
2659                ),
2660                (
2661                    ModuleManifestLintSeverity::Error,
2662                    "runtime.schedule.sync_contacts_hourly.cron".to_owned(),
2663                ),
2664                (
2665                    ModuleManifestLintSeverity::Error,
2666                    "runtime.schedule.sync_contacts_hourly".to_owned(),
2667                ),
2668            ],
2669        );
2670    }
2671}