1use crate::{
2 AutonomousServiceContract, AutonomousServiceStore, AutonomousServiceWorkload,
3 CommonContextRequirement, ContractContextRequirements, DirectGrpcBindings, DirectHttpBindings,
4 EventArtifactFormat, EventArtifactReference, EventContractArtifact,
5 ExtractionContractArtifactFormat, ExtractionContractDirection, ExtractionContractKind,
6 ExtractionInputPinKind, ExtractionPlan, ExtractionPlanInputs, ExtractionWorkloadRole,
7 ModuleManifest, ServiceArtifactFormat, ServiceArtifactReference, ServiceContractArtifact,
8 ServiceTenancyMode, WorkloadRole, ensure_extraction_plan_fresh, extraction_input_digest,
9 extraction_plan_integrity_is_valid, generate_direct_grpc_bindings,
10 generate_direct_http_bindings, validate_autonomous_service_artifact_references,
11 validate_autonomous_service_contract,
12};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use serde_json::{Value, json};
16use std::collections::{BTreeMap, BTreeSet};
17use std::fmt;
18use std::fs::{self, OpenOptions};
19use std::io::Write as _;
20use std::path::{Component, Path, PathBuf};
21
22pub const EXTRACTION_SCAFFOLD_PROTOCOL: &str = "lenso.extraction-scaffold.v1";
23pub const EXTRACTION_SCAFFOLD_GENERATOR_VERSION: &str = "lenso.extraction-scaffold-generator.v1";
24const EXTRACTION_SCAFFOLD_SCHEMA_ID: &str =
25 "https://contracts.lenso.local/extraction/lenso.extraction-scaffold.v1.schema.json";
26const EXTRACTION_SCAFFOLD_APPLY_PROTOCOL: &str = "lenso.extraction-scaffold-apply.v1";
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ExtractionScaffoldArtifact {
30 pub contract_id: String,
31 pub version: String,
32 pub contents: String,
33 pub protobuf_descriptor: Option<Vec<u8>>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ExtractionScaffoldInputs {
38 pub plan: ExtractionPlan,
39 pub module: ModuleManifest,
40 pub artifacts: Vec<ExtractionScaffoldArtifact>,
41}
42
43#[derive(
44 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
45)]
46#[serde(rename_all = "snake_case")]
47pub enum ExtractionScaffoldFileKind {
48 CargoManifest,
49 WorkloadEntrypoint,
50 ModuleManifest,
51 ServiceManifest,
52 ContractArtifact,
53 GeneratedBinding,
54 ServiceClient,
55 MigrationGuide,
56 OwnershipReceipt,
57 Readme,
58 RustLibrary,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62#[serde(rename_all = "camelCase")]
63pub struct ExtractionScaffoldFile {
64 pub path: String,
65 pub kind: ExtractionScaffoldFileKind,
66 pub digest: String,
67 pub contents: String,
68}
69
70#[derive(
71 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
72)]
73#[serde(rename_all = "snake_case")]
74pub enum ExtractionGeneratedBindingKind {
75 Http,
76 Grpc,
77 Event,
78}
79
80#[derive(
81 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
82)]
83#[serde(rename_all = "snake_case")]
84pub enum ExtractionScaffoldBindingRole {
85 Server,
86 Client,
87 Publisher,
88 Handler,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "camelCase")]
93pub struct ExtractionGeneratedBinding {
94 pub contract_id: String,
95 pub version: String,
96 pub kind: ExtractionGeneratedBindingKind,
97 pub role: ExtractionScaffoldBindingRole,
98 pub artifact_path: String,
99 pub artifact_digest: String,
100 pub binding_path: String,
101 pub binding_digest: String,
102 pub tenancy_mode: ServiceTenancyMode,
103 #[serde(default)]
104 pub required_context: Vec<CommonContextRequirement>,
105 #[serde(default)]
106 pub operation_ids: Vec<String>,
107 #[serde(default)]
108 pub event_types: Vec<String>,
109 #[serde(default)]
110 pub generated_client_ids: Vec<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[serde(rename_all = "camelCase")]
115pub struct ExtractionPreservedIdentity {
116 pub module_name: String,
117 pub module_manifest_digest: String,
118 pub module_manifest: Value,
119 pub capabilities: Vec<String>,
120 pub operation_ids: Vec<String>,
121 pub event_types: Vec<String>,
122 pub runtime_function_names: Vec<String>,
123 pub schedule_names: Vec<String>,
124 pub workflow_identities: Vec<String>,
125 pub story_titles: Vec<String>,
126 pub admin_identity: Value,
127 pub console_identity: Value,
128}
129
130#[allow(clippy::struct_excessive_bools)]
131#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132#[serde(rename_all = "camelCase")]
133pub struct ExtractionScaffoldEffects {
134 pub writes_repository_files: bool,
135 pub starts_workloads: bool,
136 pub copies_data: bool,
137 pub changes_authority: bool,
138 pub changes_provider_path: bool,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
142#[serde(rename_all = "camelCase")]
143pub struct ExtractionScaffold {
144 pub protocol: String,
145 pub generator_version: String,
146 pub scaffold_id: String,
147 pub scaffold_digest: String,
148 pub plan_id: String,
149 pub plan_digest: String,
150 pub target_module: String,
151 pub candidate_service_id: String,
152 pub destination_root: String,
153 pub linked_authority_remains_authoritative: bool,
154 pub provider_compatibility_preserved: bool,
155 pub preserved_identity: ExtractionPreservedIdentity,
156 pub candidate_service: Value,
157 pub bindings: Vec<ExtractionGeneratedBinding>,
158 pub local_behavior_ids: Vec<String>,
159 pub boundary_replacements: Vec<String>,
160 pub files: Vec<ExtractionScaffoldFile>,
161 pub patch: String,
162 pub effects: ExtractionScaffoldEffects,
163}
164
165#[derive(Serialize)]
166#[serde(rename_all = "camelCase")]
167struct ExtractionScaffoldContent<'a> {
168 protocol: &'a str,
169 generator_version: &'a str,
170 plan_id: &'a str,
171 plan_digest: &'a str,
172 target_module: &'a str,
173 candidate_service_id: &'a str,
174 destination_root: &'a str,
175 linked_authority_remains_authoritative: bool,
176 provider_compatibility_preserved: bool,
177 preserved_identity: &'a ExtractionPreservedIdentity,
178 candidate_service: &'a Value,
179 bindings: &'a [ExtractionGeneratedBinding],
180 local_behavior_ids: &'a [String],
181 boundary_replacements: &'a [String],
182 files: &'a [ExtractionScaffoldFile],
183 patch: &'a str,
184 effects: ExtractionScaffoldEffects,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum ExtractionScaffoldGenerationIssueCode {
190 PlanInvalid,
191 ModuleIdentityMismatch,
192 ArtifactMissing,
193 ArtifactUnrecognized,
194 ArtifactDigestMismatch,
195 ArtifactInvalid,
196 BindingGenerationFailed,
197 OperationIdentityMismatch,
198 EventIdentityMismatch,
199 CandidateInvalid,
200 InvalidPath,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct ExtractionScaffoldGenerationError {
206 pub code: ExtractionScaffoldGenerationIssueCode,
207 pub message: String,
208 pub next_actions: Vec<String>,
209}
210
211impl fmt::Display for ExtractionScaffoldGenerationError {
212 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213 formatter.write_str(&self.message)
214 }
215}
216
217impl std::error::Error for ExtractionScaffoldGenerationError {}
218
219#[derive(
220 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
221)]
222#[serde(rename_all = "snake_case")]
223pub enum ExtractionScaffoldIssueCode {
224 IntegrityInvalid,
225 FileDigestInvalid,
226 FilePathInvalid,
227 FileOrderInvalid,
228 PatchInvalid,
229 ModuleIdentityChanged,
230 CandidateServiceInvalid,
231 WorkloadEntrypointMissing,
232 BindingInvalid,
233 AuthorityChanged,
234 ProviderPathChanged,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
238#[serde(rename_all = "camelCase")]
239pub struct ExtractionScaffoldIssue {
240 pub code: ExtractionScaffoldIssueCode,
241 pub path: String,
242 pub message: String,
243 pub next_action: String,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum ExtractionScaffoldApplyErrorCode {
249 PlanStale,
250 ScaffoldInvalid,
251 ScaffoldConflict,
252 RepositoryInvalid,
253 Io,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct ExtractionScaffoldApplyError {
259 pub code: ExtractionScaffoldApplyErrorCode,
260 pub message: String,
261 pub conflicting_paths: Vec<String>,
262 pub next_actions: Vec<String>,
263 pub effects: ExtractionScaffoldEffects,
264}
265
266impl fmt::Display for ExtractionScaffoldApplyError {
267 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
268 formatter.write_str(&self.message)
269 }
270}
271
272impl std::error::Error for ExtractionScaffoldApplyError {}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
275#[serde(rename_all = "camelCase")]
276pub struct ExtractionScaffoldApplyResult {
277 pub protocol: String,
278 pub scaffold_id: String,
279 pub plan_id: String,
280 pub created_files: Vec<String>,
281 pub unchanged_files: Vec<String>,
282 pub linked_authority_remains_authoritative: bool,
283 pub effects: ExtractionScaffoldEffects,
284}
285
286#[derive(Debug)]
287struct GeneratedBindingOutput {
288 binding: ExtractionGeneratedBinding,
289 contents: String,
290}
291
292pub fn generate_extraction_scaffold(
293 inputs: &ExtractionScaffoldInputs,
294) -> Result<ExtractionScaffold, ExtractionScaffoldGenerationError> {
295 validate_scaffold_inputs(inputs)?;
296 let plan = &inputs.plan;
297 let destination_root = format!("services/{}", plan.proposed_service.service_id);
298 validate_relative_path(&destination_root).map_err(|message| {
299 generation_error(
300 ExtractionScaffoldGenerationIssueCode::InvalidPath,
301 message,
302 "Use a stable Service identity that produces a repository-relative destination.",
303 )
304 })?;
305
306 let artifacts = inputs
307 .artifacts
308 .iter()
309 .map(|artifact| {
310 (
311 (artifact.contract_id.as_str(), artifact.version.as_str()),
312 artifact,
313 )
314 })
315 .collect::<BTreeMap<_, _>>();
316 let mut files = Vec::new();
317 let mut generated_bindings = Vec::new();
318 for contract in &plan.proposed_service.contract_versions {
319 let artifact = artifacts
320 .get(&(contract.contract_id.as_str(), contract.version.as_str()))
321 .expect("validated artifacts cover every planned Contract Version");
322 files.push(scaffold_file(
323 &destination_root,
324 &contract.artifact_reference,
325 ExtractionScaffoldFileKind::ContractArtifact,
326 artifact.contents.clone(),
327 )?);
328 let output = generated_binding(plan, contract, artifact, &destination_root)?;
329 files.push(scaffold_file(
330 "",
331 &output.binding.binding_path,
332 ExtractionScaffoldFileKind::GeneratedBinding,
333 output.contents,
334 )?);
335 generated_bindings.push(output.binding);
336 }
337 generated_bindings.sort();
338 validate_manifest_contract_identities(&inputs.module, &generated_bindings)?;
339
340 let candidate_service = candidate_service(plan)?;
341 let candidate_service_value = serde_json::to_value(&candidate_service).map_err(|error| {
342 generation_error(
343 ExtractionScaffoldGenerationIssueCode::CandidateInvalid,
344 format!("Candidate Service could not serialize: {error}"),
345 "Correct the approved plan and regenerate the scaffold.",
346 )
347 })?;
348 let available_paths = plan
349 .proposed_service
350 .contract_versions
351 .iter()
352 .map(|contract| contract.artifact_reference.clone())
353 .collect::<BTreeSet<_>>();
354 let mut candidate_issues = validate_autonomous_service_contract(&candidate_service);
355 candidate_issues.extend(validate_autonomous_service_artifact_references(
356 &candidate_service_value,
357 &available_paths,
358 ));
359 if let Some(issue) = candidate_issues.first() {
360 return Err(generation_error(
361 ExtractionScaffoldGenerationIssueCode::CandidateInvalid,
362 format!(
363 "Candidate Service is invalid at {}: {}",
364 issue.path, issue.message
365 ),
366 issue.next_action.clone(),
367 ));
368 }
369
370 let identity = preserved_identity(&inputs.module)?;
371 let crate_name = rust_identifier(&format!("{}_candidate", plan.proposed_service.service_id));
372 let module_json = pretty_json(&identity.module_manifest)?;
373 let service_json = pretty_json(&candidate_service_value)?;
374 files.extend([
375 scaffold_file(
376 &destination_root,
377 "Cargo.toml",
378 ExtractionScaffoldFileKind::CargoManifest,
379 cargo_manifest(&crate_name, &plan.proposed_service.workloads),
380 )?,
381 scaffold_file(
382 &destination_root,
383 "lenso.module.json",
384 ExtractionScaffoldFileKind::ModuleManifest,
385 module_json,
386 )?,
387 scaffold_file(
388 &destination_root,
389 "lenso.service.json",
390 ExtractionScaffoldFileKind::ServiceManifest,
391 service_json,
392 )?,
393 scaffold_file(
394 &destination_root,
395 "src/lib.rs",
396 ExtractionScaffoldFileKind::RustLibrary,
397 rust_library(&identity, &plan.proposed_service.service_id),
398 )?,
399 scaffold_file(
400 &destination_root,
401 "README.md",
402 ExtractionScaffoldFileKind::Readme,
403 candidate_readme(plan),
404 )?,
405 scaffold_file(
406 &destination_root,
407 "migrations/README.md",
408 ExtractionScaffoldFileKind::MigrationGuide,
409 migration_readme(plan),
410 )?,
411 ]);
412 for workload in &plan.proposed_service.workloads {
413 let role = workload_role_label(workload.role);
414 files.push(scaffold_file(
415 &destination_root,
416 &format!("src/bin/{role}.rs"),
417 ExtractionScaffoldFileKind::WorkloadEntrypoint,
418 workload_entrypoint(
419 &plan.proposed_service.service_id,
420 &inputs.module.module_id,
421 &workload.workload_id,
422 role,
423 ),
424 )?);
425 }
426
427 let mut boundary_replacements = Vec::new();
428 for client in &plan.proposed_service.generated_clients {
429 let binding = generated_bindings
430 .iter()
431 .find(|binding| {
432 binding.contract_id == client.contract_id && binding.version == client.version
433 })
434 .ok_or_else(|| {
435 generation_error(
436 ExtractionScaffoldGenerationIssueCode::BindingGenerationFailed,
437 format!(
438 "Generated client `{}` has no authoritative binding.",
439 client.client_id
440 ),
441 "Pin and supply the authoritative HTTP or gRPC Contract artifact.",
442 )
443 })?;
444 let path = format!("generated/clients/{}.json", stable_slug(&client.client_id));
445 let value = json!({
446 "protocol": "lenso.generated-service-client.v1",
447 "clientId": client.client_id,
448 "ownerId": client.owner_id,
449 "contractId": client.contract_id,
450 "version": client.version,
451 "transport": binding_kind_label(binding.kind),
452 "artifact": client.artifact_reference,
453 "binding": strip_destination(&destination_root, &binding.binding_path),
454 "operationIds": binding.operation_ids,
455 "tenancyMode": binding.tenancy_mode,
456 "requiredContext": binding.required_context,
457 });
458 files.push(scaffold_file(
459 &destination_root,
460 &path,
461 ExtractionScaffoldFileKind::ServiceClient,
462 pretty_json(&value)?,
463 )?);
464 boundary_replacements.push(client.client_id.clone());
465 }
466 boundary_replacements.sort();
467 boundary_replacements.dedup();
468
469 files.sort_by(|left, right| left.path.cmp(&right.path));
470 ensure_unique_file_paths(&files)?;
471 let managed_files = files
472 .iter()
473 .map(|file| json!({ "path": file.path, "digest": file.digest }))
474 .collect::<Vec<_>>();
475 let receipt = json!({
476 "protocol": "lenso.extraction-scaffold-ownership.v1",
477 "generatorVersion": EXTRACTION_SCAFFOLD_GENERATOR_VERSION,
478 "planId": plan.plan_id,
479 "planDigest": plan.plan_digest,
480 "linkedAuthorityRemainsAuthoritative": true,
481 "managedFiles": managed_files,
482 });
483 files.push(scaffold_file(
484 &destination_root,
485 ".lenso/extraction-scaffold.json",
486 ExtractionScaffoldFileKind::OwnershipReceipt,
487 pretty_json(&receipt)?,
488 )?);
489 files.sort_by(|left, right| left.path.cmp(&right.path));
490
491 let local_behavior_ids = local_behavior_ids(&inputs.module);
492 let patch = render_patch(&files);
493 let effects = ExtractionScaffoldEffects::default();
494 let content = ExtractionScaffoldContent {
495 protocol: EXTRACTION_SCAFFOLD_PROTOCOL,
496 generator_version: EXTRACTION_SCAFFOLD_GENERATOR_VERSION,
497 plan_id: &plan.plan_id,
498 plan_digest: &plan.plan_digest,
499 target_module: &inputs.module.module_id,
500 candidate_service_id: &plan.proposed_service.service_id,
501 destination_root: &destination_root,
502 linked_authority_remains_authoritative: true,
503 provider_compatibility_preserved: true,
504 preserved_identity: &identity,
505 candidate_service: &candidate_service_value,
506 bindings: &generated_bindings,
507 local_behavior_ids: &local_behavior_ids,
508 boundary_replacements: &boundary_replacements,
509 files: &files,
510 patch: &patch,
511 effects,
512 };
513 let scaffold_digest = digest_serializable(&content)?;
514 let scaffold = ExtractionScaffold {
515 protocol: EXTRACTION_SCAFFOLD_PROTOCOL.to_owned(),
516 generator_version: EXTRACTION_SCAFFOLD_GENERATOR_VERSION.to_owned(),
517 scaffold_id: format!("extraction-scaffold:{scaffold_digest}"),
518 scaffold_digest,
519 plan_id: plan.plan_id.clone(),
520 plan_digest: plan.plan_digest.clone(),
521 target_module: inputs.module.module_id.clone(),
522 candidate_service_id: plan.proposed_service.service_id.clone(),
523 destination_root,
524 linked_authority_remains_authoritative: true,
525 provider_compatibility_preserved: true,
526 preserved_identity: identity,
527 candidate_service: candidate_service_value,
528 bindings: generated_bindings,
529 local_behavior_ids,
530 boundary_replacements,
531 files,
532 patch,
533 effects,
534 };
535 debug_assert!(extraction_scaffold_integrity_is_valid(&scaffold));
536 Ok(scaffold)
537}
538
539pub fn dry_run_extraction_scaffold(
540 inputs: &ExtractionScaffoldInputs,
541) -> Result<ExtractionScaffold, ExtractionScaffoldGenerationError> {
542 generate_extraction_scaffold(inputs)
543}
544
545fn validate_scaffold_inputs(
546 inputs: &ExtractionScaffoldInputs,
547) -> Result<(), ExtractionScaffoldGenerationError> {
548 if !extraction_plan_integrity_is_valid(&inputs.plan) {
549 return Err(generation_error(
550 ExtractionScaffoldGenerationIssueCode::PlanInvalid,
551 "The approved Extraction Plan failed content-address validation.",
552 "Discard the modified plan and generate a fresh Extraction Plan.",
553 ));
554 }
555 if inputs.plan.target_module != inputs.module.module_id
556 || inputs.plan.proposed_service.module_id != inputs.module.module_id
557 {
558 return Err(generation_error(
559 ExtractionScaffoldGenerationIssueCode::ModuleIdentityMismatch,
560 "The linked Module identity does not match the approved Extraction Plan.",
561 "Load the exact Module declaration pinned by the plan.",
562 ));
563 }
564 let module_digest = digest_serializable(&inputs.module)?;
565 let module_pin = inputs.plan.pinned_inputs.iter().find(|pin| {
566 pin.kind == ExtractionInputPinKind::ModuleDeclaration
567 && pin.subject == inputs.module.module_id
568 });
569 if module_pin.is_none_or(|pin| pin.digest != module_digest) {
570 return Err(generation_error(
571 ExtractionScaffoldGenerationIssueCode::ModuleIdentityMismatch,
572 "The current Module declaration differs from the plan-pinned declaration.",
573 "Regenerate the Extraction Plan from the current Module declaration.",
574 ));
575 }
576 let planned = inputs
577 .plan
578 .proposed_service
579 .contract_versions
580 .iter()
581 .map(|contract| (contract.contract_id.as_str(), contract.version.as_str()))
582 .collect::<BTreeSet<_>>();
583 let supplied = inputs
584 .artifacts
585 .iter()
586 .map(|artifact| (artifact.contract_id.as_str(), artifact.version.as_str()))
587 .collect::<BTreeSet<_>>();
588 if supplied.len() != inputs.artifacts.len() {
589 return Err(generation_error(
590 ExtractionScaffoldGenerationIssueCode::ArtifactUnrecognized,
591 "The scaffold input contains duplicate Contract artifacts.",
592 "Supply exactly one authoritative artifact for every planned Contract Version.",
593 ));
594 }
595 if let Some((contract_id, version)) = planned.difference(&supplied).next() {
596 return Err(generation_error(
597 ExtractionScaffoldGenerationIssueCode::ArtifactMissing,
598 format!("Contract artifact `{contract_id}@{version}` is missing."),
599 "Supply the exact artifact pinned by the approved Extraction Plan.",
600 ));
601 }
602 if let Some((contract_id, version)) = supplied.difference(&planned).next() {
603 return Err(generation_error(
604 ExtractionScaffoldGenerationIssueCode::ArtifactUnrecognized,
605 format!("Contract artifact `{contract_id}@{version}` is not in the plan."),
606 "Remove unplanned artifacts and regenerate the deterministic scaffold.",
607 ));
608 }
609 for contract in &inputs.plan.proposed_service.contract_versions {
610 let artifact = inputs
611 .artifacts
612 .iter()
613 .find(|artifact| {
614 artifact.contract_id == contract.contract_id && artifact.version == contract.version
615 })
616 .expect("planned and supplied identity sets match");
617 if extraction_input_digest(artifact.contents.as_bytes()) != contract.artifact_digest {
618 return Err(generation_error(
619 ExtractionScaffoldGenerationIssueCode::ArtifactDigestMismatch,
620 format!(
621 "Contract artifact `{}@{}` does not match its pinned digest.",
622 contract.contract_id, contract.version
623 ),
624 "Load the exact authoritative artifact or regenerate the Extraction Plan.",
625 ));
626 }
627 validate_relative_path(&contract.artifact_reference).map_err(|message| {
628 generation_error(
629 ExtractionScaffoldGenerationIssueCode::InvalidPath,
630 message,
631 "Use a repository-relative authoritative Contract artifact path.",
632 )
633 })?;
634 }
635 Ok(())
636}
637
638fn generated_binding(
639 plan: &ExtractionPlan,
640 contract: &crate::ExtractionPlanContractVersion,
641 artifact: &ExtractionScaffoldArtifact,
642 destination_root: &str,
643) -> Result<GeneratedBindingOutput, ExtractionScaffoldGenerationError> {
644 let role = match (contract.kind, contract.direction) {
645 (ExtractionContractKind::Service, ExtractionContractDirection::Provides) => {
646 ExtractionScaffoldBindingRole::Server
647 }
648 (ExtractionContractKind::Service, ExtractionContractDirection::Consumes) => {
649 ExtractionScaffoldBindingRole::Client
650 }
651 (ExtractionContractKind::Event, ExtractionContractDirection::Provides) => {
652 ExtractionScaffoldBindingRole::Publisher
653 }
654 (ExtractionContractKind::Event, ExtractionContractDirection::Consumes) => {
655 ExtractionScaffoldBindingRole::Handler
656 }
657 };
658 let client_ids = plan
659 .proposed_service
660 .generated_clients
661 .iter()
662 .filter(|client| {
663 client.contract_id == contract.contract_id && client.version == contract.version
664 })
665 .map(|client| client.client_id.clone())
666 .collect::<Vec<_>>();
667 let binding_name = format!(
668 "{}-{}-{}",
669 stable_slug(&contract.contract_id),
670 stable_slug(&contract.version),
671 binding_role_label(role)
672 );
673 let binding_relative = format!("generated/bindings/{binding_name}.json");
674 let binding_path = format!("{destination_root}/{binding_relative}");
675 let (kind, contents, operation_ids, event_types) = match contract.artifact_format {
676 ExtractionContractArtifactFormat::Openapi => {
677 let document = serde_yaml::from_str::<Value>(&artifact.contents).map_err(|error| {
678 generation_error(
679 ExtractionScaffoldGenerationIssueCode::ArtifactInvalid,
680 format!(
681 "OpenAPI artifact `{}@{}` is invalid: {error}",
682 contract.contract_id, contract.version
683 ),
684 "Correct the authoritative OpenAPI artifact and regenerate the plan.",
685 )
686 })?;
687 let bindings =
688 generate_direct_http_bindings(&contract.contract_id, &contract.version, &document)
689 .map_err(|error| {
690 generation_error(
691 ExtractionScaffoldGenerationIssueCode::BindingGenerationFailed,
692 error.to_string(),
693 "Correct the authoritative OpenAPI operation and policy declarations.",
694 )
695 })?;
696 let operation_ids = bindings
697 .operations
698 .iter()
699 .map(|operation| operation.operation_id.clone())
700 .collect();
701 (
702 ExtractionGeneratedBindingKind::Http,
703 pretty_json(&serde_json::to_value(bindings).expect("bindings serialize"))?,
704 operation_ids,
705 Vec::new(),
706 )
707 }
708 ExtractionContractArtifactFormat::Protobuf
709 if contract.kind == ExtractionContractKind::Service =>
710 {
711 let descriptor = artifact.protobuf_descriptor.as_deref().ok_or_else(|| {
712 generation_error(
713 ExtractionScaffoldGenerationIssueCode::ArtifactInvalid,
714 format!(
715 "Protobuf artifact `{}@{}` is missing its generated descriptor.",
716 contract.contract_id, contract.version
717 ),
718 "Generate the descriptor from the exact pinned Protobuf source.",
719 )
720 })?;
721 let bindings = generate_direct_grpc_bindings(
722 &contract.contract_id,
723 &contract.version,
724 &artifact.contents,
725 descriptor,
726 )
727 .map_err(|error| {
728 generation_error(
729 ExtractionScaffoldGenerationIssueCode::BindingGenerationFailed,
730 error,
731 "Correct the authoritative Protobuf operations and call-policy annotations.",
732 )
733 })?;
734 let operation_ids = bindings
735 .operations
736 .iter()
737 .map(|operation| operation.operation_id.clone())
738 .collect();
739 (
740 ExtractionGeneratedBindingKind::Grpc,
741 pretty_json(&serde_json::to_value(bindings).expect("bindings serialize"))?,
742 operation_ids,
743 Vec::new(),
744 )
745 }
746 ExtractionContractArtifactFormat::JsonSchema => {
747 let schema = serde_json::from_str::<Value>(&artifact.contents).map_err(|error| {
748 generation_error(
749 ExtractionScaffoldGenerationIssueCode::ArtifactInvalid,
750 format!(
751 "Event JSON Schema `{}@{}` is invalid: {error}",
752 contract.contract_id, contract.version
753 ),
754 "Correct the authoritative Event Contract schema.",
755 )
756 })?;
757 jsonschema::validator_for(&schema).map_err(|error| {
758 generation_error(
759 ExtractionScaffoldGenerationIssueCode::ArtifactInvalid,
760 format!(
761 "Event JSON Schema `{}@{}` cannot compile: {error}",
762 contract.contract_id, contract.version
763 ),
764 "Correct the authoritative Event Contract schema.",
765 )
766 })?;
767 let event_type = event_type_from_schema(&schema, &contract.artifact_reference)?;
768 let binding = json!({
769 "protocol": "lenso.generated-event-binding.v1",
770 "contractId": contract.contract_id,
771 "version": contract.version,
772 "eventType": event_type,
773 "direction": contract.direction,
774 "artifact": contract.artifact_reference,
775 "artifactDigest": contract.artifact_digest,
776 "tenancyMode": contract.tenancy_mode,
777 "requiredContext": contract.required_context,
778 });
779 (
780 ExtractionGeneratedBindingKind::Event,
781 pretty_json(&binding)?,
782 Vec::new(),
783 vec![event_type],
784 )
785 }
786 ExtractionContractArtifactFormat::Protobuf => {
787 return Err(generation_error(
788 ExtractionScaffoldGenerationIssueCode::BindingGenerationFailed,
789 format!(
790 "Event Protobuf scaffold generation is not available for `{}@{}`.",
791 contract.contract_id, contract.version
792 ),
793 "Use the authoritative JSON Schema Event Contract for this beta scaffold.",
794 ));
795 }
796 };
797 Ok(GeneratedBindingOutput {
798 binding: ExtractionGeneratedBinding {
799 contract_id: contract.contract_id.clone(),
800 version: contract.version.clone(),
801 kind,
802 role,
803 artifact_path: format!("{destination_root}/{}", contract.artifact_reference),
804 artifact_digest: contract.artifact_digest.clone(),
805 binding_path,
806 binding_digest: extraction_input_digest(contents.as_bytes()),
807 tenancy_mode: contract.tenancy_mode.clone(),
808 required_context: contract.required_context.clone(),
809 operation_ids,
810 event_types,
811 generated_client_ids: client_ids,
812 },
813 contents,
814 })
815}
816
817fn validate_manifest_contract_identities(
818 module: &ModuleManifest,
819 bindings: &[ExtractionGeneratedBinding],
820) -> Result<(), ExtractionScaffoldGenerationError> {
821 let http_bindings = bindings
822 .iter()
823 .filter(|binding| {
824 binding.kind == ExtractionGeneratedBindingKind::Http
825 && binding.role == ExtractionScaffoldBindingRole::Server
826 })
827 .collect::<Vec<_>>();
828 for route in &module.http_routes {
829 let Some(operation_id) = route
830 .operation
831 .as_ref()
832 .and_then(|operation| operation.operation_id.as_deref())
833 else {
834 continue;
835 };
836 if !http_bindings.iter().any(|binding| {
837 binding
838 .operation_ids
839 .iter()
840 .any(|item| item == operation_id)
841 }) {
842 return Err(generation_error(
843 ExtractionScaffoldGenerationIssueCode::OperationIdentityMismatch,
844 format!(
845 "Module operation `{operation_id}` is absent from the authoritative provided HTTP binding."
846 ),
847 "Keep the Module operation identifier and authoritative OpenAPI operationId identical.",
848 ));
849 }
850 }
851 let consumed_event_types = bindings
852 .iter()
853 .filter(|binding| binding.role == ExtractionScaffoldBindingRole::Handler)
854 .flat_map(|binding| binding.event_types.iter())
855 .collect::<BTreeSet<_>>();
856 for handler in module
857 .events
858 .iter()
859 .flat_map(|events| events.handlers.iter())
860 {
861 if !consumed_event_types.contains(&handler.event_name) {
862 return Err(generation_error(
863 ExtractionScaffoldGenerationIssueCode::EventIdentityMismatch,
864 format!(
865 "Module Event type `{}` is absent from the authoritative consumed Event bindings.",
866 handler.event_name
867 ),
868 "Keep the Module Event type and authoritative Event Contract title identical.",
869 ));
870 }
871 }
872 Ok(())
873}
874
875fn candidate_service(
876 plan: &ExtractionPlan,
877) -> Result<AutonomousServiceContract, ExtractionScaffoldGenerationError> {
878 let service_id = &plan.proposed_service.service_id;
879 let workloads = plan
880 .proposed_service
881 .workloads
882 .iter()
883 .map(|workload| {
884 AutonomousServiceWorkload::new(
885 &workload.workload_id,
886 service_id,
887 match workload.role {
888 ExtractionWorkloadRole::Api => WorkloadRole::API,
889 ExtractionWorkloadRole::Worker => WorkloadRole::WORKER,
890 ExtractionWorkloadRole::Migration => WorkloadRole::MIGRATION,
891 },
892 )
893 })
894 .collect();
895 let tenancy_mode = plan
896 .proposed_service
897 .contract_versions
898 .iter()
899 .map(|contract| contract.tenancy_mode.clone())
900 .max()
901 .unwrap_or(ServiceTenancyMode::None);
902 let mut service = AutonomousServiceContract::new(
903 service_id,
904 workloads,
905 tenancy_mode,
906 vec!["local-sandbox".to_owned()],
907 );
908 service.version = Some("0.0.0-extraction-candidate".to_owned());
909 service.modules = vec![plan.target_module.clone()];
910 service.stores = vec![AutonomousServiceStore::new(
911 &plan.proposed_service.store.store_id,
912 service_id,
913 )];
914 for contract in &plan.proposed_service.contract_versions {
915 if contract.direction != ExtractionContractDirection::Provides {
916 continue;
917 }
918 match contract.kind {
919 ExtractionContractKind::Service => {
920 let format = match contract.artifact_format {
921 ExtractionContractArtifactFormat::Openapi => ServiceArtifactFormat::Openapi,
922 ExtractionContractArtifactFormat::Protobuf => ServiceArtifactFormat::Protobuf,
923 ExtractionContractArtifactFormat::JsonSchema => {
924 return Err(generation_error(
925 ExtractionScaffoldGenerationIssueCode::CandidateInvalid,
926 "A provided Service Contract cannot use JSON Schema as its transport artifact.",
927 "Correct the approved Contract Version.",
928 ));
929 }
930 };
931 let mut declaration = ServiceContractArtifact::new(
932 &contract.contract_id,
933 &plan.target_module,
934 &contract.version,
935 contract.tenancy_mode.clone(),
936 ServiceArtifactReference::new(format, &contract.artifact_reference),
937 );
938 declaration.context =
939 ContractContextRequirements::new(contract.required_context.clone());
940 service.service_contracts.push(declaration);
941 }
942 ExtractionContractKind::Event => {
943 let format = match contract.artifact_format {
944 ExtractionContractArtifactFormat::JsonSchema => EventArtifactFormat::JsonSchema,
945 ExtractionContractArtifactFormat::Protobuf => EventArtifactFormat::Protobuf,
946 ExtractionContractArtifactFormat::Openapi => {
947 return Err(generation_error(
948 ExtractionScaffoldGenerationIssueCode::CandidateInvalid,
949 "A provided Event Contract cannot use OpenAPI as its artifact.",
950 "Correct the approved Contract Version.",
951 ));
952 }
953 };
954 let mut declaration = EventContractArtifact::new(
955 &contract.contract_id,
956 &plan.target_module,
957 &contract.version,
958 contract.tenancy_mode.clone(),
959 EventArtifactReference::new(format, &contract.artifact_reference),
960 );
961 declaration.context =
962 ContractContextRequirements::new(contract.required_context.clone());
963 service.event_contracts.push(declaration);
964 }
965 }
966 }
967 service.service_contracts.sort_by(|left, right| {
968 (&left.contract_id, &left.version).cmp(&(&right.contract_id, &right.version))
969 });
970 service.event_contracts.sort_by(|left, right| {
971 (&left.contract_id, &left.version).cmp(&(&right.contract_id, &right.version))
972 });
973 Ok(service)
974}
975
976fn preserved_identity(
977 module: &ModuleManifest,
978) -> Result<ExtractionPreservedIdentity, ExtractionScaffoldGenerationError> {
979 let module_manifest = serde_json::to_value(module).map_err(|error| {
980 generation_error(
981 ExtractionScaffoldGenerationIssueCode::ModuleIdentityMismatch,
982 format!("Module declaration could not serialize: {error}"),
983 "Correct the linked Module declaration before extraction.",
984 )
985 })?;
986 let runtime = module.runtime.as_ref();
987 let mut operation_ids = module
988 .http_routes
989 .iter()
990 .filter_map(|route| route.operation.as_ref()?.operation_id.clone())
991 .chain(runtime.into_iter().flat_map(|runtime| {
992 runtime
993 .functions
994 .iter()
995 .filter_map(|function| function.operation.as_ref()?.operation_id.clone())
996 }))
997 .collect::<Vec<_>>();
998 normalize_strings(&mut operation_ids);
999 let mut event_types = module
1000 .events
1001 .iter()
1002 .flat_map(|events| {
1003 events
1004 .handlers
1005 .iter()
1006 .map(|handler| handler.event_name.clone())
1007 })
1008 .collect::<Vec<_>>();
1009 normalize_strings(&mut event_types);
1010 let mut runtime_function_names = runtime
1011 .into_iter()
1012 .flat_map(|runtime| {
1013 runtime
1014 .functions
1015 .iter()
1016 .map(|function| function.name.clone())
1017 })
1018 .collect::<Vec<_>>();
1019 normalize_strings(&mut runtime_function_names);
1020 let mut schedule_names = runtime
1021 .into_iter()
1022 .flat_map(|runtime| {
1023 runtime
1024 .schedules
1025 .iter()
1026 .map(|schedule| schedule.name.clone())
1027 })
1028 .collect::<Vec<_>>();
1029 normalize_strings(&mut schedule_names);
1030 let mut workflow_identities = runtime
1031 .into_iter()
1032 .flat_map(|runtime| {
1033 runtime.workflows.iter().map(|workflow| {
1034 format!("{}/{}@{}", workflow.owner, workflow.name, workflow.version)
1035 })
1036 })
1037 .collect::<Vec<_>>();
1038 normalize_strings(&mut workflow_identities);
1039 let mut story_titles = module
1040 .http_routes
1041 .iter()
1042 .filter_map(|route| route.story_title.clone())
1043 .chain(
1044 module
1045 .story_display
1046 .iter()
1047 .filter_map(|story| story.story_title.clone()),
1048 )
1049 .collect::<Vec<_>>();
1050 normalize_strings(&mut story_titles);
1051 Ok(ExtractionPreservedIdentity {
1052 module_name: module.module_id.clone(),
1053 module_manifest_digest: digest_serializable(module)?,
1054 module_manifest,
1055 capabilities: module.capabilities.clone(),
1056 operation_ids,
1057 event_types,
1058 runtime_function_names,
1059 schedule_names,
1060 workflow_identities,
1061 story_titles,
1062 admin_identity: serde_json::to_value(&module.admin).expect("admin identity serializes"),
1063 console_identity: serde_json::to_value(&module.console)
1064 .expect("console identity serializes"),
1065 })
1066}
1067
1068fn local_behavior_ids(module: &ModuleManifest) -> Vec<String> {
1069 let mut behaviors = module
1070 .http_routes
1071 .iter()
1072 .map(|route| {
1073 route
1074 .operation
1075 .as_ref()
1076 .and_then(|operation| operation.operation_id.clone())
1077 .unwrap_or_else(|| format!("{} {}", http_method_label(route.method), route.path))
1078 })
1079 .chain(module.events.iter().flat_map(|events| {
1080 events
1081 .handlers
1082 .iter()
1083 .map(|handler| format!("event-handler:{}", handler.name))
1084 }))
1085 .chain(module.runtime.iter().flat_map(|runtime| {
1086 runtime
1087 .functions
1088 .iter()
1089 .map(|function| format!("runtime-function:{}", function.name))
1090 }))
1091 .chain(module.runtime.iter().flat_map(|runtime| {
1092 runtime
1093 .schedules
1094 .iter()
1095 .map(|schedule| format!("schedule:{}", schedule.name))
1096 }))
1097 .chain(module.runtime.iter().flat_map(|runtime| {
1098 runtime.workflows.iter().map(|workflow| {
1099 format!(
1100 "workflow:{}/{}@{}",
1101 workflow.owner, workflow.name, workflow.version
1102 )
1103 })
1104 }))
1105 .collect::<Vec<_>>();
1106 normalize_strings(&mut behaviors);
1107 behaviors
1108}
1109
1110fn scaffold_file(
1111 root: &str,
1112 relative: &str,
1113 kind: ExtractionScaffoldFileKind,
1114 contents: String,
1115) -> Result<ExtractionScaffoldFile, ExtractionScaffoldGenerationError> {
1116 let path = if root.is_empty() {
1117 relative.to_owned()
1118 } else {
1119 format!("{root}/{relative}")
1120 };
1121 validate_relative_path(&path).map_err(|message| {
1122 generation_error(
1123 ExtractionScaffoldGenerationIssueCode::InvalidPath,
1124 message,
1125 "Use repository-relative generated file paths without traversal or platform prefixes.",
1126 )
1127 })?;
1128 Ok(ExtractionScaffoldFile {
1129 path,
1130 kind,
1131 digest: extraction_input_digest(contents.as_bytes()),
1132 contents,
1133 })
1134}
1135
1136fn ensure_unique_file_paths(
1137 files: &[ExtractionScaffoldFile],
1138) -> Result<(), ExtractionScaffoldGenerationError> {
1139 let mut paths = BTreeSet::new();
1140 if let Some(file) = files.iter().find(|file| !paths.insert(&file.path)) {
1141 return Err(generation_error(
1142 ExtractionScaffoldGenerationIssueCode::InvalidPath,
1143 format!("Generated path `{}` is duplicated.", file.path),
1144 "Correct the Contract identities so every generated output path is unique.",
1145 ));
1146 }
1147 Ok(())
1148}
1149
1150fn event_type_from_schema(
1151 schema: &Value,
1152 artifact_reference: &str,
1153) -> Result<String, ExtractionScaffoldGenerationError> {
1154 let title = schema
1155 .get("title")
1156 .and_then(Value::as_str)
1157 .filter(|title| !title.trim().is_empty())
1158 .ok_or_else(|| {
1159 generation_error(
1160 ExtractionScaffoldGenerationIssueCode::EventIdentityMismatch,
1161 "An Event Contract schema must expose its stable Event type as `title`.",
1162 "Set the JSON Schema title to the authoritative Event type.",
1163 )
1164 })?;
1165 let file_identity = artifact_reference
1166 .rsplit('/')
1167 .next()
1168 .and_then(|name| name.strip_suffix(".schema.json"));
1169 if file_identity != Some(title) {
1170 return Err(generation_error(
1171 ExtractionScaffoldGenerationIssueCode::EventIdentityMismatch,
1172 format!("Event type `{title}` does not match artifact `{artifact_reference}`."),
1173 "Keep the Event type and authoritative schema filename identical.",
1174 ));
1175 }
1176 Ok(title.to_owned())
1177}
1178
1179fn cargo_manifest(crate_name: &str, workloads: &[crate::ExtractionWorkloadPlan]) -> String {
1180 let mut output = format!(
1181 "[package]\nname = \"{crate_name}\"\nversion = \"0.0.0\"\nedition = \"2024\"\npublish = false\n\n[lib]\npath = \"src/lib.rs\"\n"
1182 );
1183 for workload in workloads {
1184 let role = workload_role_label(workload.role);
1185 output.push_str(&format!(
1186 "\n[[bin]]\nname = \"{}\"\npath = \"src/bin/{role}.rs\"\n",
1187 stable_slug(&workload.workload_id)
1188 ));
1189 }
1190 output
1191}
1192
1193fn rust_library(identity: &ExtractionPreservedIdentity, service_id: &str) -> String {
1194 format!(
1195 "//! Generated extraction candidate. The linked Module remains authoritative.\n\npub const SERVICE_ID: &str = {};\npub const MODULE_ID: &str = {};\npub const LINKED_AUTHORITY_REMAINS_AUTHORITATIVE: bool = true;\npub const MODULE_MANIFEST_JSON: &str = include_str!(\"../lenso.module.json\");\npub const SERVICE_MANIFEST_JSON: &str = include_str!(\"../lenso.service.json\");\n\n#[must_use]\npub fn validate_public_entrypoints() -> bool {{\n !SERVICE_ID.is_empty()\n && !MODULE_ID.is_empty()\n && LINKED_AUTHORITY_REMAINS_AUTHORITATIVE\n && !MODULE_MANIFEST_JSON.is_empty()\n && !SERVICE_MANIFEST_JSON.is_empty()\n}}\n",
1196 rust_string(service_id),
1197 rust_string(&identity.module_name),
1198 )
1199}
1200
1201fn workload_entrypoint(service_id: &str, module_id: &str, workload_id: &str, role: &str) -> String {
1202 format!(
1203 "//! Generated {role} Workload validation entrypoint.\n\npub const SERVICE_ID: &str = {};\npub const MODULE_ID: &str = {};\npub const WORKLOAD_ID: &str = {};\npub const WORKLOAD_ROLE: &str = {};\n\npub fn run() {{\n println!(\"{{{{\\\"serviceId\\\":\\\"{{}}\\\",\\\"moduleId\\\":\\\"{{}}\\\",\\\"workloadId\\\":\\\"{{}}\\\",\\\"role\\\":\\\"{{}}\\\",\\\"authority\\\":\\\"linked_host\\\"}}}}\", SERVICE_ID, MODULE_ID, WORKLOAD_ID, WORKLOAD_ROLE);\n}}\n\nfn main() {{\n run();\n}}\n",
1204 rust_string(service_id),
1205 rust_string(module_id),
1206 rust_string(workload_id),
1207 rust_string(role),
1208 )
1209}
1210
1211fn candidate_readme(plan: &ExtractionPlan) -> String {
1212 format!(
1213 "# {} extraction candidate\n\nThis scaffold preserves Module `{}` and is bound to `{}`.\n\nThe linked Host remains authoritative. These API, Worker, and Migration entrypoints validate the candidate shape only; Store expansion and Workload startup belong to the next approved Extraction Plan phase.\n\nGenerated Contract bindings live under `generated/bindings/`; only planned cross-Service boundaries receive generated clients under `generated/clients/`. Existing Provider v1 files and behavior are not changed.\n",
1214 plan.proposed_service.service_id, plan.target_module, plan.plan_id
1215 )
1216}
1217
1218fn migration_readme(plan: &ExtractionPlan) -> String {
1219 let migrations = plan
1220 .data_mapping
1221 .migrations
1222 .iter()
1223 .map(|migration| {
1224 format!(
1225 "- `{}` from `{}` (`{}`)",
1226 migration.source_migration, migration.source_reference, migration.source_digest
1227 )
1228 })
1229 .collect::<Vec<_>>();
1230 format!(
1231 "# Candidate migrations\n\nThe Migration Workload is scaffolded, but this phase does not apply schema or data changes. The expand-first phase must copy the plan-owned migrations from authoritative Module sources and bind receipts to `{}`.\n\nPlanned Module migrations:\n{}\n",
1232 plan.plan_id,
1233 if migrations.is_empty() {
1234 "- none declared".to_owned()
1235 } else {
1236 migrations.join("\n")
1237 }
1238 )
1239}
1240
1241#[must_use]
1242pub fn render_extraction_scaffold_patch(scaffold: &ExtractionScaffold) -> String {
1243 render_patch(&scaffold.files)
1244}
1245
1246fn render_patch(files: &[ExtractionScaffoldFile]) -> String {
1247 let mut output = String::new();
1248 for file in files {
1249 let line_count = file.contents.lines().count();
1250 output.push_str(&format!(
1251 "diff --git a/{0} b/{0}\nnew file mode 100644\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{line_count} @@\n",
1252 file.path
1253 ));
1254 for line in file.contents.split_inclusive('\n') {
1255 output.push('+');
1256 output.push_str(line);
1257 if !line.ends_with('\n') {
1258 output.push('\n');
1259 output.push_str("\\ No newline at end of file\n");
1260 }
1261 }
1262 }
1263 output
1264}
1265
1266#[must_use]
1267pub fn extraction_scaffold_integrity_is_valid(scaffold: &ExtractionScaffold) -> bool {
1268 if scaffold.protocol != EXTRACTION_SCAFFOLD_PROTOCOL
1269 || scaffold.generator_version != EXTRACTION_SCAFFOLD_GENERATOR_VERSION
1270 || scaffold.scaffold_id != format!("extraction-scaffold:{}", scaffold.scaffold_digest)
1271 {
1272 return false;
1273 }
1274 let content = scaffold_content(scaffold);
1275 digest_serializable(&content).is_ok_and(|digest| digest == scaffold.scaffold_digest)
1276}
1277
1278fn scaffold_content(scaffold: &ExtractionScaffold) -> ExtractionScaffoldContent<'_> {
1279 ExtractionScaffoldContent {
1280 protocol: &scaffold.protocol,
1281 generator_version: &scaffold.generator_version,
1282 plan_id: &scaffold.plan_id,
1283 plan_digest: &scaffold.plan_digest,
1284 target_module: &scaffold.target_module,
1285 candidate_service_id: &scaffold.candidate_service_id,
1286 destination_root: &scaffold.destination_root,
1287 linked_authority_remains_authoritative: scaffold.linked_authority_remains_authoritative,
1288 provider_compatibility_preserved: scaffold.provider_compatibility_preserved,
1289 preserved_identity: &scaffold.preserved_identity,
1290 candidate_service: &scaffold.candidate_service,
1291 bindings: &scaffold.bindings,
1292 local_behavior_ids: &scaffold.local_behavior_ids,
1293 boundary_replacements: &scaffold.boundary_replacements,
1294 files: &scaffold.files,
1295 patch: &scaffold.patch,
1296 effects: scaffold.effects,
1297 }
1298}
1299
1300#[must_use]
1301pub fn validate_extraction_scaffold(scaffold: &ExtractionScaffold) -> Vec<ExtractionScaffoldIssue> {
1302 let mut issues = Vec::new();
1303 if !extraction_scaffold_integrity_is_valid(scaffold) {
1304 push_issue(
1305 &mut issues,
1306 ExtractionScaffoldIssueCode::IntegrityInvalid,
1307 "$.scaffoldDigest",
1308 "The scaffold content address is invalid.",
1309 "Discard the modified scaffold and regenerate it from the approved plan.",
1310 );
1311 }
1312 if !scaffold.linked_authority_remains_authoritative || scaffold.effects.changes_authority {
1313 push_issue(
1314 &mut issues,
1315 ExtractionScaffoldIssueCode::AuthorityChanged,
1316 "$.linkedAuthorityRemainsAuthoritative",
1317 "The scaffold phase must not change Module authority.",
1318 "Keep the linked Host authoritative until protected Cutover.",
1319 );
1320 }
1321 if !scaffold.provider_compatibility_preserved || scaffold.effects.changes_provider_path {
1322 push_issue(
1323 &mut issues,
1324 ExtractionScaffoldIssueCode::ProviderPathChanged,
1325 "$.providerCompatibilityPreserved",
1326 "The scaffold must not reinterpret or modify Provider v1 behavior.",
1327 "Generate the candidate under the Autonomous Service v2 destination only.",
1328 );
1329 }
1330 if scaffold.effects.starts_workloads
1331 || scaffold.effects.copies_data
1332 || scaffold.effects.writes_repository_files
1333 {
1334 push_issue(
1335 &mut issues,
1336 ExtractionScaffoldIssueCode::AuthorityChanged,
1337 "$.effects",
1338 "A generated or dry-run scaffold must have zero effects.",
1339 "Use apply only after reviewing the deterministic patch.",
1340 );
1341 }
1342 let mut previous = None;
1343 let mut paths = BTreeSet::new();
1344 for (index, file) in scaffold.files.iter().enumerate() {
1345 let path = format!("$.files[{index}]");
1346 if validate_relative_path(&file.path).is_err()
1347 || !file
1348 .path
1349 .starts_with(&format!("{}/", scaffold.destination_root))
1350 {
1351 push_issue(
1352 &mut issues,
1353 ExtractionScaffoldIssueCode::FilePathInvalid,
1354 &format!("{path}.path"),
1355 "A generated file path escapes the candidate Service destination.",
1356 "Regenerate the scaffold with repository-relative candidate paths.",
1357 );
1358 }
1359 if !paths.insert(&file.path)
1360 || previous.is_some_and(|item: &str| item >= file.path.as_str())
1361 {
1362 push_issue(
1363 &mut issues,
1364 ExtractionScaffoldIssueCode::FileOrderInvalid,
1365 &format!("{path}.path"),
1366 "Generated file paths must be unique and deterministically ordered.",
1367 "Regenerate the scaffold instead of reordering files manually.",
1368 );
1369 }
1370 previous = Some(file.path.as_str());
1371 if extraction_input_digest(file.contents.as_bytes()) != file.digest {
1372 push_issue(
1373 &mut issues,
1374 ExtractionScaffoldIssueCode::FileDigestInvalid,
1375 &format!("{path}.digest"),
1376 "A generated file no longer matches its digest.",
1377 "Discard the changed scaffold and regenerate it.",
1378 );
1379 }
1380 }
1381 if scaffold.patch != render_patch(&scaffold.files) {
1382 push_issue(
1383 &mut issues,
1384 ExtractionScaffoldIssueCode::PatchInvalid,
1385 "$.patch",
1386 "The review patch does not match the generated files.",
1387 "Regenerate the scaffold and review the exact patch.",
1388 );
1389 }
1390 validate_scaffold_manifests(scaffold, &mut issues);
1391 validate_scaffold_bindings(scaffold, &mut issues);
1392 for role in ["api", "worker", "migration"] {
1393 let expected = format!("{}/src/bin/{role}.rs", scaffold.destination_root);
1394 if !paths.contains(&expected) {
1395 push_issue(
1396 &mut issues,
1397 ExtractionScaffoldIssueCode::WorkloadEntrypointMissing,
1398 "$.files",
1399 &format!("The {role} Workload entrypoint is missing."),
1400 "Regenerate all planned Workload entrypoints.",
1401 );
1402 }
1403 }
1404 issues.sort_by(|left, right| (&left.path, &left.code).cmp(&(&right.path, &right.code)));
1405 issues
1406}
1407
1408fn validate_scaffold_manifests(
1409 scaffold: &ExtractionScaffold,
1410 issues: &mut Vec<ExtractionScaffoldIssue>,
1411) {
1412 let module_path = format!("{}/lenso.module.json", scaffold.destination_root);
1413 let module = scaffold.files.iter().find(|file| file.path == module_path);
1414 let module_matches = module
1415 .and_then(|file| serde_json::from_str::<Value>(&file.contents).ok())
1416 .is_some_and(|value| value == scaffold.preserved_identity.module_manifest);
1417 if !module_matches {
1418 push_issue(
1419 issues,
1420 ExtractionScaffoldIssueCode::ModuleIdentityChanged,
1421 "$.preservedIdentity.moduleManifest",
1422 "The generated Module declaration does not preserve the linked Module identity.",
1423 "Regenerate the candidate from the exact plan-pinned ModuleManifest.",
1424 );
1425 }
1426 let candidate_issues = validate_autonomous_service_contract_value_for_scaffold(
1427 &scaffold.candidate_service,
1428 scaffold,
1429 );
1430 if let Some(message) = candidate_issues.first() {
1431 push_issue(
1432 issues,
1433 ExtractionScaffoldIssueCode::CandidateServiceInvalid,
1434 "$.candidateService",
1435 message,
1436 "Regenerate the candidate Service from the approved plan.",
1437 );
1438 }
1439}
1440
1441fn validate_autonomous_service_contract_value_for_scaffold(
1442 value: &Value,
1443 scaffold: &ExtractionScaffold,
1444) -> Vec<String> {
1445 let contract = serde_json::from_value::<AutonomousServiceContract>(value.clone());
1446 let Ok(contract) = contract else {
1447 return vec!["Candidate Service JSON cannot be decoded.".to_owned()];
1448 };
1449 let mut messages = validate_autonomous_service_contract(&contract)
1450 .into_iter()
1451 .map(|issue| issue.message)
1452 .collect::<Vec<_>>();
1453 if contract.service_id != scaffold.candidate_service_id
1454 || contract.modules != [scaffold.target_module.clone()]
1455 {
1456 messages.push("Candidate Service or Module identity changed.".to_owned());
1457 }
1458 messages
1459}
1460
1461fn validate_scaffold_bindings(
1462 scaffold: &ExtractionScaffold,
1463 issues: &mut Vec<ExtractionScaffoldIssue>,
1464) {
1465 for (index, binding) in scaffold.bindings.iter().enumerate() {
1466 let file = scaffold
1467 .files
1468 .iter()
1469 .find(|file| file.path == binding.binding_path);
1470 let valid_digest = file.is_some_and(|file| file.digest == binding.binding_digest);
1471 let valid_identity = file.is_some_and(|file| match binding.kind {
1472 ExtractionGeneratedBindingKind::Http => {
1473 serde_json::from_str::<DirectHttpBindings>(&file.contents).is_ok_and(|value| {
1474 value.contract_id == binding.contract_id
1475 && value.version == binding.version
1476 && value
1477 .operations
1478 .iter()
1479 .map(|operation| &operation.operation_id)
1480 .eq(binding.operation_ids.iter())
1481 })
1482 }
1483 ExtractionGeneratedBindingKind::Grpc => {
1484 serde_json::from_str::<DirectGrpcBindings>(&file.contents).is_ok_and(|value| {
1485 value.contract_id == binding.contract_id
1486 && value.version == binding.version
1487 && value
1488 .operations
1489 .iter()
1490 .map(|operation| &operation.operation_id)
1491 .eq(binding.operation_ids.iter())
1492 })
1493 }
1494 ExtractionGeneratedBindingKind::Event => serde_json::from_str::<Value>(&file.contents)
1495 .is_ok_and(|value| {
1496 value.get("contractId").and_then(Value::as_str)
1497 == Some(binding.contract_id.as_str())
1498 && value.get("version").and_then(Value::as_str)
1499 == Some(binding.version.as_str())
1500 && value.get("eventType").and_then(Value::as_str)
1501 == binding.event_types.first().map(String::as_str)
1502 }),
1503 });
1504 if !valid_digest || !valid_identity {
1505 push_issue(
1506 issues,
1507 ExtractionScaffoldIssueCode::BindingInvalid,
1508 &format!("$.bindings[{index}]"),
1509 "A generated binding does not match its authoritative Contract identity.",
1510 "Regenerate bindings from the exact pinned Contract artifact.",
1511 );
1512 }
1513 }
1514}
1515
1516pub fn apply_extraction_scaffold(
1517 repository_root: &Path,
1518 scaffold: &ExtractionScaffold,
1519 plan: &ExtractionPlan,
1520 current_inputs: &ExtractionPlanInputs,
1521) -> Result<ExtractionScaffoldApplyResult, ExtractionScaffoldApplyError> {
1522 if !repository_root.is_dir() {
1523 return Err(apply_error(
1524 ExtractionScaffoldApplyErrorCode::RepositoryInvalid,
1525 "The scaffold repository root is not an existing directory.",
1526 Vec::new(),
1527 "Select the intended repository root before applying the scaffold.",
1528 ));
1529 }
1530 let issues = validate_extraction_scaffold(scaffold);
1531 if !issues.is_empty()
1532 || plan.plan_id != scaffold.plan_id
1533 || plan.plan_digest != scaffold.plan_digest
1534 || !extraction_plan_integrity_is_valid(plan)
1535 {
1536 return Err(apply_error(
1537 ExtractionScaffoldApplyErrorCode::ScaffoldInvalid,
1538 "The scaffold or its approved Extraction Plan failed integrity validation.",
1539 Vec::new(),
1540 "Discard the modified artifacts and regenerate the exact scaffold patch.",
1541 ));
1542 }
1543 ensure_extraction_plan_fresh(plan, current_inputs).map_err(|rejection| {
1544 ExtractionScaffoldApplyError {
1545 code: ExtractionScaffoldApplyErrorCode::PlanStale,
1546 message: rejection.message,
1547 conflicting_paths: Vec::new(),
1548 next_actions: rejection.next_actions,
1549 effects: ExtractionScaffoldEffects::default(),
1550 }
1551 })?;
1552
1553 let mut created = Vec::new();
1554 let mut unchanged = Vec::new();
1555 let mut conflicts = Vec::new();
1556 for file in &scaffold.files {
1557 let path = repository_root.join(&file.path);
1558 ensure_no_symlink_ancestors(repository_root, &path).map_err(|message| {
1559 apply_error(
1560 ExtractionScaffoldApplyErrorCode::ScaffoldConflict,
1561 message,
1562 vec![file.path.clone()],
1563 "Remove the symlinked target or choose a clean extraction destination.",
1564 )
1565 })?;
1566 match fs::read(&path) {
1567 Ok(contents) if contents == file.contents.as_bytes() => {
1568 unchanged.push(file.path.clone());
1569 }
1570 Ok(_) => conflicts.push(file.path.clone()),
1571 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1572 if path.exists() {
1573 conflicts.push(file.path.clone());
1574 } else {
1575 created.push(file.path.clone());
1576 }
1577 }
1578 Err(error) => {
1579 return Err(apply_error(
1580 ExtractionScaffoldApplyErrorCode::Io,
1581 format!("Could not inspect `{}`: {error}", file.path),
1582 vec![file.path.clone()],
1583 "Resolve repository permissions and retry the unchanged scaffold.",
1584 ));
1585 }
1586 }
1587 }
1588 if !conflicts.is_empty() {
1589 conflicts.sort();
1590 return Err(apply_error(
1591 ExtractionScaffoldApplyErrorCode::ScaffoldConflict,
1592 "Scaffold apply refused to overwrite changed or unrecognized user files.",
1593 conflicts,
1594 "Review the conflicting files, move the candidate destination, or regenerate from the current repository state.",
1595 ));
1596 }
1597
1598 for file in scaffold
1599 .files
1600 .iter()
1601 .filter(|file| created.binary_search(&file.path).is_ok())
1602 {
1603 let path = repository_root.join(&file.path);
1604 let parent = path.parent().expect("generated files have a parent");
1605 fs::create_dir_all(parent).map_err(|error| {
1606 apply_error(
1607 ExtractionScaffoldApplyErrorCode::Io,
1608 format!("Could not create `{}`: {error}", parent.display()),
1609 vec![file.path.clone()],
1610 "Resolve repository permissions and retry the same content-addressed scaffold.",
1611 )
1612 })?;
1613 ensure_no_symlink_ancestors(repository_root, &path).map_err(|message| {
1614 apply_error(
1615 ExtractionScaffoldApplyErrorCode::ScaffoldConflict,
1616 message,
1617 vec![file.path.clone()],
1618 "Remove the symlinked target or choose a clean extraction destination.",
1619 )
1620 })?;
1621 let mut output = OpenOptions::new()
1622 .write(true)
1623 .create_new(true)
1624 .open(&path)
1625 .map_err(|error| {
1626 apply_error(
1627 ExtractionScaffoldApplyErrorCode::ScaffoldConflict,
1628 format!("Refused to create `{}`: {error}", file.path),
1629 vec![file.path.clone()],
1630 "Inspect the target created after preflight and retry only after it is resolved.",
1631 )
1632 })?;
1633 output
1634 .write_all(file.contents.as_bytes())
1635 .map_err(|error| {
1636 apply_error(
1637 ExtractionScaffoldApplyErrorCode::Io,
1638 format!("Could not write `{}`: {error}", file.path),
1639 vec![file.path.clone()],
1640 "Resolve the filesystem error before retrying the content-addressed scaffold.",
1641 )
1642 })?;
1643 }
1644 created.sort();
1645 unchanged.sort();
1646 Ok(ExtractionScaffoldApplyResult {
1647 protocol: EXTRACTION_SCAFFOLD_APPLY_PROTOCOL.to_owned(),
1648 scaffold_id: scaffold.scaffold_id.clone(),
1649 plan_id: scaffold.plan_id.clone(),
1650 effects: ExtractionScaffoldEffects {
1651 writes_repository_files: !created.is_empty(),
1652 ..ExtractionScaffoldEffects::default()
1653 },
1654 created_files: created,
1655 unchanged_files: unchanged,
1656 linked_authority_remains_authoritative: true,
1657 })
1658}
1659
1660fn ensure_no_symlink_ancestors(root: &Path, target: &Path) -> Result<(), String> {
1661 let relative = target
1662 .strip_prefix(root)
1663 .map_err(|_| "Generated target escaped the repository root.".to_owned())?;
1664 let mut current = PathBuf::from(root);
1665 for component in relative.components() {
1666 current.push(component.as_os_str());
1667 match fs::symlink_metadata(¤t) {
1668 Ok(metadata) if metadata.file_type().is_symlink() => {
1669 return Err(format!(
1670 "Scaffold target `{}` traverses a symbolic link.",
1671 current.display()
1672 ));
1673 }
1674 Ok(_) => {}
1675 Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
1676 Err(error) => {
1677 return Err(format!(
1678 "Could not inspect scaffold target `{}`: {error}",
1679 current.display()
1680 ));
1681 }
1682 }
1683 }
1684 Ok(())
1685}
1686
1687pub fn extraction_scaffold_json(
1688 scaffold: &ExtractionScaffold,
1689) -> Result<String, serde_json::Error> {
1690 serde_json::to_string_pretty(scaffold).map(|value| format!("{value}\n"))
1691}
1692
1693#[must_use]
1694pub fn extraction_scaffold_schema() -> Value {
1695 let mut schema = serde_json::to_value(schemars::schema_for!(ExtractionScaffold))
1696 .expect("Extraction Scaffold schema must serialize");
1697 let object = schema
1698 .as_object_mut()
1699 .expect("Extraction Scaffold schema must be an object");
1700 object.insert(
1701 "$id".to_owned(),
1702 Value::String(EXTRACTION_SCAFFOLD_SCHEMA_ID.to_owned()),
1703 );
1704 object.insert(
1705 "title".to_owned(),
1706 Value::String("Lenso Extraction Scaffold v1".to_owned()),
1707 );
1708 schema["properties"]["protocol"] = json!({
1709 "type": "string",
1710 "const": EXTRACTION_SCAFFOLD_PROTOCOL
1711 });
1712 schema["properties"]["generatorVersion"] = json!({
1713 "type": "string",
1714 "const": EXTRACTION_SCAFFOLD_GENERATOR_VERSION
1715 });
1716 schema["properties"]["scaffoldId"] = json!({
1717 "type": "string",
1718 "pattern": "^extraction-scaffold:sha256:[0-9a-f]{64}$"
1719 });
1720 schema["properties"]["scaffoldDigest"] = json!({
1721 "type": "string",
1722 "pattern": "^sha256:[0-9a-f]{64}$"
1723 });
1724 for field in [
1725 "writesRepositoryFiles",
1726 "startsWorkloads",
1727 "copiesData",
1728 "changesAuthority",
1729 "changesProviderPath",
1730 ] {
1731 schema["$defs"]["ExtractionScaffoldEffects"]["properties"][field] = json!({
1732 "type": "boolean",
1733 "const": false
1734 });
1735 }
1736 schema
1737}
1738
1739fn validate_relative_path(path: &str) -> Result<(), String> {
1740 if path.trim().is_empty() || path.contains('\\') {
1741 return Err(format!(
1742 "Generated path `{path}` is not repository-relative."
1743 ));
1744 }
1745 let path = Path::new(path);
1746 if path.is_absolute()
1747 || path.components().any(|component| {
1748 matches!(
1749 component,
1750 Component::ParentDir | Component::RootDir | Component::Prefix(_)
1751 )
1752 })
1753 || path
1754 .components()
1755 .any(|component| component == Component::CurDir)
1756 {
1757 return Err(format!(
1758 "Generated path `{}` contains unsafe traversal.",
1759 path.display()
1760 ));
1761 }
1762 Ok(())
1763}
1764
1765fn digest_serializable<T: Serialize + ?Sized>(
1766 value: &T,
1767) -> Result<String, ExtractionScaffoldGenerationError> {
1768 let bytes = serde_json::to_vec(value).map_err(|error| {
1769 generation_error(
1770 ExtractionScaffoldGenerationIssueCode::CandidateInvalid,
1771 format!("Extraction scaffold content could not serialize: {error}"),
1772 "Correct the structured scaffold input and regenerate.",
1773 )
1774 })?;
1775 Ok(extraction_input_digest(&bytes))
1776}
1777
1778fn pretty_json(value: &Value) -> Result<String, ExtractionScaffoldGenerationError> {
1779 serde_json::to_string_pretty(value)
1780 .map(|value| format!("{value}\n"))
1781 .map_err(|error| {
1782 generation_error(
1783 ExtractionScaffoldGenerationIssueCode::CandidateInvalid,
1784 format!("Generated JSON could not serialize: {error}"),
1785 "Correct the structured scaffold input and regenerate.",
1786 )
1787 })
1788}
1789
1790fn generation_error(
1791 code: ExtractionScaffoldGenerationIssueCode,
1792 message: impl Into<String>,
1793 next_action: impl Into<String>,
1794) -> ExtractionScaffoldGenerationError {
1795 ExtractionScaffoldGenerationError {
1796 code,
1797 message: message.into(),
1798 next_actions: vec![next_action.into()],
1799 }
1800}
1801
1802fn apply_error(
1803 code: ExtractionScaffoldApplyErrorCode,
1804 message: impl Into<String>,
1805 conflicting_paths: Vec<String>,
1806 next_action: impl Into<String>,
1807) -> ExtractionScaffoldApplyError {
1808 ExtractionScaffoldApplyError {
1809 code,
1810 message: message.into(),
1811 conflicting_paths,
1812 next_actions: vec![next_action.into()],
1813 effects: ExtractionScaffoldEffects::default(),
1814 }
1815}
1816
1817fn push_issue(
1818 issues: &mut Vec<ExtractionScaffoldIssue>,
1819 code: ExtractionScaffoldIssueCode,
1820 path: impl Into<String>,
1821 message: impl Into<String>,
1822 next_action: impl Into<String>,
1823) {
1824 issues.push(ExtractionScaffoldIssue {
1825 code,
1826 path: path.into(),
1827 message: message.into(),
1828 next_action: next_action.into(),
1829 });
1830}
1831
1832fn stable_slug(value: &str) -> String {
1833 let mut slug = String::new();
1834 let mut separator = false;
1835 for character in value.chars() {
1836 if character.is_ascii_alphanumeric() {
1837 if separator && !slug.is_empty() {
1838 slug.push('-');
1839 }
1840 slug.push(character.to_ascii_lowercase());
1841 separator = false;
1842 } else {
1843 separator = true;
1844 }
1845 }
1846 slug
1847}
1848
1849fn rust_identifier(value: &str) -> String {
1850 let mut identifier = value
1851 .chars()
1852 .map(|character| {
1853 if character.is_ascii_alphanumeric() {
1854 character.to_ascii_lowercase()
1855 } else {
1856 '_'
1857 }
1858 })
1859 .collect::<String>();
1860 while identifier.contains("__") {
1861 identifier = identifier.replace("__", "_");
1862 }
1863 identifier.trim_matches('_').to_owned()
1864}
1865
1866fn rust_string(value: &str) -> String {
1867 serde_json::to_string(value).expect("JSON strings are valid Rust string literals here")
1868}
1869
1870fn normalize_strings(values: &mut Vec<String>) {
1871 values.retain(|value| !value.trim().is_empty());
1872 values.sort();
1873 values.dedup();
1874}
1875
1876fn strip_destination<'a>(destination: &str, path: &'a str) -> &'a str {
1877 path.strip_prefix(destination)
1878 .and_then(|path| path.strip_prefix('/'))
1879 .unwrap_or(path)
1880}
1881
1882const fn binding_kind_label(kind: ExtractionGeneratedBindingKind) -> &'static str {
1883 match kind {
1884 ExtractionGeneratedBindingKind::Http => "http",
1885 ExtractionGeneratedBindingKind::Grpc => "grpc",
1886 ExtractionGeneratedBindingKind::Event => "event",
1887 }
1888}
1889
1890const fn binding_role_label(role: ExtractionScaffoldBindingRole) -> &'static str {
1891 match role {
1892 ExtractionScaffoldBindingRole::Server => "server",
1893 ExtractionScaffoldBindingRole::Client => "client",
1894 ExtractionScaffoldBindingRole::Publisher => "publisher",
1895 ExtractionScaffoldBindingRole::Handler => "handler",
1896 }
1897}
1898
1899const fn workload_role_label(role: ExtractionWorkloadRole) -> &'static str {
1900 match role {
1901 ExtractionWorkloadRole::Api => "api",
1902 ExtractionWorkloadRole::Worker => "worker",
1903 ExtractionWorkloadRole::Migration => "migration",
1904 }
1905}
1906
1907fn http_method_label(method: lenso_contracts::ModuleHttpMethod) -> &'static str {
1908 match method {
1909 lenso_contracts::ModuleHttpMethod::Get => "GET",
1910 lenso_contracts::ModuleHttpMethod::Post => "POST",
1911 lenso_contracts::ModuleHttpMethod::Put => "PUT",
1912 lenso_contracts::ModuleHttpMethod::Patch => "PATCH",
1913 lenso_contracts::ModuleHttpMethod::Delete => "DELETE",
1914 _ => "UNKNOWN",
1915 }
1916}
1917
1918#[cfg(test)]
1919mod tests {
1920 use super::*;
1921 use crate::{
1922 CompatibilityCategory, EXTRACTION_READINESS_ANALYZER_VERSION,
1923 EXTRACTION_READINESS_REPORT_PROTOCOL, ExtractionAuthorityKind, ExtractionContractEvidence,
1924 ExtractionDataEvidenceSource, ExtractionDataTableEvidence, ExtractionEvidenceDigest,
1925 ExtractionEvidenceStatus, ExtractionExpectedAuthority, ExtractionPlanContractVersion,
1926 ExtractionPlanInputs, ExtractionReadinessEffects, ExtractionReadinessReport,
1927 ExtractionReadinessSurfaceSummary, ExtractionServiceDataEvidence, generate_extraction_plan,
1928 };
1929 use lenso_contracts::{
1930 EventHandlerDeclaration, EventSurface, ModuleHttpMethod, ModuleHttpRoute,
1931 ServiceOperationMetadata,
1932 };
1933 use std::sync::atomic::{AtomicU64, Ordering};
1934
1935 static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
1936
1937 fn module() -> ModuleManifest {
1938 ModuleManifest::builder("acme/support-ticket")
1939 .capabilities(vec!["support.tickets.read".to_owned()])
1940 .http_routes(vec![ModuleHttpRoute {
1941 method: ModuleHttpMethod::Get,
1942 path: "/v1/tickets/{ticket_id}".to_owned(),
1943 capability: Some("support.tickets.read".to_owned()),
1944 display_name: Some("Get ticket".to_owned()),
1945 story_title: Some("Support ticket opened".to_owned()),
1946 operation: Some(ServiceOperationMetadata {
1947 operation_id: Some("getTicket".to_owned()),
1948 ..ServiceOperationMetadata::default()
1949 }),
1950 }])
1951 .events(EventSurface {
1952 handlers: vec![EventHandlerDeclaration {
1953 name: "apply_sla_update".to_owned(),
1954 event_name: "support.sla-updated.v1".to_owned(),
1955 operation: None,
1956 }],
1957 })
1958 .build()
1959 }
1960
1961 fn event_schema() -> String {
1962 serde_json::to_string_pretty(&json!({
1963 "$schema": "https://json-schema.org/draft/2020-12/schema",
1964 "$id": "https://contracts.lenso.local/events/support.sla-updated.v1.schema.json",
1965 "title": "support.sla-updated.v1",
1966 "type": "object",
1967 "properties": { "ticketId": { "type": "string" } },
1968 "additionalProperties": false
1969 }))
1970 .map(|value| format!("{value}\n"))
1971 .unwrap()
1972 }
1973
1974 fn current_inputs() -> ExtractionPlanInputs {
1975 let module = module();
1976 let http = crate::DIRECT_HTTP_OPENAPI_V1_FIXTURE_YAML;
1977 let event = event_schema();
1978 ExtractionPlanInputs {
1979 readiness_report: ExtractionReadinessReport {
1980 protocol: EXTRACTION_READINESS_REPORT_PROTOCOL.to_owned(),
1981 analyzer_version: EXTRACTION_READINESS_ANALYZER_VERSION.to_owned(),
1982 target_module: module.module_id.clone(),
1983 system_id: Some("support-system".to_owned()),
1984 target_owner: Some("support-host".to_owned()),
1985 classification: CompatibilityCategory::Safe,
1986 ready: true,
1987 issue_codes: Vec::new(),
1988 contract_evidence: vec![
1989 ExtractionContractEvidence {
1990 subject: "http:GET /v1/tickets/{ticket_id}".to_owned(),
1991 kind: ExtractionContractKind::Service,
1992 direction: ExtractionContractDirection::Provides,
1993 status: ExtractionEvidenceStatus::Present,
1994 contract_id: Some("support-ticket-http.v1".to_owned()),
1995 evidence_references: vec!["contracts/openapi/support.v1.yaml".to_owned()],
1996 },
1997 ExtractionContractEvidence {
1998 subject: "event-handler:apply_sla_update".to_owned(),
1999 kind: ExtractionContractKind::Event,
2000 direction: ExtractionContractDirection::Consumes,
2001 status: ExtractionEvidenceStatus::Present,
2002 contract_id: Some("support.sla-updated.v1".to_owned()),
2003 evidence_references: vec![
2004 "contracts/events/support.sla-updated.v1.schema.json".to_owned(),
2005 ],
2006 },
2007 ],
2008 active_consumers: Vec::new(),
2009 surfaces: ExtractionReadinessSurfaceSummary::default(),
2010 service_data: ExtractionServiceDataEvidence {
2011 complete: true,
2012 tables: vec![ExtractionDataTableEvidence {
2013 table: "support.tickets".to_owned(),
2014 owner_module: Some("support-ticket".to_owned()),
2015 source: ExtractionDataEvidenceSource::StaticDeclaration,
2016 volume: None,
2017 cursor: None,
2018 evidence_references: Vec::new(),
2019 }],
2020 ..ExtractionServiceDataEvidence::default()
2021 },
2022 findings: Vec::new(),
2023 effects: ExtractionReadinessEffects::default(),
2024 },
2025 module,
2026 system: json!({
2027 "protocol": "lenso.system.v2",
2028 "systemId": "support-system",
2029 "host": { "hostId": "support-host", "modules": ["acme/support-ticket"] },
2030 "providers": [{
2031 "providerId": "notification-provider",
2032 "modules": ["notification-gateway"]
2033 }],
2034 "autonomousServices": [{
2035 "serviceId": "support-sla-service",
2036 "modules": ["support-sla"],
2037 "workloads": [{ "workloadId": "support-sla-api", "role": "api" }]
2038 }],
2039 "contracts": [{
2040 "contractId": "support.sla-updated.v1",
2041 "version": "v1",
2042 "producerKind": "autonomous_service",
2043 "producerId": "support-sla-service",
2044 "artifact": {
2045 "format": "json_schema",
2046 "path": "contracts/events/support.sla-updated.v1.schema.json"
2047 },
2048 "tenancyMode": "required"
2049 }],
2050 "consumers": [{
2051 "consumerId": "support-ticket-sla-updates",
2052 "ownerKind": "host",
2053 "ownerId": "support-host",
2054 "contractId": "support.sla-updated.v1",
2055 "tenancyMode": "required"
2056 }]
2057 }),
2058 contract_versions: vec![
2059 ExtractionPlanContractVersion {
2060 contract_id: "support-ticket-http.v1".to_owned(),
2061 version: "v1".to_owned(),
2062 kind: ExtractionContractKind::Service,
2063 direction: ExtractionContractDirection::Provides,
2064 artifact_reference: "contracts/openapi/support.v1.yaml".to_owned(),
2065 artifact_digest: extraction_input_digest(http.as_bytes()),
2066 artifact_format: ExtractionContractArtifactFormat::Openapi,
2067 tenancy_mode: ServiceTenancyMode::Required,
2068 required_context: vec![CommonContextRequirement::Tenant],
2069 producer_id: None,
2070 consumer_ids: Vec::new(),
2071 },
2072 ExtractionPlanContractVersion {
2073 contract_id: "support.sla-updated.v1".to_owned(),
2074 version: "v1".to_owned(),
2075 kind: ExtractionContractKind::Event,
2076 direction: ExtractionContractDirection::Consumes,
2077 artifact_reference: "contracts/events/support.sla-updated.v1.schema.json"
2078 .to_owned(),
2079 artifact_digest: extraction_input_digest(event.as_bytes()),
2080 artifact_format: ExtractionContractArtifactFormat::JsonSchema,
2081 tenancy_mode: ServiceTenancyMode::Required,
2082 required_context: vec![CommonContextRequirement::Tenant],
2083 producer_id: Some("support-sla-service".to_owned()),
2084 consumer_ids: Vec::new(),
2085 },
2086 ],
2087 expected_authority: ExtractionExpectedAuthority {
2088 kind: ExtractionAuthorityKind::LinkedHost,
2089 owner_id: "support-host".to_owned(),
2090 revision: "support-r1".to_owned(),
2091 },
2092 evidence_digests: vec![ExtractionEvidenceDigest {
2093 reference: "analyzer:support".to_owned(),
2094 digest: extraction_input_digest(b"support-evidence"),
2095 }],
2096 }
2097 }
2098
2099 fn scaffold_inputs() -> (ExtractionScaffoldInputs, ExtractionPlanInputs) {
2100 let current = current_inputs();
2101 let plan = generate_extraction_plan(¤t).expect("plan");
2102 (
2103 ExtractionScaffoldInputs {
2104 plan,
2105 module: current.module.clone(),
2106 artifacts: vec![
2107 ExtractionScaffoldArtifact {
2108 contract_id: "support-ticket-http.v1".to_owned(),
2109 version: "v1".to_owned(),
2110 contents: crate::DIRECT_HTTP_OPENAPI_V1_FIXTURE_YAML.to_owned(),
2111 protobuf_descriptor: None,
2112 },
2113 ExtractionScaffoldArtifact {
2114 contract_id: "support.sla-updated.v1".to_owned(),
2115 version: "v1".to_owned(),
2116 contents: event_schema(),
2117 protobuf_descriptor: None,
2118 },
2119 ],
2120 },
2121 current,
2122 )
2123 }
2124
2125 #[test]
2126 fn dry_run_is_deterministic_identity_preserving_and_zero_effect() {
2127 let (inputs, _) = scaffold_inputs();
2128 let left = generate_extraction_scaffold(&inputs).expect("scaffold");
2129 let right = dry_run_extraction_scaffold(&inputs).expect("dry run");
2130 assert_eq!(left, right);
2131 assert!(extraction_scaffold_integrity_is_valid(&left));
2132 assert!(validate_extraction_scaffold(&left).is_empty());
2133 assert_eq!(left.effects, ExtractionScaffoldEffects::default());
2134 assert!(left.linked_authority_remains_authoritative);
2135 assert!(left.provider_compatibility_preserved);
2136 assert_eq!(
2137 left.preserved_identity.module_manifest,
2138 serde_json::to_value(module()).unwrap()
2139 );
2140 assert_eq!(left.preserved_identity.operation_ids, ["getTicket"]);
2141 assert_eq!(
2142 left.preserved_identity.event_types,
2143 ["support.sla-updated.v1"]
2144 );
2145 assert!(left.patch.contains("src/bin/api.rs"));
2146 assert!(left.patch.contains("src/bin/worker.rs"));
2147 assert!(left.patch.contains("src/bin/migration.rs"));
2148 }
2149
2150 #[test]
2151 fn authoritative_contract_changes_are_rejected() {
2152 let (mut inputs, _) = scaffold_inputs();
2153 inputs.artifacts[0].contents.push_str("# changed\n");
2154 let error = generate_extraction_scaffold(&inputs).expect_err("digest drift must fail");
2155 assert_eq!(
2156 error.code,
2157 ExtractionScaffoldGenerationIssueCode::ArtifactDigestMismatch
2158 );
2159 }
2160
2161 #[test]
2162 fn apply_is_idempotent_preserves_provider_and_refuses_user_changes() {
2163 let (inputs, current) = scaffold_inputs();
2164 let scaffold = generate_extraction_scaffold(&inputs).expect("scaffold");
2165 let root = temp_root();
2166 fs::create_dir_all(&root).unwrap();
2167 let provider = root.join("lenso.service.json");
2168 fs::write(&provider, "{\"protocol\":\"lenso.service.v1\"}\n").unwrap();
2169
2170 let applied = apply_extraction_scaffold(&root, &scaffold, &inputs.plan, ¤t)
2171 .expect("first apply");
2172 assert!(!applied.created_files.is_empty());
2173 assert!(applied.effects.writes_repository_files);
2174 assert_eq!(
2175 fs::read_to_string(&provider).unwrap(),
2176 "{\"protocol\":\"lenso.service.v1\"}\n"
2177 );
2178 let repeated = apply_extraction_scaffold(&root, &scaffold, &inputs.plan, ¤t)
2179 .expect("repeated apply");
2180 assert!(repeated.created_files.is_empty());
2181 assert_eq!(repeated.unchanged_files.len(), scaffold.files.len());
2182 assert!(!repeated.effects.writes_repository_files);
2183
2184 let changed = root.join(&scaffold.files[0].path);
2185 fs::write(&changed, "user change\n").unwrap();
2186 let error = apply_extraction_scaffold(&root, &scaffold, &inputs.plan, ¤t)
2187 .expect_err("changed generated file must fail");
2188 assert_eq!(
2189 error.code,
2190 ExtractionScaffoldApplyErrorCode::ScaffoldConflict
2191 );
2192 assert_eq!(error.conflicting_paths, [scaffold.files[0].path.clone()]);
2193 fs::remove_dir_all(&root).unwrap();
2194 }
2195
2196 #[test]
2197 fn stale_plan_is_rejected_before_repository_writes() {
2198 let (inputs, mut current) = scaffold_inputs();
2199 let scaffold = generate_extraction_scaffold(&inputs).expect("scaffold");
2200 current.expected_authority.revision = "support-r2".to_owned();
2201 let root = temp_root();
2202 fs::create_dir_all(&root).unwrap();
2203 let error = apply_extraction_scaffold(&root, &scaffold, &inputs.plan, ¤t)
2204 .expect_err("stale plan must fail");
2205 assert_eq!(error.code, ExtractionScaffoldApplyErrorCode::PlanStale);
2206 assert!(!root.join(&scaffold.destination_root).exists());
2207 fs::remove_dir_all(&root).unwrap();
2208 }
2209
2210 fn temp_root() -> PathBuf {
2211 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2212 std::env::temp_dir().join(format!(
2213 "lenso-extraction-scaffold-{}-{sequence}",
2214 std::process::id()
2215 ))
2216 }
2217}