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