Skip to main content

lenso_module_management/
engine.rs

1use crate::{
2    APPLICATION_MODULE_LOCK_PROTOCOL, ApplicationModuleLock, CreateOperationResult,
3    DESIRED_MODULE_COMPOSITION_PROTOCOL, DesiredModuleComposition, EnvironmentManagementMode,
4    MODULE_APPROVAL_PROTOCOL, MODULE_CHANGE_PLAN_PROTOCOL, MODULE_ENVIRONMENT_POLICY_PROTOCOL,
5    MODULE_OPERATION_JOURNAL_PROTOCOL, MODULE_OPERATION_PROTOCOL, MODULE_REPAIR_PLAN_PROTOCOL,
6    ModuleApproval, ModuleApprovalBoundary, ModuleChangePlan, ModuleEffectReceipt,
7    ModuleEnvironmentPolicy, ModuleOperation, ModuleOperationError, ModuleOperationJournalEvent,
8    ModuleOperationKind, ModuleOperationLease, ModuleOperationState, ModuleOperationStore,
9    ModuleOperationStoreError, ModuleOperationTransition, ModulePlanEffect, ModuleRepairAction,
10    ModuleRepairPlan, ModuleResumeEvidence, ModuleRiskClass, ModuleRootChange,
11    ModuleWorkspaceBackup, application_module_lock_digest, desired_composition_digest,
12    journal_event_digest, module_change_plan_digest, module_repair_plan_digest,
13};
14use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
15use chrono::{DateTime, Duration, Utc};
16use lenso_contracts::ArtifactReference;
17use sha2::{Digest as _, Sha256};
18use std::collections::BTreeSet;
19use thiserror::Error;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ManagementActor {
23    pub actor_id: String,
24    pub verified_authorities: BTreeSet<String>,
25}
26
27#[derive(Debug, Clone)]
28pub struct StartModuleOperation<'a> {
29    pub operation_id: &'a str,
30    pub idempotency_key: &'a str,
31    pub operation_kind: ModuleOperationKind,
32    pub plan: &'a ModuleChangePlan,
33    pub policy: &'a ModuleEnvironmentPolicy,
34    pub actor: &'a ManagementActor,
35    pub approvals: Vec<ModuleApproval>,
36    pub holder_id: &'a str,
37    pub now: DateTime<Utc>,
38}
39
40#[derive(Debug, Clone)]
41pub struct AdvanceModuleOperation {
42    pub operation_id: String,
43    pub expected_revision: u64,
44    pub fencing_token: u64,
45    pub next_state: ModuleOperationState,
46    pub actor_id: String,
47    pub outcome_code: String,
48    pub evidence_references: Vec<ArtifactReference>,
49    pub error: Option<ModuleOperationError>,
50    pub next_actions: Vec<String>,
51    pub now: DateTime<Utc>,
52}
53
54#[derive(Debug, Error)]
55pub enum ModuleManagementError {
56    #[error("management contract is invalid: {0}")]
57    InvalidContract(String),
58    #[error("environment management is disabled or read-only")]
59    EnvironmentNotWritable,
60    #[error("actor lacks `{0}` authority")]
61    MissingAuthority(String),
62    #[error("approval boundary `{0}` is not satisfied")]
63    ApprovalMissing(String),
64    #[error("approval `{0}` is stale or does not bind the exact plan")]
65    ApprovalStale(String),
66    #[error("idempotency key is already bound to another plan")]
67    IdempotencyConflict,
68    #[error("application composition lease is held by `{0}`")]
69    LeaseHeld(String),
70    #[error("operation fencing token is stale")]
71    StaleFencingToken,
72    #[error("operation transition from {from:?} to {to:?} is not legal")]
73    IllegalTransition {
74        from: ModuleOperationState,
75        to: ModuleOperationState,
76    },
77    #[error("effect receipt conflicts with retained evidence for `{0}`")]
78    ReceiptConflict(String),
79    #[error(transparent)]
80    Store(#[from] ModuleOperationStoreError),
81}
82
83#[derive(Debug)]
84pub struct ModuleManagementEngine<S> {
85    store: S,
86}
87
88impl<S> ModuleManagementEngine<S>
89where
90    S: ModuleOperationStore,
91{
92    pub fn new(store: S) -> Self {
93        Self { store }
94    }
95
96    pub fn store(&self) -> &S {
97        &self.store
98    }
99
100    pub fn start(
101        &self,
102        request: StartModuleOperation<'_>,
103    ) -> Result<ModuleOperation, ModuleManagementError> {
104        validate_change_plan(request.plan)?;
105        if request.policy.mode != EnvironmentManagementMode::Full {
106            return Err(ModuleManagementError::EnvironmentNotWritable);
107        }
108        if request.policy.protocol != MODULE_ENVIRONMENT_POLICY_PROTOCOL {
109            return invalid("unsupported Module Environment Policy protocol");
110        }
111        require_authority(request.actor, "module.manage")?;
112        if request
113            .plan
114            .effects
115            .iter()
116            .any(|effect| matches!(effect, ModulePlanEffect::ServiceInstallation { .. }))
117        {
118            require_authority(request.actor, "service.manage")?;
119        }
120        if let Some(existing) = self
121            .store
122            .find_by_idempotency_key(&request.plan.application_id, request.idempotency_key)?
123        {
124            return if existing.plan_digest == request.plan.plan_digest {
125                Ok(existing)
126            } else {
127                Err(ModuleManagementError::IdempotencyConflict)
128            };
129        }
130        let approvals_satisfied = approvals_satisfied(
131            request.plan,
132            request.policy,
133            request.actor,
134            &request.approvals,
135            request.now,
136        )?;
137        let initial_state = if approvals_satisfied {
138            ModuleOperationState::Ready
139        } else {
140            ModuleOperationState::AwaitingApproval
141        };
142        let fencing_token = if initial_state == ModuleOperationState::Ready {
143            self.acquire_lease(
144                &request.plan.application_id,
145                request.holder_id,
146                request.policy.maximum_lease_seconds,
147                request.now,
148            )?
149            .fencing_token
150        } else {
151            0
152        };
153        let operation = ModuleOperation {
154            protocol: MODULE_OPERATION_PROTOCOL.to_owned(),
155            operation_id: request.operation_id.to_owned(),
156            idempotency_key: request.idempotency_key.to_owned(),
157            application_id: request.plan.application_id.clone(),
158            environment_id: request.plan.environment_id.clone(),
159            plan_digest: request.plan.plan_digest.clone(),
160            operation_kind: request.operation_kind,
161            expected_target_revision: request.plan.expected_target_revision,
162            current_lock_digest: request.plan.current_lock_digest.clone(),
163            target_lock_digest: request.plan.target_lock_digest.clone(),
164            actor_id: request.actor.actor_id.clone(),
165            verified_authorities: request.actor.verified_authorities.iter().cloned().collect(),
166            policy_revision: request.policy.revision.clone(),
167            state: initial_state,
168            revision: 0,
169            fencing_token,
170            attempt: 1,
171            approvals: request.approvals,
172            effect_receipts: Vec::new(),
173            workspace_backups: Vec::new(),
174            errors: Vec::new(),
175            next_actions: if initial_state == ModuleOperationState::AwaitingApproval {
176                vec!["obtain_plan_bound_approvals".to_owned()]
177            } else {
178                vec!["apply_reviewed_plan".to_owned()]
179            },
180            repair_operation_id: None,
181            updated_at: request.now,
182        };
183        let event = initial_event(&operation);
184        match self.store.create_idempotent(&operation, &event)? {
185            CreateOperationResult::Created => Ok(operation),
186            CreateOperationResult::Existing(existing)
187                if existing.plan_digest == request.plan.plan_digest =>
188            {
189                Ok(*existing)
190            }
191            CreateOperationResult::Existing(_) => Err(ModuleManagementError::IdempotencyConflict),
192        }
193    }
194
195    #[allow(clippy::too_many_arguments)]
196    pub fn submit_approval(
197        &self,
198        operation_id: &str,
199        expected_revision: u64,
200        plan: &ModuleChangePlan,
201        policy: &ModuleEnvironmentPolicy,
202        requester: &ManagementActor,
203        approval: ModuleApproval,
204        holder_id: &str,
205        now: DateTime<Utc>,
206    ) -> Result<ModuleOperation, ModuleManagementError> {
207        validate_change_plan(plan)?;
208        let current = self.store.load(operation_id)?;
209        if current.revision != expected_revision {
210            return Err(ModuleOperationStoreError::RevisionConflict {
211                operation_id: current.operation_id,
212                expected: expected_revision,
213                observed: current.revision,
214            }
215            .into());
216        }
217        if current.state != ModuleOperationState::AwaitingApproval
218            || current.plan_digest != plan.plan_digest
219        {
220            return Err(ModuleManagementError::IllegalTransition {
221                from: current.state,
222                to: ModuleOperationState::Ready,
223            });
224        }
225        let mut approvals = current.approvals.clone();
226        approvals.retain(|existing| existing.boundary_id != approval.boundary_id);
227        approvals.push(approval);
228        approvals.sort_by(|left, right| left.boundary_id.cmp(&right.boundary_id));
229        let ready = approvals_satisfied(plan, policy, requester, &approvals, now)?;
230        let mut updated = current.clone();
231        updated.approvals = approvals;
232        updated.revision = updated.revision.saturating_add(1);
233        updated.updated_at = now;
234        if ready {
235            let lease = self.acquire_lease(
236                &current.application_id,
237                holder_id,
238                policy.maximum_lease_seconds,
239                now,
240            )?;
241            updated.state = ModuleOperationState::Ready;
242            updated.fencing_token = lease.fencing_token;
243            updated.next_actions = vec!["apply_reviewed_plan".to_owned()];
244        } else {
245            updated.next_actions = vec!["obtain_plan_bound_approvals".to_owned()];
246        }
247        let event = next_event(
248            &current,
249            updated,
250            requester.actor_id.clone(),
251            "approval_recorded".to_owned(),
252            Vec::new(),
253            now,
254            self.store.journal(operation_id)?.events.last(),
255        )?;
256        self.store.compare_and_append(expected_revision, &event)?;
257        Ok(event.operation_after)
258    }
259
260    pub fn advance(
261        &self,
262        request: AdvanceModuleOperation,
263    ) -> Result<ModuleOperation, ModuleManagementError> {
264        let current = self.store.load(&request.operation_id)?;
265        if current.revision != request.expected_revision {
266            return Err(ModuleOperationStoreError::RevisionConflict {
267                operation_id: current.operation_id,
268                expected: request.expected_revision,
269                observed: current.revision,
270            }
271            .into());
272        }
273        if current.fencing_token != request.fencing_token {
274            return Err(ModuleManagementError::StaleFencingToken);
275        }
276        if !legal_transition(current.state, request.next_state) {
277            return Err(ModuleManagementError::IllegalTransition {
278                from: current.state,
279                to: request.next_state,
280            });
281        }
282        let mut updated = current.clone();
283        updated.state = request.next_state;
284        updated.revision = updated.revision.saturating_add(1);
285        updated.updated_at = request.now;
286        updated.next_actions = request.next_actions;
287        if let Some(error) = request.error {
288            updated.errors.push(error);
289        }
290        let event = next_event(
291            &current,
292            updated,
293            request.actor_id,
294            request.outcome_code,
295            request.evidence_references,
296            request.now,
297            self.store.journal(&request.operation_id)?.events.last(),
298        )?;
299        self.store
300            .compare_and_append(request.expected_revision, &event)?;
301        Ok(event.operation_after)
302    }
303
304    #[allow(clippy::too_many_arguments)]
305    pub fn retry_blocked(
306        &self,
307        operation_id: &str,
308        expected_revision: u64,
309        next_state: ModuleOperationState,
310        holder_id: &str,
311        maximum_lease_seconds: u64,
312        actor_id: &str,
313        now: DateTime<Utc>,
314    ) -> Result<ModuleOperation, ModuleManagementError> {
315        let current = self.store.load(operation_id)?;
316        if current.revision != expected_revision {
317            return Err(ModuleOperationStoreError::RevisionConflict {
318                operation_id: current.operation_id,
319                expected: expected_revision,
320                observed: current.revision,
321            }
322            .into());
323        }
324        if current.state != ModuleOperationState::Blocked
325            || !matches!(
326                next_state,
327                ModuleOperationState::StagingConfiguration
328                    | ModuleOperationState::Migrating
329                    | ModuleOperationState::Verifying
330                    | ModuleOperationState::Activating
331            )
332        {
333            return Err(ModuleManagementError::IllegalTransition {
334                from: current.state,
335                to: next_state,
336            });
337        }
338        let lease = self.acquire_lease(
339            &current.application_id,
340            holder_id,
341            maximum_lease_seconds,
342            now,
343        )?;
344        let mut updated = current.clone();
345        updated.state = next_state;
346        updated.revision = updated.revision.saturating_add(1);
347        updated.attempt = updated.attempt.saturating_add(1);
348        updated.fencing_token = lease.fencing_token;
349        updated.updated_at = now;
350        updated.next_actions = vec!["retry_incomplete_effects".to_owned()];
351        let event = next_event(
352            &current,
353            updated,
354            actor_id.to_owned(),
355            "blocked_operation_retry_started".to_owned(),
356            Vec::new(),
357            now,
358            self.store.journal(operation_id)?.events.last(),
359        )?;
360        self.store.compare_and_append(expected_revision, &event)?;
361        Ok(event.operation_after)
362    }
363
364    #[allow(clippy::too_many_arguments)]
365    pub fn begin_workspace_application(
366        &self,
367        operation_id: &str,
368        expected_revision: u64,
369        fencing_token: u64,
370        actor_id: &str,
371        plan: &ModuleChangePlan,
372        backups: Vec<ModuleWorkspaceBackup>,
373        evidence_references: Vec<ArtifactReference>,
374        now: DateTime<Utc>,
375    ) -> Result<ModuleOperation, ModuleManagementError> {
376        validate_change_plan(plan)?;
377        let current = self.store.load(operation_id)?;
378        if current.revision != expected_revision {
379            return Err(ModuleOperationStoreError::RevisionConflict {
380                operation_id: current.operation_id,
381                expected: expected_revision,
382                observed: current.revision,
383            }
384            .into());
385        }
386        if current.fencing_token != fencing_token {
387            return Err(ModuleManagementError::StaleFencingToken);
388        }
389        if current.plan_digest != plan.plan_digest {
390            return Err(ModuleManagementError::IdempotencyConflict);
391        }
392        if current.state != ModuleOperationState::Ready {
393            return Err(ModuleManagementError::IllegalTransition {
394                from: current.state,
395                to: ModuleOperationState::ApplyingFiles,
396            });
397        }
398        require_sorted_unique(
399            backups.iter().map(|backup| backup.path.as_str()),
400            "workspace backups",
401        )?;
402        let backup_paths = backups
403            .iter()
404            .map(|backup| backup.path.as_str())
405            .collect::<BTreeSet<_>>();
406        if plan.effects.iter().any(|effect| {
407            matches!(effect, ModulePlanEffect::WorkspaceFile { path, .. } if !backup_paths.contains(path.as_str()))
408        }) {
409            return invalid("workspace backups must cover every planned file effect");
410        }
411        for backup in &backups {
412            validate_workspace_backup(backup)?;
413        }
414        let mut updated = current.clone();
415        updated.state = ModuleOperationState::ApplyingFiles;
416        updated.revision = updated.revision.saturating_add(1);
417        updated.updated_at = now;
418        updated.workspace_backups = backups;
419        updated.next_actions = vec!["apply_next_guarded_file".to_owned()];
420        let event = next_event(
421            &current,
422            updated,
423            actor_id.to_owned(),
424            "workspace_backups_recorded".to_owned(),
425            evidence_references,
426            now,
427            self.store.journal(operation_id)?.events.last(),
428        )?;
429        self.store.compare_and_append(expected_revision, &event)?;
430        Ok(event.operation_after)
431    }
432
433    pub fn record_effect_receipt(
434        &self,
435        operation_id: &str,
436        expected_revision: u64,
437        fencing_token: u64,
438        actor_id: &str,
439        receipt: ModuleEffectReceipt,
440        now: DateTime<Utc>,
441    ) -> Result<ModuleOperation, ModuleManagementError> {
442        let current = self.store.load(operation_id)?;
443        if current.revision != expected_revision {
444            return Err(ModuleOperationStoreError::RevisionConflict {
445                operation_id: current.operation_id,
446                expected: expected_revision,
447                observed: current.revision,
448            }
449            .into());
450        }
451        if current.fencing_token != fencing_token || receipt.fencing_token != fencing_token {
452            return Err(ModuleManagementError::StaleFencingToken);
453        }
454        if !matches!(
455            current.state,
456            ModuleOperationState::ApplyingFiles
457                | ModuleOperationState::FilesApplied
458                | ModuleOperationState::StagingConfiguration
459                | ModuleOperationState::Migrating
460                | ModuleOperationState::Verifying
461                | ModuleOperationState::Activating
462        ) {
463            return invalid("effect receipts are only accepted while effects are being applied");
464        }
465        if receipt.operation_id != current.operation_id || receipt.attempt != current.attempt {
466            return Err(ModuleManagementError::ReceiptConflict(
467                receipt.effect_id.clone(),
468            ));
469        }
470        if let Some(existing) = current
471            .effect_receipts
472            .iter()
473            .find(|existing| existing.effect_id == receipt.effect_id)
474        {
475            return if existing == &receipt {
476                Ok(current)
477            } else {
478                Err(ModuleManagementError::ReceiptConflict(receipt.effect_id))
479            };
480        }
481        let mut updated = current.clone();
482        updated.revision = updated.revision.saturating_add(1);
483        updated.updated_at = now;
484        updated.effect_receipts.push(receipt.clone());
485        updated
486            .effect_receipts
487            .sort_by(|left, right| left.effect_id.cmp(&right.effect_id));
488        let event = next_event(
489            &current,
490            updated,
491            actor_id.to_owned(),
492            "effect_receipt_recorded".to_owned(),
493            receipt.evidence_references.clone(),
494            now,
495            self.store.journal(operation_id)?.events.last(),
496        )?;
497        self.store.compare_and_append(expected_revision, &event)?;
498        Ok(event.operation_after)
499    }
500
501    #[allow(clippy::too_many_arguments)]
502    pub fn resume_after_crash(
503        &self,
504        operation_id: &str,
505        expected_revision: u64,
506        evidence: &ModuleResumeEvidence,
507        holder_id: &str,
508        maximum_lease_seconds: u64,
509        actor_id: &str,
510        now: DateTime<Utc>,
511    ) -> Result<ModuleOperation, ModuleManagementError> {
512        let current = self.store.load(operation_id)?;
513        if current.revision != expected_revision {
514            return Err(ModuleOperationStoreError::RevisionConflict {
515                operation_id: current.operation_id,
516                expected: expected_revision,
517                observed: current.revision,
518            }
519            .into());
520        }
521        if !matches!(
522            current.state,
523            ModuleOperationState::ApplyingFiles
524                | ModuleOperationState::FilesApplied
525                | ModuleOperationState::StagingConfiguration
526                | ModuleOperationState::Migrating
527                | ModuleOperationState::Verifying
528                | ModuleOperationState::Activating
529        ) || evidence.plan_digest != current.plan_digest
530            || !evidence.next_effect_idempotent
531            || evidence.observed_target_digest.is_empty()
532        {
533            return Err(ModuleManagementError::InvalidContract(
534                "crash continuation is not proven safe".to_owned(),
535            ));
536        }
537        let completed = current
538            .effect_receipts
539            .iter()
540            .map(|receipt| receipt.effect_id.as_str())
541            .collect::<Vec<_>>();
542        if completed
543            != evidence
544                .completed_effect_ids
545                .iter()
546                .map(String::as_str)
547                .collect::<Vec<_>>()
548            || completed.contains(&evidence.next_effect_id.as_str())
549        {
550            return Err(ModuleManagementError::InvalidContract(
551                "resume evidence does not match completed effect receipts".to_owned(),
552            ));
553        }
554        let lease = self.acquire_lease(
555            &current.application_id,
556            holder_id,
557            maximum_lease_seconds,
558            now,
559        )?;
560        let mut updated = current.clone();
561        updated.revision = updated.revision.saturating_add(1);
562        updated.attempt = updated.attempt.saturating_add(1);
563        updated.fencing_token = lease.fencing_token;
564        updated.updated_at = now;
565        updated.next_actions = vec![format!("resume_effect:{}", evidence.next_effect_id)];
566        let event = next_event(
567            &current,
568            updated,
569            actor_id.to_owned(),
570            "crash_recovery_attempt_started".to_owned(),
571            Vec::new(),
572            now,
573            self.store.journal(operation_id)?.events.last(),
574        )?;
575        self.store.compare_and_append(expected_revision, &event)?;
576        Ok(event.operation_after)
577    }
578
579    #[allow(clippy::too_many_arguments)]
580    pub fn reconcile_with_succeeded_repair(
581        &self,
582        operation_id: &str,
583        expected_revision: u64,
584        repair_plan: &ModuleRepairPlan,
585        repair_change_plan: &ModuleChangePlan,
586        repair_operation_id: &str,
587        actor_id: &str,
588        evidence_references: Vec<ArtifactReference>,
589        now: DateTime<Utc>,
590    ) -> Result<ModuleOperation, ModuleManagementError> {
591        validate_repair_plan(repair_plan)?;
592        validate_change_plan(repair_change_plan)?;
593        let current = self.store.load(operation_id)?;
594        if current.revision != expected_revision {
595            return Err(ModuleOperationStoreError::RevisionConflict {
596                operation_id: current.operation_id,
597                expected: expected_revision,
598                observed: current.revision,
599            }
600            .into());
601        }
602        let repair_digest_matches = matches!(
603            &repair_change_plan.request,
604            ModuleRootChange::Repair { repair_plan_digest }
605                if repair_plan_digest == &repair_plan.repair_plan_digest
606        );
607        if current.state != ModuleOperationState::RepairRequired
608            || repair_plan.original_operation_id != current.operation_id
609            || repair_plan.original_operation_revision != current.revision
610            || repair_plan.application_id != current.application_id
611            || repair_plan.environment_id != current.environment_id
612            || repair_change_plan.application_id != current.application_id
613            || repair_change_plan.environment_id != current.environment_id
614            || !repair_digest_matches
615        {
616            return invalid("repair plan does not bind the exact repair-required operation");
617        }
618        let repair_operation = self.store.load(repair_operation_id)?;
619        if repair_operation.operation_kind != ModuleOperationKind::Repair
620            || repair_operation.state != ModuleOperationState::Succeeded
621            || repair_operation.plan_digest != repair_change_plan.plan_digest
622            || repair_operation.application_id != current.application_id
623            || repair_operation.environment_id != current.environment_id
624        {
625            return invalid("reconciliation requires the bound repair operation to succeed");
626        }
627        let mut updated = current.clone();
628        updated.state = ModuleOperationState::Reconciled;
629        updated.revision = updated.revision.saturating_add(1);
630        updated.updated_at = now;
631        updated.next_actions.clear();
632        updated.repair_operation_id = Some(repair_operation.operation_id);
633        let event = next_event(
634            &current,
635            updated,
636            actor_id.to_owned(),
637            "repair_reconciled".to_owned(),
638            evidence_references,
639            now,
640            self.store.journal(operation_id)?.events.last(),
641        )?;
642        self.store.compare_and_append(expected_revision, &event)?;
643        Ok(event.operation_after)
644    }
645
646    pub fn acquire_lease(
647        &self,
648        application_id: &str,
649        holder_id: &str,
650        maximum_lease_seconds: u64,
651        now: DateTime<Utc>,
652    ) -> Result<ModuleOperationLease, ModuleManagementError> {
653        let current = self.store.load_lease()?;
654        if let Some(lease) = &current
655            && lease.expires_at > now
656            && (lease.application_id != application_id || lease.holder_id != holder_id)
657        {
658            return Err(ModuleManagementError::LeaseHeld(lease.holder_id.clone()));
659        }
660        let duration = i64::try_from(maximum_lease_seconds).map_err(|_| {
661            ModuleManagementError::InvalidContract("lease duration overflows".into())
662        })?;
663        let lease = ModuleOperationLease {
664            application_id: application_id.to_owned(),
665            holder_id: holder_id.to_owned(),
666            fencing_token: current.as_ref().map_or(1, |lease| {
667                if lease.application_id == application_id
668                    && lease.holder_id == holder_id
669                    && lease.expires_at > now
670                {
671                    lease.fencing_token
672                } else {
673                    lease.fencing_token.saturating_add(1)
674                }
675            }),
676            revision: current
677                .as_ref()
678                .map_or(0, |lease| lease.revision.saturating_add(1)),
679            acquired_at: now,
680            expires_at: now + Duration::seconds(duration),
681        };
682        self.store
683            .compare_and_set_lease(current.as_ref().map(|lease| lease.revision), Some(&lease))?;
684        Ok(lease)
685    }
686
687    pub fn release_lease(
688        &self,
689        holder_id: &str,
690        fencing_token: u64,
691    ) -> Result<(), ModuleManagementError> {
692        let current = self.store.load_lease()?;
693        let Some(lease) = current else {
694            return Ok(());
695        };
696        if lease.holder_id != holder_id || lease.fencing_token != fencing_token {
697            return Err(ModuleManagementError::StaleFencingToken);
698        }
699        self.store
700            .compare_and_set_lease(Some(lease.revision), None)?;
701        Ok(())
702    }
703}
704
705fn validate_workspace_backup(backup: &ModuleWorkspaceBackup) -> Result<(), ModuleManagementError> {
706    if !safe_relative_path(&backup.path) {
707        return invalid("workspace backup path is unsafe");
708    }
709    let valid = match (backup.existence, backup.file_type) {
710        (crate::PathExistence::Absent, crate::ManagedFileType::Absent)
711        | (crate::PathExistence::Present, crate::ManagedFileType::Directory) => {
712            backup.content_base64.is_none() && backup.content_digest.is_none()
713        }
714        (crate::PathExistence::Present, crate::ManagedFileType::Regular) => {
715            let Some(encoded) = &backup.content_base64 else {
716                return invalid("regular-file backup has no exact bytes");
717            };
718            let Some(digest) = &backup.content_digest else {
719                return invalid("regular-file backup has no content digest");
720            };
721            BASE64
722                .decode(encoded)
723                .is_ok_and(|bytes| raw_digest(&bytes) == *digest)
724        }
725        _ => false,
726    };
727    if valid {
728        Ok(())
729    } else {
730        invalid("workspace backup shape or digest is invalid")
731    }
732}
733
734pub fn validate_change_plan(plan: &ModuleChangePlan) -> Result<(), ModuleManagementError> {
735    if plan.protocol != MODULE_CHANGE_PLAN_PROTOCOL {
736        return invalid("unsupported Module Change Plan protocol");
737    }
738    if module_change_plan_digest(plan).map_err(json_error)? != plan.plan_digest {
739        return invalid("Module Change Plan digest mismatch");
740    }
741    validate_desired_composition(&plan.target_desired)?;
742    if desired_composition_digest(&plan.target_desired).map_err(json_error)?
743        != plan.target_desired_digest
744        || plan.target_desired.application_id != plan.application_id
745    {
746        return invalid("target Desired Composition identity or digest mismatch");
747    }
748    validate_application_module_lock(&plan.target_lock)?;
749    if application_module_lock_digest(&plan.target_lock).map_err(json_error)?
750        != plan.target_lock_digest
751        || plan.target_lock.application_id != plan.application_id
752        || plan.target_lock.desired_composition_digest != plan.target_desired_digest
753        || plan.target_lock.catalog_snapshot_digest != plan.catalog_snapshot_digest
754        || plan.target_lock.trust_policy_digest != plan.trust_policy_digest
755    {
756        return invalid("target Application Module Lock binding or digest mismatch");
757    }
758    require_sorted_unique(
759        plan.read_set.iter().map(|entry| entry.path.as_str()),
760        "read set",
761    )?;
762    require_sorted_unique(
763        plan.effects.iter().map(ModulePlanEffect::effect_id),
764        "effect ids",
765    )?;
766    if plan.validation_commands.is_empty()
767        || plan
768            .read_set
769            .iter()
770            .any(|entry| entry.path == ".env" || entry.path.ends_with("/.env"))
771    {
772        return invalid("plans require validation commands and must never target .env");
773    }
774    for boundary in &plan.approval_boundaries {
775        validate_boundary(boundary, &plan.effects)?;
776    }
777    for effect in &plan.effects {
778        match effect {
779            ModulePlanEffect::WorkspaceFile {
780                path,
781                change,
782                before_digest,
783                after_digest,
784                after_content,
785                after_mode,
786                patch,
787                ..
788            } => validate_workspace_effect(
789                path,
790                *change,
791                before_digest.as_deref(),
792                after_digest.as_deref(),
793                after_content.as_deref(),
794                *after_mode,
795                patch,
796                &plan.read_set,
797            )?,
798            ModulePlanEffect::Migration {
799                artifact_locator,
800                artifact_digest,
801                ..
802            } => {
803                if !safe_relative_path(artifact_locator) || !valid_digest(artifact_digest) {
804                    return invalid(
805                        "migration artifacts require a safe locator and SHA-256 digest",
806                    );
807                }
808            }
809            ModulePlanEffect::ServiceInstallation {
810                service_id,
811                service_release_digest,
812                installation_plan,
813                adapter,
814                action,
815                ..
816            } => {
817                validate_service_effect(
818                    service_id,
819                    service_release_digest,
820                    *adapter,
821                    action.as_ref(),
822                )?;
823                if let Some(installation_plan) = installation_plan {
824                    crate::validate_service_installation_plan(installation_plan).map_err(
825                        |error| ModuleManagementError::InvalidContract(error.to_string()),
826                    )?;
827                    let installation = match &installation_plan.change {
828                        crate::ServiceInstallationChange::Install { installation } => installation,
829                        crate::ServiceInstallationChange::Uninstall { .. } => {
830                            return invalid(
831                                "Module Service installation effect cannot contain an uninstall plan",
832                            );
833                        }
834                    };
835                    if installation_plan.environment_id != plan.environment_id
836                        || installation.service_ref.service_id != *service_id
837                        || installation.service_release.digest != *service_release_digest
838                    {
839                        return invalid(
840                            "nested Service Installation Plan differs from the Module plan",
841                        );
842                    }
843                }
844            }
845            ModulePlanEffect::ServiceRemoval {
846                service_id,
847                service_release_digest,
848                adapter,
849                action,
850                ..
851            }
852            | ModulePlanEffect::ServiceRestart {
853                service_id,
854                service_release_digest,
855                adapter,
856                action,
857                ..
858            } => {
859                validate_service_effect(
860                    service_id,
861                    service_release_digest,
862                    *adapter,
863                    action.as_ref(),
864                )?;
865            }
866            _ => {}
867        }
868    }
869    Ok(())
870}
871
872fn validate_service_effect(
873    service_id: &str,
874    release_digest: &str,
875    adapter: Option<crate::ServiceDeploymentAdapterKind>,
876    action: Option<&crate::ServiceDeploymentAction>,
877) -> Result<(), ModuleManagementError> {
878    if service_id.trim().is_empty() || !valid_digest(release_digest) {
879        return invalid("service effects require a Service identity and release digest");
880    }
881    if adapter.is_none() != action.is_none() {
882        return invalid("service effects must bind adapter and action together");
883    }
884    let Some(action) = action else {
885        return Ok(());
886    };
887    match action {
888        crate::ServiceDeploymentAction::Command {
889            program,
890            args,
891            working_directory,
892        } => {
893            let executable = program.rsplit(['/', '\\']).next().unwrap_or_default();
894            if program.trim().is_empty()
895                || program.contains('\0')
896                || matches!(
897                    executable.to_ascii_lowercase().as_str(),
898                    "sh" | "bash"
899                        | "dash"
900                        | "zsh"
901                        | "fish"
902                        | "cmd"
903                        | "cmd.exe"
904                        | "powershell"
905                        | "powershell.exe"
906                        | "pwsh"
907                        | "pwsh.exe"
908                )
909                || args.iter().any(|argument| argument.contains('\0'))
910                || working_directory
911                    .as_deref()
912                    .is_some_and(|path| !safe_relative_path(path))
913                || adapter == Some(crate::ServiceDeploymentAdapterKind::ExternallyManaged)
914            {
915                return invalid(
916                    "service command action is unsafe or incompatible with its adapter",
917                );
918            }
919        }
920        crate::ServiceDeploymentAction::Evidence { receipt } => {
921            if !safe_relative_path(&receipt.locator) || !valid_digest(&receipt.digest) {
922                return invalid(
923                    "service evidence action requires a safe content-addressed receipt",
924                );
925            }
926        }
927    }
928    Ok(())
929}
930
931#[allow(clippy::too_many_arguments)]
932fn validate_workspace_effect(
933    path: &str,
934    change: crate::ModuleFileChange,
935    before_digest: Option<&str>,
936    after_digest: Option<&str>,
937    after_content: Option<&str>,
938    after_mode: Option<u32>,
939    exact_patch: &str,
940    read_set: &[crate::ModulePathPrecondition],
941) -> Result<(), ModuleManagementError> {
942    if !safe_relative_path(path) || exact_patch.trim().is_empty() {
943        return invalid("workspace effects require a safe relative path and exact patch");
944    }
945    let precondition = read_set
946        .iter()
947        .find(|precondition| precondition.path == path)
948        .ok_or_else(|| {
949            ModuleManagementError::InvalidContract(format!(
950                "workspace effect `{path}` is absent from the read set"
951            ))
952        })?;
953    let shape_matches = match change {
954        crate::ModuleFileChange::Create => {
955            precondition.existence == crate::PathExistence::Absent
956                && before_digest.is_none()
957                && after_digest.is_some()
958                && after_content.is_some()
959                && after_mode.is_some()
960        }
961        crate::ModuleFileChange::Modify => {
962            precondition.existence == crate::PathExistence::Present
963                && precondition.content_digest.as_deref() == before_digest
964                && before_digest.is_some()
965                && after_digest.is_some()
966                && after_content.is_some()
967                && after_mode.is_some()
968        }
969        crate::ModuleFileChange::Delete => {
970            precondition.existence == crate::PathExistence::Present
971                && precondition.content_digest.as_deref() == before_digest
972                && before_digest.is_some()
973                && after_digest.is_none()
974                && after_content.is_none()
975                && after_mode.is_none()
976        }
977    };
978    if !shape_matches
979        || before_digest.is_some_and(|digest| !valid_digest(digest))
980        || after_digest.is_some_and(|digest| !valid_digest(digest))
981        || after_mode.is_some_and(|mode| mode > 0o777)
982        || after_content
983            .is_some_and(|content| Some(raw_digest(content.as_bytes()).as_str()) != after_digest)
984    {
985        return invalid("workspace effect content does not match its exact precondition or digest");
986    }
987    Ok(())
988}
989
990pub fn validate_desired_composition(
991    composition: &DesiredModuleComposition,
992) -> Result<(), ModuleManagementError> {
993    if composition.protocol != DESIRED_MODULE_COMPOSITION_PROTOCOL
994        || composition.application_id.trim().is_empty()
995    {
996        return invalid("invalid Desired Module Composition identity or protocol");
997    }
998    require_sorted_unique(
999        composition
1000            .selected
1001            .iter()
1002            .map(|entry| entry.module_id.as_str()),
1003        "selected Module identities",
1004    )?;
1005    for entry in &composition.selected {
1006        if !valid_module_id(&entry.module_id)
1007            || semver::VersionReq::parse(&entry.version_requirement).is_err()
1008        {
1009            return invalid("selected Module identity or version requirement is invalid");
1010        }
1011        require_sorted_unique(
1012            entry.optional_requirements.iter().map(String::as_str),
1013            "optional requirements",
1014        )?;
1015    }
1016    require_sorted_unique(
1017        composition
1018            .local_overrides
1019            .iter()
1020            .map(|entry| entry.module_id.as_str()),
1021        "local overrides",
1022    )?;
1023    if composition
1024        .local_overrides
1025        .iter()
1026        .any(|entry| !entry.acknowledged_unverified || !valid_digest(&entry.content_digest))
1027    {
1028        return invalid("local overrides must be content-addressed and explicitly unverified");
1029    }
1030    Ok(())
1031}
1032
1033pub fn validate_application_module_lock(
1034    module_lock: &ApplicationModuleLock,
1035) -> Result<(), ModuleManagementError> {
1036    if module_lock.protocol != APPLICATION_MODULE_LOCK_PROTOCOL
1037        || module_lock.application_id.trim().is_empty()
1038        || !valid_digest(&module_lock.desired_composition_digest)
1039        || !valid_digest(&module_lock.catalog_snapshot_digest)
1040        || !valid_digest(&module_lock.trust_policy_digest)
1041    {
1042        return invalid("invalid Application Module Lock identity, protocol, or input digest");
1043    }
1044    require_sorted_unique(
1045        module_lock
1046            .modules
1047            .iter()
1048            .map(|entry| entry.module_id.as_str()),
1049        "locked Module identities",
1050    )?;
1051    let module_ids = module_lock
1052        .modules
1053        .iter()
1054        .map(|entry| entry.module_id.as_str())
1055        .collect::<BTreeSet<_>>();
1056    for module in &module_lock.modules {
1057        if !valid_module_id(&module.module_id)
1058            || semver::Version::parse(&module.version).is_err()
1059            || !valid_digest(&module.release_digest)
1060            || !valid_digest(&module.manifest_digest)
1061        {
1062            return invalid("locked Module identity, version, or digest is invalid");
1063        }
1064        require_sorted_unique(
1065            module.dependency_module_ids.iter().map(String::as_str),
1066            "locked dependencies",
1067        )?;
1068        if module
1069            .dependency_module_ids
1070            .iter()
1071            .any(|dependency| !module_ids.contains(dependency.as_str()))
1072        {
1073            return invalid("locked dependency references an absent Module");
1074        }
1075    }
1076    Ok(())
1077}
1078
1079pub fn validate_repair_plan(plan: &ModuleRepairPlan) -> Result<(), ModuleManagementError> {
1080    if plan.protocol != MODULE_REPAIR_PLAN_PROTOCOL
1081        || plan.original_operation_id.trim().is_empty()
1082        || plan.actions.is_empty()
1083        || module_repair_plan_digest(plan).map_err(json_error)? != plan.repair_plan_digest
1084    {
1085        return invalid("invalid Module Repair Plan protocol, identity, actions, or digest");
1086    }
1087    require_sorted_unique(
1088        plan.completed_effect_ids.iter().map(String::as_str),
1089        "completed repair-plan effects",
1090    )?;
1091    let completed = plan
1092        .completed_effect_ids
1093        .iter()
1094        .map(String::as_str)
1095        .collect::<BTreeSet<_>>();
1096    for action in &plan.actions {
1097        if let ModuleRepairAction::Resume { effect_ids } = action {
1098            require_sorted_unique(
1099                effect_ids.iter().map(String::as_str),
1100                "repair resume effects",
1101            )?;
1102            if effect_ids
1103                .iter()
1104                .any(|effect_id| completed.contains(effect_id.as_str()))
1105            {
1106                return invalid("repair plan cannot repeat a completed effect");
1107            }
1108        }
1109    }
1110    Ok(())
1111}
1112
1113fn approvals_satisfied(
1114    plan: &ModuleChangePlan,
1115    policy: &ModuleEnvironmentPolicy,
1116    requester: &ManagementActor,
1117    approvals: &[ModuleApproval],
1118    now: DateTime<Utc>,
1119) -> Result<bool, ModuleManagementError> {
1120    let mut satisfied = true;
1121    for boundary in &plan.approval_boundaries {
1122        if policy.require_backup_for_non_local_destructive_effects
1123            && plan.environment_id != "local"
1124            && matches!(
1125                boundary.risk_class,
1126                ModuleRiskClass::DestructiveMigration
1127                    | ModuleRiskClass::DataDeletion
1128                    | ModuleRiskClass::BackupRestore
1129            )
1130            && boundary.backup_evidence_digest.is_none()
1131        {
1132            return Err(ModuleManagementError::ApprovalMissing(format!(
1133                "{}:backup_evidence",
1134                boundary.boundary_id
1135            )));
1136        }
1137        let Some(approval) = approvals
1138            .iter()
1139            .find(|approval| approval.boundary_id == boundary.boundary_id)
1140        else {
1141            satisfied = false;
1142            continue;
1143        };
1144        if approval.protocol != MODULE_APPROVAL_PROTOCOL
1145            || approval.plan_digest != plan.plan_digest
1146            || approval.application_id != plan.application_id
1147            || approval.environment_id != plan.environment_id
1148            || approval.expected_target_revision != plan.expected_target_revision
1149            || approval.risk_class != boundary.risk_class
1150            || approval.expires_at <= now
1151            || approval.issued_at > now
1152            || approval.reason.trim().is_empty()
1153            || approval.nonce.trim().is_empty()
1154            || !approval
1155                .verified_authorities
1156                .contains(&boundary.required_authority)
1157            || policy.require_distinct_approver && approval.actor_id == requester.actor_id
1158        {
1159            return Err(ModuleManagementError::ApprovalStale(
1160                approval.approval_id.clone(),
1161            ));
1162        }
1163        let age = now.signed_duration_since(approval.issued_at).num_seconds();
1164        if age < 0 || u64::try_from(age).unwrap_or(u64::MAX) > policy.maximum_approval_age_seconds {
1165            return Err(ModuleManagementError::ApprovalStale(
1166                approval.approval_id.clone(),
1167            ));
1168        }
1169    }
1170    Ok(satisfied)
1171}
1172
1173fn validate_boundary(
1174    boundary: &ModuleApprovalBoundary,
1175    effects: &[ModulePlanEffect],
1176) -> Result<(), ModuleManagementError> {
1177    if boundary.risk_class == ModuleRiskClass::Ordinary
1178        || boundary.boundary_id.trim().is_empty()
1179        || boundary.effect_ids.is_empty()
1180    {
1181        return invalid("approval boundaries must identify non-ordinary protected effects");
1182    }
1183    let expected_authority = match boundary.risk_class {
1184        ModuleRiskClass::Ordinary => unreachable!("ordinary boundaries rejected above"),
1185        ModuleRiskClass::DestructiveMigration => "module.migrate.destructive",
1186        ModuleRiskClass::DataDeletion
1187        | ModuleRiskClass::BackupRestore
1188        | ModuleRiskClass::BackupWaiver => "module.data.delete",
1189        ModuleRiskClass::TrustOverride => "module.trust.override",
1190    };
1191    if boundary.required_authority != expected_authority {
1192        return invalid("approval boundary uses the wrong scoped authority");
1193    }
1194    require_sorted_unique(
1195        boundary.effect_ids.iter().map(String::as_str),
1196        "boundary effects",
1197    )?;
1198    for effect_id in &boundary.effect_ids {
1199        let effect = effects
1200            .iter()
1201            .find(|effect| effect.effect_id() == effect_id)
1202            .ok_or_else(|| {
1203                ModuleManagementError::InvalidContract(format!(
1204                    "approval boundary references unknown effect `{effect_id}`"
1205                ))
1206            })?;
1207        if effect.risk_class() != boundary.risk_class {
1208            return invalid("approval risk class does not match its protected effect");
1209        }
1210    }
1211    Ok(())
1212}
1213
1214fn require_authority(
1215    actor: &ManagementActor,
1216    authority: &str,
1217) -> Result<(), ModuleManagementError> {
1218    if actor.verified_authorities.contains(authority) {
1219        Ok(())
1220    } else {
1221        Err(ModuleManagementError::MissingAuthority(
1222            authority.to_owned(),
1223        ))
1224    }
1225}
1226
1227fn initial_event(operation: &ModuleOperation) -> ModuleOperationJournalEvent {
1228    ModuleOperationJournalEvent {
1229        protocol: MODULE_OPERATION_JOURNAL_PROTOCOL.to_owned(),
1230        event_id: format!("{}-0", operation.operation_id),
1231        operation_id: operation.operation_id.clone(),
1232        attempt: operation.attempt,
1233        revision: 0,
1234        prior_event_digest: None,
1235        plan_digest: operation.plan_digest.clone(),
1236        actor_id: operation.actor_id.clone(),
1237        fencing_token: operation.fencing_token,
1238        transition: ModuleOperationTransition {
1239            from: ModuleOperationState::Planned,
1240            to: operation.state,
1241        },
1242        outcome_code: "operation_created".to_owned(),
1243        evidence_references: Vec::new(),
1244        operation_after: operation.clone(),
1245        recorded_at: operation.updated_at,
1246    }
1247}
1248
1249#[allow(clippy::too_many_arguments)]
1250fn next_event(
1251    before: &ModuleOperation,
1252    after: ModuleOperation,
1253    actor_id: String,
1254    outcome_code: String,
1255    evidence_references: Vec<ArtifactReference>,
1256    now: DateTime<Utc>,
1257    prior: Option<&ModuleOperationJournalEvent>,
1258) -> Result<ModuleOperationJournalEvent, ModuleManagementError> {
1259    let prior_event_digest = prior
1260        .map(journal_event_digest)
1261        .transpose()
1262        .map_err(json_error)?;
1263    Ok(ModuleOperationJournalEvent {
1264        protocol: MODULE_OPERATION_JOURNAL_PROTOCOL.to_owned(),
1265        event_id: format!("{}-{}", after.operation_id, after.revision),
1266        operation_id: after.operation_id.clone(),
1267        attempt: after.attempt,
1268        revision: after.revision,
1269        prior_event_digest,
1270        plan_digest: after.plan_digest.clone(),
1271        actor_id,
1272        fencing_token: after.fencing_token,
1273        transition: ModuleOperationTransition {
1274            from: before.state,
1275            to: after.state,
1276        },
1277        outcome_code,
1278        evidence_references,
1279        operation_after: after,
1280        recorded_at: now,
1281    })
1282}
1283
1284fn legal_transition(from: ModuleOperationState, to: ModuleOperationState) -> bool {
1285    use ModuleOperationState as State;
1286    matches!(
1287        (from, to),
1288        (
1289            State::Planned,
1290            State::AwaitingApproval | State::Ready | State::Blocked | State::Cancelled
1291        ) | (
1292            State::AwaitingApproval,
1293            State::Ready | State::Blocked | State::Cancelled
1294        ) | (
1295            State::Ready,
1296            State::ApplyingFiles | State::Blocked | State::Cancelled
1297        ) | (
1298            State::ApplyingFiles,
1299            State::FilesApplied | State::Restored | State::RepairRequired
1300        ) | (
1301            State::FilesApplied,
1302            State::StagingConfiguration
1303                | State::Migrating
1304                | State::Verifying
1305                | State::Blocked
1306                | State::RepairRequired
1307        ) | (
1308            State::StagingConfiguration,
1309            State::Migrating | State::Verifying | State::Blocked | State::RepairRequired
1310        ) | (
1311            State::Migrating,
1312            State::Verifying | State::Blocked | State::RepairRequired
1313        ) | (
1314            State::Verifying,
1315            State::Activating | State::Blocked | State::RepairRequired
1316        ) | (
1317            State::Activating,
1318            State::Succeeded | State::Blocked | State::RepairRequired
1319        )
1320    )
1321}
1322
1323fn require_sorted_unique<'a>(
1324    values: impl Iterator<Item = &'a str>,
1325    subject: &str,
1326) -> Result<(), ModuleManagementError> {
1327    let values = values.collect::<Vec<_>>();
1328    if values.windows(2).all(|pair| pair[0] < pair[1]) {
1329        Ok(())
1330    } else {
1331        invalid(format!("{subject} must be sorted and unique"))
1332    }
1333}
1334
1335fn valid_module_id(value: &str) -> bool {
1336    value.split_once('/').is_some_and(|(namespace, name)| {
1337        valid_identifier(namespace) && valid_identifier(name) && !name.contains('/')
1338    })
1339}
1340
1341fn valid_identifier(value: &str) -> bool {
1342    !value.is_empty()
1343        && value.bytes().all(|byte| {
1344            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
1345        })
1346        && value
1347            .bytes()
1348            .next()
1349            .is_some_and(|byte| byte.is_ascii_lowercase())
1350}
1351
1352fn valid_digest(value: &str) -> bool {
1353    value.strip_prefix("sha256:").is_some_and(|hex| {
1354        hex.len() == 64
1355            && hex
1356                .bytes()
1357                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1358    })
1359}
1360
1361fn safe_relative_path(value: &str) -> bool {
1362    !value.is_empty()
1363        && !value.starts_with('/')
1364        && !value.contains('\\')
1365        && value
1366            .split('/')
1367            .all(|segment| !segment.is_empty() && segment != "." && segment != "..")
1368        && value != ".env"
1369        && !value.ends_with("/.env")
1370}
1371
1372fn raw_digest(bytes: &[u8]) -> String {
1373    const HEX: &[u8; 16] = b"0123456789abcdef";
1374    let digest = Sha256::digest(bytes);
1375    let mut hex = String::with_capacity(digest.len() * 2);
1376    for byte in digest {
1377        hex.push(char::from(HEX[usize::from(byte >> 4)]));
1378        hex.push(char::from(HEX[usize::from(byte & 0x0f)]));
1379    }
1380    format!("sha256:{hex}")
1381}
1382
1383fn invalid<T>(message: impl Into<String>) -> Result<T, ModuleManagementError> {
1384    Err(ModuleManagementError::InvalidContract(message.into()))
1385}
1386
1387#[allow(clippy::needless_pass_by_value)]
1388fn json_error(error: serde_json::Error) -> ModuleManagementError {
1389    ModuleManagementError::InvalidContract(error.to_string())
1390}