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, CONSOLE_BRIDGE_PROTOCOL, ConsoleSurface, ConsoleSurfacePresentation,
2079        EntitySchema, EventHandlerDeclaration, EventSurface, FieldSchema, FieldType,
2080        ModuleHttpRoute, 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::Isolated {
2150                    entry: "supportTicketConsoleModule".to_owned(),
2151
2152                    bridge_protocol: CONSOLE_BRIDGE_PROTOCOL.to_owned(),
2153                },
2154                icon: None,
2155                required_capabilities: vec!["support.tickets.read".to_owned()],
2156                navigation: None,
2157            }])
2158            .story_display(vec![StoryDisplayDescriptor {
2159                source: StoryDisplaySource::ExecutionName {
2160                    name: "support-ticket.reindex.v1".to_owned(),
2161                },
2162                display_name: "Reindex support tickets".to_owned(),
2163                story_title: Some("Support ticket maintenance".to_owned()),
2164            }])
2165            .build()
2166    }
2167
2168    fn system() -> Value {
2169        json!({
2170            "protocol": "lenso.system.v2",
2171            "systemId": "support-system",
2172            "host": { "hostId": "support-host", "modules": ["acme/support-ticket"] },
2173            "providers": [{
2174                "providerId": "notification-provider",
2175                "modules": ["notification-gateway"]
2176            }],
2177            "autonomousServices": [{
2178                "serviceId": "support-sla-service",
2179                "modules": ["support-sla"],
2180                "workloads": [{ "workloadId": "support-sla-api", "role": "api" }]
2181            }],
2182            "contracts": [{
2183                "contractId": "support.sla-updated.v1",
2184                "version": "v1",
2185                "producerKind": "autonomous_service",
2186                "producerId": "support-sla-service",
2187                "artifact": {
2188                    "format": "json_schema",
2189                    "path": "contracts/events/support.sla-updated.v1.schema.json"
2190                },
2191                "tenancyMode": "required"
2192            }],
2193            "consumers": [{
2194                "consumerId": "support-ticket-sla-updates",
2195                "ownerKind": "host",
2196                "ownerId": "support-host",
2197                "contractId": "support.sla-updated.v1",
2198                "tenancyMode": "required"
2199            }]
2200        })
2201    }
2202
2203    fn corrected_service_data() -> ExtractionServiceDataEvidence {
2204        ExtractionServiceDataEvidence {
2205            complete: true,
2206            evidence_references: vec!["analyzer:postgres/support-ticket".to_owned()],
2207            tables: vec![
2208                ExtractionDataTableEvidence {
2209                    table: "support.tickets".to_owned(),
2210                    owner_module: Some("acme/support-ticket".to_owned()),
2211                    source: ExtractionDataEvidenceSource::StaticDeclaration,
2212                    volume: None,
2213                    cursor: None,
2214                    evidence_references: vec![
2215                        "modules/support-ticket/migrations/0001_tickets.sql".to_owned(),
2216                    ],
2217                },
2218                ExtractionDataTableEvidence {
2219                    table: "support.tickets".to_owned(),
2220                    owner_module: Some("acme/support-ticket".to_owned()),
2221                    source: ExtractionDataEvidenceSource::LiveStoreObservation {
2222                        observation_id: "support-store-2026-07-19".to_owned(),
2223                        store: "host-postgres".to_owned(),
2224                        read_only: true,
2225                    },
2226                    volume: Some(ExtractionDataVolumeEvidence {
2227                        approximate_rows: Some(25_000_000),
2228                        approximate_bytes: Some(17_179_869_184),
2229                        evidence_references: vec!["postgres:pg_class/support.tickets".to_owned()],
2230                    }),
2231                    cursor: Some(ExtractionCursorEvidence {
2232                        column: "id".to_owned(),
2233                        high_water_mark: "25000000".to_owned(),
2234                        trustworthy: true,
2235                        evidence_references: vec!["postgres:max(support.tickets.id)".to_owned()],
2236                    }),
2237                    evidence_references: vec![
2238                        "postgres:observation/support-store-2026-07-19".to_owned(),
2239                    ],
2240                },
2241            ],
2242            migrations: vec![ExtractionMigrationEvidence {
2243                migration: "0001_create_support_tickets".to_owned(),
2244                owner_module: Some("acme/support-ticket".to_owned()),
2245                source: ExtractionDataEvidenceSource::StaticDeclaration,
2246                evidence_references: vec![
2247                    "modules/support-ticket/migrations/0001_tickets.sql".to_owned(),
2248                ],
2249            }],
2250            access_paths: vec![ExtractionDataAccessEvidence {
2251                accessor_module: "acme/support-ticket".to_owned(),
2252                table: "support.tickets".to_owned(),
2253                access: ExtractionDataAccessKind::ReadWrite,
2254                source: ExtractionDataEvidenceSource::StaticDeclaration,
2255                evidence_references: vec!["modules/support-ticket/src/store.rs:14".to_owned()],
2256            }],
2257            transactions: vec![ExtractionTransactionEvidence {
2258                transaction: "support-ticket-update".to_owned(),
2259                participating_modules: vec!["acme/support-ticket".to_owned()],
2260                source: ExtractionDataEvidenceSource::StaticDeclaration,
2261                evidence_references: vec!["modules/support-ticket/src/store.rs:41".to_owned()],
2262            }],
2263        }
2264    }
2265
2266    fn corrected_evidence() -> ExtractionReadinessEvidence {
2267        ExtractionReadinessEvidence {
2268            boundary: Some(ExtractionBoundaryEvidence {
2269                complete: true,
2270                evidence_references: vec!["analyzer:rust/support-ticket".to_owned()],
2271                references: Vec::new(),
2272            }),
2273            contracts: Some(vec![
2274                ExtractionContractEvidence {
2275                    subject: "http:GET /tickets/{id}".to_owned(),
2276                    kind: ExtractionContractKind::Service,
2277                    direction: ExtractionContractDirection::Provides,
2278                    status: ExtractionEvidenceStatus::Present,
2279                    contract_id: Some("support-ticket-http.v1".to_owned()),
2280                    evidence_references: vec![
2281                        "contracts/openapi/support-ticket.v1.yaml".to_owned(),
2282                    ],
2283                },
2284                ExtractionContractEvidence {
2285                    subject: "event-handler:apply_sla_update".to_owned(),
2286                    kind: ExtractionContractKind::Event,
2287                    direction: ExtractionContractDirection::Consumes,
2288                    status: ExtractionEvidenceStatus::Present,
2289                    contract_id: Some("support.sla-updated.v1".to_owned()),
2290                    evidence_references: vec![
2291                        "contracts/events/support.sla-updated.v1.schema.json".to_owned(),
2292                    ],
2293                },
2294                ExtractionContractEvidence {
2295                    subject: "event-handler:record_audit".to_owned(),
2296                    kind: ExtractionContractKind::Event,
2297                    direction: ExtractionContractDirection::Consumes,
2298                    status: ExtractionEvidenceStatus::Present,
2299                    contract_id: Some("support.audit-recorded.v1".to_owned()),
2300                    evidence_references: vec![
2301                        "contracts/events/support.audit-recorded.v1.schema.json".to_owned(),
2302                    ],
2303                },
2304            ]),
2305            active_consumers: Some(vec![ExtractionConsumerCompatibilityEvidence {
2306                consumer_id: "support-ticket-sla-updates".to_owned(),
2307                contract_id: "support.sla-updated.v1".to_owned(),
2308                classification: CompatibilityCategory::Safe,
2309                evidence_references: vec!["system:consumer/support-ticket-sla-updates".to_owned()],
2310                next_action: "No action needed.".to_owned(),
2311            }]),
2312            service_data: Some(corrected_service_data()),
2313        }
2314    }
2315
2316    #[test]
2317    fn blocked_and_corrected_reports_are_deterministic_and_fail_closed() {
2318        let module = manifest();
2319        let mut blocked = corrected_evidence();
2320        blocked.boundary = Some(ExtractionBoundaryEvidence {
2321            complete: true,
2322            evidence_references: vec!["analyzer:rust/support-ticket".to_owned()],
2323            references: vec![
2324                ExtractionBoundaryReference {
2325                    kind: ExtractionBoundaryReferenceKind::CrossModuleImport,
2326                    from_module: "acme/support-ticket".to_owned(),
2327                    to_module: "acme/support-sla".to_owned(),
2328                    symbol: "support_sla::internal::SlaPolicy".to_owned(),
2329                    evidence_reference: "modules/support-ticket/src/lib.rs:12".to_owned(),
2330                },
2331                ExtractionBoundaryReference {
2332                    kind: ExtractionBoundaryReferenceKind::InProcessBoundaryCall,
2333                    from_module: "acme/support-ticket".to_owned(),
2334                    to_module: "acme/support-sla".to_owned(),
2335                    symbol: "support_sla::public::evaluate".to_owned(),
2336                    evidence_reference: "modules/support-ticket/src/service.rs:41".to_owned(),
2337                },
2338            ],
2339        });
2340        blocked.contracts.as_mut().expect("contracts")[0].status =
2341            ExtractionEvidenceStatus::Missing;
2342        blocked.contracts.as_mut().expect("contracts")[0].contract_id = None;
2343        blocked.contracts.as_mut().expect("contracts")[2].status =
2344            ExtractionEvidenceStatus::Missing;
2345        blocked.contracts.as_mut().expect("contracts")[2].contract_id = None;
2346        blocked.active_consumers.as_mut().expect("consumers")[0].classification =
2347            CompatibilityCategory::Breaking;
2348        blocked.active_consumers.as_mut().expect("consumers")[0].next_action =
2349            "Migrate the Consumer to support.sla-updated.v1.".to_owned();
2350        let service_data = blocked.service_data.as_mut().expect("service data");
2351        service_data.tables.extend([
2352            ExtractionDataTableEvidence {
2353                table: "support.sla_policies".to_owned(),
2354                owner_module: Some("acme/support-sla".to_owned()),
2355                source: ExtractionDataEvidenceSource::StaticDeclaration,
2356                volume: None,
2357                cursor: None,
2358                evidence_references: vec!["modules/support-sla/migrations/0001.sql".to_owned()],
2359            },
2360            ExtractionDataTableEvidence {
2361                table: "support.audit_events".to_owned(),
2362                owner_module: None,
2363                source: ExtractionDataEvidenceSource::StaticDeclaration,
2364                volume: None,
2365                cursor: None,
2366                evidence_references: vec!["migrations/0009_support_audit.sql".to_owned()],
2367            },
2368        ]);
2369        service_data.migrations.push(ExtractionMigrationEvidence {
2370            migration: "0009_support_audit".to_owned(),
2371            owner_module: None,
2372            source: ExtractionDataEvidenceSource::StaticDeclaration,
2373            evidence_references: vec!["migrations/0009_support_audit.sql".to_owned()],
2374        });
2375        service_data
2376            .access_paths
2377            .push(ExtractionDataAccessEvidence {
2378                accessor_module: "acme/support-ticket".to_owned(),
2379                table: "support.sla_policies".to_owned(),
2380                access: ExtractionDataAccessKind::Read,
2381                source: ExtractionDataEvidenceSource::StaticDeclaration,
2382                evidence_references: vec!["modules/support-ticket/src/sla.rs:28".to_owned()],
2383            });
2384        service_data
2385            .transactions
2386            .push(ExtractionTransactionEvidence {
2387                transaction: "ticket-and-sla-update".to_owned(),
2388                participating_modules: vec![
2389                    "acme/support-sla".to_owned(),
2390                    "acme/support-ticket".to_owned(),
2391                ],
2392                source: ExtractionDataEvidenceSource::StaticDeclaration,
2393                evidence_references: vec!["modules/support-ticket/src/sla.rs:52".to_owned()],
2394            });
2395
2396        let first = evaluate_extraction_readiness(&module, &system(), &blocked);
2397        let second = evaluate_extraction_readiness(&module, &system(), &blocked);
2398        assert_eq!(first, second);
2399        let mut reordered = blocked.clone();
2400        let data = reordered.service_data.as_mut().expect("service data");
2401        data.tables.reverse();
2402        data.migrations.reverse();
2403        data.access_paths.reverse();
2404        data.transactions.reverse();
2405        data.evidence_references.reverse();
2406        assert_eq!(
2407            first,
2408            evaluate_extraction_readiness(&module, &system(), &reordered)
2409        );
2410        assert_eq!(first.classification, CompatibilityCategory::Blocked);
2411        assert!(!first.ready);
2412        for code in [
2413            ExtractionReadinessIssueCode::CrossModuleImport,
2414            ExtractionReadinessIssueCode::InProcessBoundaryCall,
2415            ExtractionReadinessIssueCode::RequiredServiceContractMissing,
2416            ExtractionReadinessIssueCode::RequiredEventContractMissing,
2417            ExtractionReadinessIssueCode::ActiveConsumerBreaking,
2418            ExtractionReadinessIssueCode::CrossModuleTableAccess,
2419            ExtractionReadinessIssueCode::TableOwnershipUnresolved,
2420            ExtractionReadinessIssueCode::MigrationOwnershipUnresolved,
2421            ExtractionReadinessIssueCode::TransactionSpansServiceBoundary,
2422        ] {
2423            assert!(first.issue_codes.contains(&code), "missing {code:?}");
2424        }
2425        assert_eq!(first.effects, ExtractionReadinessEffects::default());
2426
2427        let corrected = evaluate_extraction_readiness(&module, &system(), &corrected_evidence());
2428        assert_eq!(
2429            corrected.classification,
2430            CompatibilityCategory::NeedsAttention,
2431            "{:#?}",
2432            corrected.findings
2433        );
2434        assert!(corrected.ready);
2435        assert!(!corrected.surfaces.runtime_functions.is_empty());
2436        assert!(!corrected.surfaces.workflows.is_empty());
2437        assert!(!corrected.surfaces.admin.is_empty());
2438        assert!(!corrected.surfaces.console.is_empty());
2439        assert!(!corrected.surfaces.stories.is_empty());
2440        assert_eq!(corrected.service_data.tables.len(), 2);
2441        assert!(
2442            corrected
2443                .issue_codes
2444                .contains(&ExtractionReadinessIssueCode::DataVolumeLarge)
2445        );
2446        assert!(corrected.findings.iter().any(|finding| {
2447            finding.code == ExtractionReadinessIssueCode::LiveStoreObservationPresent
2448                && finding.message.contains("Read-only")
2449        }));
2450        assert!(
2451            !corrected
2452                .issue_codes
2453                .contains(&ExtractionReadinessIssueCode::CrossModuleImport)
2454        );
2455    }
2456
2457    #[test]
2458    fn missing_or_ambiguous_analysis_evidence_blocks_readiness() {
2459        let module = manifest();
2460        let report = evaluate_extraction_readiness(
2461            &module,
2462            &system(),
2463            &ExtractionReadinessEvidence::default(),
2464        );
2465        assert_eq!(report.classification, CompatibilityCategory::Blocked);
2466        for code in [
2467            ExtractionReadinessIssueCode::BoundaryEvidenceMissing,
2468            ExtractionReadinessIssueCode::ContractEvidenceMissing,
2469            ExtractionReadinessIssueCode::ActiveConsumerCompatibilityMissing,
2470            ExtractionReadinessIssueCode::ServiceDataEvidenceMissing,
2471        ] {
2472            assert!(report.issue_codes.contains(&code));
2473        }
2474    }
2475
2476    #[test]
2477    fn missing_cursor_requires_bounded_pause_full_copy_without_blocking_readiness() {
2478        let mut evidence = corrected_evidence();
2479        for table in &mut evidence.service_data.as_mut().expect("service data").tables {
2480            table.cursor = None;
2481        }
2482        let report = evaluate_extraction_readiness(&manifest(), &system(), &evidence);
2483        assert!(report.ready);
2484        let finding = report
2485            .findings
2486            .iter()
2487            .find(|finding| finding.code == ExtractionReadinessIssueCode::ExtractionCursorMissing)
2488            .expect("missing cursor should be reported");
2489        assert!(finding.message.contains("full copy"));
2490        assert!(finding.message.contains("bounded write pause"));
2491    }
2492
2493    #[test]
2494    fn live_store_observations_must_be_read_only() {
2495        let mut evidence = corrected_evidence();
2496        for table in &mut evidence.service_data.as_mut().expect("service data").tables {
2497            if let ExtractionDataEvidenceSource::LiveStoreObservation { read_only, .. } =
2498                &mut table.source
2499            {
2500                *read_only = false;
2501            }
2502        }
2503        let report = evaluate_extraction_readiness(&manifest(), &system(), &evidence);
2504        assert!(!report.ready);
2505        assert!(
2506            report
2507                .issue_codes
2508                .contains(&ExtractionReadinessIssueCode::LiveStoreObservationNotReadOnly)
2509        );
2510        assert_eq!(report.effects, ExtractionReadinessEffects::default());
2511    }
2512
2513    #[test]
2514    fn report_schema_accepts_public_json_and_v1_reader_ignores_future_fields() {
2515        let report = evaluate_extraction_readiness(&manifest(), &system(), &corrected_evidence());
2516        let value = serde_json::to_value(&report).expect("report should serialize");
2517        let validator = jsonschema::validator_for(&extraction_readiness_report_schema())
2518            .expect("report schema should compile");
2519        assert!(validator.is_valid(&value));
2520
2521        let mut future = value;
2522        future["futureField"] = json!(true);
2523        let decoded: ExtractionReadinessReport =
2524            serde_json::from_value(future).expect("v1 reader should ignore future fields");
2525        assert_eq!(decoded.protocol, EXTRACTION_READINESS_REPORT_PROTOCOL);
2526
2527        let mut older = serde_json::to_value(&report).expect("report should serialize");
2528        older
2529            .as_object_mut()
2530            .expect("report should be an object")
2531            .remove("serviceData");
2532        older
2533            .as_object_mut()
2534            .expect("report should be an object")
2535            .remove("contractEvidence");
2536        older
2537            .as_object_mut()
2538            .expect("report should be an object")
2539            .remove("activeConsumers");
2540        let decoded: ExtractionReadinessReport =
2541            serde_json::from_value(older).expect("v1 reader should default added data summary");
2542        assert_eq!(
2543            decoded.service_data,
2544            ExtractionServiceDataEvidence::default()
2545        );
2546        assert!(decoded.contract_evidence.is_empty());
2547        assert!(decoded.active_consumers.is_empty());
2548    }
2549
2550    #[test]
2551    fn human_and_json_renderers_project_the_same_report() {
2552        let report = evaluate_extraction_readiness(&manifest(), &system(), &corrected_evidence());
2553        let human = render_extraction_readiness_report(&report);
2554        assert!(human.contains("Extraction readiness: acme/support-ticket"));
2555        assert!(human.contains("Result: needs_attention (ready)"));
2556        assert!(human.contains("writesRepositoryFiles=false"));
2557        assert!(human.contains("live_store_observation"));
2558        assert!(human.contains("support.tickets"));
2559        let json = extraction_readiness_report_json(&report).expect("report should render");
2560        let decoded: ExtractionReadinessReport =
2561            serde_json::from_str(&json).expect("JSON output should be readable");
2562        assert_eq!(decoded, report);
2563    }
2564
2565    #[test]
2566    fn provider_system_is_rejected_without_reinterpreting_provider_semantics() {
2567        let provider: Value = serde_json::from_str(crate::LEGACY_SYSTEM_V1_FIXTURE_JSON)
2568            .expect("Provider System fixture should parse");
2569        let check = crate::check_contract_artifact_value(&provider)
2570            .expect("Provider System semantics should remain valid");
2571        assert_eq!(check.semantic_kind, ContractSemanticKind::ProviderSystem);
2572
2573        let report = evaluate_extraction_readiness(&manifest(), &provider, &corrected_evidence());
2574        assert_eq!(report.classification, CompatibilityCategory::Blocked);
2575        assert!(
2576            report
2577                .issue_codes
2578                .contains(&ExtractionReadinessIssueCode::SystemEvidenceInvalid)
2579        );
2580    }
2581}