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}