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 ¤t.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 ¤t,
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 ¤t,
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 ¤t.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 ¤t,
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 ¤t,
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 ¤t,
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 ¤t.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 ¤t,
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 ¤t,
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) = ¤t
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}