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