Skip to main content

lenso_service/
extraction_readiness.rs

1use crate::{
2    CompatibilityCategory, ContractSemanticKind, SystemV2Graph, SystemV2GraphRelationship,
3    system_v2_graph,
4};
5use lenso_contracts::{
6    AdminSurface, ModuleHttpMethod, ModuleManifest, ModuleManifestLintSeverity, StoryDisplaySource,
7    lint_module_manifest,
8};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use std::collections::{BTreeMap, BTreeSet};
13
14pub const EXTRACTION_READINESS_REPORT_PROTOCOL: &str = "lenso.extraction-readiness-report.v1";
15pub const EXTRACTION_READINESS_ANALYZER_VERSION: &str = "lenso.extraction-readiness.v2";
16const EXTRACTION_READINESS_SCHEMA_ID: &str =
17    "https://contracts.lenso.local/extraction/lenso.extraction-readiness-report.v1.schema.json";
18const LARGE_DATA_VOLUME_ROWS: u64 = 1_000_000;
19const LARGE_DATA_VOLUME_BYTES: u64 = 1_073_741_824;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ExtractionBoundaryReferenceKind {
24    CrossModuleImport,
25    InProcessBoundaryCall,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ExtractionBoundaryReference {
31    pub kind: ExtractionBoundaryReferenceKind,
32    pub from_module: String,
33    pub to_module: String,
34    pub symbol: String,
35    pub evidence_reference: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct ExtractionBoundaryEvidence {
41    pub complete: bool,
42    #[serde(default)]
43    pub evidence_references: Vec<String>,
44    #[serde(default)]
45    pub references: Vec<ExtractionBoundaryReference>,
46}
47
48#[derive(
49    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
50)]
51#[serde(rename_all = "snake_case")]
52pub enum ExtractionContractKind {
53    Service,
54    Event,
55}
56
57#[derive(
58    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
59)]
60#[serde(rename_all = "snake_case")]
61pub enum ExtractionContractDirection {
62    Provides,
63    Consumes,
64}
65
66#[derive(
67    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
68)]
69#[serde(rename_all = "snake_case")]
70pub enum ExtractionEvidenceStatus {
71    Present,
72    Missing,
73    Ambiguous,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
77#[serde(rename_all = "camelCase")]
78pub struct ExtractionContractEvidence {
79    pub subject: String,
80    pub kind: ExtractionContractKind,
81    pub direction: ExtractionContractDirection,
82    pub status: ExtractionEvidenceStatus,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub contract_id: Option<String>,
85    #[serde(default)]
86    pub evidence_references: Vec<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
90#[serde(rename_all = "camelCase")]
91pub struct ExtractionConsumerCompatibilityEvidence {
92    pub consumer_id: String,
93    pub contract_id: String,
94    pub classification: CompatibilityCategory,
95    #[serde(default)]
96    pub evidence_references: Vec<String>,
97    pub next_action: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
101#[serde(
102    tag = "kind",
103    rename_all = "snake_case",
104    rename_all_fields = "camelCase"
105)]
106pub enum ExtractionDataEvidenceSource {
107    StaticDeclaration,
108    LiveStoreObservation {
109        observation_id: String,
110        store: String,
111        read_only: bool,
112    },
113}
114
115#[derive(
116    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
117)]
118#[serde(rename_all = "snake_case")]
119pub enum ExtractionDataAccessKind {
120    Read,
121    Write,
122    ReadWrite,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
126#[serde(rename_all = "camelCase")]
127pub struct ExtractionCursorEvidence {
128    pub column: String,
129    pub high_water_mark: String,
130    pub trustworthy: bool,
131    #[serde(default)]
132    pub evidence_references: Vec<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "camelCase")]
137pub struct ExtractionDataVolumeEvidence {
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub approximate_rows: Option<u64>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub approximate_bytes: Option<u64>,
142    #[serde(default)]
143    pub evidence_references: Vec<String>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
147#[serde(rename_all = "camelCase")]
148pub struct ExtractionDataTableEvidence {
149    pub table: String,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub owner_module: Option<String>,
152    pub source: ExtractionDataEvidenceSource,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub volume: Option<ExtractionDataVolumeEvidence>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub cursor: Option<ExtractionCursorEvidence>,
157    #[serde(default)]
158    pub evidence_references: Vec<String>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
162#[serde(rename_all = "camelCase")]
163pub struct ExtractionMigrationEvidence {
164    pub migration: String,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub owner_module: Option<String>,
167    pub source: ExtractionDataEvidenceSource,
168    #[serde(default)]
169    pub evidence_references: Vec<String>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
173#[serde(rename_all = "camelCase")]
174pub struct ExtractionDataAccessEvidence {
175    pub accessor_module: String,
176    pub table: String,
177    pub access: ExtractionDataAccessKind,
178    pub source: ExtractionDataEvidenceSource,
179    #[serde(default)]
180    pub evidence_references: Vec<String>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
184#[serde(rename_all = "camelCase")]
185pub struct ExtractionTransactionEvidence {
186    pub transaction: String,
187    #[serde(default)]
188    pub participating_modules: Vec<String>,
189    pub source: ExtractionDataEvidenceSource,
190    #[serde(default)]
191    pub evidence_references: Vec<String>,
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
195#[serde(rename_all = "camelCase")]
196pub struct ExtractionServiceDataEvidence {
197    pub complete: bool,
198    #[serde(default)]
199    pub evidence_references: Vec<String>,
200    #[serde(default)]
201    pub tables: Vec<ExtractionDataTableEvidence>,
202    #[serde(default)]
203    pub migrations: Vec<ExtractionMigrationEvidence>,
204    #[serde(default)]
205    pub access_paths: Vec<ExtractionDataAccessEvidence>,
206    #[serde(default)]
207    pub transactions: Vec<ExtractionTransactionEvidence>,
208}
209
210#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct ExtractionReadinessEvidence {
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub boundary: Option<ExtractionBoundaryEvidence>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub contracts: Option<Vec<ExtractionContractEvidence>>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub active_consumers: Option<Vec<ExtractionConsumerCompatibilityEvidence>>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub service_data: Option<ExtractionServiceDataEvidence>,
221}
222
223#[derive(
224    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
225)]
226#[serde(rename_all = "snake_case")]
227pub enum ExtractionReadinessIssueCode {
228    ActiveConsumerBlocked,
229    ActiveConsumerBreaking,
230    ActiveConsumerCompatibilityMissing,
231    ActiveConsumerEvidenceAmbiguous,
232    ActiveConsumerNeedsAttention,
233    AdminSurfacePresent,
234    BoundaryClean,
235    BoundaryEvidenceAmbiguous,
236    BoundaryEvidenceIncomplete,
237    BoundaryEvidenceMissing,
238    BoundaryEvidenceTargetMismatch,
239    ConsoleSurfacePresent,
240    ConsumersCompatible,
241    ContractEvidenceAmbiguous,
242    ContractEvidenceMissing,
243    ContractIdentityMismatch,
244    ContractsComplete,
245    CrossModuleTableAccess,
246    CrossModuleImport,
247    DataVolumeLarge,
248    ExtractionCursorMissing,
249    ExtractionCursorUsable,
250    InProcessBoundaryCall,
251    LiveStoreObservationNotReadOnly,
252    LiveStoreObservationPresent,
253    ManifestInvalid,
254    ManifestNeedsAttention,
255    MigrationOwnershipUnresolved,
256    RequiredEventContractMissing,
257    RequiredServiceContractMissing,
258    RuntimeSurfacePresent,
259    ServiceDataEvidenceIncomplete,
260    ServiceDataEvidenceMissing,
261    ServiceDataReady,
262    StorySurfacePresent,
263    SystemEvidenceInvalid,
264    TableOwnershipUnresolved,
265    TargetModuleMissing,
266    TargetModuleNotLinked,
267    TransactionBoundaryUnresolved,
268    TransactionSpansServiceBoundary,
269    WorkflowSurfacePresent,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
273#[serde(rename_all = "camelCase")]
274pub struct ExtractionReadinessFinding {
275    pub classification: CompatibilityCategory,
276    pub code: ExtractionReadinessIssueCode,
277    pub subject: String,
278    pub message: String,
279    pub evidence_references: Vec<String>,
280    pub next_actions: Vec<String>,
281}
282
283#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
284#[serde(rename_all = "camelCase")]
285pub struct ExtractionReadinessSurfaceSummary {
286    #[serde(default)]
287    pub http_routes: Vec<String>,
288    #[serde(default)]
289    pub event_handlers: Vec<String>,
290    #[serde(default)]
291    pub runtime_functions: Vec<String>,
292    #[serde(default)]
293    pub schedules: Vec<String>,
294    #[serde(default)]
295    pub workflows: Vec<String>,
296    #[serde(default)]
297    pub admin: Vec<String>,
298    #[serde(default)]
299    pub console: Vec<String>,
300    #[serde(default)]
301    pub stories: Vec<String>,
302}
303
304#[allow(clippy::struct_excessive_bools)]
305#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
306#[serde(rename_all = "camelCase")]
307pub struct ExtractionReadinessEffects {
308    pub writes_repository_files: bool,
309    pub starts_workloads: bool,
310    pub moves_data: bool,
311    pub changes_authority: bool,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
315#[serde(rename_all = "camelCase")]
316pub struct ExtractionReadinessReport {
317    pub protocol: String,
318    pub analyzer_version: String,
319    pub target_module: String,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub system_id: Option<String>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub target_owner: Option<String>,
324    pub classification: CompatibilityCategory,
325    pub ready: bool,
326    #[serde(default)]
327    pub issue_codes: Vec<ExtractionReadinessIssueCode>,
328    #[serde(default)]
329    pub contract_evidence: Vec<ExtractionContractEvidence>,
330    #[serde(default)]
331    pub active_consumers: Vec<ExtractionConsumerCompatibilityEvidence>,
332    pub surfaces: ExtractionReadinessSurfaceSummary,
333    #[serde(default)]
334    pub service_data: ExtractionServiceDataEvidence,
335    pub findings: Vec<ExtractionReadinessFinding>,
336    pub effects: ExtractionReadinessEffects,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq)]
340struct RequiredContractSubject<'a> {
341    subject: String,
342    kind: ExtractionContractKind,
343    direction: ExtractionContractDirection,
344    expected_contract_id: Option<&'a str>,
345}
346
347#[must_use]
348pub fn evaluate_extraction_readiness(
349    module: &ModuleManifest,
350    system: &Value,
351    evidence: &ExtractionReadinessEvidence,
352) -> ExtractionReadinessReport {
353    let mut findings = Vec::new();
354    let surfaces = surface_summary(module);
355    collect_manifest_findings(module, &mut findings);
356    collect_surface_findings(&surfaces, &mut findings);
357
358    let system_id = system
359        .get("systemId")
360        .and_then(Value::as_str)
361        .map(str::to_owned);
362    let graph = match system_v2_graph(system) {
363        Ok(graph) => Some(graph),
364        Err(mut issues) => {
365            issues.sort_by(|left, right| (&left.path, &left.code).cmp(&(&right.path, &right.code)));
366            for issue in issues {
367                push_finding(
368                    &mut findings,
369                    CompatibilityCategory::Blocked,
370                    ExtractionReadinessIssueCode::SystemEvidenceInvalid,
371                    issue.path.clone(),
372                    format!(
373                        "System evidence is invalid ({}): {}",
374                        issue.code, issue.message
375                    ),
376                    vec![format!("system:{}", issue.path)],
377                    vec![issue.next_action],
378                );
379            }
380            None
381        }
382    };
383    let target_owner = graph
384        .as_ref()
385        .and_then(|graph| collect_target_owner(module, graph, &mut findings));
386
387    collect_boundary_findings(module, evidence.boundary.as_ref(), &mut findings);
388    let contract_evidence = normalized_contract_evidence(evidence.contracts.as_deref());
389    let contract_ids =
390        collect_contract_findings(module, contract_evidence.as_deref(), &mut findings);
391    let active_consumers = normalized_consumer_evidence(evidence.active_consumers.as_deref());
392    collect_consumer_findings(
393        graph.as_ref(),
394        &contract_ids,
395        active_consumers.as_deref(),
396        &mut findings,
397    );
398    let service_data = normalized_service_data(evidence.service_data.as_ref());
399    collect_service_data_findings(
400        module,
401        evidence.service_data.as_ref(),
402        &service_data,
403        &mut findings,
404    );
405
406    normalize_findings(&mut findings);
407    let classification = findings
408        .iter()
409        .map(|finding| finding.classification)
410        .max_by_key(|classification| classification_rank(*classification))
411        .unwrap_or(CompatibilityCategory::Safe);
412    let mut issue_codes = findings
413        .iter()
414        .filter(|finding| finding.classification != CompatibilityCategory::Safe)
415        .map(|finding| finding.code)
416        .collect::<Vec<_>>();
417    issue_codes.sort();
418    issue_codes.dedup();
419
420    ExtractionReadinessReport {
421        protocol: EXTRACTION_READINESS_REPORT_PROTOCOL.to_owned(),
422        analyzer_version: EXTRACTION_READINESS_ANALYZER_VERSION.to_owned(),
423        target_module: module.module_id.clone(),
424        system_id,
425        target_owner,
426        classification,
427        ready: matches!(
428            classification,
429            CompatibilityCategory::Safe | CompatibilityCategory::NeedsAttention
430        ),
431        issue_codes,
432        contract_evidence: contract_evidence.unwrap_or_default(),
433        active_consumers: active_consumers.unwrap_or_default(),
434        surfaces,
435        service_data,
436        findings,
437        effects: ExtractionReadinessEffects::default(),
438    }
439}
440
441fn normalized_contract_evidence(
442    evidence: Option<&[ExtractionContractEvidence]>,
443) -> Option<Vec<ExtractionContractEvidence>> {
444    evidence.map(|evidence| {
445        let mut normalized = evidence.to_vec();
446        for contract in &mut normalized {
447            normalize_strings(&mut contract.evidence_references);
448        }
449        normalized.sort();
450        normalized
451    })
452}
453
454fn normalized_consumer_evidence(
455    evidence: Option<&[ExtractionConsumerCompatibilityEvidence]>,
456) -> Option<Vec<ExtractionConsumerCompatibilityEvidence>> {
457    evidence.map(|evidence| {
458        let mut normalized = evidence.to_vec();
459        for consumer in &mut normalized {
460            normalize_strings(&mut consumer.evidence_references);
461        }
462        normalized.sort();
463        normalized
464    })
465}
466
467fn normalized_service_data(
468    evidence: Option<&ExtractionServiceDataEvidence>,
469) -> ExtractionServiceDataEvidence {
470    let mut normalized = evidence.cloned().unwrap_or_default();
471    normalize_strings(&mut normalized.evidence_references);
472    for table in &mut normalized.tables {
473        normalize_strings(&mut table.evidence_references);
474        if let Some(volume) = &mut table.volume {
475            normalize_strings(&mut volume.evidence_references);
476        }
477        if let Some(cursor) = &mut table.cursor {
478            normalize_strings(&mut cursor.evidence_references);
479        }
480    }
481    for migration in &mut normalized.migrations {
482        normalize_strings(&mut migration.evidence_references);
483    }
484    for access in &mut normalized.access_paths {
485        normalize_strings(&mut access.evidence_references);
486    }
487    for transaction in &mut normalized.transactions {
488        normalize_strings(&mut transaction.participating_modules);
489        normalize_strings(&mut transaction.evidence_references);
490    }
491    normalized.tables.sort();
492    normalized.tables.dedup();
493    normalized.migrations.sort();
494    normalized.migrations.dedup();
495    normalized.access_paths.sort();
496    normalized.access_paths.dedup();
497    normalized.transactions.sort();
498    normalized.transactions.dedup();
499    normalized
500}
501
502fn collect_service_data_findings(
503    module: &ModuleManifest,
504    supplied: Option<&ExtractionServiceDataEvidence>,
505    data: &ExtractionServiceDataEvidence,
506    findings: &mut Vec<ExtractionReadinessFinding>,
507) {
508    let finding_start = findings.len();
509    let Some(_) = supplied else {
510        push_finding(
511            findings,
512            CompatibilityCategory::Blocked,
513            ExtractionReadinessIssueCode::ServiceDataEvidenceMissing,
514            "service_data".to_owned(),
515            "Service Data evidence is missing, so Postgres ownership and extraction safety are unknown.",
516            vec!["extraction-evidence:serviceData".to_owned()],
517            vec!["Supply complete table, migration, access-path, transaction, volume, and cursor evidence before planning extraction.".to_owned()],
518        );
519        return;
520    };
521    if !data.complete {
522        push_finding(
523            findings,
524            CompatibilityCategory::Blocked,
525            ExtractionReadinessIssueCode::ServiceDataEvidenceIncomplete,
526            "service_data".to_owned(),
527            "Service Data analysis is incomplete, so missing Postgres coupling cannot be treated as safe.",
528            evidence_references_or(&data.evidence_references, "extraction-evidence:serviceData"),
529            vec!["Complete the Service Data analysis and rerun extraction readiness.".to_owned()],
530        );
531    }
532
533    collect_live_store_findings(data, findings);
534
535    let table_ownership = collect_table_ownership(data, findings);
536    collect_migration_ownership(data, findings);
537    collect_table_access_findings(module, data, &table_ownership, findings);
538    collect_transaction_findings(module, data, findings);
539    collect_volume_and_cursor_findings(module, data, &table_ownership, findings);
540
541    if findings[finding_start..]
542        .iter()
543        .all(|finding| finding.classification != CompatibilityCategory::Blocked)
544    {
545        push_finding(
546            findings,
547            CompatibilityCategory::Safe,
548            ExtractionReadinessIssueCode::ServiceDataReady,
549            "service_data".to_owned(),
550            "Service Data ownership, access paths, and transaction boundaries are safe enough to plan extraction.",
551            evidence_references_or(
552                &data.evidence_references,
553                "extraction-evidence:serviceData",
554            ),
555            vec!["Carry the reported tables, migrations, volume, and cursor evidence into the Extraction Plan.".to_owned()],
556        );
557    }
558}
559
560fn collect_live_store_findings(
561    data: &ExtractionServiceDataEvidence,
562    findings: &mut Vec<ExtractionReadinessFinding>,
563) {
564    let mut observations = BTreeMap::<(String, String, bool), BTreeSet<String>>::new();
565    for (source, references) in data
566        .tables
567        .iter()
568        .map(|item| (&item.source, &item.evidence_references))
569        .chain(
570            data.migrations
571                .iter()
572                .map(|item| (&item.source, &item.evidence_references)),
573        )
574        .chain(
575            data.access_paths
576                .iter()
577                .map(|item| (&item.source, &item.evidence_references)),
578        )
579        .chain(
580            data.transactions
581                .iter()
582                .map(|item| (&item.source, &item.evidence_references)),
583        )
584    {
585        if let ExtractionDataEvidenceSource::LiveStoreObservation {
586            observation_id,
587            store,
588            read_only,
589        } = source
590        {
591            observations
592                .entry((observation_id.clone(), store.clone(), *read_only))
593                .or_default()
594                .extend(references.iter().cloned());
595        }
596    }
597    for ((observation_id, store, read_only), references) in observations {
598        let subject = format!("service_data.observation.{observation_id}");
599        if read_only {
600            push_finding(
601                findings,
602                CompatibilityCategory::Safe,
603                ExtractionReadinessIssueCode::LiveStoreObservationPresent,
604                subject,
605                format!(
606                    "Read-only live Store observation `{observation_id}` from `{store}` is reported separately from static declarations."
607                ),
608                evidence_references_or_set(&references, "extraction-evidence:serviceData"),
609                vec!["Use the observation as planning evidence only; readiness analysis does not mutate the Store.".to_owned()],
610            );
611        } else {
612            push_finding(
613                findings,
614                CompatibilityCategory::Blocked,
615                ExtractionReadinessIssueCode::LiveStoreObservationNotReadOnly,
616                subject,
617                format!(
618                    "Live Store observation `{observation_id}` from `{store}` is not declared read-only."
619                ),
620                evidence_references_or_set(&references, "extraction-evidence:serviceData"),
621                vec!["Replace it with evidence collected through a read-only Store observation path.".to_owned()],
622            );
623        }
624    }
625}
626
627fn collect_table_ownership(
628    data: &ExtractionServiceDataEvidence,
629    findings: &mut Vec<ExtractionReadinessFinding>,
630) -> BTreeMap<String, Option<String>> {
631    let mut records = BTreeMap::<String, (BTreeSet<String>, bool, BTreeSet<String>)>::new();
632    for table in &data.tables {
633        let record = records.entry(table.table.clone()).or_default();
634        match table
635            .owner_module
636            .as_deref()
637            .filter(|owner| !owner.trim().is_empty())
638        {
639            Some(owner) => {
640                record.0.insert(owner.to_owned());
641            }
642            None => record.1 = true,
643        }
644        record.2.extend(table.evidence_references.iter().cloned());
645    }
646    records
647        .into_iter()
648        .map(|(table, (owners, has_unresolved, references))| {
649            let owner = if !has_unresolved && owners.len() == 1 {
650                owners.first().cloned()
651            } else {
652                push_finding(
653                    findings,
654                    CompatibilityCategory::Blocked,
655                    ExtractionReadinessIssueCode::TableOwnershipUnresolved,
656                    format!("service_data.table.{table}"),
657                    if owners.is_empty() {
658                        format!("Postgres table `{table}` has no declared Module owner.")
659                    } else {
660                        format!(
661                            "Postgres table `{table}` has unresolved ownership evidence: {}.",
662                            owners.into_iter().collect::<Vec<_>>().join(", ")
663                        )
664                    },
665                    evidence_references_or_set(
666                        &references,
667                        &format!("extraction-evidence:table/{table}"),
668                    ),
669                    vec![
670                        "Assign the table to exactly one Module before planning extraction."
671                            .to_owned(),
672                    ],
673                );
674                None
675            };
676            (table, owner)
677        })
678        .collect()
679}
680
681fn collect_migration_ownership(
682    data: &ExtractionServiceDataEvidence,
683    findings: &mut Vec<ExtractionReadinessFinding>,
684) {
685    let mut records = BTreeMap::<String, (BTreeSet<String>, bool, BTreeSet<String>)>::new();
686    for migration in &data.migrations {
687        let record = records.entry(migration.migration.clone()).or_default();
688        match migration
689            .owner_module
690            .as_deref()
691            .filter(|owner| !owner.trim().is_empty())
692        {
693            Some(owner) => {
694                record.0.insert(owner.to_owned());
695            }
696            None => record.1 = true,
697        }
698        record
699            .2
700            .extend(migration.evidence_references.iter().cloned());
701    }
702    for (migration, (owners, has_unresolved, references)) in records {
703        if !has_unresolved && owners.len() == 1 {
704            continue;
705        }
706        push_finding(
707            findings,
708            CompatibilityCategory::Blocked,
709            ExtractionReadinessIssueCode::MigrationOwnershipUnresolved,
710            format!("service_data.migration.{migration}"),
711            if owners.is_empty() {
712                format!("Postgres migration `{migration}` has no declared Module owner.")
713            } else {
714                format!(
715                    "Postgres migration `{migration}` has unresolved ownership evidence: {}.",
716                    owners.into_iter().collect::<Vec<_>>().join(", ")
717                )
718            },
719            evidence_references_or_set(
720                &references,
721                &format!("extraction-evidence:migration/{migration}"),
722            ),
723            vec!["Assign the migration to exactly one Module and preserve that schema lifecycle in the candidate Service.".to_owned()],
724        );
725    }
726}
727
728fn collect_table_access_findings(
729    module: &ModuleManifest,
730    data: &ExtractionServiceDataEvidence,
731    table_ownership: &BTreeMap<String, Option<String>>,
732    findings: &mut Vec<ExtractionReadinessFinding>,
733) {
734    for access in &data.access_paths {
735        let subject = format!(
736            "service_data.access.{}.{}",
737            access.accessor_module, access.table
738        );
739        let Some(Some(owner)) = table_ownership.get(&access.table) else {
740            push_finding(
741                findings,
742                CompatibilityCategory::Blocked,
743                ExtractionReadinessIssueCode::TableOwnershipUnresolved,
744                subject,
745                format!(
746                    "Access path from Module `{}` reaches table `{}` without trustworthy ownership evidence.",
747                    access.accessor_module, access.table
748                ),
749                evidence_references_or(
750                    &access.evidence_references,
751                    &format!("extraction-evidence:access/{}", access.table),
752                ),
753                vec!["Declare the table owner before evaluating this access path.".to_owned()],
754            );
755            continue;
756        };
757        if owner != &access.accessor_module
758            && (owner == &module.module_id || access.accessor_module == module.module_id)
759        {
760            push_finding(
761                findings,
762                CompatibilityCategory::Blocked,
763                ExtractionReadinessIssueCode::CrossModuleTableAccess,
764                subject,
765                format!(
766                    "Module `{}` directly {}-accesses table `{}` owned by Module `{owner}`.",
767                    access.accessor_module,
768                    data_access_label(access.access),
769                    access.table
770                ),
771                evidence_references_or(
772                    &access.evidence_references,
773                    &format!("extraction-evidence:access/{}", access.table),
774                ),
775                vec!["Replace direct cross-Module table access with an approved Service or Event Contract before extraction.".to_owned()],
776            );
777        }
778    }
779}
780
781fn collect_transaction_findings(
782    module: &ModuleManifest,
783    data: &ExtractionServiceDataEvidence,
784    findings: &mut Vec<ExtractionReadinessFinding>,
785) {
786    for transaction in &data.transactions {
787        let subject = format!("service_data.transaction.{}", transaction.transaction);
788        if transaction.participating_modules.is_empty() {
789            push_finding(
790                findings,
791                CompatibilityCategory::Blocked,
792                ExtractionReadinessIssueCode::TransactionBoundaryUnresolved,
793                subject,
794                format!(
795                    "Transaction `{}` has no trustworthy participating Module ownership evidence.",
796                    transaction.transaction
797                ),
798                evidence_references_or(
799                    &transaction.evidence_references,
800                    &format!("extraction-evidence:transaction/{}", transaction.transaction),
801                ),
802                vec!["Attribute every table touched by the transaction to its Module owner before extraction.".to_owned()],
803            );
804            continue;
805        }
806        if transaction
807            .participating_modules
808            .iter()
809            .any(|participant| participant == &module.module_id)
810            && transaction.participating_modules.len() > 1
811        {
812            push_finding(
813                findings,
814                CompatibilityCategory::Blocked,
815                ExtractionReadinessIssueCode::TransactionSpansServiceBoundary,
816                subject,
817                format!(
818                    "Transaction `{}` spans the proposed Service boundary across Modules: {}.",
819                    transaction.transaction,
820                    transaction.participating_modules.join(", ")
821                ),
822                evidence_references_or(
823                    &transaction.evidence_references,
824                    &format!("extraction-evidence:transaction/{}", transaction.transaction),
825                ),
826                vec!["Split the transaction into Service-local transactions coordinated through Outbox delivery, idempotent consumption, and explicit progress or compensation.".to_owned()],
827            );
828        }
829    }
830}
831
832fn collect_volume_and_cursor_findings(
833    module: &ModuleManifest,
834    data: &ExtractionServiceDataEvidence,
835    table_ownership: &BTreeMap<String, Option<String>>,
836    findings: &mut Vec<ExtractionReadinessFinding>,
837) {
838    for (table, owner) in table_ownership {
839        if owner.as_deref() != Some(module.module_id.as_str()) {
840            continue;
841        }
842        let records = data
843            .tables
844            .iter()
845            .filter(|candidate| candidate.table == *table)
846            .collect::<Vec<_>>();
847        let mut max_rows = None;
848        let mut max_bytes = None;
849        let mut volume_references = BTreeSet::new();
850        let mut cursor_references = BTreeSet::new();
851        let mut usable_cursors = Vec::new();
852        for record in records {
853            if let Some(volume) = &record.volume {
854                max_rows = max_rows.max(volume.approximate_rows);
855                max_bytes = max_bytes.max(volume.approximate_bytes);
856                volume_references.extend(record.evidence_references.iter().cloned());
857                volume_references.extend(volume.evidence_references.iter().cloned());
858            }
859            if let Some(cursor) = &record.cursor {
860                cursor_references.extend(record.evidence_references.iter().cloned());
861                cursor_references.extend(cursor.evidence_references.iter().cloned());
862                if cursor.trustworthy
863                    && !cursor.column.trim().is_empty()
864                    && !cursor.high_water_mark.trim().is_empty()
865                {
866                    usable_cursors.push(cursor);
867                }
868            }
869        }
870        let large = max_rows.is_some_and(|rows| rows >= LARGE_DATA_VOLUME_ROWS)
871            || max_bytes.is_some_and(|bytes| bytes >= LARGE_DATA_VOLUME_BYTES);
872        if large {
873            push_finding(
874                findings,
875                CompatibilityCategory::NeedsAttention,
876                ExtractionReadinessIssueCode::DataVolumeLarge,
877                format!("service_data.table.{table}.volume"),
878                format!(
879                    "Large data volume for `{table}` informs backfill and bounded-write-pause risk ({}; large means at least {LARGE_DATA_VOLUME_ROWS} rows or {LARGE_DATA_VOLUME_BYTES} bytes).",
880                    data_volume_label(max_rows, max_bytes),
881                ),
882                evidence_references_or_set(
883                    &volume_references,
884                    &format!("extraction-evidence:table/{table}/volume"),
885                ),
886                vec!["Size resumable backfill batches, reconciliation, and the bounded write pause from this evidence; volume alone does not block readiness.".to_owned()],
887            );
888        }
889        usable_cursors.sort();
890        if let Some(cursor) = usable_cursors.first() {
891            push_finding(
892                findings,
893                CompatibilityCategory::Safe,
894                ExtractionReadinessIssueCode::ExtractionCursorUsable,
895                format!("service_data.table.{table}.cursor"),
896                format!(
897                    "Table `{table}` has trustworthy extraction cursor `{}` at high-water mark `{}`.",
898                    cursor.column, cursor.high_water_mark
899                ),
900                evidence_references_or_set(
901                    &cursor_references,
902                    &format!("extraction-evidence:table/{table}/cursor"),
903                ),
904                vec!["Pin this cursor and high-water mark in the Extraction Plan.".to_owned()],
905            );
906        } else {
907            push_finding(
908                findings,
909                CompatibilityCategory::NeedsAttention,
910                ExtractionReadinessIssueCode::ExtractionCursorMissing,
911                format!("service_data.table.{table}.cursor"),
912                format!(
913                    "Table `{table}` has no trustworthy extraction cursor or high-water mark; a full copy must occur during the bounded write pause."
914                ),
915                evidence_references_or_set(
916                    &cursor_references,
917                    &format!("extraction-evidence:table/{table}/cursor"),
918                ),
919                vec!["Plan a full table copy and reconciliation while Module writes are paused, or supply a trustworthy cursor.".to_owned()],
920            );
921        }
922    }
923}
924
925fn data_volume_label(rows: Option<u64>, bytes: Option<u64>) -> String {
926    let mut values = Vec::new();
927    if let Some(rows) = rows {
928        values.push(format!("approximately {rows} rows"));
929    }
930    if let Some(bytes) = bytes {
931        values.push(format!("approximately {bytes} bytes"));
932    }
933    values.join(", ")
934}
935
936fn evidence_references_or(references: &[String], fallback: &str) -> Vec<String> {
937    if references.is_empty() {
938        vec![fallback.to_owned()]
939    } else {
940        references.to_vec()
941    }
942}
943
944fn evidence_references_or_set(references: &BTreeSet<String>, fallback: &str) -> Vec<String> {
945    if references.is_empty() {
946        vec![fallback.to_owned()]
947    } else {
948        references.iter().cloned().collect()
949    }
950}
951
952fn normalize_strings(values: &mut Vec<String>) {
953    values.retain(|value| !value.trim().is_empty());
954    values.sort();
955    values.dedup();
956}
957
958fn collect_target_owner(
959    module: &ModuleManifest,
960    graph: &SystemV2Graph,
961    findings: &mut Vec<ExtractionReadinessFinding>,
962) -> Option<String> {
963    debug_assert_eq!(graph.semantic_kind, ContractSemanticKind::MixedSystem);
964    let module_nodes = graph
965        .nodes
966        .iter()
967        .filter(|node| node.kind == "module" && node.id == module.module_id)
968        .collect::<Vec<_>>();
969    let Some(module_node) = module_nodes.first() else {
970        push_finding(
971            findings,
972            CompatibilityCategory::Blocked,
973            ExtractionReadinessIssueCode::TargetModuleMissing,
974            format!("system.module.{}", module.module_id),
975            format!(
976                "Target Module `{}` is not declared in the System graph.",
977                module.module_id
978            ),
979            vec!["system:modules".to_owned(), "module:manifest".to_owned()],
980            vec![
981                "Declare the linked Module under the System Host before analyzing extraction."
982                    .to_owned(),
983            ],
984        );
985        return None;
986    };
987    let Some(owner) = module_node.owner.as_deref() else {
988        push_finding(
989            findings,
990            CompatibilityCategory::Blocked,
991            ExtractionReadinessIssueCode::TargetModuleMissing,
992            format!("system.module.{}", module.module_id),
993            "Target Module ownership is missing from the System graph.",
994            vec![format!("system:module/{}", module.module_id)],
995            vec!["Declare exactly one Host owner for the linked Module.".to_owned()],
996        );
997        return None;
998    };
999    let owner_kind = graph
1000        .nodes
1001        .iter()
1002        .find(|node| node.id == owner && node.owner.is_none())
1003        .map(|node| node.kind.as_str());
1004    if owner_kind != Some("host") {
1005        push_finding(
1006            findings,
1007            CompatibilityCategory::Blocked,
1008            ExtractionReadinessIssueCode::TargetModuleNotLinked,
1009            format!("system.module.{}", module.module_id),
1010            format!(
1011                "Target Module `{}` is owned by `{owner}` as {}, not by the linked Host.",
1012                module.module_id,
1013                owner_kind.unwrap_or("an unknown topology kind")
1014            ),
1015            vec![format!("system:module/{}", module.module_id)],
1016            vec!["Select a Host-owned linked Module; Provider and Autonomous Service semantics are unchanged by extraction analysis.".to_owned()],
1017        );
1018    }
1019    Some(owner.to_owned())
1020}
1021
1022fn collect_manifest_findings(
1023    module: &ModuleManifest,
1024    findings: &mut Vec<ExtractionReadinessFinding>,
1025) {
1026    for lint in lint_module_manifest(module) {
1027        let (classification, code) = match lint.severity {
1028            ModuleManifestLintSeverity::Ok => continue,
1029            ModuleManifestLintSeverity::Warning => (
1030                CompatibilityCategory::NeedsAttention,
1031                ExtractionReadinessIssueCode::ManifestNeedsAttention,
1032            ),
1033            ModuleManifestLintSeverity::Error => (
1034                CompatibilityCategory::Blocked,
1035                ExtractionReadinessIssueCode::ManifestInvalid,
1036            ),
1037        };
1038        push_finding(
1039            findings,
1040            classification,
1041            code,
1042            lint.subject.clone(),
1043            lint.message,
1044            vec![format!("module:manifest/{}", lint.subject)],
1045            vec![lint.suggestion],
1046        );
1047    }
1048}
1049
1050fn collect_boundary_findings(
1051    module: &ModuleManifest,
1052    boundary: Option<&ExtractionBoundaryEvidence>,
1053    findings: &mut Vec<ExtractionReadinessFinding>,
1054) {
1055    let Some(boundary) = boundary else {
1056        push_finding(
1057            findings,
1058            CompatibilityCategory::Blocked,
1059            ExtractionReadinessIssueCode::BoundaryEvidenceMissing,
1060            "boundary.analysis",
1061            "No source-boundary analysis evidence was supplied.",
1062            vec!["analyzer:boundary".to_owned()],
1063            vec!["Run a supported source analyzer for the target Module and supply its complete evidence.".to_owned()],
1064        );
1065        return;
1066    };
1067    if !boundary.complete {
1068        push_finding(
1069            findings,
1070            CompatibilityCategory::Blocked,
1071            ExtractionReadinessIssueCode::BoundaryEvidenceIncomplete,
1072            "boundary.analysis",
1073            "Source-boundary analysis did not complete.",
1074            non_empty_references(&boundary.evidence_references, "analyzer:boundary"),
1075            vec!["Resolve analyzer errors and rerun the complete target-Module scan.".to_owned()],
1076        );
1077    }
1078    for reference in &boundary.references {
1079        if reference.evidence_reference.trim().is_empty() {
1080            push_finding(
1081                findings,
1082                CompatibilityCategory::Blocked,
1083                ExtractionReadinessIssueCode::BoundaryEvidenceAmbiguous,
1084                reference.symbol.clone(),
1085                "A source-boundary finding has no verifiable evidence reference.",
1086                vec!["analyzer:boundary".to_owned()],
1087                vec![
1088                    "Attach a repository-relative file and symbol reference to this finding."
1089                        .to_owned(),
1090                ],
1091            );
1092        }
1093        if reference.from_module != module.module_id && reference.to_module != module.module_id {
1094            push_finding(
1095                findings,
1096                CompatibilityCategory::Blocked,
1097                ExtractionReadinessIssueCode::BoundaryEvidenceTargetMismatch,
1098                reference.symbol.clone(),
1099                format!(
1100                    "Boundary evidence for `{}` does not involve target Module `{}`.",
1101                    reference.symbol, module.module_id
1102                ),
1103                non_empty_references(
1104                    std::slice::from_ref(&reference.evidence_reference),
1105                    "analyzer:boundary",
1106                ),
1107                vec![
1108                    "Regenerate boundary evidence for exactly the requested target Module."
1109                        .to_owned(),
1110                ],
1111            );
1112            continue;
1113        }
1114        let (code, message, action) = match reference.kind {
1115            ExtractionBoundaryReferenceKind::CrossModuleImport => (
1116                ExtractionReadinessIssueCode::CrossModuleImport,
1117                format!(
1118                    "Cross-Module import `{}` couples `{}` to `{}` in-process.",
1119                    reference.symbol, reference.from_module, reference.to_module
1120                ),
1121                "Remove the import and preserve the interaction through an approved Service or Event Contract.",
1122            ),
1123            ExtractionBoundaryReferenceKind::InProcessBoundaryCall => (
1124                ExtractionReadinessIssueCode::InProcessBoundaryCall,
1125                format!(
1126                    "In-process boundary call `{}` crosses from `{}` to `{}`.",
1127                    reference.symbol, reference.from_module, reference.to_module
1128                ),
1129                "Replace the boundary call with an approved Service or Event Contract before extraction.",
1130            ),
1131        };
1132        push_finding(
1133            findings,
1134            CompatibilityCategory::Blocked,
1135            code,
1136            reference.symbol.clone(),
1137            message,
1138            non_empty_references(
1139                std::slice::from_ref(&reference.evidence_reference),
1140                "analyzer:boundary",
1141            ),
1142            vec![action.to_owned()],
1143        );
1144    }
1145    if boundary.complete && boundary.references.is_empty() {
1146        push_finding(
1147            findings,
1148            CompatibilityCategory::Safe,
1149            ExtractionReadinessIssueCode::BoundaryClean,
1150            "boundary.analysis",
1151            "No cross-Module imports or in-process boundary calls were detected.",
1152            non_empty_references(&boundary.evidence_references, "analyzer:boundary"),
1153            vec!["No boundary remediation is required.".to_owned()],
1154        );
1155    }
1156}
1157
1158fn collect_contract_findings(
1159    module: &ModuleManifest,
1160    evidence: Option<&[ExtractionContractEvidence]>,
1161    findings: &mut Vec<ExtractionReadinessFinding>,
1162) -> BTreeSet<String> {
1163    let required = required_contract_subjects(module);
1164    let Some(evidence) = evidence else {
1165        push_finding(
1166            findings,
1167            CompatibilityCategory::Blocked,
1168            ExtractionReadinessIssueCode::ContractEvidenceMissing,
1169            "contracts.analysis",
1170            "No Service or Event Contract evidence was supplied.",
1171            vec!["analyzer:contracts".to_owned()],
1172            vec!["Resolve required Module surfaces to versioned contract artifacts and rerun readiness analysis.".to_owned()],
1173        );
1174        for subject in required {
1175            push_missing_contract_finding(
1176                findings,
1177                &subject,
1178                "Contract evidence is missing for this declared Module surface.",
1179            );
1180        }
1181        return BTreeSet::new();
1182    };
1183
1184    let mut present_contract_ids = BTreeSet::new();
1185    let by_subject = evidence.iter().fold(
1186        BTreeMap::<&str, Vec<&ExtractionContractEvidence>>::new(),
1187        |mut grouped, item| {
1188            grouped.entry(item.subject.as_str()).or_default().push(item);
1189            grouped
1190        },
1191    );
1192    let mut complete = true;
1193    for subject in required {
1194        let matches = by_subject
1195            .get(subject.subject.as_str())
1196            .map(Vec::as_slice)
1197            .unwrap_or_default();
1198        let [item] = matches else {
1199            complete = false;
1200            if matches.is_empty() {
1201                push_missing_contract_finding(
1202                    findings,
1203                    &subject,
1204                    "No contract evidence covers this declared Module surface.",
1205                );
1206            } else {
1207                push_finding(
1208                    findings,
1209                    CompatibilityCategory::Blocked,
1210                    ExtractionReadinessIssueCode::ContractEvidenceAmbiguous,
1211                    subject.subject.clone(),
1212                    "More than one contract-evidence entry covers this Module surface.",
1213                    vec![format!("module:{}", subject.subject)],
1214                    vec!["Provide exactly one authoritative contract-evidence entry for this surface.".to_owned()],
1215                );
1216            }
1217            continue;
1218        };
1219        if item.kind != subject.kind || item.direction != subject.direction {
1220            complete = false;
1221            push_finding(
1222                findings,
1223                CompatibilityCategory::Blocked,
1224                ExtractionReadinessIssueCode::ContractEvidenceAmbiguous,
1225                subject.subject.clone(),
1226                "Contract kind or direction does not match the declared Module surface.",
1227                non_empty_references(
1228                    &item.evidence_references,
1229                    &format!("module:{}", subject.subject),
1230                ),
1231                vec![
1232                    "Regenerate contract evidence with the required kind and direction.".to_owned(),
1233                ],
1234            );
1235            continue;
1236        }
1237        match item.status {
1238            ExtractionEvidenceStatus::Missing => {
1239                complete = false;
1240                push_missing_contract_finding(
1241                    findings,
1242                    &subject,
1243                    "The required versioned contract artifact is missing.",
1244                );
1245            }
1246            ExtractionEvidenceStatus::Ambiguous => {
1247                complete = false;
1248                push_finding(
1249                    findings,
1250                    CompatibilityCategory::Blocked,
1251                    ExtractionReadinessIssueCode::ContractEvidenceAmbiguous,
1252                    subject.subject.clone(),
1253                    "Required contract evidence is ambiguous.",
1254                    non_empty_references(&item.evidence_references, &format!("module:{}", subject.subject)),
1255                    vec!["Select one authoritative versioned contract artifact and rerun readiness analysis.".to_owned()],
1256                );
1257            }
1258            ExtractionEvidenceStatus::Present => {
1259                let contract_id = item
1260                    .contract_id
1261                    .as_deref()
1262                    .filter(|id| !id.trim().is_empty());
1263                if contract_id.is_none() || item.evidence_references.is_empty() {
1264                    complete = false;
1265                    push_finding(
1266                        findings,
1267                        CompatibilityCategory::Blocked,
1268                        ExtractionReadinessIssueCode::ContractEvidenceAmbiguous,
1269                        subject.subject.clone(),
1270                        "Present contract evidence must name a contract and its artifact reference.",
1271                        non_empty_references(
1272                            &item.evidence_references,
1273                            &format!("module:{}", subject.subject),
1274                        ),
1275                        vec![
1276                            "Attach the stable contract identity and authoritative artifact path."
1277                                .to_owned(),
1278                        ],
1279                    );
1280                    continue;
1281                }
1282                let contract_id = contract_id.expect("checked above");
1283                if subject
1284                    .expected_contract_id
1285                    .is_some_and(|expected| expected != contract_id)
1286                {
1287                    complete = false;
1288                    push_finding(
1289                        findings,
1290                        CompatibilityCategory::Blocked,
1291                        ExtractionReadinessIssueCode::ContractIdentityMismatch,
1292                        subject.subject.clone(),
1293                        format!(
1294                            "Contract `{contract_id}` does not match declared Event `{}`.",
1295                            subject.expected_contract_id.unwrap_or_default()
1296                        ),
1297                        item.evidence_references.clone(),
1298                        vec!["Use the Event Contract whose identity matches the manifest event declaration.".to_owned()],
1299                    );
1300                } else {
1301                    present_contract_ids.insert(contract_id.to_owned());
1302                }
1303            }
1304        }
1305    }
1306    if complete {
1307        let references = evidence
1308            .iter()
1309            .flat_map(|item| item.evidence_references.iter().cloned())
1310            .collect::<Vec<_>>();
1311        push_finding(
1312            findings,
1313            CompatibilityCategory::Safe,
1314            ExtractionReadinessIssueCode::ContractsComplete,
1315            "contracts.analysis",
1316            "Every declared HTTP and Event surface has authoritative contract evidence.",
1317            non_empty_references(&references, "analyzer:contracts"),
1318            vec!["Preserve these contract identities during extraction.".to_owned()],
1319        );
1320    }
1321    present_contract_ids
1322}
1323
1324fn collect_consumer_findings(
1325    graph: Option<&SystemV2Graph>,
1326    contract_ids: &BTreeSet<String>,
1327    evidence: Option<&[ExtractionConsumerCompatibilityEvidence]>,
1328    findings: &mut Vec<ExtractionReadinessFinding>,
1329) {
1330    let Some(evidence) = evidence else {
1331        push_finding(
1332            findings,
1333            CompatibilityCategory::Blocked,
1334            ExtractionReadinessIssueCode::ActiveConsumerCompatibilityMissing,
1335            "consumers.analysis",
1336            "No active Consumer compatibility evidence was supplied.",
1337            vec!["analyzer:consumers".to_owned()],
1338            vec!["Resolve active Consumers from the System graph and evaluate each pinned Contract Version.".to_owned()],
1339        );
1340        return;
1341    };
1342    let mut grouped =
1343        BTreeMap::<(&str, &str), Vec<&ExtractionConsumerCompatibilityEvidence>>::new();
1344    for item in evidence {
1345        grouped
1346            .entry((item.consumer_id.as_str(), item.contract_id.as_str()))
1347            .or_default()
1348            .push(item);
1349    }
1350    let mut complete = true;
1351    for ((consumer_id, contract_id), items) in &grouped {
1352        let [item] = items.as_slice() else {
1353            complete = false;
1354            push_finding(
1355                findings,
1356                CompatibilityCategory::Blocked,
1357                ExtractionReadinessIssueCode::ActiveConsumerEvidenceAmbiguous,
1358                format!("consumer:{consumer_id}:{contract_id}"),
1359                "Active Consumer compatibility evidence is duplicated.",
1360                vec!["analyzer:consumers".to_owned()],
1361                vec![
1362                    "Provide one compatibility result per active Consumer and Contract Version."
1363                        .to_owned(),
1364                ],
1365            );
1366            continue;
1367        };
1368        if item.evidence_references.is_empty() {
1369            complete = false;
1370            push_finding(
1371                findings,
1372                CompatibilityCategory::Blocked,
1373                ExtractionReadinessIssueCode::ActiveConsumerEvidenceAmbiguous,
1374                format!("consumer:{consumer_id}:{contract_id}"),
1375                "Active Consumer compatibility result has no evidence reference.",
1376                vec!["analyzer:consumers".to_owned()],
1377                vec!["Attach the System relationship and compatibility result used for this Consumer.".to_owned()],
1378            );
1379            continue;
1380        }
1381        let (code, message) = match item.classification {
1382            CompatibilityCategory::Safe => continue,
1383            CompatibilityCategory::NeedsAttention => (
1384                ExtractionReadinessIssueCode::ActiveConsumerNeedsAttention,
1385                "Active Consumer compatibility needs review before Cutover.",
1386            ),
1387            CompatibilityCategory::Breaking => {
1388                complete = false;
1389                (
1390                    ExtractionReadinessIssueCode::ActiveConsumerBreaking,
1391                    "Active Consumer is incompatible with the required Contract Version.",
1392                )
1393            }
1394            CompatibilityCategory::Blocked => {
1395                complete = false;
1396                (
1397                    ExtractionReadinessIssueCode::ActiveConsumerBlocked,
1398                    "Active Consumer compatibility could not be verified.",
1399                )
1400            }
1401        };
1402        push_finding(
1403            findings,
1404            item.classification,
1405            code,
1406            format!("consumer:{consumer_id}:{contract_id}"),
1407            message,
1408            item.evidence_references.clone(),
1409            vec![non_empty_action(&item.next_action)],
1410        );
1411    }
1412
1413    if let Some(graph) = graph {
1414        for relationship in relevant_consumer_relationships(graph, contract_ids) {
1415            let consumer_id = relationship.from.trim_start_matches("consumer:");
1416            let contract_id = relationship
1417                .contract_id
1418                .as_deref()
1419                .and_then(|contract| contract.split('@').next())
1420                .unwrap_or_default();
1421            if !grouped.contains_key(&(consumer_id, contract_id)) {
1422                complete = false;
1423                push_finding(
1424                    findings,
1425                    CompatibilityCategory::Blocked,
1426                    ExtractionReadinessIssueCode::ActiveConsumerCompatibilityMissing,
1427                    format!("consumer:{consumer_id}:{contract_id}"),
1428                    "System graph declares an active Consumer without compatibility evidence.",
1429                    vec![format!("system:consumer/{consumer_id}")],
1430                    vec!["Evaluate this Consumer against the pinned Contract Version and attach the result.".to_owned()],
1431                );
1432            }
1433        }
1434    }
1435
1436    if complete {
1437        let references = evidence
1438            .iter()
1439            .flat_map(|item| item.evidence_references.iter().cloned())
1440            .collect::<Vec<_>>();
1441        push_finding(
1442            findings,
1443            CompatibilityCategory::Safe,
1444            ExtractionReadinessIssueCode::ConsumersCompatible,
1445            "consumers.analysis",
1446            "All supplied active Consumer compatibility results are safe or reviewable.",
1447            non_empty_references(&references, "analyzer:consumers"),
1448            vec!["Pin these Consumer results in the Extraction Plan.".to_owned()],
1449        );
1450    }
1451}
1452
1453fn relevant_consumer_relationships<'a>(
1454    graph: &'a SystemV2Graph,
1455    contract_ids: &BTreeSet<String>,
1456) -> Vec<&'a SystemV2GraphRelationship> {
1457    graph
1458        .relationships
1459        .iter()
1460        .filter(|relationship| {
1461            relationship.kind == "consumes"
1462                && relationship
1463                    .contract_id
1464                    .as_deref()
1465                    .and_then(|contract| contract.split('@').next())
1466                    .is_some_and(|contract| contract_ids.contains(contract))
1467        })
1468        .collect()
1469}
1470
1471fn required_contract_subjects(module: &ModuleManifest) -> Vec<RequiredContractSubject<'_>> {
1472    let mut subjects = module
1473        .http_routes
1474        .iter()
1475        .map(|route| RequiredContractSubject {
1476            subject: format!("http:{} {}", http_method_label(route.method), route.path),
1477            kind: ExtractionContractKind::Service,
1478            direction: ExtractionContractDirection::Provides,
1479            expected_contract_id: None,
1480        })
1481        .collect::<Vec<_>>();
1482    if let Some(events) = &module.events {
1483        subjects.extend(
1484            events
1485                .handlers
1486                .iter()
1487                .map(|handler| RequiredContractSubject {
1488                    subject: format!("event-handler:{}", handler.name),
1489                    kind: ExtractionContractKind::Event,
1490                    direction: ExtractionContractDirection::Consumes,
1491                    expected_contract_id: Some(handler.event_name.as_str()),
1492                }),
1493        );
1494    }
1495    subjects.sort_by(|left, right| left.subject.cmp(&right.subject));
1496    subjects
1497}
1498
1499fn push_missing_contract_finding(
1500    findings: &mut Vec<ExtractionReadinessFinding>,
1501    subject: &RequiredContractSubject<'_>,
1502    message: &str,
1503) {
1504    let (code, label) = match subject.kind {
1505        ExtractionContractKind::Service => (
1506            ExtractionReadinessIssueCode::RequiredServiceContractMissing,
1507            "Service",
1508        ),
1509        ExtractionContractKind::Event => (
1510            ExtractionReadinessIssueCode::RequiredEventContractMissing,
1511            "Event",
1512        ),
1513    };
1514    push_finding(
1515        findings,
1516        CompatibilityCategory::Blocked,
1517        code,
1518        subject.subject.clone(),
1519        format!("{message} Required kind: {label} Contract."),
1520        vec![format!("module:{}", subject.subject)],
1521        vec![format!(
1522            "Publish and reference an authoritative versioned {label} Contract for this surface."
1523        )],
1524    );
1525}
1526
1527fn surface_summary(module: &ModuleManifest) -> ExtractionReadinessSurfaceSummary {
1528    let mut summary = ExtractionReadinessSurfaceSummary {
1529        http_routes: module
1530            .http_routes
1531            .iter()
1532            .map(|route| format!("{} {}", http_method_label(route.method), route.path))
1533            .collect(),
1534        event_handlers: module
1535            .events
1536            .iter()
1537            .flat_map(|events| events.handlers.iter())
1538            .map(|handler| format!("{} <- {}", handler.name, handler.event_name))
1539            .collect(),
1540        runtime_functions: module
1541            .runtime
1542            .iter()
1543            .flat_map(|runtime| runtime.functions.iter())
1544            .map(|function| format!("{}@v{}", function.name, function.version))
1545            .collect(),
1546        schedules: module
1547            .runtime
1548            .iter()
1549            .flat_map(|runtime| runtime.schedules.iter())
1550            .map(|schedule| format!("{} -> {}", schedule.name, schedule.function_name))
1551            .collect(),
1552        workflows: module
1553            .runtime
1554            .iter()
1555            .flat_map(|runtime| runtime.workflows.iter())
1556            .map(|workflow| format!("{}@{}", workflow.name, workflow.version))
1557            .collect(),
1558        admin: module
1559            .admin
1560            .iter()
1561            .map(|admin| match admin {
1562                AdminSurface::Schema(_) => "schema".to_owned(),
1563                AdminSurface::DeclarativeCustom(_) => "declarative_custom".to_owned(),
1564                AdminSurface::EmbeddedCustom(_) => "embedded_custom".to_owned(),
1565                _ => "unknown".to_owned(),
1566            })
1567            .collect(),
1568        console: module
1569            .console
1570            .iter()
1571            .map(|surface| format!("surface:{}@{}", surface.name, surface.route))
1572            .chain(
1573                module
1574                    .console_slots
1575                    .iter()
1576                    .map(|slot| format!("slot:{}@v{}", slot.id, slot.version)),
1577            )
1578            .chain(module.console_contributions.iter().map(|contribution| {
1579                format!(
1580                    "contribution:{}@v{}",
1581                    contribution.target, contribution.target_version
1582                )
1583            }))
1584            .collect(),
1585        stories: module
1586            .story_display
1587            .iter()
1588            .map(|story| match &story.source {
1589                StoryDisplaySource::ExecutionName { name } => {
1590                    format!("execution:{name} -> {}", story.display_name)
1591                }
1592                StoryDisplaySource::HttpRequest { method, path } => {
1593                    format!("http:{method} {path} -> {}", story.display_name)
1594                }
1595            })
1596            .collect(),
1597    };
1598    for values in [
1599        &mut summary.http_routes,
1600        &mut summary.event_handlers,
1601        &mut summary.runtime_functions,
1602        &mut summary.schedules,
1603        &mut summary.workflows,
1604        &mut summary.admin,
1605        &mut summary.console,
1606        &mut summary.stories,
1607    ] {
1608        values.sort();
1609        values.dedup();
1610    }
1611    summary
1612}
1613
1614fn collect_surface_findings(
1615    surfaces: &ExtractionReadinessSurfaceSummary,
1616    findings: &mut Vec<ExtractionReadinessFinding>,
1617) {
1618    let runtime = surfaces
1619        .runtime_functions
1620        .iter()
1621        .chain(&surfaces.schedules)
1622        .cloned()
1623        .collect::<Vec<_>>();
1624    push_surface_finding(
1625        findings,
1626        ExtractionReadinessIssueCode::RuntimeSurfacePresent,
1627        "runtime",
1628        &runtime,
1629        "Runtime functions and schedules must retain their identities and execution ownership.",
1630        "Carry these runtime declarations into the candidate Service and extraction drain plan.",
1631    );
1632    push_surface_finding(
1633        findings,
1634        ExtractionReadinessIssueCode::WorkflowSurfacePresent,
1635        "workflows",
1636        &surfaces.workflows,
1637        "Durable Workflow declarations require explicit ownership and in-flight-instance handling.",
1638        "Preserve pinned Workflow Definitions and plan how active instances drain before Cutover.",
1639    );
1640    push_surface_finding(
1641        findings,
1642        ExtractionReadinessIssueCode::AdminSurfacePresent,
1643        "admin",
1644        &surfaces.admin,
1645        "Admin declarations are part of the Module's operator-facing identity.",
1646        "Preserve admin declarations and authorization requirements in the candidate Service.",
1647    );
1648    push_surface_finding(
1649        findings,
1650        ExtractionReadinessIssueCode::ConsoleSurfacePresent,
1651        "console",
1652        &surfaces.console,
1653        "Console declarations are part of the Module's operator-facing identity.",
1654        "Preserve Console routes, packages, slots, and contributions during extraction.",
1655    );
1656    push_surface_finding(
1657        findings,
1658        ExtractionReadinessIssueCode::StorySurfacePresent,
1659        "stories",
1660        &surfaces.stories,
1661        "Runtime Story display declarations must remain stable across extraction.",
1662        "Preserve Story display names and titles and include them in behavior comparison.",
1663    );
1664}
1665
1666fn push_surface_finding(
1667    findings: &mut Vec<ExtractionReadinessFinding>,
1668    code: ExtractionReadinessIssueCode,
1669    subject: &str,
1670    surfaces: &[String],
1671    message: &str,
1672    action: &str,
1673) {
1674    if surfaces.is_empty() {
1675        return;
1676    }
1677    push_finding(
1678        findings,
1679        CompatibilityCategory::NeedsAttention,
1680        code,
1681        format!("module.{subject}"),
1682        message,
1683        surfaces
1684            .iter()
1685            .map(|surface| format!("module:{subject}/{surface}"))
1686            .collect(),
1687        vec![action.to_owned()],
1688    );
1689}
1690
1691fn push_finding(
1692    findings: &mut Vec<ExtractionReadinessFinding>,
1693    classification: CompatibilityCategory,
1694    code: ExtractionReadinessIssueCode,
1695    subject: impl Into<String>,
1696    message: impl Into<String>,
1697    evidence_references: Vec<String>,
1698    next_actions: Vec<String>,
1699) {
1700    findings.push(ExtractionReadinessFinding {
1701        classification,
1702        code,
1703        subject: subject.into(),
1704        message: message.into(),
1705        evidence_references,
1706        next_actions,
1707    });
1708}
1709
1710fn normalize_findings(findings: &mut Vec<ExtractionReadinessFinding>) {
1711    for finding in findings.iter_mut() {
1712        finding
1713            .evidence_references
1714            .retain(|reference| !reference.trim().is_empty());
1715        finding.evidence_references.sort();
1716        finding.evidence_references.dedup();
1717        finding
1718            .next_actions
1719            .retain(|action| !action.trim().is_empty());
1720        finding.next_actions.sort();
1721        finding.next_actions.dedup();
1722    }
1723    findings.sort_by(|left, right| {
1724        classification_rank(right.classification)
1725            .cmp(&classification_rank(left.classification))
1726            .then_with(|| left.code.cmp(&right.code))
1727            .then_with(|| left.subject.cmp(&right.subject))
1728            .then_with(|| left.message.cmp(&right.message))
1729    });
1730    findings.dedup();
1731}
1732
1733const fn classification_rank(classification: CompatibilityCategory) -> u8 {
1734    match classification {
1735        CompatibilityCategory::Safe => 0,
1736        CompatibilityCategory::NeedsAttention => 1,
1737        CompatibilityCategory::Breaking => 2,
1738        CompatibilityCategory::Blocked => 3,
1739    }
1740}
1741
1742fn non_empty_references(references: &[String], fallback: &str) -> Vec<String> {
1743    let mut references = references
1744        .iter()
1745        .filter(|reference| !reference.trim().is_empty())
1746        .cloned()
1747        .collect::<Vec<_>>();
1748    if references.is_empty() {
1749        references.push(fallback.to_owned());
1750    }
1751    references
1752}
1753
1754fn non_empty_action(action: &str) -> String {
1755    if action.trim().is_empty() {
1756        "Resolve this Consumer compatibility finding before extraction.".to_owned()
1757    } else {
1758        action.to_owned()
1759    }
1760}
1761
1762const fn http_method_label(method: ModuleHttpMethod) -> &'static str {
1763    match method {
1764        ModuleHttpMethod::Get => "GET",
1765        ModuleHttpMethod::Post => "POST",
1766        ModuleHttpMethod::Put => "PUT",
1767        ModuleHttpMethod::Patch => "PATCH",
1768        ModuleHttpMethod::Delete => "DELETE",
1769        _ => "OTHER",
1770    }
1771}
1772
1773#[must_use]
1774pub fn render_extraction_readiness_report(report: &ExtractionReadinessReport) -> String {
1775    let mut output = vec![
1776        format!("Extraction readiness: {}", report.target_module),
1777        format!(
1778            "Result: {} ({})",
1779            classification_label(report.classification),
1780            if report.ready { "ready" } else { "not ready" }
1781        ),
1782        format!(
1783            "System: {}",
1784            report.system_id.as_deref().unwrap_or("unknown")
1785        ),
1786        format!(
1787            "Linked owner: {}",
1788            report.target_owner.as_deref().unwrap_or("unknown")
1789        ),
1790        "Effects: read-only; writesRepositoryFiles=false; startsWorkloads=false; movesData=false; changesAuthority=false".to_owned(),
1791    ];
1792    output.extend(render_service_data(&report.service_data));
1793    output.push("Findings:".to_owned());
1794    for finding in &report.findings {
1795        output.push(format!(
1796            "- [{}] {} {}: {}",
1797            classification_label(finding.classification),
1798            issue_code_label(finding.code),
1799            finding.subject,
1800            finding.message
1801        ));
1802        for reference in &finding.evidence_references {
1803            output.push(format!("  evidence: {reference}"));
1804        }
1805        for action in &finding.next_actions {
1806            output.push(format!("  next: {action}"));
1807        }
1808    }
1809    output.push("Declared surfaces:".to_owned());
1810    for (label, values) in [
1811        ("http", &report.surfaces.http_routes),
1812        ("events", &report.surfaces.event_handlers),
1813        ("runtime", &report.surfaces.runtime_functions),
1814        ("schedules", &report.surfaces.schedules),
1815        ("workflows", &report.surfaces.workflows),
1816        ("admin", &report.surfaces.admin),
1817        ("console", &report.surfaces.console),
1818        ("stories", &report.surfaces.stories),
1819    ] {
1820        output.push(format!(
1821            "- {label}: {}",
1822            if values.is_empty() {
1823                "none".to_owned()
1824            } else {
1825                values.join(", ")
1826            }
1827        ));
1828    }
1829    output.push(String::new());
1830    output.join("\n")
1831}
1832
1833fn render_service_data(data: &ExtractionServiceDataEvidence) -> Vec<String> {
1834    let mut output = vec![
1835        "Service data:".to_owned(),
1836        format!(
1837            "- evidence: {}",
1838            if data.complete {
1839                "complete"
1840            } else {
1841                "missing_or_incomplete"
1842            }
1843        ),
1844    ];
1845    if data.tables.is_empty() {
1846        output.push("- tables: none".to_owned());
1847    } else {
1848        for table in &data.tables {
1849            output.push(format!(
1850                "- table {}: owner={}; source={}; volume={}; cursor={}",
1851                table.table,
1852                table.owner_module.as_deref().unwrap_or("unresolved"),
1853                data_source_label(&table.source),
1854                table
1855                    .volume
1856                    .as_ref()
1857                    .map(|volume| {
1858                        data_volume_label(volume.approximate_rows, volume.approximate_bytes)
1859                    })
1860                    .filter(|label| !label.is_empty())
1861                    .unwrap_or_else(|| "unknown".to_owned()),
1862                table.cursor.as_ref().map_or_else(
1863                    || "none".to_owned(),
1864                    |cursor| {
1865                        format!(
1866                            "{}@{} ({})",
1867                            cursor.column,
1868                            cursor.high_water_mark,
1869                            if cursor.trustworthy {
1870                                "trustworthy"
1871                            } else {
1872                                "untrusted"
1873                            }
1874                        )
1875                    },
1876                )
1877            ));
1878        }
1879    }
1880    if data.migrations.is_empty() {
1881        output.push("- migrations: none".to_owned());
1882    } else {
1883        for migration in &data.migrations {
1884            output.push(format!(
1885                "- migration {}: owner={}; source={}",
1886                migration.migration,
1887                migration.owner_module.as_deref().unwrap_or("unresolved"),
1888                data_source_label(&migration.source)
1889            ));
1890        }
1891    }
1892    if data.access_paths.is_empty() {
1893        output.push("- access paths: none".to_owned());
1894    } else {
1895        for access in &data.access_paths {
1896            output.push(format!(
1897                "- access {} -> {}: {}; source={}",
1898                access.accessor_module,
1899                access.table,
1900                data_access_label(access.access),
1901                data_source_label(&access.source)
1902            ));
1903        }
1904    }
1905    if data.transactions.is_empty() {
1906        output.push("- transactions: none".to_owned());
1907    } else {
1908        for transaction in &data.transactions {
1909            output.push(format!(
1910                "- transaction {}: modules={}; source={}",
1911                transaction.transaction,
1912                if transaction.participating_modules.is_empty() {
1913                    "unresolved".to_owned()
1914                } else {
1915                    transaction.participating_modules.join(", ")
1916                },
1917                data_source_label(&transaction.source)
1918            ));
1919        }
1920    }
1921    output
1922}
1923
1924fn data_source_label(source: &ExtractionDataEvidenceSource) -> String {
1925    match source {
1926        ExtractionDataEvidenceSource::StaticDeclaration => "static_declaration".to_owned(),
1927        ExtractionDataEvidenceSource::LiveStoreObservation {
1928            observation_id,
1929            store,
1930            read_only,
1931        } => format!(
1932            "live_store_observation:{observation_id}@{store} ({})",
1933            if *read_only {
1934                "read_only"
1935            } else {
1936                "not_read_only"
1937            }
1938        ),
1939    }
1940}
1941
1942fn data_access_label(access: ExtractionDataAccessKind) -> &'static str {
1943    match access {
1944        ExtractionDataAccessKind::Read => "read",
1945        ExtractionDataAccessKind::Write => "write",
1946        ExtractionDataAccessKind::ReadWrite => "read_write",
1947    }
1948}
1949
1950pub fn extraction_readiness_report_json(
1951    report: &ExtractionReadinessReport,
1952) -> Result<String, serde_json::Error> {
1953    serde_json::to_string_pretty(report).map(|rendered| format!("{rendered}\n"))
1954}
1955
1956#[must_use]
1957pub fn extraction_readiness_report_schema() -> Value {
1958    let mut schema = serde_json::to_value(schemars::schema_for!(ExtractionReadinessReport))
1959        .expect("Extraction Readiness Report schema must serialize");
1960    let object = schema
1961        .as_object_mut()
1962        .expect("Extraction Readiness Report schema must be an object");
1963    object.insert(
1964        "$id".to_owned(),
1965        Value::String(EXTRACTION_READINESS_SCHEMA_ID.to_owned()),
1966    );
1967    object.insert(
1968        "title".to_owned(),
1969        Value::String("Lenso Extraction Readiness Report v1".to_owned()),
1970    );
1971    schema["properties"]["protocol"] = json!({
1972        "type": "string",
1973        "const": EXTRACTION_READINESS_REPORT_PROTOCOL
1974    });
1975    schema["properties"]["analyzerVersion"] = json!({
1976        "type": "string",
1977        "const": EXTRACTION_READINESS_ANALYZER_VERSION
1978    });
1979    schema["properties"]["issueCodes"]["uniqueItems"] = Value::Bool(true);
1980    for field in [
1981        "writesRepositoryFiles",
1982        "startsWorkloads",
1983        "movesData",
1984        "changesAuthority",
1985    ] {
1986        schema["$defs"]["ExtractionReadinessEffects"]["properties"][field] = json!({
1987            "type": "boolean",
1988            "const": false
1989        });
1990    }
1991    schema
1992}
1993
1994fn classification_label(classification: CompatibilityCategory) -> &'static str {
1995    match classification {
1996        CompatibilityCategory::Safe => "safe",
1997        CompatibilityCategory::NeedsAttention => "needs_attention",
1998        CompatibilityCategory::Breaking => "breaking",
1999        CompatibilityCategory::Blocked => "blocked",
2000    }
2001}
2002
2003fn issue_code_label(code: ExtractionReadinessIssueCode) -> &'static str {
2004    match code {
2005        ExtractionReadinessIssueCode::ActiveConsumerBlocked => "active_consumer_blocked",
2006        ExtractionReadinessIssueCode::ActiveConsumerBreaking => "active_consumer_breaking",
2007        ExtractionReadinessIssueCode::ActiveConsumerCompatibilityMissing => {
2008            "active_consumer_compatibility_missing"
2009        }
2010        ExtractionReadinessIssueCode::ActiveConsumerEvidenceAmbiguous => {
2011            "active_consumer_evidence_ambiguous"
2012        }
2013        ExtractionReadinessIssueCode::ActiveConsumerNeedsAttention => {
2014            "active_consumer_needs_attention"
2015        }
2016        ExtractionReadinessIssueCode::AdminSurfacePresent => "admin_surface_present",
2017        ExtractionReadinessIssueCode::BoundaryClean => "boundary_clean",
2018        ExtractionReadinessIssueCode::BoundaryEvidenceAmbiguous => "boundary_evidence_ambiguous",
2019        ExtractionReadinessIssueCode::BoundaryEvidenceIncomplete => "boundary_evidence_incomplete",
2020        ExtractionReadinessIssueCode::BoundaryEvidenceMissing => "boundary_evidence_missing",
2021        ExtractionReadinessIssueCode::BoundaryEvidenceTargetMismatch => {
2022            "boundary_evidence_target_mismatch"
2023        }
2024        ExtractionReadinessIssueCode::ConsoleSurfacePresent => "console_surface_present",
2025        ExtractionReadinessIssueCode::ConsumersCompatible => "consumers_compatible",
2026        ExtractionReadinessIssueCode::ContractEvidenceAmbiguous => "contract_evidence_ambiguous",
2027        ExtractionReadinessIssueCode::ContractEvidenceMissing => "contract_evidence_missing",
2028        ExtractionReadinessIssueCode::ContractIdentityMismatch => "contract_identity_mismatch",
2029        ExtractionReadinessIssueCode::ContractsComplete => "contracts_complete",
2030        ExtractionReadinessIssueCode::CrossModuleTableAccess => "cross_module_table_access",
2031        ExtractionReadinessIssueCode::CrossModuleImport => "cross_module_import",
2032        ExtractionReadinessIssueCode::DataVolumeLarge => "data_volume_large",
2033        ExtractionReadinessIssueCode::ExtractionCursorMissing => "extraction_cursor_missing",
2034        ExtractionReadinessIssueCode::ExtractionCursorUsable => "extraction_cursor_usable",
2035        ExtractionReadinessIssueCode::InProcessBoundaryCall => "in_process_boundary_call",
2036        ExtractionReadinessIssueCode::LiveStoreObservationNotReadOnly => {
2037            "live_store_observation_not_read_only"
2038        }
2039        ExtractionReadinessIssueCode::LiveStoreObservationPresent => {
2040            "live_store_observation_present"
2041        }
2042        ExtractionReadinessIssueCode::ManifestInvalid => "manifest_invalid",
2043        ExtractionReadinessIssueCode::ManifestNeedsAttention => "manifest_needs_attention",
2044        ExtractionReadinessIssueCode::MigrationOwnershipUnresolved => {
2045            "migration_ownership_unresolved"
2046        }
2047        ExtractionReadinessIssueCode::RequiredEventContractMissing => {
2048            "required_event_contract_missing"
2049        }
2050        ExtractionReadinessIssueCode::RequiredServiceContractMissing => {
2051            "required_service_contract_missing"
2052        }
2053        ExtractionReadinessIssueCode::RuntimeSurfacePresent => "runtime_surface_present",
2054        ExtractionReadinessIssueCode::ServiceDataEvidenceIncomplete => {
2055            "service_data_evidence_incomplete"
2056        }
2057        ExtractionReadinessIssueCode::ServiceDataEvidenceMissing => "service_data_evidence_missing",
2058        ExtractionReadinessIssueCode::ServiceDataReady => "service_data_ready",
2059        ExtractionReadinessIssueCode::StorySurfacePresent => "story_surface_present",
2060        ExtractionReadinessIssueCode::SystemEvidenceInvalid => "system_evidence_invalid",
2061        ExtractionReadinessIssueCode::TableOwnershipUnresolved => "table_ownership_unresolved",
2062        ExtractionReadinessIssueCode::TargetModuleMissing => "target_module_missing",
2063        ExtractionReadinessIssueCode::TargetModuleNotLinked => "target_module_not_linked",
2064        ExtractionReadinessIssueCode::TransactionBoundaryUnresolved => {
2065            "transaction_boundary_unresolved"
2066        }
2067        ExtractionReadinessIssueCode::TransactionSpansServiceBoundary => {
2068            "transaction_spans_service_boundary"
2069        }
2070        ExtractionReadinessIssueCode::WorkflowSurfacePresent => "workflow_surface_present",
2071    }
2072}
2073
2074#[cfg(test)]
2075mod tests {
2076    use super::*;
2077    use lenso_contracts::{
2078        AdminSchema, ConsoleSurface, ConsoleSurfacePresentation, EntitySchema,
2079        EventHandlerDeclaration, EventSurface, FieldSchema, FieldType, ModuleHttpRoute,
2080        RuntimeFunctionDeclaration, RuntimeSurface, ScheduledFunctionDeclaration,
2081        StoryDisplayDescriptor, WorkflowDataContract, WorkflowDefinition, WorkflowStepDeclaration,
2082    };
2083    fn manifest() -> ModuleManifest {
2084        ModuleManifest::builder("acme/support-ticket")
2085            .capabilities(vec!["support.tickets.read".to_owned()])
2086            .http_routes(vec![ModuleHttpRoute {
2087                method: ModuleHttpMethod::Get,
2088                path: "/tickets/{id}".to_owned(),
2089                capability: Some("support.tickets.read".to_owned()),
2090                display_name: Some("Get ticket".to_owned()),
2091                story_title: Some("Support ticket opened".to_owned()),
2092                operation: None,
2093            }])
2094            .events(EventSurface {
2095                handlers: vec![
2096                    EventHandlerDeclaration {
2097                        name: "apply_sla_update".to_owned(),
2098                        event_name: "support.sla-updated.v1".to_owned(),
2099                        operation: None,
2100                    },
2101                    EventHandlerDeclaration {
2102                        name: "record_audit".to_owned(),
2103                        event_name: "support.audit-recorded.v1".to_owned(),
2104                        operation: None,
2105                    },
2106                ],
2107            })
2108            .runtime(RuntimeSurface {
2109                functions: vec![RuntimeFunctionDeclaration {
2110                    name: "support-ticket.reindex.v1".to_owned(),
2111                    version: 1,
2112                    queue: "support-ticket".to_owned(),
2113                    input_schema: Some("support-ticket.reindex.v1".to_owned()),
2114                    retry_policy: None,
2115                    operation: None,
2116                }],
2117                schedules: vec![ScheduledFunctionDeclaration {
2118                    name: "support-ticket-reindex".to_owned(),
2119                    function_name: "support-ticket.reindex.v1".to_owned(),
2120                    cron: "0 * * * *".to_owned(),
2121                    input: json!({}),
2122                }],
2123                workflows: vec![WorkflowDefinition::new(
2124                    "support-ticket",
2125                    "ticket_triage",
2126                    "v1",
2127                    WorkflowDataContract::new("support.ticket-triage-input", "v1"),
2128                    WorkflowDataContract::new("support.ticket-triage-result", "v1"),
2129                    vec![WorkflowStepDeclaration::new("classify")],
2130                )],
2131            })
2132            .admin(AdminSchema {
2133                entities: vec![EntitySchema {
2134                    name: "tickets".to_owned(),
2135                    label: "Tickets".to_owned(),
2136                    fields: vec![FieldSchema {
2137                        name: "id".to_owned(),
2138                        label: "ID".to_owned(),
2139                        field_type: FieldType::String,
2140                        nullable: false,
2141                    }],
2142                    read_capability: "support.tickets.read".to_owned(),
2143                }],
2144            })
2145            .console(vec![ConsoleSurface {
2146                name: "support-tickets".to_owned(),
2147                label: "Support tickets".to_owned(),
2148                route: "/support/tickets".to_owned(),
2149                presentation: ConsoleSurfacePresentation::Esm {
2150                    entry: "supportTicketConsoleModule".to_owned(),
2151                },
2152                icon: None,
2153                required_capabilities: vec!["support.tickets.read".to_owned()],
2154                navigation: None,
2155            }])
2156            .story_display(vec![StoryDisplayDescriptor {
2157                source: StoryDisplaySource::ExecutionName {
2158                    name: "support-ticket.reindex.v1".to_owned(),
2159                },
2160                display_name: "Reindex support tickets".to_owned(),
2161                story_title: Some("Support ticket maintenance".to_owned()),
2162            }])
2163            .build()
2164    }
2165
2166    fn system() -> Value {
2167        json!({
2168            "protocol": "lenso.system.v2",
2169            "systemId": "support-system",
2170            "host": { "hostId": "support-host", "modules": ["acme/support-ticket"] },
2171            "providers": [{
2172                "providerId": "notification-provider",
2173                "modules": ["notification-gateway"]
2174            }],
2175            "autonomousServices": [{
2176                "serviceId": "support-sla-service",
2177                "modules": ["support-sla"],
2178                "workloads": [{ "workloadId": "support-sla-api", "role": "api" }]
2179            }],
2180            "contracts": [{
2181                "contractId": "support.sla-updated.v1",
2182                "version": "v1",
2183                "producerKind": "autonomous_service",
2184                "producerId": "support-sla-service",
2185                "artifact": {
2186                    "format": "json_schema",
2187                    "path": "contracts/events/support.sla-updated.v1.schema.json"
2188                },
2189                "tenancyMode": "required"
2190            }],
2191            "consumers": [{
2192                "consumerId": "support-ticket-sla-updates",
2193                "ownerKind": "host",
2194                "ownerId": "support-host",
2195                "contractId": "support.sla-updated.v1",
2196                "tenancyMode": "required"
2197            }]
2198        })
2199    }
2200
2201    fn corrected_service_data() -> ExtractionServiceDataEvidence {
2202        ExtractionServiceDataEvidence {
2203            complete: true,
2204            evidence_references: vec!["analyzer:postgres/support-ticket".to_owned()],
2205            tables: vec![
2206                ExtractionDataTableEvidence {
2207                    table: "support.tickets".to_owned(),
2208                    owner_module: Some("acme/support-ticket".to_owned()),
2209                    source: ExtractionDataEvidenceSource::StaticDeclaration,
2210                    volume: None,
2211                    cursor: None,
2212                    evidence_references: vec![
2213                        "modules/support-ticket/migrations/0001_tickets.sql".to_owned(),
2214                    ],
2215                },
2216                ExtractionDataTableEvidence {
2217                    table: "support.tickets".to_owned(),
2218                    owner_module: Some("acme/support-ticket".to_owned()),
2219                    source: ExtractionDataEvidenceSource::LiveStoreObservation {
2220                        observation_id: "support-store-2026-07-19".to_owned(),
2221                        store: "host-postgres".to_owned(),
2222                        read_only: true,
2223                    },
2224                    volume: Some(ExtractionDataVolumeEvidence {
2225                        approximate_rows: Some(25_000_000),
2226                        approximate_bytes: Some(17_179_869_184),
2227                        evidence_references: vec!["postgres:pg_class/support.tickets".to_owned()],
2228                    }),
2229                    cursor: Some(ExtractionCursorEvidence {
2230                        column: "id".to_owned(),
2231                        high_water_mark: "25000000".to_owned(),
2232                        trustworthy: true,
2233                        evidence_references: vec!["postgres:max(support.tickets.id)".to_owned()],
2234                    }),
2235                    evidence_references: vec![
2236                        "postgres:observation/support-store-2026-07-19".to_owned(),
2237                    ],
2238                },
2239            ],
2240            migrations: vec![ExtractionMigrationEvidence {
2241                migration: "0001_create_support_tickets".to_owned(),
2242                owner_module: Some("acme/support-ticket".to_owned()),
2243                source: ExtractionDataEvidenceSource::StaticDeclaration,
2244                evidence_references: vec![
2245                    "modules/support-ticket/migrations/0001_tickets.sql".to_owned(),
2246                ],
2247            }],
2248            access_paths: vec![ExtractionDataAccessEvidence {
2249                accessor_module: "acme/support-ticket".to_owned(),
2250                table: "support.tickets".to_owned(),
2251                access: ExtractionDataAccessKind::ReadWrite,
2252                source: ExtractionDataEvidenceSource::StaticDeclaration,
2253                evidence_references: vec!["modules/support-ticket/src/store.rs:14".to_owned()],
2254            }],
2255            transactions: vec![ExtractionTransactionEvidence {
2256                transaction: "support-ticket-update".to_owned(),
2257                participating_modules: vec!["acme/support-ticket".to_owned()],
2258                source: ExtractionDataEvidenceSource::StaticDeclaration,
2259                evidence_references: vec!["modules/support-ticket/src/store.rs:41".to_owned()],
2260            }],
2261        }
2262    }
2263
2264    fn corrected_evidence() -> ExtractionReadinessEvidence {
2265        ExtractionReadinessEvidence {
2266            boundary: Some(ExtractionBoundaryEvidence {
2267                complete: true,
2268                evidence_references: vec!["analyzer:rust/support-ticket".to_owned()],
2269                references: Vec::new(),
2270            }),
2271            contracts: Some(vec![
2272                ExtractionContractEvidence {
2273                    subject: "http:GET /tickets/{id}".to_owned(),
2274                    kind: ExtractionContractKind::Service,
2275                    direction: ExtractionContractDirection::Provides,
2276                    status: ExtractionEvidenceStatus::Present,
2277                    contract_id: Some("support-ticket-http.v1".to_owned()),
2278                    evidence_references: vec![
2279                        "contracts/openapi/support-ticket.v1.yaml".to_owned(),
2280                    ],
2281                },
2282                ExtractionContractEvidence {
2283                    subject: "event-handler:apply_sla_update".to_owned(),
2284                    kind: ExtractionContractKind::Event,
2285                    direction: ExtractionContractDirection::Consumes,
2286                    status: ExtractionEvidenceStatus::Present,
2287                    contract_id: Some("support.sla-updated.v1".to_owned()),
2288                    evidence_references: vec![
2289                        "contracts/events/support.sla-updated.v1.schema.json".to_owned(),
2290                    ],
2291                },
2292                ExtractionContractEvidence {
2293                    subject: "event-handler:record_audit".to_owned(),
2294                    kind: ExtractionContractKind::Event,
2295                    direction: ExtractionContractDirection::Consumes,
2296                    status: ExtractionEvidenceStatus::Present,
2297                    contract_id: Some("support.audit-recorded.v1".to_owned()),
2298                    evidence_references: vec![
2299                        "contracts/events/support.audit-recorded.v1.schema.json".to_owned(),
2300                    ],
2301                },
2302            ]),
2303            active_consumers: Some(vec![ExtractionConsumerCompatibilityEvidence {
2304                consumer_id: "support-ticket-sla-updates".to_owned(),
2305                contract_id: "support.sla-updated.v1".to_owned(),
2306                classification: CompatibilityCategory::Safe,
2307                evidence_references: vec!["system:consumer/support-ticket-sla-updates".to_owned()],
2308                next_action: "No action needed.".to_owned(),
2309            }]),
2310            service_data: Some(corrected_service_data()),
2311        }
2312    }
2313
2314    #[test]
2315    fn blocked_and_corrected_reports_are_deterministic_and_fail_closed() {
2316        let module = manifest();
2317        let mut blocked = corrected_evidence();
2318        blocked.boundary = Some(ExtractionBoundaryEvidence {
2319            complete: true,
2320            evidence_references: vec!["analyzer:rust/support-ticket".to_owned()],
2321            references: vec![
2322                ExtractionBoundaryReference {
2323                    kind: ExtractionBoundaryReferenceKind::CrossModuleImport,
2324                    from_module: "acme/support-ticket".to_owned(),
2325                    to_module: "acme/support-sla".to_owned(),
2326                    symbol: "support_sla::internal::SlaPolicy".to_owned(),
2327                    evidence_reference: "modules/support-ticket/src/lib.rs:12".to_owned(),
2328                },
2329                ExtractionBoundaryReference {
2330                    kind: ExtractionBoundaryReferenceKind::InProcessBoundaryCall,
2331                    from_module: "acme/support-ticket".to_owned(),
2332                    to_module: "acme/support-sla".to_owned(),
2333                    symbol: "support_sla::public::evaluate".to_owned(),
2334                    evidence_reference: "modules/support-ticket/src/service.rs:41".to_owned(),
2335                },
2336            ],
2337        });
2338        blocked.contracts.as_mut().expect("contracts")[0].status =
2339            ExtractionEvidenceStatus::Missing;
2340        blocked.contracts.as_mut().expect("contracts")[0].contract_id = None;
2341        blocked.contracts.as_mut().expect("contracts")[2].status =
2342            ExtractionEvidenceStatus::Missing;
2343        blocked.contracts.as_mut().expect("contracts")[2].contract_id = None;
2344        blocked.active_consumers.as_mut().expect("consumers")[0].classification =
2345            CompatibilityCategory::Breaking;
2346        blocked.active_consumers.as_mut().expect("consumers")[0].next_action =
2347            "Migrate the Consumer to support.sla-updated.v1.".to_owned();
2348        let service_data = blocked.service_data.as_mut().expect("service data");
2349        service_data.tables.extend([
2350            ExtractionDataTableEvidence {
2351                table: "support.sla_policies".to_owned(),
2352                owner_module: Some("acme/support-sla".to_owned()),
2353                source: ExtractionDataEvidenceSource::StaticDeclaration,
2354                volume: None,
2355                cursor: None,
2356                evidence_references: vec!["modules/support-sla/migrations/0001.sql".to_owned()],
2357            },
2358            ExtractionDataTableEvidence {
2359                table: "support.audit_events".to_owned(),
2360                owner_module: None,
2361                source: ExtractionDataEvidenceSource::StaticDeclaration,
2362                volume: None,
2363                cursor: None,
2364                evidence_references: vec!["migrations/0009_support_audit.sql".to_owned()],
2365            },
2366        ]);
2367        service_data.migrations.push(ExtractionMigrationEvidence {
2368            migration: "0009_support_audit".to_owned(),
2369            owner_module: None,
2370            source: ExtractionDataEvidenceSource::StaticDeclaration,
2371            evidence_references: vec!["migrations/0009_support_audit.sql".to_owned()],
2372        });
2373        service_data
2374            .access_paths
2375            .push(ExtractionDataAccessEvidence {
2376                accessor_module: "acme/support-ticket".to_owned(),
2377                table: "support.sla_policies".to_owned(),
2378                access: ExtractionDataAccessKind::Read,
2379                source: ExtractionDataEvidenceSource::StaticDeclaration,
2380                evidence_references: vec!["modules/support-ticket/src/sla.rs:28".to_owned()],
2381            });
2382        service_data
2383            .transactions
2384            .push(ExtractionTransactionEvidence {
2385                transaction: "ticket-and-sla-update".to_owned(),
2386                participating_modules: vec![
2387                    "acme/support-sla".to_owned(),
2388                    "acme/support-ticket".to_owned(),
2389                ],
2390                source: ExtractionDataEvidenceSource::StaticDeclaration,
2391                evidence_references: vec!["modules/support-ticket/src/sla.rs:52".to_owned()],
2392            });
2393
2394        let first = evaluate_extraction_readiness(&module, &system(), &blocked);
2395        let second = evaluate_extraction_readiness(&module, &system(), &blocked);
2396        assert_eq!(first, second);
2397        let mut reordered = blocked.clone();
2398        let data = reordered.service_data.as_mut().expect("service data");
2399        data.tables.reverse();
2400        data.migrations.reverse();
2401        data.access_paths.reverse();
2402        data.transactions.reverse();
2403        data.evidence_references.reverse();
2404        assert_eq!(
2405            first,
2406            evaluate_extraction_readiness(&module, &system(), &reordered)
2407        );
2408        assert_eq!(first.classification, CompatibilityCategory::Blocked);
2409        assert!(!first.ready);
2410        for code in [
2411            ExtractionReadinessIssueCode::CrossModuleImport,
2412            ExtractionReadinessIssueCode::InProcessBoundaryCall,
2413            ExtractionReadinessIssueCode::RequiredServiceContractMissing,
2414            ExtractionReadinessIssueCode::RequiredEventContractMissing,
2415            ExtractionReadinessIssueCode::ActiveConsumerBreaking,
2416            ExtractionReadinessIssueCode::CrossModuleTableAccess,
2417            ExtractionReadinessIssueCode::TableOwnershipUnresolved,
2418            ExtractionReadinessIssueCode::MigrationOwnershipUnresolved,
2419            ExtractionReadinessIssueCode::TransactionSpansServiceBoundary,
2420        ] {
2421            assert!(first.issue_codes.contains(&code), "missing {code:?}");
2422        }
2423        assert_eq!(first.effects, ExtractionReadinessEffects::default());
2424
2425        let corrected = evaluate_extraction_readiness(&module, &system(), &corrected_evidence());
2426        assert_eq!(
2427            corrected.classification,
2428            CompatibilityCategory::NeedsAttention,
2429            "{:#?}",
2430            corrected.findings
2431        );
2432        assert!(corrected.ready);
2433        assert!(!corrected.surfaces.runtime_functions.is_empty());
2434        assert!(!corrected.surfaces.workflows.is_empty());
2435        assert!(!corrected.surfaces.admin.is_empty());
2436        assert!(!corrected.surfaces.console.is_empty());
2437        assert!(!corrected.surfaces.stories.is_empty());
2438        assert_eq!(corrected.service_data.tables.len(), 2);
2439        assert!(
2440            corrected
2441                .issue_codes
2442                .contains(&ExtractionReadinessIssueCode::DataVolumeLarge)
2443        );
2444        assert!(corrected.findings.iter().any(|finding| {
2445            finding.code == ExtractionReadinessIssueCode::LiveStoreObservationPresent
2446                && finding.message.contains("Read-only")
2447        }));
2448        assert!(
2449            !corrected
2450                .issue_codes
2451                .contains(&ExtractionReadinessIssueCode::CrossModuleImport)
2452        );
2453    }
2454
2455    #[test]
2456    fn missing_or_ambiguous_analysis_evidence_blocks_readiness() {
2457        let module = manifest();
2458        let report = evaluate_extraction_readiness(
2459            &module,
2460            &system(),
2461            &ExtractionReadinessEvidence::default(),
2462        );
2463        assert_eq!(report.classification, CompatibilityCategory::Blocked);
2464        for code in [
2465            ExtractionReadinessIssueCode::BoundaryEvidenceMissing,
2466            ExtractionReadinessIssueCode::ContractEvidenceMissing,
2467            ExtractionReadinessIssueCode::ActiveConsumerCompatibilityMissing,
2468            ExtractionReadinessIssueCode::ServiceDataEvidenceMissing,
2469        ] {
2470            assert!(report.issue_codes.contains(&code));
2471        }
2472    }
2473
2474    #[test]
2475    fn missing_cursor_requires_bounded_pause_full_copy_without_blocking_readiness() {
2476        let mut evidence = corrected_evidence();
2477        for table in &mut evidence.service_data.as_mut().expect("service data").tables {
2478            table.cursor = None;
2479        }
2480        let report = evaluate_extraction_readiness(&manifest(), &system(), &evidence);
2481        assert!(report.ready);
2482        let finding = report
2483            .findings
2484            .iter()
2485            .find(|finding| finding.code == ExtractionReadinessIssueCode::ExtractionCursorMissing)
2486            .expect("missing cursor should be reported");
2487        assert!(finding.message.contains("full copy"));
2488        assert!(finding.message.contains("bounded write pause"));
2489    }
2490
2491    #[test]
2492    fn live_store_observations_must_be_read_only() {
2493        let mut evidence = corrected_evidence();
2494        for table in &mut evidence.service_data.as_mut().expect("service data").tables {
2495            if let ExtractionDataEvidenceSource::LiveStoreObservation { read_only, .. } =
2496                &mut table.source
2497            {
2498                *read_only = false;
2499            }
2500        }
2501        let report = evaluate_extraction_readiness(&manifest(), &system(), &evidence);
2502        assert!(!report.ready);
2503        assert!(
2504            report
2505                .issue_codes
2506                .contains(&ExtractionReadinessIssueCode::LiveStoreObservationNotReadOnly)
2507        );
2508        assert_eq!(report.effects, ExtractionReadinessEffects::default());
2509    }
2510
2511    #[test]
2512    fn report_schema_accepts_public_json_and_v1_reader_ignores_future_fields() {
2513        let report = evaluate_extraction_readiness(&manifest(), &system(), &corrected_evidence());
2514        let value = serde_json::to_value(&report).expect("report should serialize");
2515        let validator = jsonschema::validator_for(&extraction_readiness_report_schema())
2516            .expect("report schema should compile");
2517        assert!(validator.is_valid(&value));
2518
2519        let mut future = value;
2520        future["futureField"] = json!(true);
2521        let decoded: ExtractionReadinessReport =
2522            serde_json::from_value(future).expect("v1 reader should ignore future fields");
2523        assert_eq!(decoded.protocol, EXTRACTION_READINESS_REPORT_PROTOCOL);
2524
2525        let mut older = serde_json::to_value(&report).expect("report should serialize");
2526        older
2527            .as_object_mut()
2528            .expect("report should be an object")
2529            .remove("serviceData");
2530        older
2531            .as_object_mut()
2532            .expect("report should be an object")
2533            .remove("contractEvidence");
2534        older
2535            .as_object_mut()
2536            .expect("report should be an object")
2537            .remove("activeConsumers");
2538        let decoded: ExtractionReadinessReport =
2539            serde_json::from_value(older).expect("v1 reader should default added data summary");
2540        assert_eq!(
2541            decoded.service_data,
2542            ExtractionServiceDataEvidence::default()
2543        );
2544        assert!(decoded.contract_evidence.is_empty());
2545        assert!(decoded.active_consumers.is_empty());
2546    }
2547
2548    #[test]
2549    fn human_and_json_renderers_project_the_same_report() {
2550        let report = evaluate_extraction_readiness(&manifest(), &system(), &corrected_evidence());
2551        let human = render_extraction_readiness_report(&report);
2552        assert!(human.contains("Extraction readiness: acme/support-ticket"));
2553        assert!(human.contains("Result: needs_attention (ready)"));
2554        assert!(human.contains("writesRepositoryFiles=false"));
2555        assert!(human.contains("live_store_observation"));
2556        assert!(human.contains("support.tickets"));
2557        let json = extraction_readiness_report_json(&report).expect("report should render");
2558        let decoded: ExtractionReadinessReport =
2559            serde_json::from_str(&json).expect("JSON output should be readable");
2560        assert_eq!(decoded, report);
2561    }
2562
2563    #[test]
2564    fn provider_system_is_rejected_without_reinterpreting_provider_semantics() {
2565        let provider: Value = serde_json::from_str(crate::LEGACY_SYSTEM_V1_FIXTURE_JSON)
2566            .expect("Provider System fixture should parse");
2567        let check = crate::check_contract_artifact_value(&provider)
2568            .expect("Provider System semantics should remain valid");
2569        assert_eq!(check.semantic_kind, ContractSemanticKind::ProviderSystem);
2570
2571        let report = evaluate_extraction_readiness(&manifest(), &provider, &corrected_evidence());
2572        assert_eq!(report.classification, CompatibilityCategory::Blocked);
2573        assert!(
2574            report
2575                .issue_codes
2576                .contains(&ExtractionReadinessIssueCode::SystemEvidenceInvalid)
2577        );
2578    }
2579}