Skip to main content

lenso_service/
extraction_run.rs

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/// Public behavior used by CLI-owned Postgres orchestration and candidate Workloads.
357///
358/// Implementations must persist operation receipts beside the destination effect.
359/// `inspect_receipt` is always called before `execute`, which lets a restarted
360/// caller recover an effect committed before its Extraction Run was saved.
361#[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(&current_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}