1use crate::{
2 ExtractionExpectedAuthority, ExtractionPlan, ExtractionPlanInputs, ExtractionPlanPhaseKind,
3 ExtractionScaffold, ExtractionScaffoldApplyResult, ExtractionWorkloadRole,
4 ensure_extraction_plan_fresh, extraction_input_digest, extraction_plan_integrity_is_valid,
5 extraction_scaffold_integrity_is_valid, validate_extraction_scaffold,
6};
7use async_trait::async_trait;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13
14pub const EXTRACTION_RUN_PROTOCOL: &str = "lenso.extraction-run.v1";
15pub const EXTRACTION_OPERATION_RECEIPT_PROTOCOL: &str = "lenso.extraction-operation-receipt.v1";
16pub const DESTINATION_EXPANSION_PHASE_ID: &str = "03-destination-expansion";
17const EXTRACTION_RUN_SCHEMA_ID: &str =
18 "https://contracts.lenso.local/extraction/lenso.extraction-run.v1.schema.json";
19
20#[derive(
21 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
22)]
23#[serde(rename_all = "snake_case")]
24pub enum ExtractionRunMode {
25 Apply,
26 DryRun,
27}
28
29#[derive(
30 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
31)]
32#[serde(rename_all = "snake_case")]
33pub enum ExtractionRunStatus {
34 Planned,
35 InProgress,
36 Blocked,
37 Succeeded,
38}
39
40#[derive(
41 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
42)]
43#[serde(rename_all = "snake_case")]
44pub enum ExtractionExpansionOperationKind {
45 CreateIsolatedStore,
46 ApplyExpandMigration,
47 VerifyMigrationWorkload,
48 VerifyCandidateHealth,
49}
50
51impl ExtractionExpansionOperationKind {
52 fn is_mutating(self) -> bool {
53 matches!(self, Self::CreateIsolatedStore | Self::ApplyExpandMigration)
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[serde(rename_all = "camelCase")]
59pub struct ExtractionMigrationArtifact {
60 pub migration_id: String,
61 pub source_reference: String,
62 pub source_digest: String,
63 pub sql: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "camelCase")]
68pub struct ExtractionExpandMigration {
69 pub migration_id: String,
70 pub source_reference: String,
71 pub source_digest: String,
72 pub sql: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76#[serde(rename_all = "camelCase")]
77pub struct ExtractionExpansionOperation {
78 pub operation_id: String,
79 pub operation_digest: String,
80 pub order: u16,
81 pub kind: ExtractionExpansionOperationKind,
82 pub workload_id: String,
83 pub candidate_service_id: String,
84 pub destination_store_id: String,
85 pub mutating: bool,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub migration: Option<ExtractionExpandMigration>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
91#[serde(rename_all = "camelCase")]
92pub struct ExtractionRunExpectedState {
93 pub plan_id: String,
94 pub plan_digest: String,
95 pub scaffold_id: String,
96 pub scaffold_digest: String,
97 pub phase_id: String,
98 pub source_authority: ExtractionExpectedAuthority,
99 pub linked_authority_remains_authoritative: bool,
100 pub source_store_remains_unchanged: bool,
101 pub candidate_service_id: String,
102 pub destination_store_id: String,
103 pub destination_store_engine: String,
104 pub destination_store_isolated: bool,
105 pub migration_workload_id: String,
106 pub api_workload_id: String,
107 pub ordered_operations_digest: String,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111#[serde(rename_all = "camelCase")]
112pub struct ExtractionRunPhase {
113 pub phase_id: String,
114 pub kind: ExtractionPlanPhaseKind,
115 pub status: ExtractionRunStatus,
116 #[serde(default)]
117 pub completed_operation_ids: Vec<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub next_operation_id: Option<String>,
120}
121
122#[derive(
123 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
124)]
125#[serde(rename_all = "snake_case")]
126pub enum ExtractionRunEvidenceKind {
127 StoreIsolation,
128 MigrationApplied,
129 MigrationWorkloadHealth,
130 CandidateHealth,
131 SourceAuthority,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
135#[serde(rename_all = "camelCase")]
136pub struct ExtractionRunEvidence {
137 pub kind: ExtractionRunEvidenceKind,
138 pub subject: String,
139 pub digest: String,
140 pub detail: String,
141}
142
143#[derive(
144 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
145)]
146#[serde(rename_all = "snake_case")]
147pub enum ExtractionOperationOutcome {
148 Created,
149 AlreadyExists,
150 Applied,
151 AlreadyApplied,
152 Healthy,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
156#[serde(rename_all = "camelCase")]
157pub struct ExtractionOperationReceipt {
158 pub protocol: String,
159 pub receipt_id: String,
160 pub receipt_digest: String,
161 pub run_id: String,
162 pub plan_id: String,
163 pub plan_digest: String,
164 pub expected_state_digest: String,
165 pub operation_id: String,
166 pub operation_digest: String,
167 pub operation_kind: ExtractionExpansionOperationKind,
168 pub workload_id: String,
169 pub candidate_service_id: String,
170 pub destination_store_id: String,
171 pub outcome: ExtractionOperationOutcome,
172 pub source_authority: ExtractionExpectedAuthority,
173 pub source_store_unchanged: bool,
174 pub linked_authority_remains_authoritative: bool,
175 pub candidate_authoritative: bool,
176 #[serde(default)]
177 pub evidence: Vec<ExtractionRunEvidence>,
178}
179
180#[derive(
181 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
182)]
183#[serde(rename_all = "snake_case")]
184pub enum ExtractionRunErrorCode {
185 PlanStale,
186 AuthorityChanged,
187 SourceMutationReported,
188 ReceiptInvalid,
189 StoreProvisioningFailed,
190 MigrationFailed,
191 MigrationWorkloadUnhealthy,
192 CandidateUnhealthy,
193 WorkloadUnavailable,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197#[serde(rename_all = "camelCase")]
198pub struct ExtractionRunError {
199 pub sequence: u32,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub operation_id: Option<String>,
202 pub code: ExtractionRunErrorCode,
203 pub message: String,
204 #[serde(default)]
205 pub evidence: Vec<ExtractionRunEvidence>,
206 #[serde(default)]
207 pub next_actions: Vec<String>,
208 pub resolved: bool,
209}
210
211#[allow(clippy::struct_excessive_bools)]
212#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
213#[serde(rename_all = "camelCase")]
214pub struct ExtractionRunEffects {
215 pub creates_destination_store: bool,
216 pub applies_destination_schema: bool,
217 pub invokes_candidate_workload_behavior: bool,
218 pub copies_service_data: bool,
219 pub mutates_source_store: bool,
220 pub mutates_linked_implementation: bool,
221 pub changes_authority: bool,
222 pub performs_destructive_cleanup: bool,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
226#[serde(rename_all = "camelCase")]
227pub struct ExtractionRun {
228 pub protocol: String,
229 pub run_id: String,
230 pub run_digest: String,
231 pub revision: u64,
232 pub mode: ExtractionRunMode,
233 pub plan: ExtractionPlan,
234 pub current_phase: ExtractionRunPhase,
235 pub expected_state: ExtractionRunExpectedState,
236 pub expected_state_digest: String,
237 pub ordered_operations: Vec<ExtractionExpansionOperation>,
238 #[serde(default)]
239 pub receipts: Vec<ExtractionOperationReceipt>,
240 #[serde(default)]
241 pub evidence: Vec<ExtractionRunEvidence>,
242 #[serde(default)]
243 pub errors: Vec<ExtractionRunError>,
244 #[serde(default)]
245 pub next_actions: Vec<String>,
246 pub effects: ExtractionRunEffects,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ExtractionRunInputs {
251 pub plan: ExtractionPlan,
252 pub current_plan_inputs: ExtractionPlanInputs,
253 pub scaffold: ExtractionScaffold,
254 pub scaffold_apply_result: ExtractionScaffoldApplyResult,
255 pub migrations: Vec<ExtractionMigrationArtifact>,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "snake_case")]
260pub enum ExtractionRunStartErrorCode {
261 PlanInvalid,
262 PlanStale,
263 PhaseInvalid,
264 ScaffoldInvalid,
265 ScaffoldNotApplied,
266 UnsupportedStoreEngine,
267 StoreNotIsolated,
268 WorkloadMissing,
269 MigrationArtifactMissing,
270 MigrationArtifactUnexpected,
271 MigrationArtifactChanged,
272 MigrationNotExpandFirst,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct ExtractionRunStartError {
278 pub code: ExtractionRunStartErrorCode,
279 pub message: String,
280 pub next_actions: Vec<String>,
281 pub effects: ExtractionRunEffects,
282}
283
284impl fmt::Display for ExtractionRunStartError {
285 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
286 formatter.write_str(&self.message)
287 }
288}
289
290impl std::error::Error for ExtractionRunStartError {}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum ExtractionRunAdvanceErrorCode {
295 RunInvalid,
296 DryRunCannotAdvance,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct ExtractionRunAdvanceError {
302 pub code: ExtractionRunAdvanceErrorCode,
303 pub message: String,
304 pub next_actions: Vec<String>,
305}
306
307impl fmt::Display for ExtractionRunAdvanceError {
308 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
309 formatter.write_str(&self.message)
310 }
311}
312
313impl std::error::Error for ExtractionRunAdvanceError {}
314
315#[derive(
316 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
317)]
318#[serde(rename_all = "snake_case")]
319pub enum ExtractionWorkloadFailureCode {
320 Unavailable,
321 StoreProvisioningFailed,
322 MigrationFailed,
323 MigrationWorkloadUnhealthy,
324 CandidateUnhealthy,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
328#[serde(rename_all = "camelCase")]
329pub struct ExtractionWorkloadFailure {
330 pub code: ExtractionWorkloadFailureCode,
331 pub message: String,
332 #[serde(default)]
333 pub evidence: Vec<ExtractionRunEvidence>,
334 #[serde(default)]
335 pub next_actions: Vec<String>,
336}
337
338impl fmt::Display for ExtractionWorkloadFailure {
339 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
340 formatter.write_str(&self.message)
341 }
342}
343
344impl std::error::Error for ExtractionWorkloadFailure {}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct ExtractionWorkloadRequest {
348 pub run_id: String,
349 pub plan_id: String,
350 pub plan_digest: String,
351 pub expected_state: ExtractionRunExpectedState,
352 pub expected_state_digest: String,
353 pub operation: ExtractionExpansionOperation,
354}
355
356#[async_trait]
362pub trait ExtractionExpansionWorkload: fmt::Debug + Send + Sync {
363 async fn inspect_receipt(
364 &self,
365 request: &ExtractionWorkloadRequest,
366 ) -> Result<Option<ExtractionOperationReceipt>, ExtractionWorkloadFailure>;
367
368 async fn execute(
369 &self,
370 request: &ExtractionWorkloadRequest,
371 ) -> Result<ExtractionOperationReceipt, ExtractionWorkloadFailure>;
372}
373
374pub fn start_destination_expansion(
375 inputs: &ExtractionRunInputs,
376) -> Result<ExtractionRun, ExtractionRunStartError> {
377 build_destination_expansion(inputs, ExtractionRunMode::Apply)
378}
379
380pub fn dry_run_destination_expansion(
381 inputs: &ExtractionRunInputs,
382) -> Result<ExtractionRun, ExtractionRunStartError> {
383 build_destination_expansion(inputs, ExtractionRunMode::DryRun)
384}
385
386fn build_destination_expansion(
387 inputs: &ExtractionRunInputs,
388 mode: ExtractionRunMode,
389) -> Result<ExtractionRun, ExtractionRunStartError> {
390 validate_start_inputs(inputs)?;
391 let plan = &inputs.plan;
392 let (operations, migration_workload_id, api_workload_id) =
393 destination_expansion_operations(inputs)?;
394
395 let ordered_operations_digest = digest_serializable(&operations)?;
396 let expected_state = ExtractionRunExpectedState {
397 plan_id: plan.plan_id.clone(),
398 plan_digest: plan.plan_digest.clone(),
399 scaffold_id: inputs.scaffold.scaffold_id.clone(),
400 scaffold_digest: inputs.scaffold.scaffold_digest.clone(),
401 phase_id: DESTINATION_EXPANSION_PHASE_ID.to_owned(),
402 source_authority: plan.expected_authority.clone(),
403 linked_authority_remains_authoritative: true,
404 source_store_remains_unchanged: true,
405 candidate_service_id: plan.proposed_service.service_id.clone(),
406 destination_store_id: plan.proposed_service.store.store_id.clone(),
407 destination_store_engine: plan.proposed_service.store.engine.clone(),
408 destination_store_isolated: true,
409 migration_workload_id,
410 api_workload_id,
411 ordered_operations_digest,
412 };
413 let expected_state_digest = digest_serializable(&expected_state)?;
414 let run_identity_digest = digest_serializable(&(
415 plan.plan_id.as_str(),
416 DESTINATION_EXPANSION_PHASE_ID,
417 expected_state_digest.as_str(),
418 ))?;
419 let mut run = ExtractionRun {
420 protocol: EXTRACTION_RUN_PROTOCOL.to_owned(),
421 run_id: format!("extraction-run:{run_identity_digest}"),
422 run_digest: String::new(),
423 revision: 1,
424 mode,
425 plan: plan.clone(),
426 current_phase: ExtractionRunPhase {
427 phase_id: DESTINATION_EXPANSION_PHASE_ID.to_owned(),
428 kind: ExtractionPlanPhaseKind::DestinationExpansion,
429 status: ExtractionRunStatus::Planned,
430 completed_operation_ids: Vec::new(),
431 next_operation_id: operations.first().map(|operation| operation.operation_id.clone()),
432 },
433 expected_state,
434 expected_state_digest,
435 ordered_operations: operations,
436 receipts: Vec::new(),
437 evidence: Vec::new(),
438 errors: Vec::new(),
439 next_actions: match mode {
440 ExtractionRunMode::Apply => vec![
441 "Persist this Run, then advance exactly one destination operation through the public Workload behavior."
442 .to_owned(),
443 ],
444 ExtractionRunMode::DryRun => vec![
445 "Review these exact ordered operations before starting an apply Run."
446 .to_owned(),
447 ],
448 },
449 effects: ExtractionRunEffects::default(),
450 };
451 refresh_run_digest(&mut run);
452 Ok(run)
453}
454
455fn destination_expansion_operations(
456 inputs: &ExtractionRunInputs,
457) -> Result<(Vec<ExtractionExpansionOperation>, String, String), ExtractionRunStartError> {
458 let plan = &inputs.plan;
459 let migration_workload_id = workload_id(plan, ExtractionWorkloadRole::Migration)?;
460 let api_workload_id = workload_id(plan, ExtractionWorkloadRole::Api)?;
461 let mut operations = vec![operation(
462 1,
463 ExtractionExpansionOperationKind::CreateIsolatedStore,
464 &migration_workload_id,
465 plan,
466 None,
467 )?];
468 for (index, mapping) in plan.data_mapping.migrations.iter().enumerate() {
469 let artifact = inputs
470 .migrations
471 .iter()
472 .find(|artifact| artifact.migration_id == mapping.source_migration)
473 .expect("validated migration artifact");
474 let order = u16::try_from(index + 2).map_err(|_| too_many_operations_error())?;
475 operations.push(operation(
476 order,
477 ExtractionExpansionOperationKind::ApplyExpandMigration,
478 &migration_workload_id,
479 plan,
480 Some(ExtractionExpandMigration {
481 migration_id: artifact.migration_id.clone(),
482 source_reference: artifact.source_reference.clone(),
483 source_digest: artifact.source_digest.clone(),
484 sql: artifact.sql.clone(),
485 }),
486 )?);
487 }
488 let next_order =
489 u16::try_from(operations.len() + 1).map_err(|_| too_many_operations_error())?;
490 let health_order = next_order
491 .checked_add(1)
492 .ok_or_else(too_many_operations_error)?;
493 operations.push(operation(
494 next_order,
495 ExtractionExpansionOperationKind::VerifyMigrationWorkload,
496 &migration_workload_id,
497 plan,
498 None,
499 )?);
500 operations.push(operation(
501 health_order,
502 ExtractionExpansionOperationKind::VerifyCandidateHealth,
503 &api_workload_id,
504 plan,
505 None,
506 )?);
507 Ok((operations, migration_workload_id, api_workload_id))
508}
509
510fn too_many_operations_error() -> ExtractionRunStartError {
511 start_error(
512 ExtractionRunStartErrorCode::MigrationArtifactUnexpected,
513 "Too many destination operations were supplied for one Extraction Run.",
514 "Split the Module migration set into a reviewable Extraction Plan.",
515 )
516}
517
518pub async fn advance_destination_expansion(
519 mut run: ExtractionRun,
520 current_inputs: &ExtractionPlanInputs,
521 workload: &dyn ExtractionExpansionWorkload,
522) -> Result<ExtractionRun, ExtractionRunAdvanceError> {
523 if !extraction_run_integrity_is_valid(&run) {
524 return Err(advance_error(
525 ExtractionRunAdvanceErrorCode::RunInvalid,
526 "Extraction Run integrity validation failed before Workload behavior was invoked.",
527 "Discard the changed Run and resume from the last integrity-valid revision.",
528 ));
529 }
530 if run.mode == ExtractionRunMode::DryRun {
531 return Err(advance_error(
532 ExtractionRunAdvanceErrorCode::DryRunCannotAdvance,
533 "A dry-run Extraction Run cannot execute destination operations.",
534 "Start an apply Run from the same fresh inputs after review.",
535 ));
536 }
537 if run.current_phase.status == ExtractionRunStatus::Succeeded {
538 return Ok(run);
539 }
540 if let Err(rejection) = ensure_extraction_plan_fresh(&run.plan, current_inputs) {
541 block_run(
542 &mut run,
543 None,
544 ExtractionRunErrorCode::PlanStale,
545 rejection.message,
546 Vec::new(),
547 rejection.next_actions,
548 );
549 return Ok(run);
550 }
551 let Some(operation) = next_unreceipted_operation(&run).cloned() else {
552 finish_run(&mut run);
553 return Ok(run);
554 };
555 for error in &mut run.errors {
556 if error.operation_id.as_deref() == Some(operation.operation_id.as_str()) {
557 error.resolved = true;
558 }
559 }
560 let request = ExtractionWorkloadRequest {
561 run_id: run.run_id.clone(),
562 plan_id: run.plan.plan_id.clone(),
563 plan_digest: run.plan.plan_digest.clone(),
564 expected_state: run.expected_state.clone(),
565 expected_state_digest: run.expected_state_digest.clone(),
566 operation: operation.clone(),
567 };
568 let inspected = match workload.inspect_receipt(&request).await {
569 Ok(receipt) => receipt,
570 Err(failure) => {
571 record_workload_failure(&mut run, &operation, failure);
572 return Ok(run);
573 }
574 };
575 let receipt = match inspected {
576 Some(receipt) => receipt,
577 None => match workload.execute(&request).await {
578 Ok(receipt) => receipt,
579 Err(failure) => {
580 record_workload_failure(&mut run, &operation, failure);
581 return Ok(run);
582 }
583 },
584 };
585 record_destination_expansion_receipt(run, receipt)
586}
587
588pub fn record_destination_expansion_receipt(
589 mut run: ExtractionRun,
590 mut receipt: ExtractionOperationReceipt,
591) -> Result<ExtractionRun, ExtractionRunAdvanceError> {
592 if !extraction_run_integrity_is_valid(&run) {
593 return Err(advance_error(
594 ExtractionRunAdvanceErrorCode::RunInvalid,
595 "Extraction Run integrity validation failed before recording a receipt.",
596 "Resume from the last integrity-valid Run revision.",
597 ));
598 }
599 let Some(operation) = next_unreceipted_operation(&run).cloned() else {
600 return Ok(run);
601 };
602 receipt.evidence.sort();
603 receipt.evidence.dedup();
604 if let Err((code, message, next_action)) = validate_receipt(&run, &operation, &receipt) {
605 block_run(
606 &mut run,
607 Some(operation.operation_id),
608 code,
609 message,
610 Vec::new(),
611 vec![next_action],
612 );
613 return Ok(run);
614 }
615 run.effects.invokes_candidate_workload_behavior = true;
616 match (operation.kind, receipt.outcome) {
617 (
618 ExtractionExpansionOperationKind::CreateIsolatedStore,
619 ExtractionOperationOutcome::Created,
620 ) => run.effects.creates_destination_store = true,
621 (
622 ExtractionExpansionOperationKind::ApplyExpandMigration,
623 ExtractionOperationOutcome::Applied,
624 ) => run.effects.applies_destination_schema = true,
625 _ => {}
626 }
627 run.evidence.extend(receipt.evidence.iter().cloned());
628 run.evidence.sort();
629 run.evidence.dedup();
630 run.current_phase
631 .completed_operation_ids
632 .push(operation.operation_id.clone());
633 run.receipts.push(receipt);
634 run.current_phase.status = ExtractionRunStatus::InProgress;
635 run.current_phase.next_operation_id =
636 next_unreceipted_operation(&run).map(|operation| operation.operation_id.clone());
637 run.next_actions = if let Some(next) = &run.current_phase.next_operation_id {
638 vec![format!(
639 "Persist this Run revision, then advance operation `{next}`."
640 )]
641 } else {
642 vec![
643 "Persist the successful destination expansion evidence before starting backfill."
644 .to_owned(),
645 ]
646 };
647 run.revision += 1;
648 if run.current_phase.next_operation_id.is_none() {
649 run.current_phase.status = ExtractionRunStatus::Succeeded;
650 }
651 refresh_run_digest(&mut run);
652 Ok(run)
653}
654
655pub fn build_extraction_operation_receipt(
656 request: &ExtractionWorkloadRequest,
657 outcome: ExtractionOperationOutcome,
658 mut evidence: Vec<ExtractionRunEvidence>,
659) -> Result<ExtractionOperationReceipt, ExtractionRunStartError> {
660 evidence.sort();
661 evidence.dedup();
662 let mut receipt = ExtractionOperationReceipt {
663 protocol: EXTRACTION_OPERATION_RECEIPT_PROTOCOL.to_owned(),
664 receipt_id: String::new(),
665 receipt_digest: String::new(),
666 run_id: request.run_id.clone(),
667 plan_id: request.plan_id.clone(),
668 plan_digest: request.plan_digest.clone(),
669 expected_state_digest: request.expected_state_digest.clone(),
670 operation_id: request.operation.operation_id.clone(),
671 operation_digest: request.operation.operation_digest.clone(),
672 operation_kind: request.operation.kind,
673 workload_id: request.operation.workload_id.clone(),
674 candidate_service_id: request.expected_state.candidate_service_id.clone(),
675 destination_store_id: request.expected_state.destination_store_id.clone(),
676 outcome,
677 source_authority: request.expected_state.source_authority.clone(),
678 source_store_unchanged: true,
679 linked_authority_remains_authoritative: true,
680 candidate_authoritative: false,
681 evidence,
682 };
683 let digest = receipt_digest(&receipt)?;
684 receipt.receipt_id = format!("extraction-operation-receipt:{digest}");
685 receipt.receipt_digest = digest;
686 Ok(receipt)
687}
688
689#[must_use]
690pub fn extraction_operation_receipt_integrity_is_valid(
691 receipt: &ExtractionOperationReceipt,
692) -> bool {
693 receipt.protocol == EXTRACTION_OPERATION_RECEIPT_PROTOCOL
694 && receipt.receipt_id == format!("extraction-operation-receipt:{}", receipt.receipt_digest)
695 && receipt_digest(receipt).is_ok_and(|digest| digest == receipt.receipt_digest)
696}
697
698#[must_use]
699pub fn extraction_run_integrity_is_valid(run: &ExtractionRun) -> bool {
700 if run.protocol != EXTRACTION_RUN_PROTOCOL
701 || !extraction_plan_integrity_is_valid(&run.plan)
702 || run.current_phase.phase_id != DESTINATION_EXPANSION_PHASE_ID
703 || run.current_phase.kind != ExtractionPlanPhaseKind::DestinationExpansion
704 || run.expected_state.plan_id != run.plan.plan_id
705 || run.expected_state.plan_digest != run.plan.plan_digest
706 || run.expected_state.source_authority != run.plan.expected_authority
707 || run.expected_state.candidate_service_id != run.plan.proposed_service.service_id
708 || run.expected_state.destination_store_id != run.plan.proposed_service.store.store_id
709 || run.expected_state.destination_store_engine != run.plan.proposed_service.store.engine
710 || !run.expected_state.destination_store_isolated
711 || !run.expected_state.linked_authority_remains_authoritative
712 || !run.expected_state.source_store_remains_unchanged
713 || run.effects.copies_service_data
714 || run.effects.mutates_source_store
715 || run.effects.mutates_linked_implementation
716 || run.effects.changes_authority
717 || run.effects.performs_destructive_cleanup
718 || !digest_serializable(&run.expected_state)
719 .is_ok_and(|digest| digest == run.expected_state_digest)
720 || !digest_serializable(&run.ordered_operations)
721 .is_ok_and(|digest| digest == run.expected_state.ordered_operations_digest)
722 {
723 return false;
724 }
725 let identity = digest_serializable(&(
726 run.plan.plan_id.as_str(),
727 DESTINATION_EXPANSION_PHASE_ID,
728 run.expected_state_digest.as_str(),
729 ));
730 if !identity.is_ok_and(|digest| run.run_id == format!("extraction-run:{digest}")) {
731 return false;
732 }
733 let operation_ids = run
734 .ordered_operations
735 .iter()
736 .map(|operation| operation.operation_id.as_str())
737 .collect::<BTreeSet<_>>();
738 if operation_ids.len() != run.ordered_operations.len()
739 || run
740 .ordered_operations
741 .iter()
742 .enumerate()
743 .any(|(index, operation)| {
744 operation.order != u16::try_from(index + 1).unwrap_or(u16::MAX)
745 || operation.mutating != operation.kind.is_mutating()
746 || !operation_digest(operation)
747 .is_ok_and(|digest| digest == operation.operation_digest)
748 })
749 {
750 return false;
751 }
752 if run.receipts.iter().any(|receipt| {
753 let operation = run
754 .ordered_operations
755 .iter()
756 .find(|operation| operation.operation_id == receipt.operation_id);
757 operation.is_none_or(|operation| validate_receipt(run, operation, receipt).is_err())
758 }) {
759 return false;
760 }
761 let completed = run
762 .receipts
763 .iter()
764 .map(|receipt| receipt.operation_id.as_str())
765 .collect::<Vec<_>>();
766 let expected_completed = run
767 .ordered_operations
768 .iter()
769 .take(completed.len())
770 .map(|operation| operation.operation_id.as_str())
771 .collect::<Vec<_>>();
772 if completed
773 != run
774 .current_phase
775 .completed_operation_ids
776 .iter()
777 .map(String::as_str)
778 .collect::<Vec<_>>()
779 || completed != expected_completed
780 {
781 return false;
782 }
783 let expected_next = run
784 .ordered_operations
785 .get(completed.len())
786 .map(|operation| operation.operation_id.as_str());
787 if run.current_phase.next_operation_id.as_deref() != expected_next
788 || (expected_next.is_none() && run.current_phase.status != ExtractionRunStatus::Succeeded)
789 || (expected_next.is_some() && run.current_phase.status == ExtractionRunStatus::Succeeded)
790 {
791 return false;
792 }
793 run_digest(run).is_ok_and(|digest| digest == run.run_digest)
794}
795
796pub fn extraction_run_json(run: &ExtractionRun) -> Result<String, serde_json::Error> {
797 serde_json::to_string_pretty(run).map(|value| format!("{value}\n"))
798}
799
800#[must_use]
801pub fn extraction_run_schema() -> Value {
802 let mut schema = serde_json::to_value(schemars::schema_for!(ExtractionRun))
803 .expect("Extraction Run schema must serialize");
804 schema["$id"] = Value::String(EXTRACTION_RUN_SCHEMA_ID.to_owned());
805 schema["title"] = Value::String("Lenso Extraction Run v1".to_owned());
806 schema["properties"]["protocol"] = json!({
807 "type": "string",
808 "const": EXTRACTION_RUN_PROTOCOL
809 });
810 schema["properties"]["runId"] = json!({
811 "type": "string",
812 "pattern": "^extraction-run:sha256:[0-9a-f]{64}$"
813 });
814 schema["properties"]["runDigest"] = json!({
815 "type": "string",
816 "pattern": "^sha256:[0-9a-f]{64}$"
817 });
818 schema
819}
820
821#[must_use]
822pub fn render_extraction_run(run: &ExtractionRun) -> String {
823 let mut output = vec![
824 format!("Extraction Run: {}", run.run_id),
825 format!("Plan: {}", run.plan.plan_id),
826 format!("Mode: {:?}", run.mode).to_lowercase(),
827 format!(
828 "Phase: {} ({:?})",
829 run.current_phase.phase_id, run.current_phase.status
830 )
831 .to_lowercase(),
832 format!(
833 "Authority: {:?}:{}@{} (unchanged)",
834 run.expected_state.source_authority.kind,
835 run.expected_state.source_authority.owner_id,
836 run.expected_state.source_authority.revision
837 )
838 .to_lowercase(),
839 format!(
840 "Candidate Store: {} ({}, isolated={})",
841 run.expected_state.destination_store_id,
842 run.expected_state.destination_store_engine,
843 run.expected_state.destination_store_isolated
844 ),
845 String::new(),
846 "Ordered operations:".to_owned(),
847 ];
848 output.extend(run.ordered_operations.iter().map(|operation| {
849 let completed = run
850 .current_phase
851 .completed_operation_ids
852 .contains(&operation.operation_id);
853 format!(
854 "- {:02} [{}] {:?}: {}",
855 operation.order,
856 if completed { "done" } else { "pending" },
857 operation.kind,
858 operation.operation_id
859 )
860 .to_lowercase()
861 }));
862 if !run.errors.is_empty() {
863 output.push(String::new());
864 output.push("Errors:".to_owned());
865 output.extend(run.errors.iter().map(|error| {
866 format!(
867 "- {:?}: {}{}",
868 error.code,
869 error.message,
870 if error.resolved { " (resolved)" } else { "" }
871 )
872 .to_lowercase()
873 }));
874 }
875 output.push(String::new());
876 output.push("Next actions:".to_owned());
877 output.extend(run.next_actions.iter().map(|action| format!("- {action}")));
878 format!("{}\n", output.join("\n"))
879}
880
881#[must_use]
882pub fn validate_expand_first_postgres_sql(sql: &str) -> bool {
883 let mut source = String::new();
884 for line in sql.lines() {
885 let code = line.split("--").next().unwrap_or_default();
886 if code.contains("/*") || code.contains("*/") {
887 return false;
888 }
889 source.push_str(code);
890 source.push('\n');
891 }
892 let statements = source
893 .split(';')
894 .map(|statement| statement.split_whitespace().collect::<Vec<_>>().join(" "))
895 .filter(|statement| !statement.is_empty())
896 .map(|statement| statement.to_ascii_lowercase())
897 .collect::<Vec<_>>();
898 !statements.is_empty()
899 && statements.iter().all(|statement| {
900 let forbidden = [
901 " drop ",
902 " truncate ",
903 " delete ",
904 " update ",
905 " insert ",
906 " rename ",
907 " alter column ",
908 " set schema ",
909 ];
910 let padded = format!(" {statement} ");
911 !forbidden.iter().any(|token| padded.contains(token))
912 && (statement.starts_with("create schema ")
913 || statement.starts_with("create table ")
914 || statement.starts_with("create index ")
915 || statement.starts_with("create unique index ")
916 || statement.starts_with("create type ")
917 || statement.starts_with("create extension ")
918 || statement.starts_with("comment on ")
919 || (statement.starts_with("alter table ")
920 && (statement.contains(" add column ")
921 || statement.contains(" add constraint "))))
922 })
923}
924
925fn validate_start_inputs(inputs: &ExtractionRunInputs) -> Result<(), ExtractionRunStartError> {
926 let plan = &inputs.plan;
927 if !extraction_plan_integrity_is_valid(plan) {
928 return Err(start_error(
929 ExtractionRunStartErrorCode::PlanInvalid,
930 "Extraction Plan integrity validation failed before destination expansion.",
931 "Regenerate the exact content-addressed Extraction Plan.",
932 ));
933 }
934 ensure_extraction_plan_fresh(plan, &inputs.current_plan_inputs).map_err(|rejection| {
935 ExtractionRunStartError {
936 code: ExtractionRunStartErrorCode::PlanStale,
937 message: rejection.message,
938 next_actions: rejection.next_actions,
939 effects: ExtractionRunEffects::default(),
940 }
941 })?;
942 let phase = plan
943 .phases
944 .iter()
945 .find(|phase| phase.phase_id == DESTINATION_EXPANSION_PHASE_ID);
946 if phase.is_none_or(|phase| phase.kind != ExtractionPlanPhaseKind::DestinationExpansion) {
947 return Err(start_error(
948 ExtractionRunStartErrorCode::PhaseInvalid,
949 "The exact Extraction Plan does not contain the destination expansion phase.",
950 "Regenerate the plan with the supported ordered phase protocol.",
951 ));
952 }
953 if !extraction_scaffold_integrity_is_valid(&inputs.scaffold)
954 || !validate_extraction_scaffold(&inputs.scaffold).is_empty()
955 || inputs.scaffold.plan_id != plan.plan_id
956 || inputs.scaffold.plan_digest != plan.plan_digest
957 {
958 return Err(start_error(
959 ExtractionRunStartErrorCode::ScaffoldInvalid,
960 "The candidate scaffold does not match the exact Extraction Plan.",
961 "Regenerate and apply the identity-preserving scaffold from this plan.",
962 ));
963 }
964 let mut applied = inputs
965 .scaffold_apply_result
966 .created_files
967 .iter()
968 .chain(&inputs.scaffold_apply_result.unchanged_files)
969 .cloned()
970 .collect::<Vec<_>>();
971 applied.sort();
972 applied.dedup();
973 let mut expected = inputs
974 .scaffold
975 .files
976 .iter()
977 .map(|file| file.path.clone())
978 .collect::<Vec<_>>();
979 expected.sort();
980 if inputs.scaffold_apply_result.protocol != "lenso.extraction-scaffold-apply.v1"
981 || inputs.scaffold_apply_result.scaffold_id != inputs.scaffold.scaffold_id
982 || inputs.scaffold_apply_result.plan_id != plan.plan_id
983 || !inputs
984 .scaffold_apply_result
985 .linked_authority_remains_authoritative
986 || inputs.scaffold_apply_result.effects.starts_workloads
987 || inputs.scaffold_apply_result.effects.copies_data
988 || inputs.scaffold_apply_result.effects.changes_authority
989 || inputs.scaffold_apply_result.effects.changes_provider_path
990 || applied != expected
991 {
992 return Err(start_error(
993 ExtractionRunStartErrorCode::ScaffoldNotApplied,
994 "The complete candidate scaffold has not been applied idempotently.",
995 "Apply every plan-owned scaffold file without changing linked authority.",
996 ));
997 }
998 if plan.proposed_service.store.engine != "postgres"
999 || plan.data_mapping.store_engine != "postgres"
1000 {
1001 return Err(start_error(
1002 ExtractionRunStartErrorCode::UnsupportedStoreEngine,
1003 "Destination expansion currently supports Postgres Service Stores only.",
1004 "Use Postgres or block the phase until the Store has equivalent safety semantics.",
1005 ));
1006 }
1007 if !plan.proposed_service.store.isolated
1008 || plan.proposed_service.store.store_id != plan.data_mapping.destination_store
1009 {
1010 return Err(start_error(
1011 ExtractionRunStartErrorCode::StoreNotIsolated,
1012 "The candidate destination Store is not isolated and plan-owned.",
1013 "Generate one isolated Store owned only by the candidate Autonomous Service.",
1014 ));
1015 }
1016 validate_migration_artifacts(inputs)
1017}
1018
1019fn validate_migration_artifacts(
1020 inputs: &ExtractionRunInputs,
1021) -> Result<(), ExtractionRunStartError> {
1022 let mappings = inputs
1023 .plan
1024 .data_mapping
1025 .migrations
1026 .iter()
1027 .map(|mapping| (mapping.source_migration.as_str(), mapping))
1028 .collect::<BTreeMap<_, _>>();
1029 let artifacts = inputs
1030 .migrations
1031 .iter()
1032 .map(|artifact| (artifact.migration_id.as_str(), artifact))
1033 .collect::<BTreeMap<_, _>>();
1034 if mappings.len() != inputs.plan.data_mapping.migrations.len()
1035 || artifacts.len() != inputs.migrations.len()
1036 {
1037 return Err(start_error(
1038 ExtractionRunStartErrorCode::MigrationArtifactUnexpected,
1039 "Migration identities must be unique within one destination expansion phase.",
1040 "Rename or deduplicate the migrations, then generate a new Extraction Plan.",
1041 ));
1042 }
1043 let missing = mappings
1044 .keys()
1045 .filter(|migration| !artifacts.contains_key(**migration))
1046 .copied()
1047 .collect::<Vec<_>>();
1048 if !missing.is_empty() {
1049 return Err(start_error(
1050 ExtractionRunStartErrorCode::MigrationArtifactMissing,
1051 format!(
1052 "Plan-owned migration artifacts are missing: {}.",
1053 missing.join(", ")
1054 ),
1055 "Supply the exact digest-pinned source migrations from the target Module.",
1056 ));
1057 }
1058 let unexpected = artifacts
1059 .keys()
1060 .filter(|migration| !mappings.contains_key(**migration))
1061 .copied()
1062 .collect::<Vec<_>>();
1063 if !unexpected.is_empty() {
1064 return Err(start_error(
1065 ExtractionRunStartErrorCode::MigrationArtifactUnexpected,
1066 format!(
1067 "Unplanned migration artifacts were supplied: {}.",
1068 unexpected.join(", ")
1069 ),
1070 "Remove every migration not pinned by the exact Extraction Plan.",
1071 ));
1072 }
1073 for (migration_id, mapping) in mappings {
1074 let artifact = artifacts[migration_id];
1075 if artifact.source_reference != mapping.source_reference
1076 || artifact.source_digest != mapping.source_digest
1077 || extraction_input_digest(artifact.sql.as_bytes()) != artifact.source_digest
1078 {
1079 return Err(start_error(
1080 ExtractionRunStartErrorCode::MigrationArtifactChanged,
1081 format!("Migration `{migration_id}` changed after plan approval."),
1082 "Regenerate readiness evidence and the Extraction Plan from the current migration content.",
1083 ));
1084 }
1085 if !validate_expand_first_postgres_sql(&artifact.sql) {
1086 return Err(start_error(
1087 ExtractionRunStartErrorCode::MigrationNotExpandFirst,
1088 format!(
1089 "Migration `{migration_id}` contains a non-expand or data-mutating Postgres statement."
1090 ),
1091 "Split the migration so destination expansion contains only additive schema statements.",
1092 ));
1093 }
1094 }
1095 Ok(())
1096}
1097
1098fn workload_id(
1099 plan: &ExtractionPlan,
1100 role: ExtractionWorkloadRole,
1101) -> Result<String, ExtractionRunStartError> {
1102 let matches = plan
1103 .proposed_service
1104 .workloads
1105 .iter()
1106 .filter(|workload| workload.role == role)
1107 .collect::<Vec<_>>();
1108 let [workload] = matches.as_slice() else {
1109 return Err(start_error(
1110 ExtractionRunStartErrorCode::WorkloadMissing,
1111 format!("The candidate must declare exactly one {role:?} Workload."),
1112 "Regenerate the candidate Service with API, Worker, and Migration Workloads.",
1113 ));
1114 };
1115 Ok(workload.workload_id.clone())
1116}
1117
1118fn operation(
1119 order: u16,
1120 kind: ExtractionExpansionOperationKind,
1121 workload_id: &str,
1122 plan: &ExtractionPlan,
1123 migration: Option<ExtractionExpandMigration>,
1124) -> Result<ExtractionExpansionOperation, ExtractionRunStartError> {
1125 let label = match kind {
1126 ExtractionExpansionOperationKind::CreateIsolatedStore => "create-isolated-store".to_owned(),
1127 ExtractionExpansionOperationKind::ApplyExpandMigration => format!(
1128 "apply-expand-migration-{}",
1129 migration
1130 .as_ref()
1131 .map_or("missing", |migration| migration.migration_id.as_str())
1132 ),
1133 ExtractionExpansionOperationKind::VerifyMigrationWorkload => {
1134 "verify-migration-workload".to_owned()
1135 }
1136 ExtractionExpansionOperationKind::VerifyCandidateHealth => {
1137 "verify-candidate-health".to_owned()
1138 }
1139 };
1140 let mut operation = ExtractionExpansionOperation {
1141 operation_id: format!("{DESTINATION_EXPANSION_PHASE_ID}/{order:02}-{label}"),
1142 operation_digest: String::new(),
1143 order,
1144 kind,
1145 workload_id: workload_id.to_owned(),
1146 candidate_service_id: plan.proposed_service.service_id.clone(),
1147 destination_store_id: plan.proposed_service.store.store_id.clone(),
1148 mutating: kind.is_mutating(),
1149 migration,
1150 };
1151 operation.operation_digest = operation_digest(&operation)?;
1152 Ok(operation)
1153}
1154
1155fn validate_receipt(
1156 run: &ExtractionRun,
1157 operation: &ExtractionExpansionOperation,
1158 receipt: &ExtractionOperationReceipt,
1159) -> Result<(), (ExtractionRunErrorCode, String, String)> {
1160 if !extraction_operation_receipt_integrity_is_valid(receipt)
1161 || receipt.run_id != run.run_id
1162 || receipt.plan_id != run.plan.plan_id
1163 || receipt.plan_digest != run.plan.plan_digest
1164 || receipt.expected_state_digest != run.expected_state_digest
1165 || receipt.operation_id != operation.operation_id
1166 || receipt.operation_digest != operation.operation_digest
1167 || receipt.operation_kind != operation.kind
1168 || receipt.workload_id != operation.workload_id
1169 || receipt.candidate_service_id != run.expected_state.candidate_service_id
1170 || receipt.destination_store_id != run.expected_state.destination_store_id
1171 || receipt.evidence.is_empty()
1172 || !outcome_matches(operation.kind, receipt.outcome)
1173 {
1174 return Err((
1175 ExtractionRunErrorCode::ReceiptInvalid,
1176 format!(
1177 "Operation `{}` returned a receipt that is not bound to the exact plan and expected state.",
1178 operation.operation_id
1179 ),
1180 "Inspect the candidate Workload receipt store and retry only with the exact operation identity."
1181 .to_owned(),
1182 ));
1183 }
1184 if receipt.source_authority != run.expected_state.source_authority {
1185 return Err((
1186 ExtractionRunErrorCode::AuthorityChanged,
1187 "Linked source authority changed during destination expansion.".to_owned(),
1188 "Stop preparation, refresh authority evidence, and generate a new Extraction Plan."
1189 .to_owned(),
1190 ));
1191 }
1192 if !receipt.source_store_unchanged
1193 || !receipt.linked_authority_remains_authoritative
1194 || receipt.candidate_authoritative
1195 {
1196 return Err((
1197 ExtractionRunErrorCode::SourceMutationReported,
1198 "A Workload receipt did not preserve the source Store and linked authority invariants."
1199 .to_owned(),
1200 "Stop extraction and inspect the source before any further candidate operation."
1201 .to_owned(),
1202 ));
1203 }
1204 Ok(())
1205}
1206
1207fn outcome_matches(
1208 kind: ExtractionExpansionOperationKind,
1209 outcome: ExtractionOperationOutcome,
1210) -> bool {
1211 matches!(
1212 (kind, outcome),
1213 (
1214 ExtractionExpansionOperationKind::CreateIsolatedStore,
1215 ExtractionOperationOutcome::Created | ExtractionOperationOutcome::AlreadyExists
1216 ) | (
1217 ExtractionExpansionOperationKind::ApplyExpandMigration,
1218 ExtractionOperationOutcome::Applied | ExtractionOperationOutcome::AlreadyApplied
1219 ) | (
1220 ExtractionExpansionOperationKind::VerifyMigrationWorkload
1221 | ExtractionExpansionOperationKind::VerifyCandidateHealth,
1222 ExtractionOperationOutcome::Healthy
1223 )
1224 )
1225}
1226
1227fn record_workload_failure(
1228 run: &mut ExtractionRun,
1229 operation: &ExtractionExpansionOperation,
1230 failure: ExtractionWorkloadFailure,
1231) {
1232 let code = match failure.code {
1233 ExtractionWorkloadFailureCode::Unavailable => ExtractionRunErrorCode::WorkloadUnavailable,
1234 ExtractionWorkloadFailureCode::StoreProvisioningFailed => {
1235 ExtractionRunErrorCode::StoreProvisioningFailed
1236 }
1237 ExtractionWorkloadFailureCode::MigrationFailed => ExtractionRunErrorCode::MigrationFailed,
1238 ExtractionWorkloadFailureCode::MigrationWorkloadUnhealthy => {
1239 ExtractionRunErrorCode::MigrationWorkloadUnhealthy
1240 }
1241 ExtractionWorkloadFailureCode::CandidateUnhealthy => {
1242 ExtractionRunErrorCode::CandidateUnhealthy
1243 }
1244 };
1245 block_run(
1246 run,
1247 Some(operation.operation_id.clone()),
1248 code,
1249 failure.message,
1250 failure.evidence,
1251 failure.next_actions,
1252 );
1253}
1254
1255fn block_run(
1256 run: &mut ExtractionRun,
1257 operation_id: Option<String>,
1258 code: ExtractionRunErrorCode,
1259 message: impl Into<String>,
1260 evidence: Vec<ExtractionRunEvidence>,
1261 next_actions: Vec<String>,
1262) {
1263 let sequence = u32::try_from(run.errors.len() + 1).unwrap_or(u32::MAX);
1264 run.errors.push(ExtractionRunError {
1265 sequence,
1266 operation_id,
1267 code,
1268 message: message.into(),
1269 evidence,
1270 next_actions: next_actions.clone(),
1271 resolved: false,
1272 });
1273 run.current_phase.status = ExtractionRunStatus::Blocked;
1274 run.next_actions = next_actions;
1275 run.revision += 1;
1276 refresh_run_digest(run);
1277}
1278
1279fn finish_run(run: &mut ExtractionRun) {
1280 run.current_phase.status = ExtractionRunStatus::Succeeded;
1281 run.current_phase.next_operation_id = None;
1282 run.next_actions = vec![
1283 "Persist the successful destination expansion evidence before starting backfill."
1284 .to_owned(),
1285 ];
1286 run.revision += 1;
1287 refresh_run_digest(run);
1288}
1289
1290fn next_unreceipted_operation(run: &ExtractionRun) -> Option<&ExtractionExpansionOperation> {
1291 let completed = run
1292 .receipts
1293 .iter()
1294 .map(|receipt| receipt.operation_id.as_str())
1295 .collect::<BTreeSet<_>>();
1296 run.ordered_operations
1297 .iter()
1298 .find(|operation| !completed.contains(operation.operation_id.as_str()))
1299}
1300
1301#[derive(Serialize)]
1302#[serde(rename_all = "camelCase")]
1303struct OperationContent<'a> {
1304 operation_id: &'a str,
1305 order: u16,
1306 kind: ExtractionExpansionOperationKind,
1307 workload_id: &'a str,
1308 candidate_service_id: &'a str,
1309 destination_store_id: &'a str,
1310 mutating: bool,
1311 migration: &'a Option<ExtractionExpandMigration>,
1312}
1313
1314fn operation_digest(
1315 operation: &ExtractionExpansionOperation,
1316) -> Result<String, ExtractionRunStartError> {
1317 digest_serializable(&OperationContent {
1318 operation_id: &operation.operation_id,
1319 order: operation.order,
1320 kind: operation.kind,
1321 workload_id: &operation.workload_id,
1322 candidate_service_id: &operation.candidate_service_id,
1323 destination_store_id: &operation.destination_store_id,
1324 mutating: operation.mutating,
1325 migration: &operation.migration,
1326 })
1327}
1328
1329#[derive(Serialize)]
1330#[serde(rename_all = "camelCase")]
1331struct ReceiptContent<'a> {
1332 protocol: &'a str,
1333 run_id: &'a str,
1334 plan_id: &'a str,
1335 plan_digest: &'a str,
1336 expected_state_digest: &'a str,
1337 operation_id: &'a str,
1338 operation_digest: &'a str,
1339 operation_kind: ExtractionExpansionOperationKind,
1340 workload_id: &'a str,
1341 candidate_service_id: &'a str,
1342 destination_store_id: &'a str,
1343 outcome: ExtractionOperationOutcome,
1344 source_authority: &'a ExtractionExpectedAuthority,
1345 source_store_unchanged: bool,
1346 linked_authority_remains_authoritative: bool,
1347 candidate_authoritative: bool,
1348 evidence: &'a [ExtractionRunEvidence],
1349}
1350
1351fn receipt_digest(receipt: &ExtractionOperationReceipt) -> Result<String, ExtractionRunStartError> {
1352 digest_serializable(&ReceiptContent {
1353 protocol: &receipt.protocol,
1354 run_id: &receipt.run_id,
1355 plan_id: &receipt.plan_id,
1356 plan_digest: &receipt.plan_digest,
1357 expected_state_digest: &receipt.expected_state_digest,
1358 operation_id: &receipt.operation_id,
1359 operation_digest: &receipt.operation_digest,
1360 operation_kind: receipt.operation_kind,
1361 workload_id: &receipt.workload_id,
1362 candidate_service_id: &receipt.candidate_service_id,
1363 destination_store_id: &receipt.destination_store_id,
1364 outcome: receipt.outcome,
1365 source_authority: &receipt.source_authority,
1366 source_store_unchanged: receipt.source_store_unchanged,
1367 linked_authority_remains_authoritative: receipt.linked_authority_remains_authoritative,
1368 candidate_authoritative: receipt.candidate_authoritative,
1369 evidence: &receipt.evidence,
1370 })
1371}
1372
1373#[derive(Serialize)]
1374#[serde(rename_all = "camelCase")]
1375struct RunContent<'a> {
1376 protocol: &'a str,
1377 run_id: &'a str,
1378 revision: u64,
1379 mode: ExtractionRunMode,
1380 plan: &'a ExtractionPlan,
1381 current_phase: &'a ExtractionRunPhase,
1382 expected_state: &'a ExtractionRunExpectedState,
1383 expected_state_digest: &'a str,
1384 ordered_operations: &'a [ExtractionExpansionOperation],
1385 receipts: &'a [ExtractionOperationReceipt],
1386 evidence: &'a [ExtractionRunEvidence],
1387 errors: &'a [ExtractionRunError],
1388 next_actions: &'a [String],
1389 effects: ExtractionRunEffects,
1390}
1391
1392fn run_digest(run: &ExtractionRun) -> Result<String, ExtractionRunStartError> {
1393 digest_serializable(&RunContent {
1394 protocol: &run.protocol,
1395 run_id: &run.run_id,
1396 revision: run.revision,
1397 mode: run.mode,
1398 plan: &run.plan,
1399 current_phase: &run.current_phase,
1400 expected_state: &run.expected_state,
1401 expected_state_digest: &run.expected_state_digest,
1402 ordered_operations: &run.ordered_operations,
1403 receipts: &run.receipts,
1404 evidence: &run.evidence,
1405 errors: &run.errors,
1406 next_actions: &run.next_actions,
1407 effects: run.effects,
1408 })
1409}
1410
1411fn refresh_run_digest(run: &mut ExtractionRun) {
1412 run.run_digest = run_digest(run).expect("Extraction Run content must serialize");
1413}
1414
1415fn digest_serializable(value: &impl Serialize) -> Result<String, ExtractionRunStartError> {
1416 serde_json::to_vec(value)
1417 .map(extraction_input_digest)
1418 .map_err(|error| {
1419 start_error(
1420 ExtractionRunStartErrorCode::PlanInvalid,
1421 format!("Extraction Run content could not be serialized: {error}"),
1422 "Correct the public artifact input and retry without mutation.",
1423 )
1424 })
1425}
1426
1427fn start_error(
1428 code: ExtractionRunStartErrorCode,
1429 message: impl Into<String>,
1430 next_action: impl Into<String>,
1431) -> ExtractionRunStartError {
1432 ExtractionRunStartError {
1433 code,
1434 message: message.into(),
1435 next_actions: vec![next_action.into()],
1436 effects: ExtractionRunEffects::default(),
1437 }
1438}
1439
1440fn advance_error(
1441 code: ExtractionRunAdvanceErrorCode,
1442 message: impl Into<String>,
1443 next_action: impl Into<String>,
1444) -> ExtractionRunAdvanceError {
1445 ExtractionRunAdvanceError {
1446 code,
1447 message: message.into(),
1448 next_actions: vec![next_action.into()],
1449 }
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454 use super::*;
1455 use crate::{
1456 CommonContextRequirement, DIRECT_HTTP_OPENAPI_V1_FIXTURE_YAML,
1457 EXTRACTION_READINESS_ANALYZER_VERSION, EXTRACTION_READINESS_REPORT_PROTOCOL,
1458 ExtractionAuthorityKind, ExtractionContractArtifactFormat, ExtractionContractDirection,
1459 ExtractionContractKind, ExtractionEvidenceDigest, ExtractionPlanContractVersion,
1460 ExtractionReadinessEffects, ExtractionReadinessReport, ExtractionReadinessSurfaceSummary,
1461 ExtractionScaffoldArtifact, ExtractionScaffoldEffects, ExtractionScaffoldInputs,
1462 ExtractionServiceDataEvidence, ServiceTenancyMode, generate_extraction_plan,
1463 generate_extraction_scaffold,
1464 };
1465 use lenso_contracts::{
1466 ModuleHttpMethod, ModuleHttpRoute, ModuleManifest, ServiceOperationMetadata,
1467 };
1468 use std::sync::Mutex;
1469
1470 #[derive(Debug, Default)]
1471 struct FakeWorkload {
1472 receipts: Mutex<BTreeMap<String, ExtractionOperationReceipt>>,
1473 executions: Mutex<Vec<String>>,
1474 }
1475
1476 impl FakeWorkload {
1477 fn execution_count(&self) -> usize {
1478 self.executions.lock().unwrap().len()
1479 }
1480 }
1481
1482 #[async_trait]
1483 impl ExtractionExpansionWorkload for FakeWorkload {
1484 async fn inspect_receipt(
1485 &self,
1486 request: &ExtractionWorkloadRequest,
1487 ) -> Result<Option<ExtractionOperationReceipt>, ExtractionWorkloadFailure> {
1488 Ok(self
1489 .receipts
1490 .lock()
1491 .unwrap()
1492 .get(&request.operation.operation_id)
1493 .cloned())
1494 }
1495
1496 async fn execute(
1497 &self,
1498 request: &ExtractionWorkloadRequest,
1499 ) -> Result<ExtractionOperationReceipt, ExtractionWorkloadFailure> {
1500 self.executions
1501 .lock()
1502 .unwrap()
1503 .push(request.operation.operation_id.clone());
1504 let (outcome, kind) = match request.operation.kind {
1505 ExtractionExpansionOperationKind::CreateIsolatedStore => (
1506 ExtractionOperationOutcome::Created,
1507 ExtractionRunEvidenceKind::StoreIsolation,
1508 ),
1509 ExtractionExpansionOperationKind::ApplyExpandMigration => (
1510 ExtractionOperationOutcome::Applied,
1511 ExtractionRunEvidenceKind::MigrationApplied,
1512 ),
1513 ExtractionExpansionOperationKind::VerifyMigrationWorkload => (
1514 ExtractionOperationOutcome::Healthy,
1515 ExtractionRunEvidenceKind::MigrationWorkloadHealth,
1516 ),
1517 ExtractionExpansionOperationKind::VerifyCandidateHealth => (
1518 ExtractionOperationOutcome::Healthy,
1519 ExtractionRunEvidenceKind::CandidateHealth,
1520 ),
1521 };
1522 let evidence = vec![ExtractionRunEvidence {
1523 kind,
1524 subject: request.operation.operation_id.clone(),
1525 digest: extraction_input_digest(request.operation.operation_digest.as_bytes()),
1526 detail: "verified through candidate Workload behavior".to_owned(),
1527 }];
1528 let receipt = build_extraction_operation_receipt(request, outcome, evidence)
1529 .expect("fake receipt must build");
1530 self.receipts
1531 .lock()
1532 .unwrap()
1533 .insert(request.operation.operation_id.clone(), receipt.clone());
1534 Ok(receipt)
1535 }
1536 }
1537
1538 fn module() -> ModuleManifest {
1539 ModuleManifest::builder("acme/support-ticket")
1540 .capabilities(vec!["support.tickets.read".to_owned()])
1541 .http_routes(vec![ModuleHttpRoute {
1542 method: ModuleHttpMethod::Get,
1543 path: "/v1/tickets/{ticket_id}".to_owned(),
1544 capability: Some("support.tickets.read".to_owned()),
1545 display_name: Some("Get ticket".to_owned()),
1546 story_title: Some("Support ticket opened".to_owned()),
1547 operation: Some(ServiceOperationMetadata {
1548 operation_id: Some("getTicket".to_owned()),
1549 summary: Some("Get ticket".to_owned()),
1550 ..ServiceOperationMetadata::default()
1551 }),
1552 }])
1553 .build()
1554 }
1555
1556 #[allow(clippy::too_many_lines)]
1557 fn run_inputs(sql: &str) -> ExtractionRunInputs {
1558 let module = module();
1559 let migration_reference = "modules/support-ticket/migrations/0001_tickets.sql";
1560 let migration_digest = extraction_input_digest(sql.as_bytes());
1561 let report = ExtractionReadinessReport {
1562 protocol: EXTRACTION_READINESS_REPORT_PROTOCOL.to_owned(),
1563 analyzer_version: EXTRACTION_READINESS_ANALYZER_VERSION.to_owned(),
1564 target_module: module.module_id.clone(),
1565 system_id: Some("support-system".to_owned()),
1566 target_owner: Some("support-host".to_owned()),
1567 classification: crate::CompatibilityCategory::Safe,
1568 ready: true,
1569 issue_codes: Vec::new(),
1570 contract_evidence: Vec::new(),
1571 active_consumers: Vec::new(),
1572 surfaces: ExtractionReadinessSurfaceSummary::default(),
1573 service_data: ExtractionServiceDataEvidence {
1574 complete: true,
1575 migrations: vec![crate::ExtractionMigrationEvidence {
1576 migration: "0001_create_support_tickets".to_owned(),
1577 owner_module: Some("support-ticket".to_owned()),
1578 source: crate::ExtractionDataEvidenceSource::StaticDeclaration,
1579 evidence_references: vec![migration_reference.to_owned()],
1580 }],
1581 ..ExtractionServiceDataEvidence::default()
1582 },
1583 findings: Vec::new(),
1584 effects: ExtractionReadinessEffects::default(),
1585 };
1586 let current_plan_inputs = ExtractionPlanInputs {
1587 readiness_report: report,
1588 module: module.clone(),
1589 system: json!({
1590 "protocol": "lenso.system.v2",
1591 "systemId": "support-system",
1592 "host": { "hostId": "support-host", "modules": ["acme/support-ticket"] },
1593 "providers": [{
1594 "providerId": "notification-provider",
1595 "modules": ["notification-gateway"]
1596 }],
1597 "autonomousServices": [{
1598 "serviceId": "support-sla-service",
1599 "modules": ["support-sla"],
1600 "workloads": [{ "workloadId": "support-sla-api", "role": "api" }]
1601 }],
1602 "contracts": [{
1603 "contractId": "support.sla-updated.v1",
1604 "version": "v1",
1605 "producerKind": "autonomous_service",
1606 "producerId": "support-sla-service",
1607 "artifact": {
1608 "format": "json_schema",
1609 "path": "contracts/events/support.sla-updated.v1.schema.json"
1610 },
1611 "tenancyMode": "required"
1612 }],
1613 "consumers": [{
1614 "consumerId": "support-ticket-sla-updates",
1615 "ownerKind": "host",
1616 "ownerId": "support-host",
1617 "contractId": "support.sla-updated.v1",
1618 "tenancyMode": "required"
1619 }]
1620 }),
1621 contract_versions: vec![ExtractionPlanContractVersion {
1622 contract_id: "support-ticket-http.v1".to_owned(),
1623 version: "v1".to_owned(),
1624 kind: ExtractionContractKind::Service,
1625 direction: ExtractionContractDirection::Provides,
1626 artifact_reference: "contracts/openapi/support.v1.yaml".to_owned(),
1627 artifact_digest: extraction_input_digest(
1628 DIRECT_HTTP_OPENAPI_V1_FIXTURE_YAML.as_bytes(),
1629 ),
1630 artifact_format: ExtractionContractArtifactFormat::Openapi,
1631 tenancy_mode: ServiceTenancyMode::Required,
1632 required_context: vec![CommonContextRequirement::Tenant],
1633 producer_id: None,
1634 consumer_ids: Vec::new(),
1635 }],
1636 expected_authority: ExtractionExpectedAuthority {
1637 kind: ExtractionAuthorityKind::LinkedHost,
1638 owner_id: "support-host".to_owned(),
1639 revision: "support-authority-r7".to_owned(),
1640 },
1641 evidence_digests: vec![ExtractionEvidenceDigest {
1642 reference: migration_reference.to_owned(),
1643 digest: migration_digest.clone(),
1644 }],
1645 };
1646 let plan = generate_extraction_plan(¤t_plan_inputs).expect("plan");
1647 let scaffold = generate_extraction_scaffold(&ExtractionScaffoldInputs {
1648 plan: plan.clone(),
1649 module,
1650 artifacts: vec![ExtractionScaffoldArtifact {
1651 contract_id: "support-ticket-http.v1".to_owned(),
1652 version: "v1".to_owned(),
1653 contents: DIRECT_HTTP_OPENAPI_V1_FIXTURE_YAML.to_owned(),
1654 protobuf_descriptor: None,
1655 }],
1656 })
1657 .expect("scaffold");
1658 let unchanged_files = scaffold
1659 .files
1660 .iter()
1661 .map(|file| file.path.clone())
1662 .collect();
1663 let scaffold_apply_result = ExtractionScaffoldApplyResult {
1664 protocol: "lenso.extraction-scaffold-apply.v1".to_owned(),
1665 scaffold_id: scaffold.scaffold_id.clone(),
1666 plan_id: plan.plan_id.clone(),
1667 created_files: Vec::new(),
1668 unchanged_files,
1669 linked_authority_remains_authoritative: true,
1670 effects: ExtractionScaffoldEffects::default(),
1671 };
1672 ExtractionRunInputs {
1673 plan,
1674 current_plan_inputs,
1675 scaffold,
1676 scaffold_apply_result,
1677 migrations: vec![ExtractionMigrationArtifact {
1678 migration_id: "0001_create_support_tickets".to_owned(),
1679 source_reference: migration_reference.to_owned(),
1680 source_digest: migration_digest,
1681 sql: sql.to_owned(),
1682 }],
1683 }
1684 }
1685
1686 fn safe_sql() -> &'static str {
1687 "create schema if not exists support;\ncreate table if not exists support.tickets (id text primary key);\n"
1688 }
1689
1690 fn request_for(
1691 run: &ExtractionRun,
1692 operation: ExtractionExpansionOperation,
1693 ) -> ExtractionWorkloadRequest {
1694 ExtractionWorkloadRequest {
1695 run_id: run.run_id.clone(),
1696 plan_id: run.plan.plan_id.clone(),
1697 plan_digest: run.plan.plan_digest.clone(),
1698 expected_state: run.expected_state.clone(),
1699 expected_state_digest: run.expected_state_digest.clone(),
1700 operation,
1701 }
1702 }
1703
1704 #[test]
1705 fn dry_run_reports_the_exact_apply_operations_without_effects() {
1706 let inputs = run_inputs(safe_sql());
1707 let apply = start_destination_expansion(&inputs).expect("apply run");
1708 let dry_run = dry_run_destination_expansion(&inputs).expect("dry run");
1709
1710 assert_eq!(dry_run.run_id, apply.run_id);
1711 assert_eq!(dry_run.expected_state, apply.expected_state);
1712 assert_eq!(dry_run.ordered_operations, apply.ordered_operations);
1713 assert_eq!(dry_run.effects, ExtractionRunEffects::default());
1714 assert!(extraction_run_integrity_is_valid(&dry_run));
1715 assert_eq!(
1716 dry_run
1717 .ordered_operations
1718 .iter()
1719 .map(|operation| operation.kind)
1720 .collect::<Vec<_>>(),
1721 vec![
1722 ExtractionExpansionOperationKind::CreateIsolatedStore,
1723 ExtractionExpansionOperationKind::ApplyExpandMigration,
1724 ExtractionExpansionOperationKind::VerifyMigrationWorkload,
1725 ExtractionExpansionOperationKind::VerifyCandidateHealth,
1726 ]
1727 );
1728 }
1729
1730 #[test]
1731 fn destructive_or_data_mutating_sql_is_rejected_before_workload_behavior() {
1732 let inputs = run_inputs("drop table support.tickets;");
1733 let error = start_destination_expansion(&inputs).expect_err("drop must fail closed");
1734 assert_eq!(
1735 error.code,
1736 ExtractionRunStartErrorCode::MigrationNotExpandFirst
1737 );
1738 assert_eq!(error.effects, ExtractionRunEffects::default());
1739 assert!(!validate_expand_first_postgres_sql(
1740 "alter table support.tickets drop column title;"
1741 ));
1742 assert!(!validate_expand_first_postgres_sql(
1743 "insert into support.tickets values ('x');"
1744 ));
1745 }
1746
1747 #[tokio::test]
1748 async fn interrupted_run_recovers_the_workload_receipt_without_repeating_effects() {
1749 let inputs = run_inputs(safe_sql());
1750 let workload = FakeWorkload::default();
1751 let mut run = start_destination_expansion(&inputs).expect("run");
1752
1753 run = advance_destination_expansion(run, &inputs.current_plan_inputs, &workload)
1754 .await
1755 .expect("create Store");
1756 assert_eq!(workload.execution_count(), 1);
1757
1758 let migration = run
1759 .ordered_operations
1760 .iter()
1761 .find(|operation| {
1762 operation.kind == ExtractionExpansionOperationKind::ApplyExpandMigration
1763 })
1764 .cloned()
1765 .unwrap();
1766 workload
1767 .execute(&request_for(&run, migration))
1768 .await
1769 .expect("commit effect and durable receipt before simulated crash");
1770 assert_eq!(workload.execution_count(), 2);
1771
1772 run = advance_destination_expansion(run, &inputs.current_plan_inputs, &workload)
1773 .await
1774 .expect("recover receipt");
1775 assert_eq!(workload.execution_count(), 2, "migration must not repeat");
1776 while run.current_phase.status != ExtractionRunStatus::Succeeded {
1777 run = advance_destination_expansion(run, &inputs.current_plan_inputs, &workload)
1778 .await
1779 .expect("advance remaining health checks");
1780 }
1781
1782 assert_eq!(run.receipts.len(), run.ordered_operations.len());
1783 assert!(run.effects.creates_destination_store);
1784 assert!(run.effects.applies_destination_schema);
1785 assert!(!run.effects.copies_service_data);
1786 assert!(!run.effects.mutates_source_store);
1787 assert!(!run.effects.mutates_linked_implementation);
1788 assert!(!run.effects.changes_authority);
1789 assert!(!run.effects.performs_destructive_cleanup);
1790 assert!(extraction_run_integrity_is_valid(&run));
1791 }
1792
1793 #[tokio::test]
1794 async fn stale_plan_blocks_before_workload_behavior() {
1795 let inputs = run_inputs(safe_sql());
1796 let workload = FakeWorkload::default();
1797 let run = start_destination_expansion(&inputs).expect("run");
1798 let mut changed = inputs.current_plan_inputs.clone();
1799 changed.expected_authority.revision = "support-authority-r8".to_owned();
1800
1801 let blocked = advance_destination_expansion(run, &changed, &workload)
1802 .await
1803 .expect("stale plan becomes blocked evidence");
1804 assert_eq!(blocked.current_phase.status, ExtractionRunStatus::Blocked);
1805 assert_eq!(blocked.errors[0].code, ExtractionRunErrorCode::PlanStale);
1806 assert_eq!(workload.execution_count(), 0);
1807 assert!(extraction_run_integrity_is_valid(&blocked));
1808 }
1809
1810 #[test]
1811 fn public_schema_accepts_a_versioned_run() {
1812 let run = dry_run_destination_expansion(&run_inputs(safe_sql())).expect("dry run");
1813 let value = serde_json::to_value(&run).unwrap();
1814 let validator = jsonschema::validator_for(&extraction_run_schema()).unwrap();
1815 assert!(validator.is_valid(&value));
1816 }
1817}