Skip to main content

lenso_service/
extraction_scaffold.rs

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(&current) {
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(&current).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, &current)
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, &current)
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, &current)
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, &current)
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}