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