Skip to main content

lenso_service/
lib.rs

1use axum::{Json, Router, routing::get};
2use prost::Message;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::{BTreeMap, BTreeSet};
6use utoipa::ToSchema;
7
8mod call_policy;
9mod delegated_context;
10mod delivery_console_projection;
11mod delivery_failure_recovery;
12mod direct_grpc;
13mod direct_http;
14mod disaster_recovery;
15mod endpoint_resolution;
16mod event_envelope;
17mod extraction_authority_commit;
18mod extraction_backfill;
19mod extraction_console_projection;
20mod extraction_plan;
21mod extraction_provisional_cutover;
22mod extraction_quiescence;
23mod extraction_readiness;
24mod extraction_reconciliation;
25mod extraction_run;
26mod extraction_scaffold;
27mod extraction_verification;
28mod ga_support;
29mod performance_profile;
30mod production_delivery;
31mod reliability_report;
32mod security_review;
33mod service_backup;
34mod story_segment;
35mod support_envelope;
36mod workload_identity;
37
38pub mod system_plane;
39
40pub use call_policy::{
41    CallPolicyCircuitBreaker, CallPolicyClock, CallPolicyConcurrency, CallPolicyDeclaration,
42    CallPolicyEvent, CallPolicyEvidence, CallPolicyFailure, CallPolicyFallback, CallPolicyOverload,
43    CallPolicyPermit, CallPolicyRuntime, CallPolicyTerminalOutcome, CallPolicyValidationIssue,
44    ManualCallPolicyClock, SystemCallPolicyClock,
45};
46pub use delegated_context::{
47    AuthenticatedServiceContext, DelegatedActorCredentialRequest, DelegatedContextError,
48    DelegatedContextErrorCode, DelegatedContextIssuer, DelegatedContextProvider,
49    DelegatedContextVerifier, Ed25519DelegatedContextVerifier, IdentityDecisionEvidence,
50    IdentityDecisionRecorder, JsonlIdentityDecisionRecorder, MemoryIdentityDecisionRecorder,
51    ServiceContext, ServiceContextAdmission, ServiceContextPolicy,
52    SystemSandboxDelegatedContextProvider, TenantCredentialRequest, delegated_actor_signing_bytes,
53    tenant_context_signing_bytes,
54};
55pub use delivery_console_projection::{
56    DELIVERY_ARTIFACT_BATCH_PROTOCOL, DELIVERY_CONSOLE_PROJECTION_PROTOCOL,
57    DeliveryConsoleAdapterDrift, DeliveryConsoleArtifacts, DeliveryConsoleConfiguration,
58    DeliveryConsoleDeployment, DeliveryConsoleEdge, DeliveryConsoleIssue, DeliveryConsolePolicy,
59    DeliveryConsoleProjection, DeliveryConsoleRelease, DeliveryConsoleSecretReference,
60    DeliveryConsoleState, DeliveryConsoleSupplyChainWorkload, DeliveryConsoleTimelineEntry,
61    delivery_artifact_batch_subject, load_delivery_console_projection, project_delivery_console,
62    record_delivery_artifact, record_delivery_artifacts,
63};
64pub use delivery_failure_recovery::*;
65pub use disaster_recovery::*;
66pub use performance_profile::*;
67
68pub mod support_grpc_v1 {
69    tonic::include_proto!("lenso.services.support.v1");
70}
71
72pub use direct_grpc::{
73    DirectGrpcAdmission, DirectGrpcAdmissionError, DirectGrpcBindings, DirectGrpcCallError,
74    DirectGrpcClient, DirectGrpcEvidence, DirectGrpcOperation, DirectGrpcResponse,
75    DirectGrpcServerPolicy, GrpcIdempotency, generate_direct_grpc_bindings,
76    parse_protobuf_call_policies,
77};
78pub use direct_http::{
79    DirectHttpBindings, DirectHttpCall, DirectHttpCallError, DirectHttpClient, DirectHttpEvidence,
80    DirectHttpOperation, DirectHttpRequest, DirectHttpResponse, DirectHttpServerBinding,
81    HttpIdempotency, RetryDecision, generate_direct_http_bindings,
82};
83
84pub use endpoint_resolution::{
85    Endpoint, EndpointResolutionError, EndpointResolutionErrorCode, EndpointResolver,
86    EndpointState, LastValidEndpointResolver, LocalProcessEndpointResolver, ServiceReference,
87    StaticEndpointResolver,
88};
89pub use event_envelope::{
90    CloudEvent, EVENT_CONTRACT_ARTIFACT_PROTOCOL, EVENT_ENVELOPE_PROTOCOL, EventContent,
91    EventContext, EventContractGenerationError, EventEnvelope, EventEnvelopeIssue,
92    EventEnvelopeIssueCode, GeneratedEventContract,
93    evaluate_generated_event_contract_compatibility, event_envelope_from_cloudevent,
94    generate_event_contract, validate_event_envelope, validate_event_envelope_value,
95};
96pub use extraction_authority_commit::{
97    EXTRACTION_AUTHORITY_COMMIT_PROTOCOL, EXTRACTION_CANDIDATE_HEALTH_PROTOCOL, ExtractionApproval,
98    ExtractionApprovalVerifier, ExtractionAuthorityCommitError, ExtractionAuthorityCommitErrorCode,
99    ExtractionAuthorityCommitInputs, ExtractionAuthorityCommitReceipt,
100    ExtractionAuthorityCommitResult, ExtractionAuthorityCommitRevalidation,
101    ExtractionAuthorityCommitStatus, ExtractionCandidateHealthEvidence,
102    ExtractionFastRollbackError, ExtractionFastRollbackIssueCode,
103    ExtractionReverseMigrationEvidence, ExtractionTopologyState, commit_extraction_authority,
104    commit_extraction_authority_postgres, extraction_candidate_health_integrity_is_valid,
105    initialize_extraction_topology_state, record_autonomous_mutation,
106    request_fast_extraction_rollback,
107};
108pub use extraction_backfill::{
109    EXTRACTION_BACKFILL_PROTOCOL, ExtractionBackfillBatchReceipt, ExtractionBackfillBoundary,
110    ExtractionBackfillEffects, ExtractionBackfillError, ExtractionBackfillErrorCode,
111    ExtractionBackfillEvidence, ExtractionBackfillProgress, ExtractionBackfillRecord,
112    ExtractionBackfillRequest, ExtractionBackfillRun, ExtractionBackfillScope,
113    ExtractionBackfillStatus, apply_extraction_backfill_batch,
114    apply_postgres_extraction_backfill_batch, copy_postgres_extraction_service_data_batch,
115    extraction_backfill_integrity_is_valid, load_postgres_extraction_backfill,
116    start_extraction_backfill,
117};
118pub use extraction_console_projection::{
119    EXTRACTION_CONSOLE_PROJECTION_PROTOCOL, ExtractionConsoleApprovalBoundary,
120    ExtractionConsoleArtifacts, ExtractionConsoleAuthority, ExtractionConsoleBlocker,
121    ExtractionConsoleEvidence, ExtractionConsoleProjection, ExtractionConsoleState,
122    ExtractionConsoleTimelineEntry, load_extraction_artifact, load_extraction_console_projection,
123    project_extraction_console, record_extraction_artifact,
124};
125pub use extraction_plan::{
126    EXTRACTION_PLAN_GENERATOR_VERSION, EXTRACTION_PLAN_PROTOCOL, ExtractionApprovalBoundary,
127    ExtractionAuthorityKind, ExtractionContractArtifactFormat, ExtractionCopyMode,
128    ExtractionDataMapping, ExtractionEvidenceDigest, ExtractionExpectedAuthority,
129    ExtractionGeneratedClientPlan, ExtractionInputPin, ExtractionInputPinKind,
130    ExtractionMigrationMapping, ExtractionPlan, ExtractionPlanContractVersion, ExtractionPlanDiff,
131    ExtractionPlanDiffEntry, ExtractionPlanEffects, ExtractionPlanGenerationError,
132    ExtractionPlanGenerationIssueCode, ExtractionPlanInputs, ExtractionPlanIssueCode,
133    ExtractionPlanPhase, ExtractionPlanPhaseKind, ExtractionPlanRejection, ExtractionServicePlan,
134    ExtractionServiceReferencePlan, ExtractionStaleInput, ExtractionStorePlan,
135    ExtractionTableMapping, ExtractionWorkloadPlan, ExtractionWorkloadRole,
136    dry_run_extraction_plan, ensure_extraction_plan_fresh, extraction_input_digest,
137    extraction_plan_integrity_is_valid, extraction_plan_json, extraction_plan_schema,
138    generate_extraction_plan, render_extraction_plan,
139};
140pub use extraction_provisional_cutover::{
141    EXTRACTION_PROVISIONAL_CUTOVER_PROTOCOL, ExtractionCutoverEvidence, ExtractionCutoverReceipt,
142    ExtractionLinkedRollbackValidation, ExtractionProvisionalCutoverError,
143    ExtractionProvisionalCutoverInputs, ExtractionProvisionalCutoverIssueCode,
144    ExtractionProvisionalCutoverRun, ExtractionProvisionalCutoverStatus, ExtractionTrafficRoute,
145    complete_provisional_rollback_validation, extraction_provisional_cutover_integrity_is_valid,
146    fail_provisional_cutover, start_provisional_cutover, verify_provisional_cutover,
147};
148pub use extraction_quiescence::{
149    EXTRACTION_QUIESCENCE_PROTOCOL, ExtractionDrainSnapshot, ExtractionQuiescenceEffects,
150    ExtractionQuiescenceEvidence, ExtractionQuiescenceIssue, ExtractionQuiescenceIssueCode,
151    ExtractionQuiescenceRun, ExtractionQuiescenceStartError, ExtractionQuiescenceStatus,
152    cancel_extraction_quiescence, complete_extraction_quiescence,
153    extraction_quiescence_integrity_is_valid, record_extraction_drain, start_extraction_quiescence,
154};
155pub use extraction_readiness::{
156    EXTRACTION_READINESS_ANALYZER_VERSION, EXTRACTION_READINESS_REPORT_PROTOCOL,
157    ExtractionBoundaryEvidence, ExtractionBoundaryReference, ExtractionBoundaryReferenceKind,
158    ExtractionConsumerCompatibilityEvidence, ExtractionContractDirection,
159    ExtractionContractEvidence, ExtractionContractKind, ExtractionCursorEvidence,
160    ExtractionDataAccessEvidence, ExtractionDataAccessKind, ExtractionDataEvidenceSource,
161    ExtractionDataTableEvidence, ExtractionDataVolumeEvidence, ExtractionEvidenceStatus,
162    ExtractionMigrationEvidence, ExtractionReadinessEffects, ExtractionReadinessEvidence,
163    ExtractionReadinessFinding, ExtractionReadinessIssueCode, ExtractionReadinessReport,
164    ExtractionReadinessSurfaceSummary, ExtractionServiceDataEvidence,
165    ExtractionTransactionEvidence, evaluate_extraction_readiness, extraction_readiness_report_json,
166    extraction_readiness_report_schema, render_extraction_readiness_report,
167};
168pub use extraction_reconciliation::{
169    EXTRACTION_RECONCILIATION_PROTOCOL, ExtractionBusinessInvariant, ExtractionNormalizedField,
170    ExtractionReconciliationEffects, ExtractionReconciliationEvidence,
171    ExtractionReconciliationInputs, ExtractionReconciliationIssue,
172    ExtractionReconciliationIssueCode, ExtractionReconciliationReadError,
173    ExtractionReconciliationResult, ExtractionReconciliationStatus, ExtractionRelationshipCount,
174    ExtractionSourceSnapshot, extraction_reconciliation_integrity_is_valid,
175    reconcile_extraction_data, reconcile_postgres_extraction_service_data,
176};
177pub use extraction_run::{
178    DESTINATION_EXPANSION_PHASE_ID, EXTRACTION_OPERATION_RECEIPT_PROTOCOL, EXTRACTION_RUN_PROTOCOL,
179    ExtractionExpandMigration, ExtractionExpansionOperation, ExtractionExpansionOperationKind,
180    ExtractionExpansionWorkload, ExtractionMigrationArtifact, ExtractionOperationOutcome,
181    ExtractionOperationReceipt, ExtractionRun, ExtractionRunAdvanceError,
182    ExtractionRunAdvanceErrorCode, ExtractionRunEffects, ExtractionRunError,
183    ExtractionRunErrorCode, ExtractionRunEvidence, ExtractionRunEvidenceKind,
184    ExtractionRunExpectedState, ExtractionRunInputs, ExtractionRunMode, ExtractionRunPhase,
185    ExtractionRunStartError, ExtractionRunStartErrorCode, ExtractionRunStatus,
186    ExtractionWorkloadFailure, ExtractionWorkloadFailureCode, ExtractionWorkloadRequest,
187    advance_destination_expansion, build_extraction_operation_receipt,
188    dry_run_destination_expansion, extraction_operation_receipt_integrity_is_valid,
189    extraction_run_integrity_is_valid, extraction_run_json, extraction_run_schema,
190    record_destination_expansion_receipt, render_extraction_run, start_destination_expansion,
191    validate_expand_first_postgres_sql,
192};
193pub use extraction_scaffold::{
194    EXTRACTION_SCAFFOLD_GENERATOR_VERSION, EXTRACTION_SCAFFOLD_PROTOCOL,
195    ExtractionGeneratedBinding, ExtractionGeneratedBindingKind, ExtractionPreservedIdentity,
196    ExtractionScaffold, ExtractionScaffoldApplyError, ExtractionScaffoldApplyErrorCode,
197    ExtractionScaffoldApplyResult, ExtractionScaffoldArtifact, ExtractionScaffoldBindingRole,
198    ExtractionScaffoldEffects, ExtractionScaffoldFile, ExtractionScaffoldFileKind,
199    ExtractionScaffoldGenerationError, ExtractionScaffoldGenerationIssueCode,
200    ExtractionScaffoldInputs, ExtractionScaffoldIssue, ExtractionScaffoldIssueCode,
201    apply_extraction_scaffold, dry_run_extraction_scaffold, extraction_scaffold_integrity_is_valid,
202    extraction_scaffold_json, extraction_scaffold_schema, generate_extraction_scaffold,
203    render_extraction_scaffold_patch, validate_extraction_scaffold,
204};
205pub use extraction_verification::{
206    EXTRACTION_VERIFICATION_PROTOCOL, ExtractionBehaviorObservation,
207    ExtractionCompatibilityEvidence, ExtractionPolicyEvidence, ExtractionVerificationEffects,
208    ExtractionVerificationEvidence, ExtractionVerificationInputs, ExtractionVerificationIssue,
209    ExtractionVerificationIssueCode, ExtractionVerificationResult, ExtractionVerificationStatus,
210    extraction_verification_integrity_is_valid, verify_extraction_behavior,
211};
212pub use ga_support::*;
213pub use production_delivery::*;
214pub use reliability_report::{
215    ActiveDegradedMode, RELIABILITY_REPORT_PROTOCOL, ReliabilityCheck, ReliabilityCheckState,
216    ReliabilityEnforcementBoundary, ReliabilityHealthResult, ReliabilityIssueCode,
217    ReliabilityReport, ReliabilityServiceState,
218};
219pub use security_review::*;
220pub use service_backup::*;
221pub use story_segment::{
222    STORY_SEGMENT_FEED_PROTOCOL, StorySegment, StorySegmentContract, StorySegmentFeed,
223    StorySegmentOperation, StorySegmentSource, StorySegmentWorkflow,
224};
225pub use support_envelope::*;
226pub use workload_identity::{
227    AuthenticatedServicePrincipal, AuthenticatedTransportBinding, SpiffeWorkloadIdentityConfig,
228    SpiffeWorkloadIdentityProvider, SystemSandboxWorkloadIdentityProvider, WorkloadCredential,
229    WorkloadCredentialRequest, WorkloadIdentityError, WorkloadIdentityErrorCode,
230    WorkloadIdentityEvidence, WorkloadIdentityProvider, WorkloadIdentityRotationEvidence,
231    WorkloadIdentityVerification,
232};
233
234pub use lenso_contracts::{
235    ArtifactReference, ConsoleUiArtifact, LinkedModuleDelivery, MODULE_MANIFEST_PROTOCOL,
236    MODULE_RELEASE_PROTOCOL, ModuleCompatibilityDeclaration, ModuleContractIssue, ModuleDelivery,
237    ModuleManifest, ModuleRelease, ServiceModuleDelivery, ServiceResponsibilityProfile,
238    canonical_json as canonical_module_json, digest_json as digest_module_json,
239};
240
241pub const SERVICE_CONTRACT_PROTOCOL: &str = "lenso.service.v1";
242pub const AUTONOMOUS_SERVICE_PROTOCOL: &str = "lenso.service.v2";
243pub const COMMON_CONTEXT_PROTOCOL: &str = "lenso.context.v1";
244pub const SERVICE_PACKAGE_PROTOCOL: &str = "lenso.service-package.v1";
245pub const SERVICE_WORKSPACE_PROTOCOL: &str = "lenso.service-workspace.v1";
246pub const SERVICE_RELEASE_PLAN_PROTOCOL: &str = "lenso.service-release-plan.v1";
247pub const SERVICE_SYSTEM_PROTOCOL: &str = "lenso.system.v1";
248pub const SYSTEM_V2_PROTOCOL: &str = "lenso.system.v2";
249pub const SERVICE_CONTRACT_SCHEMA_JSON: &str =
250    include_str!("../schemas/lenso-service.v1.schema.json");
251pub const SERVICE_V2_CONTRACT_SCHEMA_JSON: &str =
252    include_str!("../schemas/lenso-service.v2.schema.json");
253pub const COMMON_CONTEXT_V1_SCHEMA_JSON: &str =
254    include_str!("../schemas/lenso-context.v1.schema.json");
255pub const EVENT_ENVELOPE_V1_SCHEMA_JSON: &str =
256    include_str!("../schemas/lenso-event-envelope.v1.schema.json");
257pub const SERVICE_PACKAGE_SCHEMA_JSON: &str =
258    include_str!("../schemas/lenso-service-package.v1.schema.json");
259pub const SERVICE_WORKSPACE_SCHEMA_JSON: &str =
260    include_str!("../schemas/lenso-service-workspace.v1.schema.json");
261pub const SERVICE_SYSTEM_SCHEMA_JSON: &str = include_str!("../schemas/lenso-system.v1.schema.json");
262pub const SYSTEM_V2_CONTRACT_SCHEMA_JSON: &str =
263    include_str!("../schemas/lenso-system.v2.schema.json");
264pub const MODULE_MANIFEST_SCHEMA_JSON: &str = include_str!(concat!(
265    env!("OUT_DIR"),
266    "/lenso.module-manifest.v1.schema.json"
267));
268pub const MODULE_RELEASE_SCHEMA_JSON: &str = include_str!(concat!(
269    env!("OUT_DIR"),
270    "/lenso.module-release.v1.schema.json"
271));
272pub const LEGACY_SERVICE_V1_FIXTURE_JSON: &str =
273    include_str!("../fixtures/contracts/v1/service-provider.json");
274pub const LEGACY_SYSTEM_V1_FIXTURE_JSON: &str =
275    include_str!("../fixtures/contracts/v1/system-provider.json");
276pub const AUTONOMOUS_SERVICE_V2_FIXTURE_JSON: &str =
277    include_str!("../fixtures/contracts/v2/autonomous-service.json");
278pub const DIRECT_HTTP_OPENAPI_V1_FIXTURE_YAML: &str =
279    include_str!("../fixtures/contracts/v2/support-http.v1.yaml");
280pub const DIRECT_GRPC_PROTO_V1_FIXTURE: &str =
281    include_str!("../fixtures/contracts/v2/support-grpc.v1.proto");
282pub const SUPPORT_EVENT_SCHEMA_JSON: &str =
283    include_str!("../fixtures/contracts/v2/support-ticket-opened.v1.schema.json");
284pub const DIRECT_GRPC_DESCRIPTOR_V1: &[u8] =
285    tonic::include_file_descriptor_set!("support_descriptor");
286pub const MIXED_SYSTEM_V2_FIXTURE_JSON: &str =
287    include_str!("../fixtures/contracts/v2/mixed-system.json");
288pub const COMMON_CONTEXT_V1_FIXTURE_JSON: &str =
289    include_str!("../fixtures/contracts/v1/common-context.json");
290pub const COMMON_CONTEXT_GLOSSARY_MARKDOWN: &str =
291    include_str!("../docs/common-context-contracts.md");
292pub const REQUEST_RESPONSE_COMPATIBILITY_MARKDOWN: &str =
293    include_str!("../docs/request-response-compatibility.md");
294pub const REQUEST_RESPONSE_COMPATIBILITY_SAFE_FIXTURE_JSON: &str =
295    include_str!("../fixtures/compatibility/request-response/safe.json");
296pub const REQUEST_RESPONSE_COMPATIBILITY_NEEDS_ATTENTION_FIXTURE_JSON: &str =
297    include_str!("../fixtures/compatibility/request-response/needs-attention.json");
298pub const REQUEST_RESPONSE_COMPATIBILITY_BREAKING_FIXTURE_JSON: &str =
299    include_str!("../fixtures/compatibility/request-response/breaking.json");
300pub const REQUEST_RESPONSE_COMPATIBILITY_BLOCKED_FIXTURE_JSON: &str =
301    include_str!("../fixtures/compatibility/request-response/blocked.json");
302pub const EVENT_COMPATIBILITY_SAFE_FIXTURE_JSON: &str =
303    include_str!("../fixtures/compatibility/event/safe.json");
304pub const EVENT_COMPATIBILITY_NEEDS_ATTENTION_FIXTURE_JSON: &str =
305    include_str!("../fixtures/compatibility/event/needs-attention.json");
306pub const EVENT_COMPATIBILITY_BREAKING_FIXTURE_JSON: &str =
307    include_str!("../fixtures/compatibility/event/breaking.json");
308pub const EVENT_COMPATIBILITY_BLOCKED_FIXTURE_JSON: &str =
309    include_str!("../fixtures/compatibility/event/blocked.json");
310pub const CONFIG_COMPATIBILITY_SAFE_FIXTURE_JSON: &str =
311    include_str!("../fixtures/compatibility/config/safe.json");
312pub const CONFIG_COMPATIBILITY_NEEDS_ATTENTION_FIXTURE_JSON: &str =
313    include_str!("../fixtures/compatibility/config/needs-attention.json");
314pub const CONFIG_COMPATIBILITY_BREAKING_FIXTURE_JSON: &str =
315    include_str!("../fixtures/compatibility/config/breaking.json");
316pub const CONFIG_COMPATIBILITY_BLOCKED_FIXTURE_JSON: &str =
317    include_str!("../fixtures/compatibility/config/blocked.json");
318pub const RELIABILITY_COMPATIBILITY_SAFE_FIXTURE_JSON: &str =
319    include_str!("../fixtures/compatibility/reliability/safe.json");
320pub const RELIABILITY_COMPATIBILITY_NEEDS_ATTENTION_FIXTURE_JSON: &str =
321    include_str!("../fixtures/compatibility/reliability/needs-attention.json");
322pub const RELIABILITY_COMPATIBILITY_BREAKING_FIXTURE_JSON: &str =
323    include_str!("../fixtures/compatibility/reliability/breaking.json");
324pub const RELIABILITY_COMPATIBILITY_BLOCKED_FIXTURE_JSON: &str =
325    include_str!("../fixtures/compatibility/reliability/blocked.json");
326pub const CONTRACT_COMPATIBILITY_MARKDOWN: &str = include_str!("../docs/contract-compatibility.md");
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub struct CompatibilityFixture {
330    pub name: &'static str,
331    pub json: &'static str,
332}
333
334pub const REQUEST_RESPONSE_COMPATIBILITY_FIXTURES: &[CompatibilityFixture] = &[
335    CompatibilityFixture {
336        name: "safe",
337        json: REQUEST_RESPONSE_COMPATIBILITY_SAFE_FIXTURE_JSON,
338    },
339    CompatibilityFixture {
340        name: "needs_attention",
341        json: REQUEST_RESPONSE_COMPATIBILITY_NEEDS_ATTENTION_FIXTURE_JSON,
342    },
343    CompatibilityFixture {
344        name: "breaking",
345        json: REQUEST_RESPONSE_COMPATIBILITY_BREAKING_FIXTURE_JSON,
346    },
347    CompatibilityFixture {
348        name: "blocked",
349        json: REQUEST_RESPONSE_COMPATIBILITY_BLOCKED_FIXTURE_JSON,
350    },
351];
352
353pub const EVENT_COMPATIBILITY_FIXTURES: &[CompatibilityFixture] = &[
354    CompatibilityFixture {
355        name: "safe",
356        json: EVENT_COMPATIBILITY_SAFE_FIXTURE_JSON,
357    },
358    CompatibilityFixture {
359        name: "needs_attention",
360        json: EVENT_COMPATIBILITY_NEEDS_ATTENTION_FIXTURE_JSON,
361    },
362    CompatibilityFixture {
363        name: "breaking",
364        json: EVENT_COMPATIBILITY_BREAKING_FIXTURE_JSON,
365    },
366    CompatibilityFixture {
367        name: "blocked",
368        json: EVENT_COMPATIBILITY_BLOCKED_FIXTURE_JSON,
369    },
370];
371pub const CONFIG_COMPATIBILITY_FIXTURES: &[CompatibilityFixture] = &[
372    CompatibilityFixture {
373        name: "safe",
374        json: CONFIG_COMPATIBILITY_SAFE_FIXTURE_JSON,
375    },
376    CompatibilityFixture {
377        name: "needs_attention",
378        json: CONFIG_COMPATIBILITY_NEEDS_ATTENTION_FIXTURE_JSON,
379    },
380    CompatibilityFixture {
381        name: "breaking",
382        json: CONFIG_COMPATIBILITY_BREAKING_FIXTURE_JSON,
383    },
384    CompatibilityFixture {
385        name: "blocked",
386        json: CONFIG_COMPATIBILITY_BLOCKED_FIXTURE_JSON,
387    },
388];
389pub const RELIABILITY_COMPATIBILITY_FIXTURES: &[CompatibilityFixture] = &[
390    CompatibilityFixture {
391        name: "safe",
392        json: RELIABILITY_COMPATIBILITY_SAFE_FIXTURE_JSON,
393    },
394    CompatibilityFixture {
395        name: "needs_attention",
396        json: RELIABILITY_COMPATIBILITY_NEEDS_ATTENTION_FIXTURE_JSON,
397    },
398    CompatibilityFixture {
399        name: "breaking",
400        json: RELIABILITY_COMPATIBILITY_BREAKING_FIXTURE_JSON,
401    },
402    CompatibilityFixture {
403        name: "blocked",
404        json: RELIABILITY_COMPATIBILITY_BLOCKED_FIXTURE_JSON,
405    },
406];
407
408#[derive(
409    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
410)]
411#[serde(rename_all = "snake_case")]
412pub enum CompatibilityCategory {
413    Safe,
414    NeedsAttention,
415    Breaking,
416    Blocked,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum ContractCompatibilityKind {
422    EventContract,
423    ConfigContract,
424    ReliabilityContract,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct CompatibilityReason {
430    pub code: String,
431    pub path: String,
432    pub message: String,
433    pub next_action: String,
434}
435
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct ContractCompatibilityResult {
439    pub category: CompatibilityCategory,
440    pub contract_kind: ContractCompatibilityKind,
441    pub contract_id: String,
442    pub changed_version: String,
443    pub affected_references: Vec<String>,
444    pub reasons: Vec<CompatibilityReason>,
445}
446
447#[must_use]
448pub fn evaluate_event_compatibility(input: &Value) -> ContractCompatibilityResult {
449    let mut result = compatibility_result(input, ContractCompatibilityKind::EventContract);
450    let before = valid_candidate(input, &mut result);
451    let after = input.get("after");
452    let formats = before
453        .and_then(|v| v.get("format"))
454        .zip(after.and_then(|v| v.get("format")));
455    if !matches!(formats, Some((a, b)) if a == b && matches!(a.as_str(), Some("json_schema" | "protobuf")))
456    {
457        add_contract_reason(
458            &mut result,
459            CompatibilityCategory::Blocked,
460            "event_artifact_unverifiable",
461            "$.before.format",
462            "Event artifacts must use the same supported transport-independent format.",
463            "Provide canonical JSON Schema or Protobuf event artifacts; broker choice is not part of this comparison.",
464        );
465    } else if formats.is_some_and(|(a, _)| a == "json_schema") {
466        compare_event_json_schema(
467            &mut result,
468            before.and_then(|v| v.get("schema")),
469            after.and_then(|v| v.get("schema")),
470        );
471    } else {
472        compare_event_protobuf(&mut result, before, after);
473    }
474    finish_contract_result(
475        &mut result,
476        "event_backward_compatible",
477        "The business event remains compatible for the affected references.",
478        "Publish the new event version and monitor Consumers.",
479    );
480    result
481}
482
483#[must_use]
484pub fn evaluate_config_compatibility(input: &Value) -> ContractCompatibilityResult {
485    let mut result = compatibility_result(input, ContractCompatibilityKind::ConfigContract);
486    let before = valid_candidate(input, &mut result);
487    let after = input.get("after");
488    let old_fields = indexed_values(before.and_then(|v| v.get("fields")), "path");
489    let new_fields = indexed_values(after.and_then(|v| v.get("fields")), "path");
490    if old_fields.is_none() || new_fields.is_none() {
491        add_contract_reason(
492            &mut result,
493            CompatibilityCategory::Blocked,
494            "config_fields_unverifiable",
495            "$.after.fields",
496            "Config field declarations are missing or ambiguous.",
497            "Provide unique field paths in both Config Contract versions.",
498        );
499    } else if let (Some(old_fields), Some(new_fields)) = (old_fields, new_fields) {
500        if !old_fields.values().all(valid_config_field)
501            || !new_fields.values().all(valid_config_field)
502        {
503            add_contract_reason(
504                &mut result,
505                CompatibilityCategory::Blocked,
506                "config_field_declaration_unverifiable",
507                "$.after.fields",
508                "A Config field dimension is missing or unsupported.",
509                "Provide required, sensitivity, scope, mutability, and activation metadata for every field.",
510            );
511        }
512        for (path, old) in &old_fields {
513            let Some(new) = new_fields.get(path) else {
514                add_contract_reason(
515                    &mut result,
516                    CompatibilityCategory::Breaking,
517                    "config_field_removed",
518                    &format!("$.after.fields.{path}"),
519                    "A declared configuration value was removed.",
520                    "Restore the field or coordinate migration with affected operators.",
521                );
522                continue;
523            };
524            config_change(
525                &mut result,
526                path,
527                old,
528                new,
529                "shape",
530                "config_shape_changed",
531                CompatibilityCategory::Breaking,
532                "The configuration value shape changed.",
533            );
534            if !old
535                .get("required")
536                .and_then(Value::as_bool)
537                .unwrap_or(false)
538                && new
539                    .get("required")
540                    .and_then(Value::as_bool)
541                    .unwrap_or(false)
542            {
543                add_contract_reason(
544                    &mut result,
545                    CompatibilityCategory::Breaking,
546                    "config_required_value_added",
547                    &format!("$.after.fields.{path}.required"),
548                    "An optional configuration value became required.",
549                    "Keep it optional or provide a migration and activation plan.",
550                );
551            }
552            if old
553                .get("required")
554                .and_then(Value::as_bool)
555                .unwrap_or(false)
556                && !new
557                    .get("required")
558                    .and_then(Value::as_bool)
559                    .unwrap_or(false)
560            {
561                add_contract_reason(
562                    &mut result,
563                    CompatibilityCategory::NeedsAttention,
564                    "config_required_value_relaxed",
565                    &format!("$.after.fields.{path}.required"),
566                    "A required configuration value became optional.",
567                    "Review default and absence semantics with affected operators.",
568                );
569            }
570            if old.get("sensitive") == Some(&Value::Bool(true))
571                && new.get("sensitive") == Some(&Value::Bool(false))
572            {
573                add_contract_reason(
574                    &mut result,
575                    CompatibilityCategory::Breaking,
576                    "config_sensitivity_weakened",
577                    &format!("$.after.fields.{path}.sensitive"),
578                    "A sensitive value is no longer declared sensitive.",
579                    "Restore sensitivity before exposing or persisting the value.",
580                );
581            }
582            if old.get("sensitive") == Some(&Value::Bool(false))
583                && new.get("sensitive") == Some(&Value::Bool(true))
584            {
585                add_contract_reason(
586                    &mut result,
587                    CompatibilityCategory::NeedsAttention,
588                    "config_sensitivity_strengthened",
589                    &format!("$.after.fields.{path}.sensitive"),
590                    "A configuration value is newly declared sensitive.",
591                    "Verify storage, display, logging, and secret-provider handling before activation.",
592                );
593            }
594            config_change(
595                &mut result,
596                path,
597                old,
598                new,
599                "scope",
600                "config_scope_changed",
601                CompatibilityCategory::Breaking,
602                "The configuration ownership scope changed.",
603            );
604            if old.get("mutability").and_then(Value::as_str) == Some("mutable")
605                && new.get("mutability").and_then(Value::as_str) == Some("immutable")
606            {
607                add_contract_reason(
608                    &mut result,
609                    CompatibilityCategory::Breaking,
610                    "config_mutability_restricted",
611                    &format!("$.after.fields.{path}.mutability"),
612                    "A mutable value became immutable.",
613                    "Preserve mutability or provide a replacement and migration path.",
614                );
615            } else {
616                config_change(
617                    &mut result,
618                    path,
619                    old,
620                    new,
621                    "mutability",
622                    "config_mutability_changed",
623                    CompatibilityCategory::NeedsAttention,
624                    "Configuration mutability changed.",
625                );
626            }
627            config_change(
628                &mut result,
629                path,
630                old,
631                new,
632                "activation",
633                "config_activation_changed",
634                CompatibilityCategory::NeedsAttention,
635                "Configuration activation requirements changed.",
636            );
637        }
638        for (path, new) in new_fields
639            .iter()
640            .filter(|(path, _)| !old_fields.contains_key(*path))
641        {
642            let (category, code, message) = if new
643                .get("required")
644                .and_then(Value::as_bool)
645                .unwrap_or(false)
646            {
647                (
648                    CompatibilityCategory::Breaking,
649                    "config_required_value_added",
650                    "A new required configuration value was added.",
651                )
652            } else {
653                (
654                    CompatibilityCategory::Safe,
655                    "config_optional_value_added",
656                    "A new optional configuration value was added.",
657                )
658            };
659            add_contract_reason(
660                &mut result,
661                category,
662                code,
663                &format!("$.after.fields.{path}"),
664                message,
665                "Provide the value when required and retain activation guidance for operators.",
666            );
667        }
668    }
669    finish_contract_result(
670        &mut result,
671        "config_backward_compatible",
672        "The Config Contract remains compatible for the affected references.",
673        "Publish the Config Contract and retain the activation evidence.",
674    );
675    result
676}
677
678#[must_use]
679pub fn evaluate_reliability_compatibility(input: &Value) -> ContractCompatibilityResult {
680    let mut result = compatibility_result(input, ContractCompatibilityKind::ReliabilityContract);
681    let before = valid_candidate(input, &mut result);
682    let after = input.get("after");
683    if let (Some(old), Some(new)) = (
684        before.and_then(Value::as_object),
685        after.and_then(Value::as_object),
686    ) {
687        for key in ["availabilityTarget", "latencyTargetMs"] {
688            if old.get(key) != new.get(key) {
689                let tightened = reliability_target_tightened(key, old.get(key), new.get(key));
690                let category = if tightened {
691                    CompatibilityCategory::Breaking
692                } else {
693                    CompatibilityCategory::NeedsAttention
694                };
695                let code = if tightened {
696                    "reliability_target_tightened".to_owned()
697                } else {
698                    format!("reliability_{}_changed", camel_to_snake(key))
699                };
700                add_contract_reason(
701                    &mut result,
702                    category,
703                    &code,
704                    &format!("$.after.{key}"),
705                    "A whole-Service reliability target changed; declaration compatibility does not replace a runtime Reliability Report.",
706                    "Review the target direction and runtime evidence with affected owners.",
707                );
708            }
709        }
710        for key in old.keys().chain(new.keys()).collect::<BTreeSet<_>>() {
711            if key != "version"
712                && !matches!(key.as_str(), "availabilityTarget" | "latencyTargetMs")
713                && old.get(key) != new.get(key)
714            {
715                add_contract_reason(
716                    &mut result,
717                    CompatibilityCategory::NeedsAttention,
718                    &format!("reliability_{}_changed", camel_to_snake(key)),
719                    &format!("$.after.{key}"),
720                    "A Reliability Contract declaration changed; evaluate the effective profile against runtime evidence.",
721                    "Review the declared whole-Service expectation, Reliability Report, and affected owners.",
722                );
723            }
724        }
725    }
726    finish_contract_result(
727        &mut result,
728        "reliability_declaration_compatible",
729        "The Reliability Contract declaration is unchanged; runtime observations remain a separate report.",
730        "Retain the runtime Reliability Report beside this declaration check.",
731    );
732    result
733}
734
735fn compatibility_result(
736    input: &Value,
737    kind: ContractCompatibilityKind,
738) -> ContractCompatibilityResult {
739    let mut affected_references = input
740        .get("affectedReferences")
741        .and_then(Value::as_array)
742        .into_iter()
743        .flatten()
744        .filter_map(Value::as_str)
745        .map(str::to_owned)
746        .collect::<Vec<_>>();
747    affected_references.sort();
748    affected_references.dedup();
749    ContractCompatibilityResult {
750        category: CompatibilityCategory::Safe,
751        contract_kind: kind,
752        contract_id: input
753            .get("contractId")
754            .and_then(Value::as_str)
755            .unwrap_or_default()
756            .to_owned(),
757        changed_version: input
758            .get("changedVersion")
759            .and_then(Value::as_str)
760            .unwrap_or_default()
761            .to_owned(),
762        affected_references,
763        reasons: Vec::new(),
764    }
765}
766
767fn valid_candidate<'a>(
768    input: &'a Value,
769    result: &mut ContractCompatibilityResult,
770) -> Option<&'a Value> {
771    let before = input.get("before");
772    let after = input.get("after");
773    let before_version = before
774        .and_then(|v| v.get("version"))
775        .and_then(Value::as_str);
776    let after_version = after.and_then(|v| v.get("version")).and_then(Value::as_str);
777    if result.contract_id.is_empty()
778        || result.changed_version.is_empty()
779        || result.affected_references.is_empty()
780    {
781        add_contract_reason(
782            result,
783            CompatibilityCategory::Blocked,
784            "contract_relationship_unverifiable",
785            "$",
786            "Contract identity, changed version, and affected references must be explicit.",
787            "Resolve the contract identity and affected references before comparing versions.",
788        );
789    }
790    if before_version.is_none()
791        || after_version != Some(result.changed_version.as_str())
792        || before_version == after_version
793    {
794        add_contract_reason(
795            result,
796            CompatibilityCategory::Blocked,
797            "contract_version_unverifiable",
798            "$.after.version",
799            "Both versions must be explicit, different, and match changedVersion.",
800            "Provide authoritative before and after contract versions.",
801        );
802    }
803    before
804}
805
806fn compare_event_json_schema(
807    result: &mut ContractCompatibilityResult,
808    old: Option<&Value>,
809    new: Option<&Value>,
810) {
811    let (Some(old), Some(new)) = (old, new) else {
812        add_contract_reason(
813            result,
814            CompatibilityCategory::Blocked,
815            "event_schema_unverifiable",
816            "$.after.schema",
817            "The event schemas are missing.",
818            "Provide both canonical event schemas.",
819        );
820        return;
821    };
822    if old.get("type") != new.get("type") {
823        add_contract_reason(
824            result,
825            CompatibilityCategory::Breaking,
826            "event_type_changed",
827            "$.after.schema.type",
828            "The event payload type changed.",
829            "Restore the payload type or introduce a coordinated event version.",
830        );
831    }
832    let old_properties = old.get("properties").and_then(Value::as_object);
833    let new_properties = new.get("properties").and_then(Value::as_object);
834    if let (Some(old_properties), Some(new_properties)) = (old_properties, new_properties) {
835        for (field, old_field) in old_properties {
836            let Some(new_field) = new_properties.get(field) else {
837                add_contract_reason(
838                    result,
839                    CompatibilityCategory::Breaking,
840                    "event_field_removed",
841                    &format!("$.after.schema.properties.{field}"),
842                    "An event payload field was removed.",
843                    "Restore the field or coordinate a new version with every affected Consumer.",
844                );
845                continue;
846            };
847            if old_field.get("type") != new_field.get("type") {
848                add_contract_reason(
849                    result,
850                    CompatibilityCategory::Breaking,
851                    "event_field_type_changed",
852                    &format!("$.after.schema.properties.{field}"),
853                    "An event payload field type changed.",
854                    "Restore the type or add a new versioned field.",
855                );
856            } else if old_field != new_field {
857                add_contract_reason(
858                    result,
859                    CompatibilityCategory::NeedsAttention,
860                    "event_field_constraints_changed",
861                    &format!("$.after.schema.properties.{field}"),
862                    "Event field constraints changed and require semantic review.",
863                    "Review Producer and Consumer behavior before publishing.",
864                );
865            }
866        }
867        let old_required = string_set(old.get("required"));
868        let new_required = string_set(new.get("required"));
869        for field in old_required.difference(&new_required) {
870            add_contract_reason(
871                result,
872                CompatibilityCategory::Breaking,
873                "event_required_field_became_optional",
874                &format!("$.after.schema.properties.{field}"),
875                "A field guaranteed to Consumers may now be omitted.",
876                "Keep the event field required or coordinate a new event version.",
877            );
878        }
879    } else if old != new {
880        add_contract_reason(
881            result,
882            CompatibilityCategory::NeedsAttention,
883            "event_schema_not_structurally_proven",
884            "$.after.schema",
885            "The event schema change cannot be proven compatible structurally.",
886            "Review the schemas with affected Producers and Consumers.",
887        );
888    }
889}
890
891fn compare_event_protobuf(
892    result: &mut ContractCompatibilityResult,
893    old: Option<&Value>,
894    new: Option<&Value>,
895) {
896    if !valid_protobuf_event_fields(old) || !valid_protobuf_event_fields(new) {
897        add_contract_reason(
898            result,
899            CompatibilityCategory::Blocked,
900            "event_protobuf_descriptor_unverifiable",
901            "$.after.fields",
902            "The Protobuf event field descriptors are incomplete or ambiguous.",
903            "Provide unique positive field numbers with non-empty names and types.",
904        );
905        return;
906    }
907    let old_fields = protobuf_fields(old);
908    let new_fields = protobuf_fields(new);
909    let (Some(old_fields), Some(new_fields)) = (old_fields, new_fields) else {
910        add_contract_reason(
911            result,
912            CompatibilityCategory::Blocked,
913            "event_protobuf_descriptor_unverifiable",
914            "$.after.fields",
915            "The Protobuf event field descriptors are missing or invalid.",
916            "Provide descriptor-based fields for both event versions.",
917        );
918        return;
919    };
920    for (number, old_field) in &old_fields {
921        let Some(new_field) = new_fields.get(number) else {
922            add_contract_reason(
923                result,
924                CompatibilityCategory::Breaking,
925                "event_protobuf_field_removed",
926                &format!("$.after.fields.{number}"),
927                "A Protobuf event field number was removed.",
928                "Restore or reserve the number and coordinate a new event version.",
929            );
930            continue;
931        };
932        if old_field.get("type") != new_field.get("type") {
933            add_contract_reason(
934                result,
935                CompatibilityCategory::Breaking,
936                "event_protobuf_field_type_changed",
937                &format!("$.after.fields.{number}"),
938                "A Protobuf event field number changed wire type.",
939                "Restore the wire-compatible type or allocate a new field number.",
940            );
941        } else if old_field.get("name") != new_field.get("name") {
942            add_contract_reason(
943                result,
944                CompatibilityCategory::NeedsAttention,
945                "event_protobuf_field_renamed",
946                &format!("$.after.fields.{number}"),
947                "A Protobuf event field kept its number but changed source name.",
948                "Review generated clients and JSON mappings before publishing.",
949            );
950        }
951    }
952}
953
954fn valid_protobuf_event_fields(value: Option<&Value>) -> bool {
955    let Some(fields) = value
956        .and_then(|value| value.get("fields"))
957        .and_then(Value::as_array)
958    else {
959        return false;
960    };
961    let mut numbers = BTreeSet::new();
962    fields.iter().all(|field| {
963        field
964            .get("number")
965            .and_then(Value::as_i64)
966            .is_some_and(|number| number > 0 && numbers.insert(number))
967            && field
968                .get("name")
969                .and_then(Value::as_str)
970                .is_some_and(|value| !value.is_empty())
971            && field
972                .get("type")
973                .and_then(Value::as_str)
974                .is_some_and(|value| !value.is_empty())
975    })
976}
977
978fn valid_config_field(field: &Value) -> bool {
979    field.get("required").is_some_and(Value::is_boolean)
980        && field.get("sensitive").is_some_and(Value::is_boolean)
981        && matches!(
982            field.get("scope").and_then(Value::as_str),
983            Some("service" | "region" | "tenant")
984        )
985        && matches!(
986            field.get("mutability").and_then(Value::as_str),
987            Some("immutable" | "mutable")
988        )
989        && matches!(
990            field.get("activation").and_then(Value::as_str),
991            Some("hot" | "restart")
992        )
993}
994
995fn indexed_values(value: Option<&Value>, key: &str) -> Option<BTreeMap<String, Value>> {
996    let values = value?.as_array()?;
997    let mut indexed = BTreeMap::new();
998    for value in values {
999        let identity = value.get(key)?.as_str()?.to_owned();
1000        if indexed.insert(identity, value.clone()).is_some() {
1001            return None;
1002        }
1003    }
1004    Some(indexed)
1005}
1006
1007fn config_change(
1008    result: &mut ContractCompatibilityResult,
1009    path: &str,
1010    old: &Value,
1011    new: &Value,
1012    field: &str,
1013    code: &str,
1014    category: CompatibilityCategory,
1015    message: &str,
1016) {
1017    if old.get(field) != new.get(field) {
1018        add_contract_reason(
1019            result,
1020            category,
1021            code,
1022            &format!("$.after.fields.{path}.{field}"),
1023            message,
1024            "Review the change and provide an operator migration and activation plan.",
1025        );
1026    }
1027}
1028
1029fn add_contract_reason(
1030    result: &mut ContractCompatibilityResult,
1031    category: CompatibilityCategory,
1032    code: &str,
1033    path: &str,
1034    message: &str,
1035    next_action: &str,
1036) {
1037    result.category = result.category.max(category);
1038    result.reasons.push(CompatibilityReason {
1039        code: code.to_owned(),
1040        path: path.to_owned(),
1041        message: message.to_owned(),
1042        next_action: next_action.to_owned(),
1043    });
1044}
1045
1046fn finish_contract_result(
1047    result: &mut ContractCompatibilityResult,
1048    safe_code: &str,
1049    message: &str,
1050    next_action: &str,
1051) {
1052    if result.reasons.is_empty() {
1053        add_contract_reason(
1054            result,
1055            CompatibilityCategory::Safe,
1056            safe_code,
1057            "$",
1058            message,
1059            next_action,
1060        );
1061    }
1062    result.reasons.sort();
1063    result.reasons.dedup();
1064}
1065
1066fn camel_to_snake(value: &str) -> String {
1067    value
1068        .chars()
1069        .enumerate()
1070        .fold(String::new(), |mut output, (index, ch)| {
1071            if ch.is_ascii_uppercase() {
1072                if index > 0 {
1073                    output.push('_');
1074                }
1075                output.push(ch.to_ascii_lowercase());
1076            } else {
1077                output.push(ch);
1078            }
1079            output
1080        })
1081}
1082
1083fn reliability_target_tightened(key: &str, old: Option<&Value>, new: Option<&Value>) -> bool {
1084    match key {
1085        "availabilityTarget" => {
1086            let parse = |value: Option<&Value>| {
1087                value
1088                    .and_then(Value::as_str)
1089                    .and_then(|value| value.trim_end_matches('%').parse::<f64>().ok())
1090            };
1091            matches!((parse(old), parse(new)), (Some(old), Some(new)) if new > old)
1092        }
1093        "latencyTargetMs" => {
1094            matches!((old.and_then(Value::as_u64), new.and_then(Value::as_u64)), (Some(old), Some(new)) if new < old)
1095        }
1096        _ => false,
1097    }
1098}
1099
1100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1101#[serde(rename_all = "snake_case")]
1102pub enum RequestResponseCompatibilityCategory {
1103    Safe,
1104    NeedsAttention,
1105    Breaking,
1106    Blocked,
1107}
1108
1109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1110#[serde(rename_all = "snake_case")]
1111pub enum RequestResponseContractKind {
1112    ProviderProtocol,
1113    ServiceContract,
1114}
1115
1116#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1117#[serde(rename_all = "camelCase")]
1118pub struct RequestResponseCompatibilityReason {
1119    pub code: String,
1120    pub path: String,
1121    pub message: String,
1122    pub next_action: String,
1123}
1124
1125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1126#[serde(rename_all = "camelCase")]
1127pub struct RequestResponseCompatibilityResult {
1128    pub category: RequestResponseCompatibilityCategory,
1129    pub contract_kind: RequestResponseContractKind,
1130    pub contract_id: String,
1131    pub changed_version: String,
1132    pub producers: Vec<String>,
1133    pub consumers: Vec<String>,
1134    pub reasons: Vec<RequestResponseCompatibilityReason>,
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1138#[serde(rename_all = "camelCase")]
1139pub struct RequestResponseCanonicalizationError {
1140    pub code: String,
1141    pub path: String,
1142    pub message: String,
1143    pub next_action: String,
1144}
1145
1146/// Converts a raw OpenAPI JSON value into the canonical operation shape consumed by the
1147/// compatibility evaluator. YAML callers should deserialize to `serde_json::Value` first.
1148pub fn canonicalize_openapi_request_response(
1149    document: &Value,
1150) -> Result<Value, Vec<RequestResponseCanonicalizationError>> {
1151    if !document
1152        .get("openapi")
1153        .and_then(Value::as_str)
1154        .is_some_and(|version| version.starts_with("3.0.") || version.starts_with("3.1."))
1155    {
1156        return Err(vec![canonicalization_error(
1157            "openapi_protocol_unsupported",
1158            "$.openapi",
1159            "Only OpenAPI 3.0 and 3.1 documents are supported.",
1160            "Generate a complete OpenAPI 3.0 or 3.1 document before comparing contracts.",
1161        )]);
1162    }
1163    let mut reference_errors = Vec::new();
1164    validate_openapi_references(document, document, "$", &mut reference_errors);
1165    if !reference_errors.is_empty() {
1166        return Err(reference_errors);
1167    }
1168    let Some(version) = document
1169        .get("info")
1170        .and_then(|info| info.get("version"))
1171        .and_then(Value::as_str)
1172        .filter(|version| !version.is_empty())
1173    else {
1174        return Err(vec![canonicalization_error(
1175            "openapi_version_missing",
1176            "$.info.version",
1177            "OpenAPI info.version is required.",
1178            "Set info.version to the authoritative contract version.",
1179        )]);
1180    };
1181    let Some(paths) = document.get("paths").and_then(Value::as_object) else {
1182        return Err(vec![canonicalization_error(
1183            "openapi_paths_missing",
1184            "$.paths",
1185            "OpenAPI paths must be an object.",
1186            "Generate a complete OpenAPI document with request-response paths.",
1187        )]);
1188    };
1189    let mut operations = serde_json::Map::new();
1190    let mut errors = Vec::new();
1191    for (path, path_item) in paths {
1192        let Some(path_item) = path_item.as_object() else {
1193            continue;
1194        };
1195        for method in [
1196            "get", "put", "post", "delete", "patch", "head", "options", "trace",
1197        ] {
1198            let Some(operation) = path_item.get(method).and_then(Value::as_object) else {
1199                continue;
1200            };
1201            let operation_id = operation
1202                .get("operationId")
1203                .and_then(Value::as_str)
1204                .map(str::to_owned)
1205                .unwrap_or_else(|| format!("{} {}", method.to_ascii_uppercase(), path));
1206            let declared_parameters = path_item
1207                .get("parameters")
1208                .and_then(Value::as_array)
1209                .map_or(0, Vec::len)
1210                + operation
1211                    .get("parameters")
1212                    .and_then(Value::as_array)
1213                    .map_or(0, Vec::len);
1214            let parameters = canonicalize_openapi_parameters(document, path_item, operation);
1215            if declared_parameters > 0 && parameters.is_none() {
1216                errors.push(canonicalization_error(
1217                    "openapi_parameter_unverifiable",
1218                    format!("$.paths.{path}.{method}.parameters"),
1219                    "An OpenAPI parameter cannot be canonicalized.",
1220                    "Resolve parameter references and provide name, location, and schema metadata.",
1221                ));
1222                continue;
1223            }
1224            let request = operation
1225                .get("requestBody")
1226                .and_then(|body| {
1227                    let mut schema = canonicalize_openapi_content(document, body.get("content")?)?;
1228                    if let Some(object) = schema.as_object_mut() {
1229                        object.insert(
1230                            "x-lenso-request-body-required".to_owned(),
1231                            Value::Bool(
1232                                body.get("required")
1233                                    .and_then(Value::as_bool)
1234                                    .unwrap_or(false),
1235                            ),
1236                        );
1237                        if let Some(parameters) = &parameters {
1238                            object.insert("x-lenso-parameters".to_owned(), parameters.clone());
1239                        }
1240                    }
1241                    Some(schema)
1242                })
1243                .or(parameters);
1244            let declared_success_responses = operation
1245                .get("responses")
1246                .and_then(Value::as_object)
1247                .into_iter()
1248                .flatten()
1249                .filter(|(status, _)| status.starts_with('2'))
1250                .count();
1251            let responses = operation
1252                .get("responses")
1253                .and_then(Value::as_object)
1254                .into_iter()
1255                .flatten()
1256                .filter(|(status, _)| status.starts_with('2'))
1257                .filter_map(|(status, response)| {
1258                    canonicalize_openapi_response(document, response)
1259                        .map(|schema| (status.clone(), schema))
1260                })
1261                .collect::<serde_json::Map<_, _>>();
1262            if responses.len() != declared_success_responses {
1263                errors.push(canonicalization_error(
1264                    "openapi_response_unverifiable",
1265                    format!("$.paths.{path}.{method}.responses"),
1266                    "A successful OpenAPI response cannot be canonicalized.",
1267                    "Use one explicit media type per response and provide a valid schema.",
1268                ));
1269                continue;
1270            }
1271            let response_statuses = responses.keys().cloned().collect::<Vec<_>>();
1272            let response = (!responses.is_empty()).then(|| {
1273                serde_json::json!({
1274                    "type": "object",
1275                    "required": response_statuses,
1276                    "properties": responses,
1277                })
1278            });
1279            let (Some(request), Some(response)) = (request, response) else {
1280                errors.push(canonicalization_error(
1281                    "openapi_operation_schema_missing",
1282                    format!("$.paths.{path}.{method}"),
1283                    "A request-response operation needs request and success response schemas.",
1284                    "Declare requestBody and a 2xx response schema for this operation.",
1285                ));
1286                continue;
1287            };
1288            if operations.contains_key(&operation_id) {
1289                errors.push(canonicalization_error(
1290                    "openapi_operation_id_duplicate",
1291                    format!("$.paths.{path}.{method}.operationId"),
1292                    "OpenAPI operationId values must be unique.",
1293                    "Assign a unique stable operationId before comparing contracts.",
1294                ));
1295                continue;
1296            }
1297            operations.insert(
1298                operation_id,
1299                serde_json::json!({
1300                    "method": method,
1301                    "path": path,
1302                    "request": request,
1303                    "response": response,
1304                }),
1305            );
1306        }
1307    }
1308    if operations.is_empty() {
1309        errors.push(canonicalization_error(
1310            "openapi_operations_missing",
1311            "$.paths",
1312            "No verifiable request-response operations were found.",
1313            "Declare at least one operation with request and success response schemas.",
1314        ));
1315    }
1316    if !errors.is_empty() {
1317        return Err(errors);
1318    }
1319    Ok(serde_json::json!({ "format": "openapi", "version": version, "operations": operations }))
1320}
1321
1322/// Converts a binary Protobuf `FileDescriptorSet` into the canonical operation shape.
1323pub fn canonicalize_protobuf_request_response(
1324    version: &str,
1325    descriptor_bytes: &[u8],
1326) -> Result<Value, Vec<RequestResponseCanonicalizationError>> {
1327    if version.is_empty() {
1328        return Err(vec![canonicalization_error(
1329            "protobuf_version_missing",
1330            "$.version",
1331            "The Protobuf contract version is required.",
1332            "Provide the authoritative Protobuf contract version.",
1333        )]);
1334    }
1335    let descriptor = prost_types::FileDescriptorSet::decode(descriptor_bytes).map_err(|error| {
1336        vec![canonicalization_error(
1337            "protobuf_descriptor_invalid",
1338            "$",
1339            format!("Invalid FileDescriptorSet: {error}"),
1340            "Generate a binary descriptor set with protoc --descriptor_set_out.",
1341        )]
1342    })?;
1343    let descriptor_errors = validate_supported_protobuf_descriptor(&descriptor);
1344    if !descriptor_errors.is_empty() {
1345        return Err(descriptor_errors);
1346    }
1347    let mut messages = BTreeMap::new();
1348    for file in &descriptor.file {
1349        let package = file.package.as_deref().unwrap_or_default();
1350        collect_protobuf_messages(package, "", &file.message_type, &mut messages);
1351    }
1352    let mut operations = serde_json::Map::new();
1353    for file in &descriptor.file {
1354        let package = file.package.as_deref().unwrap_or_default();
1355        for service in &file.service {
1356            let service_name = service.name.as_deref().unwrap_or("Service");
1357            for method in &service.method {
1358                let method_name = method.name.as_deref().unwrap_or("Method");
1359                let operation_id = [package, service_name, method_name]
1360                    .into_iter()
1361                    .filter(|part| !part.is_empty())
1362                    .collect::<Vec<_>>()
1363                    .join(".");
1364                let request_type = method
1365                    .input_type
1366                    .as_deref()
1367                    .map(|name| name.trim_start_matches('.'));
1368                let response_type = method
1369                    .output_type
1370                    .as_deref()
1371                    .map(|name| name.trim_start_matches('.'));
1372                let request = method.input_type.as_deref().and_then(|name| {
1373                    expand_protobuf_message(
1374                        name.trim_start_matches('.'),
1375                        &messages,
1376                        &mut BTreeSet::new(),
1377                    )
1378                });
1379                let response = method.output_type.as_deref().and_then(|name| {
1380                    expand_protobuf_message(
1381                        name.trim_start_matches('.'),
1382                        &messages,
1383                        &mut BTreeSet::new(),
1384                    )
1385                });
1386                let (Some(mut request), Some(mut response)) = (request, response) else {
1387                    return Err(vec![canonicalization_error(
1388                        "protobuf_message_unresolved",
1389                        format!("$.operations.{operation_id}"),
1390                        "RPC input or output message is missing from the descriptor set.",
1391                        "Include imports when generating the FileDescriptorSet and retry.",
1392                    )]);
1393                };
1394                let syntax = file.syntax.as_deref().unwrap_or("proto2");
1395                for (schema, message_name) in [
1396                    (&mut request, request_type.unwrap_or_default()),
1397                    (&mut response, response_type.unwrap_or_default()),
1398                ] {
1399                    if let Some(object) = schema.as_object_mut() {
1400                        object.insert("message".to_owned(), Value::String(message_name.to_owned()));
1401                        object.insert("syntax".to_owned(), Value::String(syntax.to_owned()));
1402                    }
1403                }
1404                operations.insert(
1405                    operation_id,
1406                    serde_json::json!({ "request": request, "response": response }),
1407                );
1408            }
1409        }
1410    }
1411    if operations.is_empty() {
1412        return Err(vec![canonicalization_error(
1413            "protobuf_operations_missing",
1414            "$",
1415            "No verifiable Protobuf RPC operations were found.",
1416            "Include at least one service RPC in the FileDescriptorSet.",
1417        )]);
1418    }
1419    Ok(serde_json::json!({ "format": "protobuf", "version": version, "operations": operations }))
1420}
1421
1422fn canonicalize_openapi_content(document: &Value, content: &Value) -> Option<Value> {
1423    let content = content.as_object()?;
1424    if content.len() != 1 {
1425        return None;
1426    }
1427    let (media_type, media) = content.iter().next()?;
1428    let mut primary = resolve_openapi_schema(document, media.get("schema")?, &mut BTreeSet::new());
1429    if let Some(object) = primary.as_object_mut() {
1430        object.insert(
1431            "x-lenso-media-type".to_owned(),
1432            Value::String(media_type.clone()),
1433        );
1434        Some(primary)
1435    } else {
1436        Some(serde_json::json!({
1437            "allOf": [primary],
1438            "x-lenso-media-type": media_type,
1439        }))
1440    }
1441}
1442
1443fn canonicalize_openapi_response(document: &Value, response: &Value) -> Option<Value> {
1444    let mut schema = match response.get("content") {
1445        None => serde_json::json!({
1446            "type": "null",
1447            "x-lenso-no-content": true,
1448        }),
1449        Some(content) if content.as_object().is_some_and(serde_json::Map::is_empty) => {
1450            serde_json::json!({
1451                "type": "null",
1452                "x-lenso-no-content": true,
1453            })
1454        }
1455        Some(content) => canonicalize_openapi_content(document, content)?,
1456    };
1457    let headers = response
1458        .get("headers")
1459        .and_then(Value::as_object)
1460        .into_iter()
1461        .flatten()
1462        .map(|(name, header)| {
1463            (
1464                name.clone(),
1465                resolve_openapi_schema(document, header, &mut BTreeSet::new()),
1466            )
1467        })
1468        .collect::<serde_json::Map<_, _>>();
1469    if let Some(object) = schema.as_object_mut() {
1470        object.insert(
1471            "x-lenso-response-headers".to_owned(),
1472            Value::Object(headers),
1473        );
1474    }
1475    Some(schema)
1476}
1477
1478fn validate_openapi_references(
1479    document: &Value,
1480    value: &Value,
1481    path: &str,
1482    errors: &mut Vec<RequestResponseCanonicalizationError>,
1483) {
1484    match value {
1485        Value::Object(object) => {
1486            if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
1487                let resolved = reference
1488                    .strip_prefix('#')
1489                    .and_then(|pointer| document.pointer(pointer));
1490                if resolved.is_none() {
1491                    errors.push(canonicalization_error(
1492                        "openapi_reference_unverifiable",
1493                        format!("{path}.$ref"),
1494                        format!("OpenAPI reference `{reference}` cannot be resolved locally."),
1495                        "Bundle external references and fix unresolved pointers before comparing contracts.",
1496                    ));
1497                }
1498            }
1499            for (key, child) in object {
1500                validate_openapi_references(document, child, &format!("{path}.{key}"), errors);
1501            }
1502        }
1503        Value::Array(values) => {
1504            for (index, child) in values.iter().enumerate() {
1505                validate_openapi_references(document, child, &format!("{path}[{index}]"), errors);
1506            }
1507        }
1508        _ => {}
1509    }
1510}
1511
1512fn canonicalize_openapi_parameters(
1513    document: &Value,
1514    path_item: &serde_json::Map<String, Value>,
1515    operation: &serde_json::Map<String, Value>,
1516) -> Option<Value> {
1517    let parameters = path_item
1518        .get("parameters")
1519        .and_then(Value::as_array)
1520        .into_iter()
1521        .flatten()
1522        .chain(
1523            operation
1524                .get("parameters")
1525                .and_then(Value::as_array)
1526                .into_iter()
1527                .flatten(),
1528        )
1529        .collect::<Vec<_>>();
1530    if parameters.is_empty() {
1531        return Some(serde_json::json!({ "type": "object", "properties": {} }));
1532    }
1533    let mut properties = serde_json::Map::new();
1534    let mut required = BTreeSet::new();
1535    for parameter in parameters {
1536        let parameter = resolve_openapi_schema(document, parameter, &mut BTreeSet::new());
1537        let name = parameter.get("name").and_then(Value::as_str)?;
1538        let schema = parameter.get("schema")?;
1539        let mut schema = resolve_openapi_schema(document, schema, &mut BTreeSet::new());
1540        if let Some(object) = schema.as_object_mut() {
1541            for key in ["in", "style", "explode", "allowEmptyValue", "allowReserved"] {
1542                if let Some(value) = parameter.get(key) {
1543                    object.insert(format!("x-lenso-parameter-{key}"), value.clone());
1544                }
1545            }
1546        }
1547        properties.insert(name.to_owned(), schema);
1548        if parameter
1549            .get("required")
1550            .and_then(Value::as_bool)
1551            .unwrap_or(false)
1552        {
1553            required.insert(name.to_owned());
1554        }
1555    }
1556    Some(serde_json::json!({
1557        "type": "object",
1558        "required": required,
1559        "properties": properties,
1560    }))
1561}
1562
1563fn resolve_openapi_schema(document: &Value, schema: &Value, seen: &mut BTreeSet<String>) -> Value {
1564    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
1565        if !seen.insert(reference.to_owned()) {
1566            return serde_json::json!({ "$ref": reference, "recursive": true });
1567        }
1568        let resolved = reference
1569            .strip_prefix('#')
1570            .and_then(|pointer| document.pointer(pointer))
1571            .map(|value| resolve_openapi_schema(document, value, seen));
1572        seen.remove(reference);
1573        if let Some(mut resolved) = resolved {
1574            if let (Some(target), Some(source)) = (resolved.as_object_mut(), schema.as_object()) {
1575                for (key, value) in source {
1576                    if key != "$ref" {
1577                        target.insert(key.clone(), resolve_openapi_schema(document, value, seen));
1578                    }
1579                }
1580            }
1581            return resolved;
1582        }
1583    }
1584    match schema {
1585        Value::Array(values) => Value::Array(
1586            values
1587                .iter()
1588                .map(|value| resolve_openapi_schema(document, value, seen))
1589                .collect(),
1590        ),
1591        Value::Object(object) => Value::Object(
1592            object
1593                .iter()
1594                .map(|(key, value)| (key.clone(), resolve_openapi_schema(document, value, seen)))
1595                .collect(),
1596        ),
1597        _ => schema.clone(),
1598    }
1599}
1600
1601fn collect_protobuf_messages(
1602    package: &str,
1603    parent: &str,
1604    descriptors: &[prost_types::DescriptorProto],
1605    messages: &mut BTreeMap<String, Value>,
1606) {
1607    for descriptor in descriptors {
1608        let name = descriptor.name.as_deref().unwrap_or("Message");
1609        let local_name = if parent.is_empty() {
1610            name.to_owned()
1611        } else {
1612            format!("{parent}.{name}")
1613        };
1614        let full_name = if package.is_empty() {
1615            local_name.clone()
1616        } else {
1617            format!("{package}.{local_name}")
1618        };
1619        let fields = descriptor.field.iter().map(|field| {
1620            let field_type = field.type_name.clone().unwrap_or_else(|| format!("{:?}", field.r#type.and_then(|value| prost_types::field_descriptor_proto::Type::try_from(value).ok())));
1621            let label = field.label.and_then(|value| prost_types::field_descriptor_proto::Label::try_from(value).ok()).map(|label| format!("{label:?}").to_ascii_lowercase()).unwrap_or_else(|| "optional".to_owned());
1622            serde_json::json!({ "number": field.number.unwrap_or_default(), "name": field.name.clone().unwrap_or_default(), "jsonName": field.json_name, "type": field_type, "label": label })
1623        }).collect::<Vec<_>>();
1624        messages.insert(full_name, serde_json::json!({ "fields": fields }));
1625        collect_protobuf_messages(package, &local_name, &descriptor.nested_type, messages);
1626    }
1627}
1628
1629fn validate_supported_protobuf_descriptor(
1630    descriptor: &prost_types::FileDescriptorSet,
1631) -> Vec<RequestResponseCanonicalizationError> {
1632    let mut errors = Vec::new();
1633    for (file_index, file) in descriptor.file.iter().enumerate() {
1634        if !matches!(
1635            file.syntax.as_deref().unwrap_or("proto2"),
1636            "proto2" | "proto3"
1637        ) {
1638            errors.push(unsupported_protobuf_feature(
1639                format!("$.file[{file_index}].syntax"),
1640                "Protobuf editions or unknown syntax",
1641            ));
1642        }
1643        if !file.enum_type.is_empty() {
1644            errors.push(unsupported_protobuf_feature(
1645                format!("$.file[{file_index}].enumType"),
1646                "enum definitions",
1647            ));
1648        }
1649        validate_supported_protobuf_messages(
1650            &file.message_type,
1651            &format!("$.file[{file_index}].messageType"),
1652            &mut errors,
1653        );
1654        for (service_index, service) in file.service.iter().enumerate() {
1655            for (method_index, method) in service.method.iter().enumerate() {
1656                if method.client_streaming.unwrap_or(false)
1657                    || method.server_streaming.unwrap_or(false)
1658                    || method.options.as_ref().is_some_and(|options| {
1659                        options.deprecated.unwrap_or(false)
1660                            || !options.uninterpreted_option.is_empty()
1661                    })
1662                {
1663                    errors.push(unsupported_protobuf_feature(
1664                        format!(
1665                            "$.file[{file_index}].service[{service_index}].method[{method_index}]"
1666                        ),
1667                        "streaming RPCs or unsupported method options",
1668                    ));
1669                }
1670            }
1671        }
1672    }
1673    errors
1674}
1675
1676fn validate_supported_protobuf_messages(
1677    messages: &[prost_types::DescriptorProto],
1678    path: &str,
1679    errors: &mut Vec<RequestResponseCanonicalizationError>,
1680) {
1681    for (message_index, message) in messages.iter().enumerate() {
1682        let message_path = format!("{path}[{message_index}]");
1683        if !message.enum_type.is_empty()
1684            || !message.oneof_decl.is_empty()
1685            || message.options.is_some()
1686        {
1687            errors.push(unsupported_protobuf_feature(
1688                &message_path,
1689                "enum, oneof, map, or message options",
1690            ));
1691        }
1692        for (field_index, field) in message.field.iter().enumerate() {
1693            let is_enum = field
1694                .r#type
1695                .and_then(|value| prost_types::field_descriptor_proto::Type::try_from(value).ok())
1696                == Some(prost_types::field_descriptor_proto::Type::Enum);
1697            if is_enum
1698                || field.oneof_index.is_some()
1699                || field.proto3_optional.unwrap_or(false)
1700                || field.default_value.is_some()
1701                || field.options.is_some()
1702            {
1703                errors.push(unsupported_protobuf_feature(
1704                    format!("{message_path}.field[{field_index}]"),
1705                    "enum, oneof, proto3 optional, defaults, or field options",
1706                ));
1707            }
1708        }
1709        validate_supported_protobuf_messages(
1710            &message.nested_type,
1711            &format!("{message_path}.nestedType"),
1712            errors,
1713        );
1714    }
1715}
1716
1717fn unsupported_protobuf_feature(
1718    path: impl Into<String>,
1719    feature: &str,
1720) -> RequestResponseCanonicalizationError {
1721    canonicalization_error(
1722        "protobuf_feature_unverifiable",
1723        path,
1724        format!("Compatibility for {feature} is not yet structurally verifiable."),
1725        "Review this Protobuf change manually or use only currently supported descriptor features.",
1726    )
1727}
1728
1729fn expand_protobuf_message(
1730    name: &str,
1731    messages: &BTreeMap<String, Value>,
1732    seen: &mut BTreeSet<String>,
1733) -> Option<Value> {
1734    let canonical_name = name.trim_start_matches('.');
1735    let message = messages.get(canonical_name)?;
1736    if !seen.insert(canonical_name.to_owned()) {
1737        return Some(serde_json::json!({
1738            "message": canonical_name,
1739            "recursive": true,
1740        }));
1741    }
1742    let mut expanded = message.clone();
1743    if let Some(fields) = expanded.get_mut("fields").and_then(Value::as_array_mut) {
1744        for field in fields {
1745            let Some(field_type) = field.get("type").and_then(Value::as_str) else {
1746                continue;
1747            };
1748            if let Some(schema) = expand_protobuf_message(field_type, messages, seen) {
1749                field["type"] = serde_json::json!({
1750                    "message": field_type.trim_start_matches('.'),
1751                    "schema": schema,
1752                });
1753            }
1754        }
1755    }
1756    seen.remove(canonical_name);
1757    Some(expanded)
1758}
1759
1760fn canonicalization_error(
1761    code: impl Into<String>,
1762    path: impl Into<String>,
1763    message: impl Into<String>,
1764    next_action: impl Into<String>,
1765) -> RequestResponseCanonicalizationError {
1766    RequestResponseCanonicalizationError {
1767        code: code.into(),
1768        path: path.into(),
1769        message: message.into(),
1770        next_action: next_action.into(),
1771    }
1772}
1773
1774/// Compares canonical OpenAPI or Protobuf request/response operation shapes.
1775///
1776/// The input is deliberately JSON so the public library and CLI can call the same evaluator.
1777/// `operations` is keyed by stable operation or RPC name. OpenAPI request/response values are
1778/// JSON Schemas; Protobuf values contain descriptor-like `fields` arrays keyed by field number.
1779#[must_use]
1780pub fn evaluate_request_response_compatibility(
1781    input: &Value,
1782) -> RequestResponseCompatibilityResult {
1783    evaluate_request_response_compatibility_inner(None, input)
1784}
1785
1786#[must_use]
1787pub fn evaluate_request_response_compatibility_in_system(
1788    system: &Value,
1789    input: &Value,
1790) -> RequestResponseCompatibilityResult {
1791    evaluate_request_response_compatibility_inner(Some(system), input)
1792}
1793
1794fn evaluate_request_response_compatibility_inner(
1795    system: Option<&Value>,
1796    input: &Value,
1797) -> RequestResponseCompatibilityResult {
1798    let raw_contract_kind = input.get("contractKind").and_then(Value::as_str);
1799    let contract_kind = match raw_contract_kind {
1800        Some("provider_protocol") => RequestResponseContractKind::ProviderProtocol,
1801        _ => RequestResponseContractKind::ServiceContract,
1802    };
1803    let prefix = match contract_kind {
1804        RequestResponseContractKind::ProviderProtocol => "provider_protocol_",
1805        RequestResponseContractKind::ServiceContract => "service_contract_",
1806    };
1807    let mut result = RequestResponseCompatibilityResult {
1808        category: RequestResponseCompatibilityCategory::Safe,
1809        contract_kind,
1810        contract_id: input
1811            .get("contractId")
1812            .and_then(Value::as_str)
1813            .unwrap_or_default()
1814            .to_owned(),
1815        changed_version: input
1816            .get("changedVersion")
1817            .and_then(Value::as_str)
1818            .unwrap_or_default()
1819            .to_owned(),
1820        producers: Vec::new(),
1821        consumers: Vec::new(),
1822        reasons: Vec::new(),
1823    };
1824    if !matches!(
1825        raw_contract_kind,
1826        Some("provider_protocol" | "service_contract")
1827    ) {
1828        compatibility_reason(
1829            &mut result,
1830            RequestResponseCompatibilityCategory::Blocked,
1831            "relationship_unverifiable",
1832            "$.contractKind",
1833            "The request-response contract kind is missing or unsupported.",
1834            "Declare `provider_protocol` or `service_contract` explicitly.",
1835        );
1836    }
1837    if let Some(system) = system {
1838        resolve_system_v2_relationships(system, input, &mut result);
1839    }
1840    if result.contract_id.is_empty()
1841        || result.changed_version.is_empty()
1842        || result.producers.is_empty()
1843        || result.consumers.is_empty()
1844    {
1845        compatibility_reason(
1846            &mut result,
1847            RequestResponseCompatibilityCategory::Blocked,
1848            "relationship_unverifiable",
1849            "$",
1850            "Contract identity, changed version, Producer, and Consumer relationships must all be verifiable.",
1851            "Resolve the contract and System graph references before evaluating compatibility.",
1852        );
1853    }
1854    let before = input.get("before");
1855    let after = input.get("after");
1856    let before_version = before
1857        .and_then(|value| value.get("version"))
1858        .and_then(Value::as_str);
1859    let after_version = after
1860        .and_then(|value| value.get("version"))
1861        .and_then(Value::as_str);
1862    if before_version.is_none()
1863        || after_version.is_none()
1864        || after_version != Some(result.changed_version.as_str())
1865        || before_version == after_version
1866    {
1867        compatibility_reason(
1868            &mut result,
1869            RequestResponseCompatibilityCategory::Blocked,
1870            &format!("{prefix}artifact_version_unverifiable"),
1871            "$.after.version",
1872            "Both artifact versions must be explicit, different, and the candidate must match changedVersion.",
1873            "Provide authoritative before and after artifact versions and retry.",
1874        );
1875    }
1876    let before_format = before
1877        .and_then(|value| value.get("format"))
1878        .and_then(Value::as_str);
1879    let after_format = after
1880        .and_then(|value| value.get("format"))
1881        .and_then(Value::as_str);
1882    if before_format != after_format || !matches!(before_format, Some("openapi" | "protobuf")) {
1883        compatibility_reason(
1884            &mut result,
1885            RequestResponseCompatibilityCategory::Blocked,
1886            &format!("{prefix}artifact_unverifiable"),
1887            "$.before.format",
1888            "Both artifacts must use the same supported request-response format.",
1889            "Provide canonical OpenAPI or Protobuf artifacts in the same format.",
1890        );
1891    } else if let (Some(before_operations), Some(after_operations)) = (
1892        before
1893            .and_then(|value| value.get("operations"))
1894            .and_then(Value::as_object),
1895        after
1896            .and_then(|value| value.get("operations"))
1897            .and_then(Value::as_object),
1898    ) {
1899        for (operation, old) in before_operations {
1900            let Some(new) = after_operations.get(operation) else {
1901                compatibility_reason(
1902                    &mut result,
1903                    RequestResponseCompatibilityCategory::Breaking,
1904                    &format!("{prefix}operation_removed"),
1905                    &format!("$.after.operations.{operation}"),
1906                    "A request-response operation was removed.",
1907                    "Restore the operation or coordinate a new contract version with every Consumer.",
1908                );
1909                continue;
1910            };
1911            match before_format {
1912                Some("openapi") => {
1913                    compare_openapi_operation(&mut result, prefix, operation, old, new)
1914                }
1915                Some("protobuf") => {
1916                    compare_protobuf_operation(&mut result, prefix, operation, old, new)
1917                }
1918                _ => {}
1919            }
1920        }
1921    } else {
1922        compatibility_reason(
1923            &mut result,
1924            RequestResponseCompatibilityCategory::Blocked,
1925            &format!("{prefix}artifact_unverifiable"),
1926            "$.before.operations",
1927            "Both artifacts must expose canonical operations.",
1928            "Generate canonical operation shapes from both artifacts and retry.",
1929        );
1930    }
1931    if result.reasons.is_empty() {
1932        compatibility_reason(
1933            &mut result,
1934            RequestResponseCompatibilityCategory::Safe,
1935            &format!("{prefix}backward_compatible"),
1936            "$",
1937            "All known Producer and Consumer request-response relationships remain compatible.",
1938            "Publish the changed version and monitor known Consumers.",
1939        );
1940    }
1941    result.reasons.sort();
1942    result.reasons.dedup();
1943    result
1944}
1945
1946fn resolve_system_v2_relationships(
1947    system: &Value,
1948    input: &Value,
1949    result: &mut RequestResponseCompatibilityResult,
1950) {
1951    if system_v2_graph(system).is_err() {
1952        return;
1953    }
1954    let Some(contract) = system
1955        .get("contracts")
1956        .and_then(Value::as_array)
1957        .into_iter()
1958        .flatten()
1959        .find(|contract| {
1960            contract.get("contractId").and_then(Value::as_str) == Some(&result.contract_id)
1961        })
1962    else {
1963        return;
1964    };
1965    let Some(producer_kind) = contract.get("producerKind").and_then(Value::as_str) else {
1966        return;
1967    };
1968    let Some(producer_id) = contract.get("producerId").and_then(Value::as_str) else {
1969        return;
1970    };
1971    let resolved_kind = if producer_kind == "provider" {
1972        RequestResponseContractKind::ProviderProtocol
1973    } else {
1974        RequestResponseContractKind::ServiceContract
1975    };
1976    let graph_version = contract.get("version").and_then(Value::as_str);
1977    let graph_format = contract
1978        .get("artifact")
1979        .and_then(|artifact| artifact.get("format"))
1980        .and_then(Value::as_str);
1981    let baseline_version = input
1982        .get("before")
1983        .and_then(|artifact| artifact.get("version"))
1984        .and_then(Value::as_str);
1985    let baseline_format = input
1986        .get("before")
1987        .and_then(|artifact| artifact.get("format"))
1988        .and_then(Value::as_str);
1989    if resolved_kind != result.contract_kind
1990        || graph_version != baseline_version
1991        || graph_format != baseline_format
1992    {
1993        compatibility_reason(
1994            result,
1995            RequestResponseCompatibilityCategory::Blocked,
1996            "relationship_unverifiable",
1997            "$.before",
1998            "The declared contract kind, baseline version, or format does not match the resolved System graph contract.",
1999            "Use the contract kind, version, and artifact format declared by the authoritative System graph.",
2000        );
2001        return;
2002    }
2003    result.producers = vec![format!("{producer_kind}:{producer_id}")];
2004    result.consumers = system
2005        .get("consumers")
2006        .and_then(Value::as_array)
2007        .into_iter()
2008        .flatten()
2009        .filter(|consumer| {
2010            consumer.get("contractId").and_then(Value::as_str) == Some(&result.contract_id)
2011        })
2012        .filter_map(|consumer| {
2013            Some(format!(
2014                "{}:{}",
2015                consumer.get("ownerKind")?.as_str()?,
2016                consumer.get("ownerId")?.as_str()?
2017            ))
2018        })
2019        .collect();
2020    result.consumers.sort();
2021    result.consumers.dedup();
2022}
2023
2024fn compare_openapi_operation(
2025    result: &mut RequestResponseCompatibilityResult,
2026    prefix: &str,
2027    operation: &str,
2028    old: &Value,
2029    new: &Value,
2030) {
2031    if old.get("method") != new.get("method") || old.get("path") != new.get("path") {
2032        compatibility_reason(
2033            result,
2034            RequestResponseCompatibilityCategory::Breaking,
2035            &format!("{prefix}operation_transport_changed"),
2036            &format!("$.after.operations.{operation}"),
2037            "The HTTP method or path for an existing operation changed.",
2038            "Restore the previous method and path or coordinate a new contract version with every Consumer.",
2039        );
2040    }
2041    compare_json_schema(
2042        result,
2043        prefix,
2044        operation,
2045        "request",
2046        old.get("request"),
2047        new.get("request"),
2048        true,
2049    );
2050    compare_json_schema(
2051        result,
2052        prefix,
2053        operation,
2054        "response",
2055        old.get("response"),
2056        new.get("response"),
2057        false,
2058    );
2059}
2060
2061fn compare_json_schema(
2062    result: &mut RequestResponseCompatibilityResult,
2063    prefix: &str,
2064    operation: &str,
2065    direction: &str,
2066    old: Option<&Value>,
2067    new: Option<&Value>,
2068    request: bool,
2069) {
2070    let path = format!("$.after.operations.{operation}.{direction}");
2071    let (Some(old), Some(new)) = (old, new) else {
2072        compatibility_reason(
2073            result,
2074            RequestResponseCompatibilityCategory::Blocked,
2075            &format!("{prefix}{direction}_schema_unverifiable"),
2076            &path,
2077            "The request or response schema is missing.",
2078            "Provide both canonical schemas before evaluating compatibility.",
2079        );
2080        return;
2081    };
2082    if old.get("type") != new.get("type") {
2083        compatibility_reason(
2084            result,
2085            RequestResponseCompatibilityCategory::Breaking,
2086            &format!("{prefix}{direction}_type_changed"),
2087            &path,
2088            "The request or response type changed.",
2089            "Restore the previous type or coordinate a new contract version with affected Consumers.",
2090        );
2091    }
2092    if request
2093        && !old
2094            .get("x-lenso-request-body-required")
2095            .and_then(Value::as_bool)
2096            .unwrap_or(false)
2097        && new
2098            .get("x-lenso-request-body-required")
2099            .and_then(Value::as_bool)
2100            .unwrap_or(false)
2101    {
2102        compatibility_reason(
2103            result,
2104            RequestResponseCompatibilityCategory::Breaking,
2105            &format!("{prefix}request_body_became_required"),
2106            &path,
2107            "An optional request body became required.",
2108            "Keep the request body optional or coordinate the change with every Consumer.",
2109        );
2110    }
2111    let old_required = string_set(old.get("required"));
2112    let new_required = string_set(new.get("required"));
2113    let old_properties = old.get("properties").and_then(Value::as_object);
2114    let new_properties = new.get("properties").and_then(Value::as_object);
2115    if let (Some(old_properties), Some(new_properties)) = (old_properties, new_properties) {
2116        if request {
2117            for field in new_required.difference(&old_required) {
2118                compatibility_reason(
2119                    result,
2120                    RequestResponseCompatibilityCategory::Breaking,
2121                    &format!("{prefix}request_required_field_added"),
2122                    &format!("{path}.properties.{field}"),
2123                    "A new required request field rejects requests from existing Consumers.",
2124                    "Make the field optional or coordinate the required request change with every Consumer.",
2125                );
2126            }
2127        } else {
2128            for field in old_required.difference(&new_required) {
2129                compatibility_reason(
2130                    result,
2131                    RequestResponseCompatibilityCategory::Breaking,
2132                    &format!("{prefix}response_required_field_became_optional"),
2133                    &format!("{path}.properties.{field}"),
2134                    "A response field required by existing Consumers may now be omitted.",
2135                    "Keep the response field required or coordinate a new contract version with affected Consumers.",
2136                );
2137            }
2138            for field in old_properties
2139                .keys()
2140                .filter(|field| !new_properties.contains_key(*field))
2141            {
2142                compatibility_reason(
2143                    result,
2144                    RequestResponseCompatibilityCategory::Breaking,
2145                    &format!("{prefix}response_field_removed"),
2146                    &format!("{path}.properties.{field}"),
2147                    "A response field used by existing Consumers was removed.",
2148                    "Restore the response field or coordinate a new contract version with affected Consumers.",
2149                );
2150            }
2151        }
2152        for (field, old_field) in old_properties {
2153            if let Some(new_field) = new_properties.get(field) {
2154                if old_field.get("type") != new_field.get("type") {
2155                    compatibility_reason(
2156                        result,
2157                        RequestResponseCompatibilityCategory::Breaking,
2158                        &format!("{prefix}{direction}_field_type_changed"),
2159                        &format!("{path}.properties.{field}"),
2160                        "A field type changed.",
2161                        "Restore the previous field type or introduce a new field and version.",
2162                    );
2163                } else if old_field != new_field {
2164                    compatibility_reason(
2165                        result,
2166                        RequestResponseCompatibilityCategory::NeedsAttention,
2167                        &format!("{prefix}{direction}_field_constraints_changed"),
2168                        &format!("{path}.properties.{field}"),
2169                        "A field constraint changed and compatibility cannot be proven structurally.",
2170                        "Review enum, range, format, and composition constraints with affected owners.",
2171                    );
2172                }
2173            }
2174        }
2175        let old_constraints = schema_constraints(old);
2176        let new_constraints = schema_constraints(new);
2177        if old_constraints != new_constraints {
2178            compatibility_reason(
2179                result,
2180                RequestResponseCompatibilityCategory::NeedsAttention,
2181                &format!("{prefix}{direction}_constraints_changed"),
2182                &path,
2183                "Schema-level constraints changed and require semantic review.",
2184                "Review the changed constraints with affected Producer and Consumer owners.",
2185            );
2186        }
2187    } else if old != new {
2188        compatibility_reason(
2189            result,
2190            RequestResponseCompatibilityCategory::NeedsAttention,
2191            &format!("{prefix}{direction}_schema_not_structurally_proven"),
2192            &path,
2193            "The schema change cannot be proven compatible from object properties.",
2194            "Review the schema constraints with affected Producer and Consumer owners.",
2195        );
2196    }
2197}
2198
2199fn compare_protobuf_operation(
2200    result: &mut RequestResponseCompatibilityResult,
2201    prefix: &str,
2202    operation: &str,
2203    old: &Value,
2204    new: &Value,
2205) {
2206    for direction in ["request", "response"] {
2207        let old_message = old.get(direction);
2208        let new_message = new.get(direction);
2209        for attribute in ["message", "syntax"] {
2210            if old_message.and_then(|value| value.get(attribute))
2211                != new_message.and_then(|value| value.get(attribute))
2212            {
2213                compatibility_reason(
2214                    result,
2215                    RequestResponseCompatibilityCategory::NeedsAttention,
2216                    &format!("{prefix}protobuf_{attribute}_changed"),
2217                    &format!("$.after.operations.{operation}.{direction}.{attribute}"),
2218                    "Protobuf message identity or language semantics changed.",
2219                    "Review generated clients and presence/default semantics with affected owners.",
2220                );
2221            }
2222        }
2223        let path = format!("$.after.operations.{operation}.{direction}.fields");
2224        let old_fields = protobuf_fields(old.get(direction));
2225        let new_fields = protobuf_fields(new.get(direction));
2226        let (Some(old_fields), Some(new_fields)) = (old_fields, new_fields) else {
2227            compatibility_reason(
2228                result,
2229                RequestResponseCompatibilityCategory::Blocked,
2230                &format!("{prefix}protobuf_descriptor_unverifiable"),
2231                &path,
2232                "The Protobuf field descriptors are missing or invalid.",
2233                "Generate descriptor-based canonical fields for both contract versions.",
2234            );
2235            continue;
2236        };
2237        for (number, old_field) in &old_fields {
2238            let Some(new_field) = new_fields.get(number) else {
2239                if direction == "response" {
2240                    compatibility_reason(
2241                        result,
2242                        RequestResponseCompatibilityCategory::Breaking,
2243                        &format!("{prefix}protobuf_response_field_removed"),
2244                        &format!("{path}.{number}"),
2245                        "A response field number was removed.",
2246                        "Restore or reserve the field and coordinate a new response contract version.",
2247                    );
2248                } else {
2249                    compatibility_reason(
2250                        result,
2251                        RequestResponseCompatibilityCategory::NeedsAttention,
2252                        &format!("{prefix}protobuf_request_field_removed"),
2253                        &format!("{path}.{number}"),
2254                        "A request field number was removed and its semantic handling cannot be proven.",
2255                        "Reserve the removed number and confirm all Producers tolerate the old wire field.",
2256                    );
2257                }
2258                continue;
2259            };
2260            if old_field.get("type") != new_field.get("type") {
2261                compatibility_reason(
2262                    result,
2263                    RequestResponseCompatibilityCategory::Breaking,
2264                    &format!("{prefix}protobuf_field_type_changed"),
2265                    &format!("{path}.{number}"),
2266                    "A Protobuf field number changed wire type.",
2267                    "Restore the wire-compatible type or allocate a new field number.",
2268                );
2269            }
2270            if old_field.get("name") != new_field.get("name") {
2271                compatibility_reason(
2272                    result,
2273                    RequestResponseCompatibilityCategory::NeedsAttention,
2274                    &format!("{prefix}protobuf_field_renamed"),
2275                    &format!("{path}.{number}"),
2276                    "A Protobuf field kept its number but changed source name.",
2277                    "Review generated clients and JSON mappings before publishing.",
2278                );
2279            }
2280            if old_field.get("label") != new_field.get("label") {
2281                let category = if new_field.get("label").and_then(Value::as_str) == Some("required")
2282                {
2283                    RequestResponseCompatibilityCategory::Breaking
2284                } else {
2285                    RequestResponseCompatibilityCategory::NeedsAttention
2286                };
2287                compatibility_reason(
2288                    result,
2289                    category,
2290                    &format!("{prefix}protobuf_field_label_changed"),
2291                    &format!("{path}.{number}"),
2292                    "A Protobuf field cardinality changed.",
2293                    "Restore the previous label or coordinate the cardinality change with affected owners.",
2294                );
2295            }
2296            if old_field.get("jsonName") != new_field.get("jsonName") {
2297                compatibility_reason(
2298                    result,
2299                    RequestResponseCompatibilityCategory::NeedsAttention,
2300                    &format!("{prefix}protobuf_json_name_changed"),
2301                    &format!("{path}.{number}"),
2302                    "A Protobuf field JSON name changed.",
2303                    "Review JSON-transcoded Consumers before publishing the changed contract.",
2304                );
2305            }
2306        }
2307        for (number, new_field) in &new_fields {
2308            if !old_fields.contains_key(number)
2309                && new_field.get("label").and_then(Value::as_str) == Some("required")
2310            {
2311                compatibility_reason(
2312                    result,
2313                    RequestResponseCompatibilityCategory::Breaking,
2314                    &format!("{prefix}protobuf_required_field_added"),
2315                    &format!("{path}.{number}"),
2316                    "A required Protobuf field was added.",
2317                    "Make the field optional or coordinate a new contract version with every affected owner.",
2318                );
2319            }
2320        }
2321    }
2322}
2323
2324fn compatibility_reason(
2325    result: &mut RequestResponseCompatibilityResult,
2326    category: RequestResponseCompatibilityCategory,
2327    code: &str,
2328    path: &str,
2329    message: &str,
2330    next_action: &str,
2331) {
2332    result.category = result.category.max(category);
2333    result.reasons.push(RequestResponseCompatibilityReason {
2334        code: code.to_owned(),
2335        path: path.to_owned(),
2336        message: message.to_owned(),
2337        next_action: next_action.to_owned(),
2338    });
2339}
2340
2341fn string_set(value: Option<&Value>) -> BTreeSet<String> {
2342    value
2343        .and_then(Value::as_array)
2344        .into_iter()
2345        .flatten()
2346        .filter_map(Value::as_str)
2347        .map(str::to_owned)
2348        .collect()
2349}
2350
2351fn schema_constraints(schema: &Value) -> BTreeMap<String, Value> {
2352    schema
2353        .as_object()
2354        .into_iter()
2355        .flatten()
2356        .filter(|(key, _)| {
2357            !matches!(
2358                key.as_str(),
2359                "type"
2360                    | "properties"
2361                    | "required"
2362                    | "description"
2363                    | "default"
2364                    | "title"
2365                    | "examples"
2366            )
2367        })
2368        .map(|(key, value)| (key.clone(), value.clone()))
2369        .collect()
2370}
2371
2372fn protobuf_fields(value: Option<&Value>) -> Option<BTreeMap<u64, Value>> {
2373    let fields = value?.get("fields")?.as_array()?;
2374    fields
2375        .iter()
2376        .map(|field| Some((field.get("number")?.as_u64()?, field.clone())))
2377        .collect()
2378}
2379
2380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2381#[serde(rename_all = "camelCase", deny_unknown_fields)]
2382pub struct StoryContext {
2383    pub story_id: String,
2384    pub segment_id: String,
2385}
2386
2387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2388#[serde(rename_all = "camelCase", deny_unknown_fields)]
2389pub struct TraceContext {
2390    pub traceparent: String,
2391    #[serde(default, skip_serializing_if = "Option::is_none")]
2392    pub tracestate: Option<String>,
2393    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2394    pub baggage: BTreeMap<String, String>,
2395}
2396
2397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2398#[serde(rename_all = "camelCase", deny_unknown_fields)]
2399pub struct ContextClaimProof {
2400    pub verification_method: String,
2401    pub algorithm: String,
2402    pub signature: String,
2403}
2404
2405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2406#[serde(rename_all = "camelCase", deny_unknown_fields)]
2407pub struct ServicePrincipal {
2408    pub issuer: String,
2409    pub subject: String,
2410    pub audiences: Vec<String>,
2411    pub expires_at_unix_ms: u64,
2412    pub credential_id: String,
2413    pub proof: ContextClaimProof,
2414}
2415
2416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2417#[serde(rename_all = "camelCase", deny_unknown_fields)]
2418pub struct DelegatedActorContext {
2419    pub issuer: String,
2420    pub subject: String,
2421    pub audiences: Vec<String>,
2422    pub intent: String,
2423    pub permissions: Vec<String>,
2424    pub expires_at_unix_ms: u64,
2425    pub delegation_id: String,
2426    pub proof: ContextClaimProof,
2427}
2428
2429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2430#[serde(rename_all = "camelCase", deny_unknown_fields)]
2431pub struct TenantContext {
2432    pub issuer: String,
2433    pub tenant_id: String,
2434    pub actor_subject: String,
2435    pub delegation_id: String,
2436    pub audiences: Vec<String>,
2437    pub expires_at_unix_ms: u64,
2438    pub claim_id: String,
2439    pub proof: ContextClaimProof,
2440}
2441
2442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2443#[serde(rename_all = "camelCase", deny_unknown_fields)]
2444pub struct DeadlineContext {
2445    pub expires_at_unix_ms: u64,
2446}
2447
2448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2449#[serde(rename_all = "camelCase", deny_unknown_fields)]
2450pub struct IdempotencyKeyContext {
2451    pub value: String,
2452    pub scope: String,
2453}
2454
2455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2456#[serde(rename_all = "camelCase", deny_unknown_fields)]
2457pub struct CausationContext {
2458    pub causation_id: String,
2459    #[serde(default, skip_serializing_if = "Option::is_none")]
2460    pub correlation_id: Option<String>,
2461}
2462
2463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2464#[serde(rename_all = "camelCase", deny_unknown_fields)]
2465pub struct RegionContext {
2466    pub operating_region: String,
2467    #[serde(default, skip_serializing_if = "Option::is_none")]
2468    pub failure_domain: Option<String>,
2469}
2470
2471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2472#[serde(rename_all = "camelCase", deny_unknown_fields)]
2473pub struct CommonContextContract {
2474    pub protocol: String,
2475    pub story: StoryContext,
2476    pub trace: TraceContext,
2477    pub service_principal: ServicePrincipal,
2478    pub delegated_actor: DelegatedActorContext,
2479    pub tenant: TenantContext,
2480    pub deadline: DeadlineContext,
2481    pub idempotency_key: IdempotencyKeyContext,
2482    pub causation: CausationContext,
2483    pub region: RegionContext,
2484}
2485
2486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2487#[serde(rename_all = "snake_case")]
2488pub enum CommonContextIssueCode {
2489    InvalidProtocol,
2490    InvalidStoryContext,
2491    InvalidTraceContext,
2492    InvalidServicePrincipal,
2493    InvalidDelegatedActorContext,
2494    InvalidTenantContext,
2495    InvalidDeadline,
2496    InvalidIdempotencyKey,
2497    InvalidCausation,
2498    InvalidRegion,
2499    UntrustedActorClaim,
2500    UntrustedTenantClaim,
2501    AudienceMismatch,
2502}
2503
2504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2505#[serde(rename_all = "camelCase")]
2506pub struct CommonContextIssue {
2507    pub code: CommonContextIssueCode,
2508    pub path: String,
2509    pub message: String,
2510    pub next_action: String,
2511}
2512
2513#[must_use]
2514pub fn validate_common_context_contract(
2515    contract: &CommonContextContract,
2516) -> Vec<CommonContextIssue> {
2517    validate_common_context_contract_value(
2518        &serde_json::to_value(contract).expect("CommonContextContract must serialize"),
2519    )
2520}
2521
2522#[must_use]
2523pub fn validate_common_context_contract_for_audience(
2524    contract: &CommonContextContract,
2525    expected_audience: &str,
2526) -> Vec<CommonContextIssue> {
2527    let mut issues = validate_common_context_contract(contract);
2528    for (field, audiences) in [
2529        ("servicePrincipal", &contract.service_principal.audiences),
2530        ("delegatedActor", &contract.delegated_actor.audiences),
2531        ("tenant", &contract.tenant.audiences),
2532    ] {
2533        if !audiences
2534            .iter()
2535            .any(|audience| audience == expected_audience)
2536        {
2537            push_common_context_issue(
2538                &mut issues,
2539                CommonContextIssueCode::AudienceMismatch,
2540                format!("$.{field}.audiences"),
2541                format!("claim is not intended for audience `{expected_audience}`"),
2542                "Reject the context or obtain a claim issued for this receiving audience.",
2543            );
2544        }
2545    }
2546    issues
2547}
2548
2549#[must_use]
2550pub fn validate_common_context_contract_value(value: &Value) -> Vec<CommonContextIssue> {
2551    let mut issues = Vec::new();
2552    if value.get("protocol").and_then(Value::as_str) != Some(COMMON_CONTEXT_PROTOCOL) {
2553        push_common_context_issue(
2554            &mut issues,
2555            CommonContextIssueCode::InvalidProtocol,
2556            "$.protocol",
2557            "protocol must be `lenso.context.v1`",
2558            "Set `protocol` to `lenso.context.v1`.",
2559        );
2560    }
2561    validate_required_strings(
2562        value,
2563        "story",
2564        &["storyId", "segmentId"],
2565        CommonContextIssueCode::InvalidStoryContext,
2566        &mut issues,
2567    );
2568    validate_required_strings(
2569        value,
2570        "trace",
2571        &["traceparent"],
2572        CommonContextIssueCode::InvalidTraceContext,
2573        &mut issues,
2574    );
2575    validate_verifiable_claim(
2576        value,
2577        "servicePrincipal",
2578        "subject",
2579        "credentialId",
2580        CommonContextIssueCode::InvalidServicePrincipal,
2581        &mut issues,
2582    );
2583    validate_required_strings(
2584        value,
2585        "delegatedActor",
2586        &["intent"],
2587        CommonContextIssueCode::InvalidDelegatedActorContext,
2588        &mut issues,
2589    );
2590    validate_verifiable_claim(
2591        value,
2592        "delegatedActor",
2593        "subject",
2594        "delegationId",
2595        CommonContextIssueCode::InvalidDelegatedActorContext,
2596        &mut issues,
2597    );
2598    if value
2599        .pointer("/delegatedActor/permissions")
2600        .and_then(Value::as_array)
2601        .is_none_or(|items| {
2602            items.is_empty()
2603                || items
2604                    .iter()
2605                    .any(|item| item.as_str().is_none_or(|text| text.trim().is_empty()))
2606        })
2607    {
2608        push_common_context_issue(
2609            &mut issues,
2610            CommonContextIssueCode::InvalidDelegatedActorContext,
2611            "$.delegatedActor.permissions",
2612            "permissions must contain non-empty delegated permissions",
2613            "Declare at least one permission narrowed for this delegation.",
2614        );
2615    }
2616    validate_verifiable_claim(
2617        value,
2618        "tenant",
2619        "tenantId",
2620        "claimId",
2621        CommonContextIssueCode::InvalidTenantContext,
2622        &mut issues,
2623    );
2624    validate_required_strings(
2625        value,
2626        "tenant",
2627        &["actorSubject", "delegationId"],
2628        CommonContextIssueCode::InvalidTenantContext,
2629        &mut issues,
2630    );
2631    validate_positive_integer(
2632        value,
2633        "deadline",
2634        "expiresAtUnixMs",
2635        CommonContextIssueCode::InvalidDeadline,
2636        &mut issues,
2637    );
2638    validate_required_strings(
2639        value,
2640        "idempotencyKey",
2641        &["value", "scope"],
2642        CommonContextIssueCode::InvalidIdempotencyKey,
2643        &mut issues,
2644    );
2645    validate_required_strings(
2646        value,
2647        "causation",
2648        &["causationId"],
2649        CommonContextIssueCode::InvalidCausation,
2650        &mut issues,
2651    );
2652    validate_required_strings(
2653        value,
2654        "region",
2655        &["operatingRegion"],
2656        CommonContextIssueCode::InvalidRegion,
2657        &mut issues,
2658    );
2659
2660    if let Some(baggage) = value.pointer("/trace/baggage").and_then(Value::as_object) {
2661        let mut keys = baggage.keys().collect::<Vec<_>>();
2662        keys.sort();
2663        for key in keys {
2664            let normalized = key.to_ascii_lowercase();
2665            let words = normalized
2666                .split(|character: char| !character.is_ascii_alphanumeric())
2667                .collect::<Vec<_>>();
2668            let compact = words.join("");
2669            let (code, claim) = if words.contains(&"tenant") || compact.starts_with("tenant") {
2670                (
2671                    CommonContextIssueCode::UntrustedTenantClaim,
2672                    "tenant authorization",
2673                )
2674            } else if words.iter().any(|word| {
2675                matches!(
2676                    *word,
2677                    "actor"
2678                        | "auth"
2679                        | "user"
2680                        | "enduser"
2681                        | "permission"
2682                        | "permissions"
2683                        | "role"
2684                        | "delegation"
2685                        | "subject"
2686                        | "audience"
2687                )
2688            }) || [
2689                "actor",
2690                "authz",
2691                "userrole",
2692                "enduserid",
2693                "permission",
2694                "delegatedactor",
2695                "subject",
2696                "audience",
2697            ]
2698            .iter()
2699            .any(|prefix| compact.starts_with(prefix))
2700            {
2701                (
2702                    CommonContextIssueCode::UntrustedActorClaim,
2703                    "actor authorization",
2704                )
2705            } else {
2706                continue;
2707            };
2708            push_common_context_issue(
2709                &mut issues,
2710                code,
2711                format!("$.trace.baggage.{key}"),
2712                format!("OpenTelemetry Baggage must not supply {claim} claims"),
2713                "Remove the Baggage entry and use the signed, audience-bounded context claim.",
2714            );
2715        }
2716    }
2717    issues
2718}
2719
2720fn validate_verifiable_claim(
2721    value: &Value,
2722    field: &str,
2723    subject: &str,
2724    claim_id: &str,
2725    code: CommonContextIssueCode,
2726    issues: &mut Vec<CommonContextIssue>,
2727) {
2728    validate_required_strings(value, field, &["issuer", subject, claim_id], code, issues);
2729    validate_required_strings(
2730        value,
2731        &format!("{field}/proof"),
2732        &["verificationMethod", "algorithm", "signature"],
2733        code,
2734        issues,
2735    );
2736    let path = format!("/{field}/audiences");
2737    if value
2738        .pointer(&path)
2739        .and_then(Value::as_array)
2740        .is_none_or(|items| {
2741            items.is_empty()
2742                || items
2743                    .iter()
2744                    .any(|item| item.as_str().is_none_or(|text| text.trim().is_empty()))
2745        })
2746    {
2747        push_common_context_issue(
2748            issues,
2749            code,
2750            format!("$.{field}.audiences"),
2751            "audiences must contain non-empty audience identifiers",
2752            "Declare at least one intended receiving Service or Workload audience.",
2753        );
2754    }
2755    validate_positive_integer(value, field, "expiresAtUnixMs", code, issues);
2756}
2757
2758fn validate_required_strings(
2759    value: &Value,
2760    field: &str,
2761    names: &[&str],
2762    code: CommonContextIssueCode,
2763    issues: &mut Vec<CommonContextIssue>,
2764) {
2765    let json_path = field.replace('/', ".");
2766    for name in names {
2767        let pointer = format!("/{field}/{name}");
2768        if value
2769            .pointer(&pointer)
2770            .and_then(Value::as_str)
2771            .is_none_or(|text| text.trim().is_empty())
2772        {
2773            push_common_context_issue(
2774                issues,
2775                code,
2776                format!("$.{json_path}.{name}"),
2777                format!("{name} must be a non-empty string"),
2778                format!("Set a non-empty `{name}` value."),
2779            );
2780        }
2781    }
2782}
2783
2784fn validate_positive_integer(
2785    value: &Value,
2786    field: &str,
2787    name: &str,
2788    code: CommonContextIssueCode,
2789    issues: &mut Vec<CommonContextIssue>,
2790) {
2791    let pointer = format!("/{field}/{name}");
2792    if value
2793        .pointer(&pointer)
2794        .and_then(Value::as_u64)
2795        .is_none_or(|number| number == 0)
2796    {
2797        push_common_context_issue(
2798            issues,
2799            code,
2800            format!("$.{field}.{name}"),
2801            format!("{name} must be a positive integer"),
2802            format!("Set `{name}` to an absolute Unix timestamp in milliseconds."),
2803        );
2804    }
2805}
2806
2807fn push_common_context_issue(
2808    issues: &mut Vec<CommonContextIssue>,
2809    code: CommonContextIssueCode,
2810    path: impl Into<String>,
2811    message: impl Into<String>,
2812    next_action: impl Into<String>,
2813) {
2814    issues.push(CommonContextIssue {
2815        code,
2816        path: path.into(),
2817        message: message.into(),
2818        next_action: next_action.into(),
2819    });
2820}
2821
2822#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2823#[serde(rename_all = "snake_case")]
2824pub enum ContractArtifactKind {
2825    Service,
2826    System,
2827}
2828
2829#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2830#[serde(rename_all = "snake_case")]
2831pub enum ContractSemanticKind {
2832    Provider,
2833    ProviderSystem,
2834    AutonomousService,
2835    MixedSystem,
2836}
2837
2838impl ContractSemanticKind {
2839    #[must_use]
2840    pub const fn as_str(self) -> &'static str {
2841        match self {
2842            Self::Provider => "provider",
2843            Self::ProviderSystem => "provider_system",
2844            Self::AutonomousService => "autonomous_service",
2845            Self::MixedSystem => "mixed_system",
2846        }
2847    }
2848}
2849
2850#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2851#[serde(rename_all = "snake_case")]
2852pub enum ContractOwner {
2853    Host,
2854}
2855
2856#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2857#[serde(rename_all = "camelCase")]
2858pub struct ProviderSemantics {
2859    pub providers: Vec<String>,
2860    pub auth_owner: ContractOwner,
2861    pub proxy_policy_owner: ContractOwner,
2862    pub retry_owner: ContractOwner,
2863    pub runtime_queue_owner: ContractOwner,
2864    pub outbox_owner: ContractOwner,
2865    pub story_owner: ContractOwner,
2866}
2867
2868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2869#[serde(rename_all = "camelCase")]
2870pub struct ContractArtifactCheck {
2871    pub detected_protocol: String,
2872    pub artifact_kind: ContractArtifactKind,
2873    pub semantic_kind: ContractSemanticKind,
2874    #[serde(skip_serializing_if = "Option::is_none")]
2875    pub provider_semantics: Option<ProviderSemantics>,
2876    #[serde(skip_serializing_if = "Option::is_none")]
2877    pub autonomous_service: Option<AutonomousServiceSummary>,
2878}
2879
2880#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2881#[serde(rename_all = "camelCase")]
2882pub struct AutonomousServiceSummary {
2883    pub service_id: String,
2884    pub workloads: Vec<String>,
2885    pub modules: Vec<String>,
2886    pub service_contracts: Vec<String>,
2887    pub event_contracts: Vec<String>,
2888    pub has_config_contract: bool,
2889    pub has_reliability_contract: bool,
2890}
2891
2892#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2893#[serde(rename_all = "snake_case")]
2894pub enum ContractArtifactCheckErrorCode {
2895    AmbiguousProtocol,
2896    UnsupportedProtocol,
2897    InvalidArtifact,
2898    UnknownField,
2899    InvalidProtocol,
2900    InvalidVersion,
2901    InvalidServiceIdentity,
2902    InvalidWorkloadIdentity,
2903    WorkloadOwnerMismatch,
2904    DuplicateWorkloadIdentity,
2905    InvalidWorkloadRole,
2906    InvalidModuleIdentity,
2907    DuplicateModuleIdentity,
2908    InvalidStoreIdentity,
2909    StoreOwnerMismatch,
2910    DuplicateStoreIdentity,
2911    InvalidTenancyMode,
2912    InvalidOperatingRegion,
2913    DuplicateOperatingRegion,
2914    InvalidContractIdentity,
2915    DuplicateContractIdentity,
2916    UnresolvedModuleReference,
2917    InvalidArtifactReference,
2918    UnresolvedArtifactReference,
2919    UnsupportedArtifactFormat,
2920    InvalidConfigContract,
2921    DuplicateConfigField,
2922    InvalidReliabilityContract,
2923}
2924
2925#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2926#[serde(rename_all = "camelCase")]
2927pub struct ContractArtifactCheckError {
2928    pub code: ContractArtifactCheckErrorCode,
2929    pub path: String,
2930    pub message: String,
2931    pub next_action: String,
2932}
2933
2934impl std::fmt::Display for ContractArtifactCheckError {
2935    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2936        let payload = serde_json::to_string(self).map_err(|_| std::fmt::Error)?;
2937        formatter.write_str(&payload)
2938    }
2939}
2940
2941impl std::error::Error for ContractArtifactCheckError {}
2942
2943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2944pub struct ContractFixture {
2945    pub name: &'static str,
2946    pub protocol: &'static str,
2947    pub semantic_kind: ContractSemanticKind,
2948    pub json: &'static str,
2949}
2950
2951pub const LEGACY_CONTRACT_FIXTURES: &[ContractFixture] = &[
2952    ContractFixture {
2953        name: "service-provider-v1",
2954        protocol: SERVICE_CONTRACT_PROTOCOL,
2955        semantic_kind: ContractSemanticKind::Provider,
2956        json: LEGACY_SERVICE_V1_FIXTURE_JSON,
2957    },
2958    ContractFixture {
2959        name: "system-provider-v1",
2960        protocol: SERVICE_SYSTEM_PROTOCOL,
2961        semantic_kind: ContractSemanticKind::ProviderSystem,
2962        json: LEGACY_SYSTEM_V1_FIXTURE_JSON,
2963    },
2964];
2965
2966#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2967#[serde(rename_all = "snake_case")]
2968pub enum ServiceDeploymentTarget {
2969    Kubernetes,
2970}
2971
2972#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2973#[serde(rename_all = "camelCase")]
2974pub struct ServiceEnvironmentsFile {
2975    pub version: u64,
2976    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2977    pub environments: Vec<ServiceEnvironment>,
2978}
2979
2980#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2981#[serde(rename_all = "camelCase")]
2982pub struct ServiceEnvironment {
2983    pub name: String,
2984    pub service_name: String,
2985    pub target: ServiceDeploymentTarget,
2986    #[serde(default, skip_serializing_if = "Option::is_none")]
2987    pub namespace: Option<String>,
2988    #[serde(default, skip_serializing_if = "Option::is_none")]
2989    pub kube_context: Option<String>,
2990    #[serde(default, skip_serializing_if = "Option::is_none")]
2991    pub image: Option<String>,
2992    #[serde(default, skip_serializing_if = "Option::is_none")]
2993    pub public_base_url: Option<String>,
2994    #[serde(default, skip_serializing_if = "Option::is_none")]
2995    pub manifest_reference: Option<String>,
2996    #[serde(default, skip_serializing_if = "Option::is_none")]
2997    pub release_track: Option<String>,
2998    #[serde(default, skip_serializing_if = "Option::is_none")]
2999    pub config: Option<KubernetesDeploymentConfig>,
3000}
3001
3002impl ServiceEnvironment {
3003    #[must_use]
3004    pub fn kubernetes(name: impl Into<String>, service_name: impl Into<String>) -> Self {
3005        Self {
3006            name: name.into(),
3007            service_name: service_name.into(),
3008            target: ServiceDeploymentTarget::Kubernetes,
3009            namespace: None,
3010            kube_context: None,
3011            image: None,
3012            public_base_url: None,
3013            manifest_reference: None,
3014            release_track: None,
3015            config: None,
3016        }
3017    }
3018}
3019
3020#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
3021#[serde(rename_all = "camelCase")]
3022pub struct KubernetesDeploymentConfig {
3023    #[serde(default, skip_serializing_if = "Option::is_none")]
3024    pub replicas: Option<u32>,
3025    #[serde(default, skip_serializing_if = "Option::is_none")]
3026    pub port: Option<u16>,
3027    #[serde(default, skip_serializing_if = "Option::is_none")]
3028    pub ingress_host: Option<String>,
3029    #[serde(default, skip_serializing_if = "Option::is_none")]
3030    pub cpu_request: Option<String>,
3031    #[serde(default, skip_serializing_if = "Option::is_none")]
3032    pub memory_request: Option<String>,
3033    #[serde(default, skip_serializing_if = "Option::is_none")]
3034    pub cpu_limit: Option<String>,
3035    #[serde(default, skip_serializing_if = "Option::is_none")]
3036    pub memory_limit: Option<String>,
3037    #[serde(default, skip_serializing_if = "Option::is_none")]
3038    pub autoscaling: Option<bool>,
3039    #[serde(default, skip_serializing_if = "Option::is_none")]
3040    pub disruption_budget: Option<bool>,
3041    #[serde(default, skip_serializing_if = "Option::is_none")]
3042    pub network_policy: Option<bool>,
3043}
3044
3045impl KubernetesDeploymentConfig {
3046    #[must_use]
3047    pub fn new() -> Self {
3048        Self::default()
3049    }
3050
3051    #[must_use]
3052    pub fn port(mut self, port: u16) -> Self {
3053        self.port = Some(port);
3054        self
3055    }
3056
3057    #[must_use]
3058    pub fn replicas(mut self, replicas: u32) -> Self {
3059        self.replicas = Some(replicas);
3060        self
3061    }
3062
3063    #[must_use]
3064    pub fn ingress_host(mut self, ingress_host: impl Into<String>) -> Self {
3065        self.ingress_host = Some(ingress_host.into());
3066        self
3067    }
3068}
3069
3070#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3071#[serde(rename_all = "snake_case")]
3072pub enum ServiceDeploymentState {
3073    Ready,
3074    Progressing,
3075    Failed,
3076    Unknown,
3077}
3078
3079#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3080#[serde(rename_all = "snake_case")]
3081pub enum ServiceDeploymentDrift {
3082    InSync,
3083    HostAhead,
3084    ClusterAhead,
3085    ImageDrift,
3086    Unknown,
3087}
3088
3089#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3090#[serde(rename_all = "camelCase")]
3091pub struct ServiceDeploymentsFile {
3092    pub version: u64,
3093    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3094    pub observations: Vec<ServiceDeploymentObservation>,
3095}
3096
3097#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3098#[serde(rename_all = "camelCase")]
3099pub struct ServiceDeploymentObservation {
3100    pub service_name: String,
3101    pub environment: String,
3102    pub target: ServiceDeploymentTarget,
3103    pub observed_at_unix_ms: u64,
3104    pub state: ServiceDeploymentState,
3105    pub drift: ServiceDeploymentDrift,
3106    #[serde(default, skip_serializing_if = "Option::is_none")]
3107    pub cluster: Option<KubernetesDeploymentObservation>,
3108    #[serde(default, skip_serializing_if = "Option::is_none")]
3109    pub host: Option<ServiceDeploymentHostObservation>,
3110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3111    pub checks: Vec<ServiceDeploymentCheck>,
3112    #[serde(default, skip_serializing_if = "Option::is_none")]
3113    pub next_action: Option<String>,
3114}
3115
3116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3117#[serde(rename_all = "camelCase")]
3118pub struct KubernetesDeploymentObservation {
3119    pub namespace: String,
3120    pub deployment: String,
3121    #[serde(default, skip_serializing_if = "Option::is_none")]
3122    pub ready_replicas: Option<u32>,
3123    #[serde(default, skip_serializing_if = "Option::is_none")]
3124    pub desired_replicas: Option<u32>,
3125    #[serde(default, skip_serializing_if = "Option::is_none")]
3126    pub available_replicas: Option<u32>,
3127    #[serde(default, skip_serializing_if = "Option::is_none")]
3128    pub image: Option<String>,
3129    #[serde(default, skip_serializing_if = "Option::is_none")]
3130    pub release_id: Option<String>,
3131    #[serde(default, skip_serializing_if = "Option::is_none")]
3132    pub manifest_reference: Option<String>,
3133    #[serde(default, skip_serializing_if = "Option::is_none")]
3134    pub service_endpoint: Option<String>,
3135    #[serde(default, skip_serializing_if = "Option::is_none")]
3136    pub ingress_host: Option<String>,
3137}
3138
3139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3140#[serde(rename_all = "camelCase")]
3141pub struct ServiceDeploymentHostObservation {
3142    #[serde(default, skip_serializing_if = "Option::is_none")]
3143    pub release_id: Option<String>,
3144    #[serde(default, skip_serializing_if = "Option::is_none")]
3145    pub candidate_version: Option<String>,
3146}
3147
3148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3149#[serde(rename_all = "camelCase")]
3150pub struct ServiceDeploymentCheck {
3151    pub name: String,
3152    pub status: String,
3153    #[serde(default, skip_serializing_if = "Option::is_none")]
3154    pub detail: Option<String>,
3155}
3156
3157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3158#[serde(rename_all = "camelCase")]
3159pub struct ServiceHealth {
3160    #[serde(default, skip_serializing_if = "Option::is_none")]
3161    pub manifest_url: Option<String>,
3162    #[serde(default, skip_serializing_if = "Option::is_none")]
3163    pub ready_url: Option<String>,
3164    #[serde(default, skip_serializing_if = "Option::is_none")]
3165    pub liveness_url: Option<String>,
3166    #[serde(default, skip_serializing_if = "Option::is_none")]
3167    pub status_url: Option<String>,
3168}
3169
3170#[derive(Debug, Clone, Serialize, Deserialize)]
3171#[serde(rename_all = "camelCase")]
3172pub struct ServiceProvider {
3173    pub name: String,
3174    #[serde(default, skip_serializing_if = "Option::is_none")]
3175    pub vendor: Option<String>,
3176    #[serde(default, skip_serializing_if = "Option::is_none")]
3177    pub summary: Option<String>,
3178    #[serde(default, skip_serializing_if = "Option::is_none")]
3179    pub homepage: Option<String>,
3180}
3181
3182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3183#[serde(rename_all = "camelCase")]
3184pub struct ServiceCompatibility {
3185    #[serde(default, skip_serializing_if = "Option::is_none")]
3186    pub service_protocol_version: Option<String>,
3187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3188    pub required_host_features: Vec<String>,
3189    #[serde(default, skip_serializing_if = "Option::is_none")]
3190    pub sdk_language: Option<String>,
3191    #[serde(default, skip_serializing_if = "Option::is_none")]
3192    pub sdk_version: Option<String>,
3193}
3194
3195#[derive(Debug, Clone, Serialize, Deserialize)]
3196#[serde(rename_all = "camelCase")]
3197pub struct ServiceConfigField {
3198    pub key: String,
3199    #[serde(default)]
3200    pub required: bool,
3201    #[serde(default, skip_serializing_if = "Option::is_none")]
3202    pub default_value: Option<Value>,
3203    #[serde(default)]
3204    pub secret: bool,
3205}
3206
3207#[derive(Debug, Clone, Serialize, Deserialize)]
3208#[serde(rename_all = "camelCase")]
3209pub struct ServiceEnvField {
3210    pub name: String,
3211    #[serde(default)]
3212    pub required: bool,
3213    #[serde(default, skip_serializing_if = "Option::is_none")]
3214    pub example: Option<String>,
3215}
3216
3217#[derive(Debug, Clone, Serialize, Deserialize)]
3218#[serde(rename_all = "camelCase")]
3219pub struct ServiceLocalProcess {
3220    pub command: String,
3221    #[serde(default, skip_serializing_if = "Option::is_none")]
3222    pub cwd: Option<String>,
3223    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3224    pub env: BTreeMap<String, String>,
3225    #[serde(default = "default_service_auto_start")]
3226    pub auto_start: bool,
3227    #[serde(default = "default_service_ready_timeout_ms")]
3228    pub ready_timeout_ms: u64,
3229}
3230
3231#[derive(Debug, Clone, Serialize, Deserialize)]
3232#[serde(rename_all = "camelCase")]
3233pub struct ServiceWorkspace {
3234    pub protocol: String,
3235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3236    pub services: Vec<ServiceWorkspaceService>,
3237}
3238
3239impl ServiceWorkspace {
3240    #[must_use]
3241    pub fn new(services: Vec<ServiceWorkspaceService>) -> Self {
3242        Self {
3243            protocol: SERVICE_WORKSPACE_PROTOCOL.to_owned(),
3244            services,
3245        }
3246    }
3247}
3248
3249#[must_use]
3250pub fn service_workspace_to_module_services(
3251    workspace: &ServiceWorkspace,
3252) -> ServiceWorkspaceModuleServicesFile {
3253    ServiceWorkspaceModuleServicesFile {
3254        version: 1,
3255        modules: workspace
3256            .services
3257            .iter()
3258            .map(|service| ServiceWorkspaceModuleServices {
3259                module_name: service.name.clone(),
3260                services: vec![ServiceWorkspaceProcess {
3261                    name: service.name.clone(),
3262                    command: service.command.clone(),
3263                    cwd: service.cwd.clone(),
3264                    ready_url: service.ready_url.clone(),
3265                    auto_start: service.auto_start,
3266                    ready_timeout_ms: service.ready_timeout_ms,
3267                }],
3268            })
3269            .collect(),
3270    }
3271}
3272
3273#[must_use]
3274pub fn service_workspace_base_url(service: &ServiceWorkspaceService) -> Option<String> {
3275    service_base_url_from_ready_url(&service.ready_url)
3276        .or_else(|| service_base_url_from_manifest_url(&service.manifest))
3277}
3278
3279#[must_use]
3280pub fn service_base_url_from_ready_url(ready_url: &str) -> Option<String> {
3281    service_base_url_from_url_suffix(ready_url, &["/status", "/ready", "/health", "/healthz"])
3282}
3283
3284#[must_use]
3285pub fn service_base_url_from_manifest_url(manifest_url: &str) -> Option<String> {
3286    service_base_url_from_url_suffix(manifest_url, &["/manifest"])
3287}
3288
3289#[derive(Debug, Clone, Serialize, Deserialize)]
3290#[serde(rename_all = "camelCase")]
3291pub struct ServiceWorkspaceService {
3292    pub name: String,
3293    pub lang: String,
3294    pub cwd: String,
3295    #[serde(default = "default_service_manifest")]
3296    pub manifest: String,
3297    pub command: String,
3298    pub ready_url: String,
3299    #[serde(default = "default_service_auto_start")]
3300    pub auto_start: bool,
3301    #[serde(default = "default_workspace_service_ready_timeout_ms")]
3302    pub ready_timeout_ms: u64,
3303    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3304    pub modules: Vec<String>,
3305}
3306
3307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3308#[serde(rename_all = "camelCase")]
3309pub struct ServiceWorkspaceProcess {
3310    pub name: String,
3311    pub command: String,
3312    pub cwd: String,
3313    pub ready_url: String,
3314    pub auto_start: bool,
3315    pub ready_timeout_ms: u64,
3316}
3317
3318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3319#[serde(rename_all = "camelCase")]
3320pub struct ServiceWorkspaceModuleServices {
3321    pub module_name: String,
3322    pub services: Vec<ServiceWorkspaceProcess>,
3323}
3324
3325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3326#[serde(rename_all = "camelCase")]
3327pub struct ServiceWorkspaceModuleServicesFile {
3328    pub version: u64,
3329    pub modules: Vec<ServiceWorkspaceModuleServices>,
3330}
3331
3332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3333#[serde(rename_all = "camelCase")]
3334pub struct SystemV2Graph {
3335    pub artifact_protocol: String,
3336    pub semantic_kind: ContractSemanticKind,
3337    pub system_id: String,
3338    pub nodes: Vec<SystemV2GraphNode>,
3339    pub relationships: Vec<SystemV2GraphRelationship>,
3340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3341    pub issues: Vec<SystemV2Issue>,
3342}
3343
3344#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
3345#[serde(rename_all = "camelCase")]
3346pub struct SystemV2GraphNode {
3347    pub id: String,
3348    pub kind: String,
3349    #[serde(default, skip_serializing_if = "Option::is_none")]
3350    pub owner: Option<String>,
3351}
3352
3353#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
3354#[serde(rename_all = "camelCase")]
3355pub struct SystemV2GraphRelationship {
3356    pub kind: String,
3357    pub from: String,
3358    pub to: String,
3359    #[serde(default, skip_serializing_if = "Option::is_none")]
3360    pub contract_id: Option<String>,
3361}
3362
3363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3364#[serde(rename_all = "camelCase")]
3365pub struct SystemV2Issue {
3366    pub code: String,
3367    pub path: String,
3368    pub message: String,
3369    pub next_action: String,
3370}
3371
3372/// Validates and canonicalizes a declarative mixed-topology System v2 artifact.
3373///
3374/// This projection is intentionally control-plane-only: it contains no endpoint resolution or
3375/// runtime dispatch behavior.
3376pub fn system_v2_graph(value: &Value) -> Result<SystemV2Graph, Vec<SystemV2Issue>> {
3377    let mut issues = Vec::new();
3378    let Some(object) = value.as_object() else {
3379        return Err(vec![system_v2_issue(
3380            "ambiguous_kind",
3381            "$",
3382            "System artifact must be an object.",
3383            "Provide a lenso.system.v2 JSON object with explicit topology kinds.",
3384        )]);
3385    };
3386    if object.get("protocol").and_then(Value::as_str) != Some(SYSTEM_V2_PROTOCOL) {
3387        issues.push(system_v2_issue(
3388            "unsupported_protocol",
3389            "$.protocol",
3390            "protocol must be `lenso.system.v2`",
3391            "Set protocol to `lenso.system.v2` or use the System v1 compatibility adapter.",
3392        ));
3393    }
3394    let system_id = required_system_v2_string(object.get("systemId"), "$.systemId", &mut issues);
3395    let mut nodes = Vec::new();
3396    let mut relationships = Vec::new();
3397    let mut owners = BTreeSet::new();
3398    let mut identity_kinds = BTreeMap::<String, BTreeSet<String>>::new();
3399    let mut module_owners = BTreeMap::<String, String>::new();
3400
3401    if let Some(host) = object.get("host").and_then(Value::as_object) {
3402        let id = required_system_v2_string(host.get("hostId"), "$.host.hostId", &mut issues);
3403        if !id.is_empty() {
3404            owners.insert(("host".to_owned(), id.clone()));
3405            identity_kinds
3406                .entry(id.clone())
3407                .or_default()
3408                .insert("host".to_owned());
3409            nodes.push(SystemV2GraphNode {
3410                id: id.clone(),
3411                kind: "host".to_owned(),
3412                owner: None,
3413            });
3414            collect_system_v2_modules(
3415                host.get("modules"),
3416                "$.host.modules",
3417                &id,
3418                &mut module_owners,
3419                &mut nodes,
3420                &mut relationships,
3421                &mut issues,
3422            );
3423        }
3424    } else {
3425        issues.push(system_v2_issue(
3426            "missing_ownership",
3427            "$.host",
3428            "System v2 requires one explicit Host.",
3429            "Declare host.hostId and the Modules owned by the Host.",
3430        ));
3431    }
3432    let providers = system_v2_sorted_objects(
3433        object.get("providers"),
3434        "$.providers",
3435        "providerId",
3436        &mut issues,
3437    );
3438    let autonomous_services = system_v2_sorted_objects(
3439        object.get("autonomousServices"),
3440        "$.autonomousServices",
3441        "serviceId",
3442        &mut issues,
3443    );
3444    collect_system_v2_owners(
3445        &providers,
3446        "providers",
3447        "providerId",
3448        "provider",
3449        &mut owners,
3450        &mut identity_kinds,
3451        &mut module_owners,
3452        &mut nodes,
3453        &mut relationships,
3454        &mut issues,
3455    );
3456    collect_system_v2_owners(
3457        &autonomous_services,
3458        "autonomousServices",
3459        "serviceId",
3460        "autonomous_service",
3461        &mut owners,
3462        &mut identity_kinds,
3463        &mut module_owners,
3464        &mut nodes,
3465        &mut relationships,
3466        &mut issues,
3467    );
3468
3469    for (index, service) in autonomous_services.iter().enumerate() {
3470        let owner = service
3471            .get("serviceId")
3472            .and_then(Value::as_str)
3473            .unwrap_or_default();
3474        let workloads = system_v2_sorted_objects(
3475            service.get("workloads"),
3476            &format!("$.autonomousServices[{index}].workloads"),
3477            "workloadId",
3478            &mut issues,
3479        );
3480        if workloads.is_empty() {
3481            issues.push(system_v2_issue(
3482                "missing_ownership",
3483                format!("$.autonomousServices[{index}].workloads"),
3484                "Autonomous Service workloads must be explicit.",
3485                "Declare every Workload under its owning Autonomous Service.",
3486            ));
3487        } else {
3488            for (workload_index, workload) in workloads.iter().enumerate() {
3489                let id = workload
3490                    .get("workloadId")
3491                    .and_then(Value::as_str)
3492                    .unwrap_or_default();
3493                if id.is_empty() {
3494                    issues.push(system_v2_issue(
3495                        "missing_ownership",
3496                        format!(
3497                            "$.autonomousServices[{index}].workloads[{workload_index}].workloadId"
3498                        ),
3499                        "Workload identity is required.",
3500                        "Declare workloadId under its owning Autonomous Service.",
3501                    ));
3502                } else {
3503                    nodes.push(SystemV2GraphNode {
3504                        id: id.to_owned(),
3505                        kind: "workload".to_owned(),
3506                        owner: Some(owner.to_owned()),
3507                    });
3508                    relationships.push(SystemV2GraphRelationship {
3509                        kind: "owns".to_owned(),
3510                        from: owner.to_owned(),
3511                        to: id.to_owned(),
3512                        contract_id: None,
3513                    });
3514                }
3515            }
3516        }
3517    }
3518
3519    for (identity, kinds) in &identity_kinds {
3520        if kinds.len() > 1 {
3521            issues.push(system_v2_issue(
3522                "ambiguous_kind",
3523                format!("$.identities.{identity}"),
3524                format!(
3525                    "Identity `{identity}` is declared with multiple kinds: {}.",
3526                    kinds.iter().cloned().collect::<Vec<_>>().join(", ")
3527                ),
3528                "Give every Host, Provider, and Autonomous Service a distinct stable identity.",
3529            ));
3530        }
3531    }
3532
3533    let mut contracts = BTreeMap::<String, (String, String)>::new();
3534    let contract_items = system_v2_sorted_objects(
3535        object.get("contracts"),
3536        "$.contracts",
3537        "contractId",
3538        &mut issues,
3539    );
3540    for (index, item) in contract_items.iter().enumerate() {
3541        let contract_id = required_system_v2_string(
3542            item.get("contractId"),
3543            &format!("$.contracts[{index}].contractId"),
3544            &mut issues,
3545        );
3546        let producer_kind = item
3547            .get("producerKind")
3548            .and_then(Value::as_str)
3549            .unwrap_or_default();
3550        let producer_id = item
3551            .get("producerId")
3552            .and_then(Value::as_str)
3553            .unwrap_or_default();
3554        if !matches!(producer_kind, "provider" | "autonomous_service") {
3555            issues.push(system_v2_issue(
3556                "ambiguous_kind",
3557                format!("$.contracts[{index}].producerKind"),
3558                "Producer kind must be explicit.",
3559                "Use `provider` or `autonomous_service`.",
3560            ));
3561        } else if !owners.contains(&(producer_kind.to_owned(), producer_id.to_owned())) {
3562            issues.push(system_v2_issue(
3563                "unresolved_reference",
3564                format!("$.contracts[{index}].producerId"),
3565                "Producer reference does not resolve.",
3566                "Reference a declared Provider or Autonomous Service.",
3567            ));
3568        }
3569        let tenancy = item
3570            .get("tenancyMode")
3571            .and_then(Value::as_str)
3572            .unwrap_or_default()
3573            .to_owned();
3574        let version = required_system_v2_string(
3575            item.get("version"),
3576            &format!("$.contracts[{index}].version"),
3577            &mut issues,
3578        );
3579        let artifact = item.get("artifact").and_then(Value::as_object);
3580        let artifact_valid = artifact
3581            .and_then(|artifact| artifact.get("format"))
3582            .and_then(Value::as_str)
3583            .is_some_and(|format| {
3584                matches!(
3585                    format,
3586                    "openapi" | "protobuf" | "json_schema" | "config" | "reliability"
3587                )
3588            })
3589            && artifact
3590                .and_then(|artifact| artifact.get("path"))
3591                .and_then(Value::as_str)
3592                .is_some_and(|path| !path.is_empty());
3593        if !artifact_valid {
3594            issues.push(system_v2_issue(
3595                "unresolved_reference",
3596                format!("$.contracts[{index}].artifact"),
3597                "Versioned contract artifact format and path are required.",
3598                "Declare a supported artifact.format and non-empty artifact.path.",
3599            ));
3600        }
3601        if contracts
3602            .insert(contract_id.clone(), (tenancy, version.clone()))
3603            .is_some()
3604        {
3605            issues.push(system_v2_issue(
3606                "ambiguous_kind",
3607                format!("$.contracts[{index}].contractId"),
3608                "Contract identity is declared more than once.",
3609                "Give every versioned contract a unique contractId.",
3610            ));
3611        }
3612        nodes.push(SystemV2GraphNode {
3613            id: format!("producer:{contract_id}"),
3614            kind: "producer".to_owned(),
3615            owner: Some(producer_id.to_owned()),
3616        });
3617        relationships.push(SystemV2GraphRelationship {
3618            kind: "produces".to_owned(),
3619            from: producer_id.to_owned(),
3620            to: format!("producer:{contract_id}"),
3621            contract_id: Some(format!("{contract_id}@{version}")),
3622        });
3623    }
3624
3625    let consumer_items = system_v2_sorted_objects(
3626        object.get("consumers"),
3627        "$.consumers",
3628        "consumerId",
3629        &mut issues,
3630    );
3631    for (index, item) in consumer_items.iter().enumerate() {
3632        let consumer_id = required_system_v2_string(
3633            item.get("consumerId"),
3634            &format!("$.consumers[{index}].consumerId"),
3635            &mut issues,
3636        );
3637        let owner_kind = item
3638            .get("ownerKind")
3639            .and_then(Value::as_str)
3640            .unwrap_or_default();
3641        let owner_id = item
3642            .get("ownerId")
3643            .and_then(Value::as_str)
3644            .unwrap_or_default();
3645        if !matches!(owner_kind, "host" | "provider" | "autonomous_service") {
3646            issues.push(system_v2_issue(
3647                "ambiguous_kind",
3648                format!("$.consumers[{index}].ownerKind"),
3649                "Consumer owner kind is ambiguous.",
3650                "Use `host`, `provider`, or `autonomous_service`.",
3651            ));
3652        } else if !owners.contains(&(owner_kind.to_owned(), owner_id.to_owned())) {
3653            issues.push(system_v2_issue(
3654                "unresolved_reference",
3655                format!("$.consumers[{index}].ownerId"),
3656                "Consumer owner reference does not resolve.",
3657                "Reference a declared Host, Provider, or Autonomous Service.",
3658            ));
3659        }
3660        let contract_id = item
3661            .get("contractId")
3662            .and_then(Value::as_str)
3663            .unwrap_or_default();
3664        match contracts.get(contract_id) {
3665            None => issues.push(system_v2_issue(
3666                "unresolved_reference",
3667                format!("$.consumers[{index}].contractId"),
3668                "Consumer contract reference does not resolve.",
3669                "Reference a contractId declared in contracts.",
3670            )),
3671            Some((required, _version))
3672                if !system_v2_tenancy_compatible(
3673                    required,
3674                    item.get("tenancyMode")
3675                        .and_then(Value::as_str)
3676                        .unwrap_or_default(),
3677                ) =>
3678            {
3679                issues.push(system_v2_issue(
3680                    "incompatible_tenancy",
3681                    format!("$.consumers[{index}].tenancyMode"),
3682                    "Consumer tenancy does not satisfy the Producer contract.",
3683                    "Align the Consumer tenancyMode with the Producer contract requirement.",
3684                ))
3685            }
3686            Some(_) => {}
3687        }
3688        nodes.push(SystemV2GraphNode {
3689            id: format!("consumer:{consumer_id}"),
3690            kind: "consumer".to_owned(),
3691            owner: Some(owner_id.to_owned()),
3692        });
3693        let contract_version = contracts
3694            .get(contract_id)
3695            .map(|(_, version)| version.as_str())
3696            .unwrap_or_default();
3697        relationships.push(SystemV2GraphRelationship {
3698            kind: "consumes".to_owned(),
3699            from: format!("consumer:{consumer_id}"),
3700            to: format!("producer:{contract_id}"),
3701            contract_id: Some(format!("{contract_id}@{contract_version}")),
3702        });
3703    }
3704    let mut node_id_kinds = BTreeMap::<&str, Vec<&str>>::new();
3705    for node in &nodes {
3706        node_id_kinds
3707            .entry(node.id.as_str())
3708            .or_default()
3709            .push(node.kind.as_str());
3710    }
3711    for (id, kinds) in node_id_kinds {
3712        if kinds.len() > 1 {
3713            issues.push(system_v2_issue(
3714                "ambiguous_kind",
3715                format!("$.nodes.{id}"),
3716                format!("Graph node identity `{id}` is declared more than once."),
3717                "Give every Host, Provider, Autonomous Service, Module, and Workload a unique identity.",
3718            ));
3719        }
3720    }
3721    if !issues.is_empty() {
3722        issues.sort_by(|a, b| (&a.path, &a.code).cmp(&(&b.path, &b.code)));
3723        return Err(issues);
3724    }
3725    nodes.sort();
3726    nodes.dedup();
3727    relationships.sort();
3728    relationships.dedup();
3729    Ok(SystemV2Graph {
3730        artifact_protocol: SYSTEM_V2_PROTOCOL.to_owned(),
3731        semantic_kind: ContractSemanticKind::MixedSystem,
3732        system_id,
3733        nodes,
3734        relationships,
3735        issues: Vec::new(),
3736    })
3737}
3738
3739fn collect_system_v2_owners(
3740    items: &[&serde_json::Map<String, Value>],
3741    field: &str,
3742    id_field: &str,
3743    kind: &str,
3744    owners: &mut BTreeSet<(String, String)>,
3745    identity_kinds: &mut BTreeMap<String, BTreeSet<String>>,
3746    module_owners: &mut BTreeMap<String, String>,
3747    nodes: &mut Vec<SystemV2GraphNode>,
3748    relationships: &mut Vec<SystemV2GraphRelationship>,
3749    issues: &mut Vec<SystemV2Issue>,
3750) {
3751    for (index, item) in items.iter().enumerate() {
3752        let id = required_system_v2_string(
3753            item.get(id_field),
3754            &format!("$.{field}[{index}].{id_field}"),
3755            issues,
3756        );
3757        if id.is_empty() {
3758            continue;
3759        }
3760        owners.insert((kind.to_owned(), id.clone()));
3761        identity_kinds
3762            .entry(id.clone())
3763            .or_default()
3764            .insert(kind.to_owned());
3765        nodes.push(SystemV2GraphNode {
3766            id: id.clone(),
3767            kind: kind.to_owned(),
3768            owner: None,
3769        });
3770        collect_system_v2_modules(
3771            item.get("modules"),
3772            &format!("$.{field}[{index}].modules"),
3773            &id,
3774            module_owners,
3775            nodes,
3776            relationships,
3777            issues,
3778        );
3779    }
3780}
3781
3782fn system_v2_sorted_objects<'a>(
3783    value: Option<&'a Value>,
3784    path: &str,
3785    identity_field: &str,
3786    issues: &mut Vec<SystemV2Issue>,
3787) -> Vec<&'a serde_json::Map<String, Value>> {
3788    let Some(items) = value.and_then(Value::as_array) else {
3789        issues.push(system_v2_issue(
3790            "missing_ownership",
3791            path,
3792            "A non-empty explicit topology collection is required.",
3793            "Declare this field as a non-empty array of explicitly typed objects.",
3794        ));
3795        return Vec::new();
3796    };
3797    if items.is_empty() {
3798        issues.push(system_v2_issue(
3799            "missing_ownership",
3800            path,
3801            "A non-empty explicit topology collection is required.",
3802            "Add at least one explicitly typed object.",
3803        ));
3804    }
3805    let mut objects = Vec::new();
3806    for (index, item) in items.iter().enumerate() {
3807        if let Some(object) = item.as_object() {
3808            objects.push(object);
3809        } else {
3810            issues.push(system_v2_issue(
3811                "ambiguous_kind",
3812                format!("{path}[{index}]"),
3813                "Topology entries must be objects with explicit kinds and identities.",
3814                "Replace this entry with the documented object shape.",
3815            ));
3816        }
3817    }
3818    objects.sort_by_key(|object| {
3819        (
3820            object
3821                .get(identity_field)
3822                .and_then(Value::as_str)
3823                .unwrap_or_default()
3824                .to_owned(),
3825            serde_json::to_string(object).unwrap_or_default(),
3826        )
3827    });
3828    objects
3829}
3830
3831fn collect_system_v2_modules(
3832    value: Option<&Value>,
3833    path: &str,
3834    owner: &str,
3835    module_owners: &mut BTreeMap<String, String>,
3836    nodes: &mut Vec<SystemV2GraphNode>,
3837    relationships: &mut Vec<SystemV2GraphRelationship>,
3838    issues: &mut Vec<SystemV2Issue>,
3839) {
3840    let Some(modules) = value.and_then(Value::as_array) else {
3841        issues.push(system_v2_issue(
3842            "missing_ownership",
3843            path,
3844            "Module ownership collection is required.",
3845            "Declare modules as an array under its explicit owner.",
3846        ));
3847        return;
3848    };
3849    if modules.is_empty() {
3850        issues.push(system_v2_issue(
3851            "missing_ownership",
3852            path,
3853            "Every topology owner must declare at least one Module.",
3854            "Add the Modules owned by this Host, Provider, or Autonomous Service.",
3855        ));
3856    }
3857    let mut modules = modules.iter().enumerate().collect::<Vec<_>>();
3858    modules.sort_by_key(|(_, module)| serde_json::to_string(module).unwrap_or_default());
3859    let mut seen = BTreeSet::new();
3860    for (index, module) in modules {
3861        let Some(id) = module.as_str().filter(|id| !id.is_empty()) else {
3862            issues.push(system_v2_issue(
3863                "missing_ownership",
3864                format!("{path}[{index}]"),
3865                "Module identity is required.",
3866                "Declare a non-empty Module identity under its owner.",
3867            ));
3868            continue;
3869        };
3870        if !seen.insert(id) {
3871            issues.push(system_v2_issue(
3872                "ambiguous_kind",
3873                format!("{path}[{index}]"),
3874                "Module identity is declared more than once for this owner.",
3875                "Keep each Module identity once under its owning topology node.",
3876            ));
3877        }
3878        if let Some(existing) = module_owners.insert(id.to_owned(), owner.to_owned())
3879            && existing != owner
3880        {
3881            issues.push(system_v2_issue(
3882                "ambiguous_kind",
3883                format!("{path}[{index}]"),
3884                "Module has more than one owner.",
3885                "Keep each Module under exactly one Host, Provider, or Autonomous Service.",
3886            ));
3887        }
3888        nodes.push(SystemV2GraphNode {
3889            id: id.to_owned(),
3890            kind: "module".to_owned(),
3891            owner: Some(owner.to_owned()),
3892        });
3893        relationships.push(SystemV2GraphRelationship {
3894            kind: "owns".to_owned(),
3895            from: owner.to_owned(),
3896            to: id.to_owned(),
3897            contract_id: None,
3898        });
3899    }
3900}
3901
3902fn required_system_v2_string(
3903    value: Option<&Value>,
3904    path: &str,
3905    issues: &mut Vec<SystemV2Issue>,
3906) -> String {
3907    match value
3908        .and_then(Value::as_str)
3909        .filter(|value| !value.trim().is_empty())
3910    {
3911        Some(value) => value.to_owned(),
3912        None => {
3913            issues.push(system_v2_issue(
3914                "missing_ownership",
3915                path,
3916                "Explicit identity is required.",
3917                "Declare a stable non-empty identity.",
3918            ));
3919            String::new()
3920        }
3921    }
3922}
3923
3924fn system_v2_tenancy_compatible(producer: &str, consumer: &str) -> bool {
3925    matches!(
3926        (producer, consumer),
3927        ("none", "none") | ("optional", "optional" | "required") | ("required", "required")
3928    )
3929}
3930
3931fn system_v2_issue(
3932    code: impl Into<String>,
3933    path: impl Into<String>,
3934    message: impl Into<String>,
3935    next_action: impl Into<String>,
3936) -> SystemV2Issue {
3937    SystemV2Issue {
3938        code: code.into(),
3939        path: path.into(),
3940        message: message.into(),
3941        next_action: next_action.into(),
3942    }
3943}
3944
3945#[derive(Debug, Clone, Serialize, Deserialize)]
3946#[serde(rename_all = "camelCase")]
3947pub struct ServiceSystem {
3948    pub protocol: String,
3949    pub name: String,
3950    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3951    pub environments: Vec<String>,
3952    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3953    pub services: Vec<ServiceSystemService>,
3954    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3955    pub modules: Vec<ServiceSystemModule>,
3956    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3957    pub dependencies: Vec<ServiceSystemDependency>,
3958}
3959
3960impl ServiceSystem {
3961    #[must_use]
3962    pub fn new(name: impl Into<String>) -> Self {
3963        Self {
3964            protocol: SERVICE_SYSTEM_PROTOCOL.to_owned(),
3965            name: name.into(),
3966            environments: Vec::new(),
3967            services: Vec::new(),
3968            modules: Vec::new(),
3969            dependencies: Vec::new(),
3970        }
3971    }
3972}
3973
3974#[derive(Debug, Clone, Serialize, Deserialize)]
3975#[serde(rename_all = "camelCase")]
3976pub struct ServiceSystemService {
3977    pub name: String,
3978    pub target: String,
3979    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3980    pub modules: Vec<String>,
3981    #[serde(default, skip_serializing_if = "Option::is_none")]
3982    pub cwd: Option<String>,
3983    #[serde(default, skip_serializing_if = "Option::is_none")]
3984    pub manifest: Option<String>,
3985}
3986
3987#[derive(Debug, Clone, Serialize, Deserialize)]
3988#[serde(rename_all = "camelCase")]
3989pub struct ServiceSystemModule {
3990    pub name: String,
3991    #[serde(default, skip_serializing_if = "Option::is_none")]
3992    pub install_to: Option<String>,
3993    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3994    pub capabilities: Vec<String>,
3995    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3996    pub dependencies: Vec<String>,
3997}
3998
3999#[derive(Debug, Clone, Serialize, Deserialize)]
4000#[serde(rename_all = "camelCase")]
4001pub struct ServiceSystemDependency {
4002    pub from: String,
4003    pub capability: String,
4004    #[serde(default, skip_serializing_if = "Option::is_none")]
4005    pub to: Option<String>,
4006}
4007
4008#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4009#[serde(rename_all = "camelCase")]
4010pub struct ServiceSystemGraph {
4011    pub name: String,
4012    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4013    pub environments: Vec<String>,
4014    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4015    pub services: Vec<ServiceSystemGraphService>,
4016    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4017    pub modules: Vec<ServiceSystemGraphModule>,
4018    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4019    pub dependencies: Vec<ServiceSystemGraphDependency>,
4020    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4021    pub issues: Vec<ServiceSystemGraphIssue>,
4022}
4023
4024#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4025#[serde(rename_all = "camelCase")]
4026pub struct ServiceSystemGraphService {
4027    pub name: String,
4028    pub target: String,
4029    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4030    pub modules: Vec<String>,
4031}
4032
4033#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4034#[serde(rename_all = "camelCase")]
4035pub struct ServiceSystemGraphModule {
4036    pub name: String,
4037    pub owner: String,
4038    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4039    pub capabilities: Vec<String>,
4040    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4041    pub dependencies: Vec<String>,
4042}
4043
4044#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4045#[serde(rename_all = "camelCase")]
4046pub struct ServiceSystemGraphDependency {
4047    pub from: String,
4048    pub capability: String,
4049    pub state: String,
4050    #[serde(default, skip_serializing_if = "Option::is_none")]
4051    pub to: Option<String>,
4052}
4053
4054#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4055#[serde(rename_all = "camelCase")]
4056pub struct ServiceSystemGraphIssue {
4057    pub code: String,
4058    pub message: String,
4059}
4060
4061#[must_use]
4062pub fn service_system_graph(system: &ServiceSystem) -> ServiceSystemGraph {
4063    let services_by_name = system
4064        .services
4065        .iter()
4066        .map(|service| (service.name.as_str(), service))
4067        .collect::<BTreeMap<_, _>>();
4068    let modules_by_name = system
4069        .modules
4070        .iter()
4071        .map(|module| (module.name.as_str(), module))
4072        .collect::<BTreeMap<_, _>>();
4073    let mut module_owner = BTreeMap::new();
4074    let mut issues = Vec::new();
4075    for service in &system.services {
4076        for module_name in &service.modules {
4077            if !modules_by_name.contains_key(module_name.as_str()) {
4078                issues.push(ServiceSystemGraphIssue {
4079                    code: "module_not_declared".to_owned(),
4080                    message: format!(
4081                        "Service `{}` references undeclared module `{module_name}`.",
4082                        service.name
4083                    ),
4084                });
4085            }
4086            if let Some(existing) = module_owner.insert(module_name.as_str(), service.name.as_str())
4087            {
4088                issues.push(ServiceSystemGraphIssue {
4089                    code: "module_owned_twice".to_owned(),
4090                    message: format!(
4091                        "Module `{module_name}` is assigned to both `{existing}` and `{}`.",
4092                        service.name
4093                    ),
4094                });
4095            }
4096        }
4097    }
4098    for module in &system.modules {
4099        if let Some(service_name) = module
4100            .install_to
4101            .as_deref()
4102            .and_then(|install_to| install_to.strip_prefix("service:"))
4103            && !services_by_name.contains_key(service_name)
4104        {
4105            issues.push(ServiceSystemGraphIssue {
4106                code: "install_target_missing".to_owned(),
4107                message: format!(
4108                    "Module `{}` installs to missing service `{service_name}`.",
4109                    module.name
4110                ),
4111            });
4112        }
4113    }
4114
4115    let capability_owners = service_system_capability_owners(system, &module_owner);
4116    let mut dependencies = Vec::new();
4117    for module in &system.modules {
4118        let from = service_system_module_owner(module, &module_owner);
4119        for capability in &module.dependencies {
4120            dependencies.push(service_system_dependency_edge(
4121                from,
4122                capability,
4123                capability_owners
4124                    .get(capability.as_str())
4125                    .map(Vec::as_slice),
4126            ));
4127        }
4128    }
4129    for dependency in &system.dependencies {
4130        if let Some(to) = dependency.to.as_deref() {
4131            let target_exists =
4132                services_by_name.contains_key(to) || modules_by_name.contains_key(to);
4133            let target_has_capability = service_system_target_owns_capability(
4134                to,
4135                &dependency.capability,
4136                &capability_owners,
4137                &modules_by_name,
4138            );
4139            dependencies.push(ServiceSystemGraphDependency {
4140                from: dependency.from.clone(),
4141                capability: dependency.capability.clone(),
4142                state: if !target_exists {
4143                    "unresolved".to_owned()
4144                } else if target_has_capability {
4145                    "resolved".to_owned()
4146                } else {
4147                    "missing_capability".to_owned()
4148                },
4149                to: Some(to.to_owned()),
4150            });
4151        } else {
4152            dependencies.push(service_system_dependency_edge(
4153                &dependency.from,
4154                &dependency.capability,
4155                capability_owners
4156                    .get(dependency.capability.as_str())
4157                    .map(Vec::as_slice),
4158            ));
4159        }
4160    }
4161    for dependency in &dependencies {
4162        if dependency.state != "resolved" {
4163            issues.push(ServiceSystemGraphIssue {
4164                code: format!("dependency_{}", dependency.state),
4165                message: format!(
4166                    "`{}` depends on `{}`, but it is {}.",
4167                    dependency.from, dependency.capability, dependency.state
4168                ),
4169            });
4170        }
4171    }
4172
4173    ServiceSystemGraph {
4174        name: system.name.clone(),
4175        environments: system.environments.clone(),
4176        services: system
4177            .services
4178            .iter()
4179            .map(|service| ServiceSystemGraphService {
4180                name: service.name.clone(),
4181                target: service.target.clone(),
4182                modules: service.modules.clone(),
4183            })
4184            .collect(),
4185        modules: system
4186            .modules
4187            .iter()
4188            .map(|module| ServiceSystemGraphModule {
4189                name: module.name.clone(),
4190                owner: service_system_module_owner(module, &module_owner).to_owned(),
4191                capabilities: module.capabilities.clone(),
4192                dependencies: module.dependencies.clone(),
4193            })
4194            .collect(),
4195        dependencies,
4196        issues,
4197    }
4198}
4199
4200fn service_system_install_owner(module: &ServiceSystemModule) -> Option<&str> {
4201    let install_to = module.install_to.as_deref()?;
4202    install_to.strip_prefix("service:").or(Some(install_to))
4203}
4204
4205fn service_system_module_owner<'a>(
4206    module: &'a ServiceSystemModule,
4207    module_owner: &BTreeMap<&'a str, &'a str>,
4208) -> &'a str {
4209    module_owner
4210        .get(module.name.as_str())
4211        .copied()
4212        .or_else(|| service_system_install_owner(module))
4213        .unwrap_or("host")
4214}
4215
4216fn service_system_capability_owners<'a>(
4217    system: &'a ServiceSystem,
4218    module_owner: &BTreeMap<&'a str, &'a str>,
4219) -> BTreeMap<&'a str, Vec<&'a str>> {
4220    let mut owners: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
4221    for module in &system.modules {
4222        let owner = service_system_module_owner(module, module_owner);
4223        for capability in &module.capabilities {
4224            owners.entry(capability.as_str()).or_default().push(owner);
4225        }
4226    }
4227    owners
4228}
4229
4230fn service_system_target_owns_capability(
4231    target: &str,
4232    capability: &str,
4233    capability_owners: &BTreeMap<&str, Vec<&str>>,
4234    modules_by_name: &BTreeMap<&str, &ServiceSystemModule>,
4235) -> bool {
4236    capability_owners
4237        .get(capability)
4238        .is_some_and(|owners| owners.iter().any(|owner| *owner == target))
4239        || modules_by_name.get(target).is_some_and(|module| {
4240            module
4241                .capabilities
4242                .iter()
4243                .any(|provided| provided == capability)
4244        })
4245}
4246
4247fn service_system_dependency_edge(
4248    from: &str,
4249    capability: &str,
4250    owners: Option<&[&str]>,
4251) -> ServiceSystemGraphDependency {
4252    let (state, to) = match owners {
4253        Some(owners) if owners.len() == 1 => ("resolved", Some(owners[0].to_owned())),
4254        Some(owners) if owners.len() > 1 => ("ambiguous", Some(owners.join(","))),
4255        _ => ("unresolved", None),
4256    };
4257    ServiceSystemGraphDependency {
4258        from: from.to_owned(),
4259        capability: capability.to_owned(),
4260        state: state.to_owned(),
4261        to,
4262    }
4263}
4264
4265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4266#[serde(rename_all = "snake_case")]
4267pub enum ServiceReleaseRisk {
4268    Safe,
4269    NeedsAttention,
4270    Breaking,
4271    Blocked,
4272}
4273
4274#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
4275#[serde(rename_all = "camelCase")]
4276pub struct ServiceReleaseChangeSet {
4277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4278    pub added: Vec<String>,
4279    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4280    pub removed: Vec<String>,
4281}
4282
4283#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
4284#[serde(rename_all = "camelCase")]
4285pub struct ServiceReleaseModuleChangeSet {
4286    pub module: String,
4287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4288    pub added: Vec<String>,
4289    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4290    pub removed: Vec<String>,
4291}
4292
4293#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
4294#[serde(rename_all = "camelCase")]
4295pub struct ServiceReleaseDiff {
4296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4297    pub capabilities: Vec<ServiceReleaseModuleChangeSet>,
4298    #[serde(default, skip_serializing_if = "is_false")]
4299    pub compatibility_changed: bool,
4300    #[serde(default)]
4301    pub config: ServiceReleaseChangeSet,
4302    #[serde(default)]
4303    pub env: ServiceReleaseChangeSet,
4304    #[serde(default)]
4305    pub modules: ServiceReleaseChangeSet,
4306    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4307    pub operations: Vec<ServiceReleaseModuleChangeSet>,
4308}
4309
4310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4311#[serde(rename_all = "camelCase")]
4312pub struct ServiceReleaseManifestSummary {
4313    pub name: String,
4314    #[serde(default, skip_serializing_if = "Option::is_none")]
4315    pub version: Option<String>,
4316    pub manifest_reference: String,
4317    #[serde(default, skip_serializing_if = "Option::is_none")]
4318    pub package_reference: Option<String>,
4319    #[serde(default, skip_serializing_if = "Option::is_none")]
4320    pub input_reference: Option<String>,
4321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4322    pub modules: Vec<String>,
4323    #[serde(default, skip_serializing_if = "Option::is_none")]
4324    pub compatibility_issue: Option<String>,
4325}
4326
4327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4328#[serde(rename_all = "camelCase")]
4329pub struct ServiceReleasePolicyIssue {
4330    pub code: String,
4331    pub level: ServiceReleaseRisk,
4332    pub message: String,
4333}
4334
4335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4336#[serde(rename_all = "camelCase")]
4337pub struct ServiceReleasePolicy {
4338    pub risk: ServiceReleaseRisk,
4339    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4340    pub issues: Vec<ServiceReleasePolicyIssue>,
4341}
4342
4343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4344#[serde(rename_all = "camelCase")]
4345pub struct ServiceReleasePlan {
4346    pub protocol: String,
4347    pub service: BTreeMap<String, String>,
4348    pub current: ServiceReleaseManifestSummary,
4349    pub candidate: ServiceReleaseManifestSummary,
4350    pub diff: ServiceReleaseDiff,
4351    pub policy: ServiceReleasePolicy,
4352    pub restart_required: bool,
4353    pub next_action: String,
4354    #[serde(default, skip_serializing_if = "Option::is_none")]
4355    pub created_at_unix_ms: Option<u64>,
4356}
4357
4358impl ServiceReleasePlan {
4359    #[must_use]
4360    pub fn new(
4361        service_name: impl Into<String>,
4362        current: ServiceReleaseManifestSummary,
4363        candidate: ServiceReleaseManifestSummary,
4364        diff: ServiceReleaseDiff,
4365    ) -> Self {
4366        let policy =
4367            evaluate_service_release_policy(&diff, candidate.compatibility_issue.as_deref());
4368        let mut service = BTreeMap::new();
4369        service.insert("name".to_owned(), service_name.into());
4370        Self {
4371            protocol: SERVICE_RELEASE_PLAN_PROTOCOL.to_owned(),
4372            service,
4373            current,
4374            candidate,
4375            restart_required: service_release_restart_required(&diff),
4376            next_action: service_release_next_action(policy.risk).to_owned(),
4377            diff,
4378            policy,
4379            created_at_unix_ms: None,
4380        }
4381    }
4382}
4383
4384#[derive(
4385    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
4386)]
4387#[serde(rename_all = "snake_case")]
4388pub enum ServiceTenancyMode {
4389    None,
4390    Optional,
4391    Required,
4392}
4393
4394#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
4395pub enum WorkloadRole {
4396    Api,
4397    Worker,
4398    Migration,
4399    Other(String),
4400}
4401
4402impl WorkloadRole {
4403    pub const API: Self = Self::Api;
4404    pub const WORKER: Self = Self::Worker;
4405    pub const MIGRATION: Self = Self::Migration;
4406
4407    #[must_use]
4408    pub fn new(role: impl Into<String>) -> Self {
4409        match role.into().as_str() {
4410            "api" => Self::Api,
4411            "worker" => Self::Worker,
4412            "migration" => Self::Migration,
4413            role => Self::Other(role.to_owned()),
4414        }
4415    }
4416
4417    #[must_use]
4418    pub fn as_str(&self) -> &str {
4419        match self {
4420            Self::Api => "api",
4421            Self::Worker => "worker",
4422            Self::Migration => "migration",
4423            Self::Other(role) => role,
4424        }
4425    }
4426}
4427
4428impl Serialize for WorkloadRole {
4429    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
4430    where
4431        S: serde::Serializer,
4432    {
4433        serializer.serialize_str(self.as_str())
4434    }
4435}
4436
4437impl<'de> Deserialize<'de> for WorkloadRole {
4438    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4439    where
4440        D: serde::Deserializer<'de>,
4441    {
4442        String::deserialize(deserializer).map(Self::new)
4443    }
4444}
4445
4446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4447#[serde(rename_all = "camelCase", deny_unknown_fields)]
4448pub struct AutonomousServiceWorkload {
4449    pub workload_id: String,
4450    pub service_id: String,
4451    pub role: WorkloadRole,
4452}
4453
4454impl AutonomousServiceWorkload {
4455    #[must_use]
4456    pub fn new(
4457        workload_id: impl Into<String>,
4458        service_id: impl Into<String>,
4459        role: WorkloadRole,
4460    ) -> Self {
4461        Self {
4462            workload_id: workload_id.into(),
4463            service_id: service_id.into(),
4464            role,
4465        }
4466    }
4467}
4468
4469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4470#[serde(rename_all = "camelCase", deny_unknown_fields)]
4471pub struct AutonomousServiceStore {
4472    pub store_id: String,
4473    pub service_id: String,
4474}
4475
4476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4477#[serde(rename_all = "snake_case")]
4478pub enum ServiceArtifactFormat {
4479    Openapi,
4480    Protobuf,
4481}
4482
4483#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4484#[serde(rename_all = "snake_case")]
4485pub enum EventArtifactFormat {
4486    JsonSchema,
4487    Protobuf,
4488}
4489
4490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4491#[serde(rename_all = "camelCase", deny_unknown_fields)]
4492pub struct ServiceArtifactReference {
4493    pub format: ServiceArtifactFormat,
4494    pub path: String,
4495}
4496
4497impl ServiceArtifactReference {
4498    #[must_use]
4499    pub fn new(format: ServiceArtifactFormat, path: impl Into<String>) -> Self {
4500        Self {
4501            format,
4502            path: path.into(),
4503        }
4504    }
4505}
4506
4507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4508#[serde(rename_all = "camelCase", deny_unknown_fields)]
4509pub struct EventArtifactReference {
4510    pub format: EventArtifactFormat,
4511    pub path: String,
4512}
4513
4514impl EventArtifactReference {
4515    #[must_use]
4516    pub fn new(format: EventArtifactFormat, path: impl Into<String>) -> Self {
4517        Self {
4518            format,
4519            path: path.into(),
4520        }
4521    }
4522}
4523
4524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4525#[serde(rename_all = "camelCase", deny_unknown_fields)]
4526pub struct SchemaArtifactReference {
4527    pub path: String,
4528}
4529
4530impl SchemaArtifactReference {
4531    #[must_use]
4532    pub fn new(path: impl Into<String>) -> Self {
4533        Self { path: path.into() }
4534    }
4535}
4536
4537#[derive(
4538    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
4539)]
4540#[serde(rename_all = "snake_case")]
4541pub enum CommonContextRequirement {
4542    Story,
4543    Trace,
4544    ServicePrincipal,
4545    DelegatedActor,
4546    Tenant,
4547    Deadline,
4548    IdempotencyKey,
4549    Causation,
4550    Region,
4551}
4552
4553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4554#[serde(rename_all = "camelCase", deny_unknown_fields)]
4555pub struct ContractContextRequirements {
4556    pub protocol: String,
4557    pub required: Vec<CommonContextRequirement>,
4558}
4559
4560impl ContractContextRequirements {
4561    #[must_use]
4562    pub fn new(required: Vec<CommonContextRequirement>) -> Self {
4563        Self {
4564            protocol: COMMON_CONTEXT_PROTOCOL.to_owned(),
4565            required,
4566        }
4567    }
4568}
4569
4570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4571#[serde(rename_all = "camelCase", deny_unknown_fields)]
4572pub struct ServiceContractArtifact {
4573    pub contract_id: String,
4574    pub module_id: String,
4575    pub version: String,
4576    pub tenancy_mode: ServiceTenancyMode,
4577    pub artifact: ServiceArtifactReference,
4578    pub context: ContractContextRequirements,
4579}
4580
4581impl ServiceContractArtifact {
4582    #[must_use]
4583    pub fn new(
4584        contract_id: impl Into<String>,
4585        module_id: impl Into<String>,
4586        version: impl Into<String>,
4587        tenancy_mode: ServiceTenancyMode,
4588        artifact: ServiceArtifactReference,
4589    ) -> Self {
4590        Self {
4591            contract_id: contract_id.into(),
4592            module_id: module_id.into(),
4593            version: version.into(),
4594            tenancy_mode,
4595            artifact,
4596            context: ContractContextRequirements::new(Vec::new()),
4597        }
4598    }
4599}
4600
4601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4602#[serde(rename_all = "camelCase", deny_unknown_fields)]
4603pub struct EventContractArtifact {
4604    pub contract_id: String,
4605    pub module_id: String,
4606    pub version: String,
4607    pub tenancy_mode: ServiceTenancyMode,
4608    pub artifact: EventArtifactReference,
4609    pub context: ContractContextRequirements,
4610}
4611
4612impl EventContractArtifact {
4613    #[must_use]
4614    pub fn new(
4615        contract_id: impl Into<String>,
4616        module_id: impl Into<String>,
4617        version: impl Into<String>,
4618        tenancy_mode: ServiceTenancyMode,
4619        artifact: EventArtifactReference,
4620    ) -> Self {
4621        Self {
4622            contract_id: contract_id.into(),
4623            module_id: module_id.into(),
4624            version: version.into(),
4625            tenancy_mode,
4626            artifact,
4627            context: ContractContextRequirements::new(Vec::new()),
4628        }
4629    }
4630}
4631
4632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4633#[serde(rename_all = "snake_case")]
4634pub enum ConfigScope {
4635    Service,
4636    Region,
4637    Tenant,
4638}
4639
4640#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4641#[serde(rename_all = "snake_case")]
4642pub enum ConfigMutability {
4643    Immutable,
4644    Mutable,
4645}
4646
4647#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4648#[serde(rename_all = "snake_case")]
4649pub enum ConfigActivation {
4650    Hot,
4651    Restart,
4652}
4653
4654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4655#[serde(rename_all = "camelCase", deny_unknown_fields)]
4656pub struct ConfigFieldContract {
4657    pub path: String,
4658    pub shape: String,
4659    pub sensitive: bool,
4660    pub scope: ConfigScope,
4661    pub mutability: ConfigMutability,
4662    pub activation: ConfigActivation,
4663}
4664
4665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4666#[serde(rename_all = "camelCase", deny_unknown_fields)]
4667pub struct ConfigContract {
4668    pub contract_id: String,
4669    pub version: String,
4670    pub artifact: SchemaArtifactReference,
4671    pub fields: Vec<ConfigFieldContract>,
4672}
4673
4674impl ConfigContract {
4675    #[must_use]
4676    pub fn new(
4677        contract_id: impl Into<String>,
4678        version: impl Into<String>,
4679        artifact: SchemaArtifactReference,
4680        fields: Vec<ConfigFieldContract>,
4681    ) -> Self {
4682        Self {
4683            contract_id: contract_id.into(),
4684            version: version.into(),
4685            artifact,
4686            fields,
4687        }
4688    }
4689}
4690
4691#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
4692#[serde(rename_all = "snake_case")]
4693pub enum ReliabilityProfile {
4694    Development,
4695    Standard,
4696    Critical,
4697}
4698
4699impl Default for ReliabilityProfile {
4700    fn default() -> Self {
4701        Self::Standard
4702    }
4703}
4704
4705#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
4706#[serde(rename_all = "snake_case")]
4707pub enum ReliabilityReadinessSemantics {
4708    Serving,
4709    Healthy,
4710}
4711
4712#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
4713#[serde(rename_all = "snake_case")]
4714pub enum ReliabilityLivenessSemantics {
4715    ProcessRunning,
4716    RuntimeOperational,
4717}
4718
4719#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, Default)]
4720#[serde(rename_all = "camelCase", deny_unknown_fields)]
4721pub struct ReliabilityProfileOverrides {
4722    #[serde(default, skip_serializing_if = "Option::is_none")]
4723    pub queue_backlog_limit: Option<u64>,
4724    #[serde(default, skip_serializing_if = "Option::is_none")]
4725    pub workflow_backlog_limit: Option<u64>,
4726    #[serde(default, skip_serializing_if = "Option::is_none")]
4727    pub timer_lag_limit_ms: Option<u64>,
4728    #[serde(default, skip_serializing_if = "Option::is_none")]
4729    pub retry_exhaustion_limit: Option<u64>,
4730    #[serde(default, skip_serializing_if = "Option::is_none")]
4731    pub compensation_pressure_limit: Option<u64>,
4732    #[serde(default, skip_serializing_if = "Option::is_none")]
4733    pub error_budget_consumed_limit_basis_points: Option<u32>,
4734    #[serde(default, skip_serializing_if = "Option::is_none")]
4735    pub readiness: Option<ReliabilityReadinessSemantics>,
4736    #[serde(default, skip_serializing_if = "Option::is_none")]
4737    pub liveness: Option<ReliabilityLivenessSemantics>,
4738}
4739
4740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
4741#[serde(rename_all = "camelCase")]
4742pub struct EffectiveReliabilityValues {
4743    pub availability_target_basis_points: u32,
4744    pub latency_target_ms: u64,
4745    pub queue_backlog_limit: u64,
4746    pub workflow_backlog_limit: u64,
4747    pub timer_lag_limit_ms: u64,
4748    pub retry_exhaustion_limit: u64,
4749    pub compensation_pressure_limit: u64,
4750    pub error_budget: String,
4751    pub error_budget_consumed_limit_basis_points: u32,
4752    pub readiness: ReliabilityReadinessSemantics,
4753    pub liveness: ReliabilityLivenessSemantics,
4754}
4755
4756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4757struct ReliabilityProfileDefaults {
4758    workflow_backlog_limit: u64,
4759    timer_lag_limit_ms: u64,
4760    retry_exhaustion_limit: u64,
4761    compensation_pressure_limit: u64,
4762    error_budget_consumed_limit_basis_points: u32,
4763    readiness: ReliabilityReadinessSemantics,
4764    liveness: ReliabilityLivenessSemantics,
4765}
4766
4767impl ReliabilityProfile {
4768    const fn defaults(self) -> ReliabilityProfileDefaults {
4769        match self {
4770            Self::Development => ReliabilityProfileDefaults {
4771                workflow_backlog_limit: 1_000,
4772                timer_lag_limit_ms: 60_000,
4773                retry_exhaustion_limit: 100,
4774                compensation_pressure_limit: 100,
4775                error_budget_consumed_limit_basis_points: 10_000,
4776                readiness: ReliabilityReadinessSemantics::Serving,
4777                liveness: ReliabilityLivenessSemantics::ProcessRunning,
4778            },
4779            Self::Standard => ReliabilityProfileDefaults {
4780                workflow_backlog_limit: 250,
4781                timer_lag_limit_ms: 30_000,
4782                retry_exhaustion_limit: 25,
4783                compensation_pressure_limit: 25,
4784                error_budget_consumed_limit_basis_points: 10_000,
4785                readiness: ReliabilityReadinessSemantics::Serving,
4786                liveness: ReliabilityLivenessSemantics::RuntimeOperational,
4787            },
4788            Self::Critical => ReliabilityProfileDefaults {
4789                workflow_backlog_limit: 50,
4790                timer_lag_limit_ms: 5_000,
4791                retry_exhaustion_limit: 5,
4792                compensation_pressure_limit: 5,
4793                error_budget_consumed_limit_basis_points: 8_000,
4794                readiness: ReliabilityReadinessSemantics::Healthy,
4795                liveness: ReliabilityLivenessSemantics::RuntimeOperational,
4796            },
4797        }
4798    }
4799}
4800
4801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4802#[serde(rename_all = "camelCase", deny_unknown_fields)]
4803pub struct ReliabilityContract {
4804    pub contract_id: String,
4805    pub version: String,
4806    pub artifact: SchemaArtifactReference,
4807    pub profile: ReliabilityProfile,
4808    #[serde(default)]
4809    pub overrides: ReliabilityProfileOverrides,
4810    pub availability_target: String,
4811    pub latency_target_ms: u64,
4812    pub dependency_criticality: BTreeMap<String, String>,
4813    pub health_semantics: Vec<String>,
4814    pub degraded_modes: Vec<String>,
4815    #[serde(default)]
4816    pub degraded_mode_by_dependency: BTreeMap<String, String>,
4817    pub backlog_limit: u64,
4818    pub error_budget: String,
4819    pub rollout_safety: Vec<String>,
4820}
4821
4822impl ReliabilityContract {
4823    #[must_use]
4824    pub fn new(
4825        contract_id: impl Into<String>,
4826        version: impl Into<String>,
4827        artifact: SchemaArtifactReference,
4828        availability_target: impl Into<String>,
4829        error_budget: impl Into<String>,
4830    ) -> Self {
4831        Self {
4832            contract_id: contract_id.into(),
4833            version: version.into(),
4834            artifact,
4835            profile: ReliabilityProfile::Standard,
4836            overrides: ReliabilityProfileOverrides::default(),
4837            availability_target: availability_target.into(),
4838            latency_target_ms: 0,
4839            dependency_criticality: BTreeMap::new(),
4840            health_semantics: Vec::new(),
4841            degraded_modes: Vec::new(),
4842            degraded_mode_by_dependency: BTreeMap::new(),
4843            backlog_limit: 0,
4844            error_budget: error_budget.into(),
4845            rollout_safety: Vec::new(),
4846        }
4847    }
4848
4849    /// Resolves one deterministic runtime view from a validated declaration.
4850    #[must_use]
4851    pub fn effective_values(&self) -> Option<EffectiveReliabilityValues> {
4852        let defaults = self.profile.defaults();
4853        let availability_target_basis_points =
4854            parse_percentage_basis_points(&self.availability_target)?;
4855        Some(EffectiveReliabilityValues {
4856            availability_target_basis_points,
4857            latency_target_ms: self.latency_target_ms,
4858            queue_backlog_limit: self
4859                .overrides
4860                .queue_backlog_limit
4861                .unwrap_or(self.backlog_limit),
4862            workflow_backlog_limit: self
4863                .overrides
4864                .workflow_backlog_limit
4865                .unwrap_or(defaults.workflow_backlog_limit),
4866            timer_lag_limit_ms: self
4867                .overrides
4868                .timer_lag_limit_ms
4869                .unwrap_or(defaults.timer_lag_limit_ms),
4870            retry_exhaustion_limit: self
4871                .overrides
4872                .retry_exhaustion_limit
4873                .unwrap_or(defaults.retry_exhaustion_limit),
4874            compensation_pressure_limit: self
4875                .overrides
4876                .compensation_pressure_limit
4877                .unwrap_or(defaults.compensation_pressure_limit),
4878            error_budget: self.error_budget.clone(),
4879            error_budget_consumed_limit_basis_points: self
4880                .overrides
4881                .error_budget_consumed_limit_basis_points
4882                .unwrap_or(defaults.error_budget_consumed_limit_basis_points),
4883            readiness: self.overrides.readiness.unwrap_or(defaults.readiness),
4884            liveness: self.overrides.liveness.unwrap_or(defaults.liveness),
4885        })
4886    }
4887}
4888
4889fn parse_percentage_basis_points(value: &str) -> Option<u32> {
4890    let percent = value.trim().strip_suffix('%')?.trim();
4891    let (whole, fraction) = percent.split_once('.').unwrap_or((percent, ""));
4892    if fraction.len() > 2
4893        || whole.is_empty()
4894        || !whole.chars().all(|c| c.is_ascii_digit())
4895        || !fraction.chars().all(|c| c.is_ascii_digit())
4896    {
4897        return None;
4898    }
4899    let whole = whole.parse::<u32>().ok()?;
4900    let fraction = match fraction.len() {
4901        0 => 0,
4902        1 => fraction.parse::<u32>().ok()? * 10,
4903        2 => fraction.parse::<u32>().ok()?,
4904        _ => return None,
4905    };
4906    let basis_points = whole.checked_mul(100)?.checked_add(fraction)?;
4907    (basis_points <= 10_000).then_some(basis_points)
4908}
4909
4910impl AutonomousServiceStore {
4911    #[must_use]
4912    pub fn new(store_id: impl Into<String>, service_id: impl Into<String>) -> Self {
4913        Self {
4914            store_id: store_id.into(),
4915            service_id: service_id.into(),
4916        }
4917    }
4918}
4919
4920#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4921#[serde(rename_all = "camelCase", deny_unknown_fields)]
4922pub struct AutonomousServiceContract {
4923    pub protocol: String,
4924    pub service_id: String,
4925    #[serde(default, skip_serializing_if = "Option::is_none")]
4926    pub version: Option<String>,
4927    pub workloads: Vec<AutonomousServiceWorkload>,
4928    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4929    pub modules: Vec<String>,
4930    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4931    pub stores: Vec<AutonomousServiceStore>,
4932    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4933    pub service_contracts: Vec<ServiceContractArtifact>,
4934    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4935    pub event_contracts: Vec<EventContractArtifact>,
4936    #[serde(default, skip_serializing_if = "Option::is_none")]
4937    pub config_contract: Option<ConfigContract>,
4938    #[serde(default, skip_serializing_if = "Option::is_none")]
4939    pub reliability_contract: Option<ReliabilityContract>,
4940    pub tenancy_mode: ServiceTenancyMode,
4941    pub operating_regions: Vec<String>,
4942}
4943
4944impl AutonomousServiceContract {
4945    #[must_use]
4946    pub fn new(
4947        service_id: impl Into<String>,
4948        workloads: Vec<AutonomousServiceWorkload>,
4949        tenancy_mode: ServiceTenancyMode,
4950        operating_regions: Vec<String>,
4951    ) -> Self {
4952        Self {
4953            protocol: AUTONOMOUS_SERVICE_PROTOCOL.to_owned(),
4954            service_id: service_id.into(),
4955            version: None,
4956            workloads,
4957            modules: Vec::new(),
4958            stores: Vec::new(),
4959            service_contracts: Vec::new(),
4960            event_contracts: Vec::new(),
4961            config_contract: None,
4962            reliability_contract: None,
4963            tenancy_mode,
4964            operating_regions,
4965        }
4966    }
4967}
4968
4969#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4970#[serde(rename_all = "snake_case")]
4971pub enum AutonomousServiceIssueCode {
4972    UnknownField,
4973    InvalidProtocol,
4974    InvalidVersion,
4975    InvalidServiceIdentity,
4976    InvalidWorkloadIdentity,
4977    WorkloadOwnerMismatch,
4978    DuplicateWorkloadIdentity,
4979    InvalidWorkloadRole,
4980    InvalidModuleIdentity,
4981    DuplicateModuleIdentity,
4982    InvalidStoreIdentity,
4983    StoreOwnerMismatch,
4984    DuplicateStoreIdentity,
4985    InvalidTenancyMode,
4986    InvalidOperatingRegion,
4987    DuplicateOperatingRegion,
4988    InvalidContractIdentity,
4989    DuplicateContractIdentity,
4990    UnresolvedModuleReference,
4991    InvalidArtifactReference,
4992    UnresolvedArtifactReference,
4993    UnsupportedArtifactFormat,
4994    InvalidConfigContract,
4995    DuplicateConfigField,
4996    InvalidReliabilityContract,
4997}
4998
4999impl From<AutonomousServiceIssueCode> for ContractArtifactCheckErrorCode {
5000    fn from(code: AutonomousServiceIssueCode) -> Self {
5001        match code {
5002            AutonomousServiceIssueCode::UnknownField => Self::UnknownField,
5003            AutonomousServiceIssueCode::InvalidProtocol => Self::InvalidProtocol,
5004            AutonomousServiceIssueCode::InvalidVersion => Self::InvalidVersion,
5005            AutonomousServiceIssueCode::InvalidServiceIdentity => Self::InvalidServiceIdentity,
5006            AutonomousServiceIssueCode::InvalidWorkloadIdentity => Self::InvalidWorkloadIdentity,
5007            AutonomousServiceIssueCode::WorkloadOwnerMismatch => Self::WorkloadOwnerMismatch,
5008            AutonomousServiceIssueCode::DuplicateWorkloadIdentity => {
5009                Self::DuplicateWorkloadIdentity
5010            }
5011            AutonomousServiceIssueCode::InvalidWorkloadRole => Self::InvalidWorkloadRole,
5012            AutonomousServiceIssueCode::InvalidModuleIdentity => Self::InvalidModuleIdentity,
5013            AutonomousServiceIssueCode::DuplicateModuleIdentity => Self::DuplicateModuleIdentity,
5014            AutonomousServiceIssueCode::InvalidStoreIdentity => Self::InvalidStoreIdentity,
5015            AutonomousServiceIssueCode::StoreOwnerMismatch => Self::StoreOwnerMismatch,
5016            AutonomousServiceIssueCode::DuplicateStoreIdentity => Self::DuplicateStoreIdentity,
5017            AutonomousServiceIssueCode::InvalidTenancyMode => Self::InvalidTenancyMode,
5018            AutonomousServiceIssueCode::InvalidOperatingRegion => Self::InvalidOperatingRegion,
5019            AutonomousServiceIssueCode::DuplicateOperatingRegion => Self::DuplicateOperatingRegion,
5020            AutonomousServiceIssueCode::InvalidContractIdentity => Self::InvalidContractIdentity,
5021            AutonomousServiceIssueCode::DuplicateContractIdentity => {
5022                Self::DuplicateContractIdentity
5023            }
5024            AutonomousServiceIssueCode::UnresolvedModuleReference => {
5025                Self::UnresolvedModuleReference
5026            }
5027            AutonomousServiceIssueCode::InvalidArtifactReference => Self::InvalidArtifactReference,
5028            AutonomousServiceIssueCode::UnresolvedArtifactReference => {
5029                Self::UnresolvedArtifactReference
5030            }
5031            AutonomousServiceIssueCode::UnsupportedArtifactFormat => {
5032                Self::UnsupportedArtifactFormat
5033            }
5034            AutonomousServiceIssueCode::InvalidConfigContract => Self::InvalidConfigContract,
5035            AutonomousServiceIssueCode::DuplicateConfigField => Self::DuplicateConfigField,
5036            AutonomousServiceIssueCode::InvalidReliabilityContract => {
5037                Self::InvalidReliabilityContract
5038            }
5039        }
5040    }
5041}
5042
5043#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5044#[serde(rename_all = "camelCase")]
5045pub struct AutonomousServiceIssue {
5046    pub code: AutonomousServiceIssueCode,
5047    pub path: String,
5048    pub message: String,
5049    pub next_action: String,
5050}
5051
5052#[derive(Debug, Clone, Serialize, Deserialize)]
5053#[serde(rename_all = "camelCase")]
5054pub struct ServiceContract {
5055    #[serde(default = "default_service_contract_protocol")]
5056    pub protocol: String,
5057    pub name: String,
5058    #[serde(default, skip_serializing_if = "Option::is_none")]
5059    pub version: Option<String>,
5060    #[serde(default, skip_serializing_if = "Option::is_none")]
5061    pub provider: Option<ServiceProvider>,
5062    #[serde(default, skip_serializing_if = "Option::is_none")]
5063    pub compatibility: Option<ServiceCompatibility>,
5064    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5065    pub config: Vec<ServiceConfigField>,
5066    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5067    pub env: Vec<ServiceEnvField>,
5068    #[serde(default, skip_serializing_if = "Option::is_none")]
5069    pub health: Option<ServiceHealth>,
5070    #[serde(default, skip_serializing_if = "Option::is_none")]
5071    pub local_process: Option<ServiceLocalProcess>,
5072    pub modules: Vec<ModuleManifest>,
5073}
5074
5075#[derive(Debug, Clone, Serialize, Deserialize)]
5076#[serde(rename_all = "camelCase")]
5077pub struct ServicePackage {
5078    pub protocol: String,
5079    pub name: String,
5080    pub version: String,
5081    pub service_manifest: String,
5082    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5083    pub modules: Vec<String>,
5084}
5085
5086impl ServicePackage {
5087    #[must_use]
5088    pub fn new(name: impl Into<String>, version: impl Into<String>, modules: Vec<String>) -> Self {
5089        Self {
5090            protocol: SERVICE_PACKAGE_PROTOCOL.to_owned(),
5091            name: name.into(),
5092            version: version.into(),
5093            service_manifest: "lenso.service.json".to_owned(),
5094            modules,
5095        }
5096    }
5097}
5098
5099impl ServiceContract {
5100    #[must_use]
5101    pub fn new(name: impl Into<String>, modules: Vec<ModuleManifest>) -> Self {
5102        Self {
5103            protocol: SERVICE_CONTRACT_PROTOCOL.to_owned(),
5104            name: name.into(),
5105            version: None,
5106            provider: None,
5107            compatibility: None,
5108            config: Vec::new(),
5109            env: Vec::new(),
5110            health: None,
5111            local_process: None,
5112            modules,
5113        }
5114    }
5115
5116    #[must_use]
5117    pub fn version(mut self, version: impl Into<String>) -> Self {
5118        self.version = Some(version.into());
5119        self
5120    }
5121
5122    #[must_use]
5123    pub fn provider(mut self, provider: ServiceProvider) -> Self {
5124        self.provider = Some(provider);
5125        self
5126    }
5127
5128    #[must_use]
5129    pub fn compatibility(mut self, compatibility: ServiceCompatibility) -> Self {
5130        self.compatibility = Some(compatibility);
5131        self
5132    }
5133
5134    #[must_use]
5135    pub fn config(mut self, config: Vec<ServiceConfigField>) -> Self {
5136        self.config = config;
5137        self
5138    }
5139
5140    #[must_use]
5141    pub fn env(mut self, env: Vec<ServiceEnvField>) -> Self {
5142        self.env = env;
5143        self
5144    }
5145
5146    #[must_use]
5147    pub fn health(mut self, health: ServiceHealth) -> Self {
5148        self.health = Some(health);
5149        self
5150    }
5151
5152    #[must_use]
5153    pub fn local_process(mut self, local_process: ServiceLocalProcess) -> Self {
5154        self.local_process = Some(local_process);
5155        self
5156    }
5157}
5158
5159fn default_service_contract_protocol() -> String {
5160    SERVICE_CONTRACT_PROTOCOL.to_owned()
5161}
5162
5163#[must_use]
5164pub fn evaluate_service_release_policy(
5165    diff: &ServiceReleaseDiff,
5166    compatibility_issue: Option<&str>,
5167) -> ServiceReleasePolicy {
5168    let mut issues = Vec::new();
5169    if let Some(issue) = compatibility_issue {
5170        issues.push(ServiceReleasePolicyIssue {
5171            code: "host_incompatible".to_owned(),
5172            level: ServiceReleaseRisk::Blocked,
5173            message: issue.to_owned(),
5174        });
5175    } else if diff.compatibility_changed {
5176        issues.push(ServiceReleasePolicyIssue {
5177            code: "compatibility_changed".to_owned(),
5178            level: ServiceReleaseRisk::NeedsAttention,
5179            message: "Service compatibility metadata changed; review host support before applying."
5180                .to_owned(),
5181        });
5182    }
5183    for module in &diff.modules.removed {
5184        issues.push(ServiceReleasePolicyIssue {
5185            code: "module_removed".to_owned(),
5186            level: ServiceReleaseRisk::Breaking,
5187            message: format!("Module `{module}` is removed by this release."),
5188        });
5189    }
5190    for env in &diff.env.added {
5191        issues.push(ServiceReleasePolicyIssue {
5192            code: "env_added".to_owned(),
5193            level: ServiceReleaseRisk::NeedsAttention,
5194            message: format!("Environment value `{env}` is newly required by this release."),
5195        });
5196    }
5197    for config in &diff.config.added {
5198        issues.push(ServiceReleasePolicyIssue {
5199            code: "config_added".to_owned(),
5200            level: ServiceReleaseRisk::NeedsAttention,
5201            message: format!("Runtime config `{config}` is newly declared by this release."),
5202        });
5203    }
5204    for change in &diff.capabilities {
5205        for capability in &change.removed {
5206            issues.push(ServiceReleasePolicyIssue {
5207                code: "capability_removed".to_owned(),
5208                level: ServiceReleaseRisk::Breaking,
5209                message: format!(
5210                    "Capability `{capability}` is removed from module `{}`.",
5211                    change.module
5212                ),
5213            });
5214        }
5215    }
5216    for change in &diff.operations {
5217        for operation in &change.removed {
5218            issues.push(ServiceReleasePolicyIssue {
5219                code: "operation_removed".to_owned(),
5220                level: ServiceReleaseRisk::Breaking,
5221                message: format!(
5222                    "Operation `{operation}` is removed from module `{}`.",
5223                    change.module
5224                ),
5225            });
5226        }
5227    }
5228    let risk = issues
5229        .iter()
5230        .map(|issue| issue.level)
5231        .max_by_key(|risk| service_release_risk_rank(*risk))
5232        .unwrap_or(ServiceReleaseRisk::Safe);
5233    ServiceReleasePolicy { risk, issues }
5234}
5235
5236#[must_use]
5237pub fn service_release_restart_required(diff: &ServiceReleaseDiff) -> bool {
5238    diff.compatibility_changed
5239        || !diff.modules.added.is_empty()
5240        || !diff.modules.removed.is_empty()
5241        || !diff.env.added.is_empty()
5242        || !diff.env.removed.is_empty()
5243        || !diff.config.added.is_empty()
5244        || !diff.config.removed.is_empty()
5245        || diff
5246            .capabilities
5247            .iter()
5248            .any(|change| !change.added.is_empty() || !change.removed.is_empty())
5249        || diff
5250            .operations
5251            .iter()
5252            .any(|change| !change.added.is_empty() || !change.removed.is_empty())
5253}
5254
5255#[must_use]
5256pub fn service_release_next_action(risk: ServiceReleaseRisk) -> &'static str {
5257    match risk {
5258        ServiceReleaseRisk::Safe => "Run `lenso service release apply <plan.json>` when ready.",
5259        ServiceReleaseRisk::NeedsAttention => {
5260            "Review required env/config, then run `lenso service release apply <plan.json>`."
5261        }
5262        ServiceReleaseRisk::Breaking => {
5263            "Review removed modules, capabilities, or operations before applying."
5264        }
5265        ServiceReleaseRisk::Blocked => "Fix blocked policy issues before applying this release.",
5266    }
5267}
5268
5269#[must_use]
5270pub fn health_router() -> Router {
5271    Router::new()
5272        .route(
5273            "/lenso/service/v1/ready",
5274            get(|| async { Json(serde_json::json!({"ready": true})) }),
5275        )
5276        .route(
5277            "/lenso/service/v1/status",
5278            get(|| async { Json(serde_json::json!({"state": "ready"})) }),
5279        )
5280}
5281
5282#[derive(Debug, Clone, PartialEq, Eq)]
5283pub struct ServiceContractIssue {
5284    pub path: String,
5285    pub message: String,
5286}
5287
5288impl ServiceContractIssue {
5289    fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
5290        Self {
5291            path: path.into(),
5292            message: message.into(),
5293        }
5294    }
5295}
5296
5297/// Checks a versioned Provider or Autonomous Service artifact and projects its meaning.
5298///
5299/// The returned read model is separate from the source JSON so compatibility checks never
5300/// rewrite a legacy artifact or reinterpret Provider declarations as Autonomous Services.
5301pub fn check_contract_artifact_value(
5302    value: &Value,
5303) -> Result<ContractArtifactCheck, ContractArtifactCheckError> {
5304    let Some(object) = value.as_object() else {
5305        return Err(ambiguous_protocol_error(
5306            "artifact must be an object with an explicit versioned protocol",
5307        ));
5308    };
5309    let Some(protocol) = object
5310        .get("protocol")
5311        .and_then(Value::as_str)
5312        .filter(|protocol| !protocol.trim().is_empty())
5313    else {
5314        return Err(ambiguous_protocol_error(
5315            "artifact protocol is required to determine its semantic kind",
5316        ));
5317    };
5318
5319    if protocol == SYSTEM_V2_PROTOCOL {
5320        if let Err(issues) = system_v2_graph(value) {
5321            let issue = &issues[0];
5322            return Err(ContractArtifactCheckError {
5323                code: ContractArtifactCheckErrorCode::InvalidArtifact,
5324                path: issue.path.clone(),
5325                message: issue.message.clone(),
5326                next_action: issue.next_action.clone(),
5327            });
5328        }
5329        return Ok(ContractArtifactCheck {
5330            detected_protocol: protocol.to_owned(),
5331            artifact_kind: ContractArtifactKind::System,
5332            semantic_kind: ContractSemanticKind::MixedSystem,
5333            provider_semantics: None,
5334            autonomous_service: None,
5335        });
5336    }
5337
5338    if protocol == AUTONOMOUS_SERVICE_PROTOCOL {
5339        let issues = validate_autonomous_service_contract_value(value);
5340        if let Some(issue) = issues.first() {
5341            return Err(ContractArtifactCheckError {
5342                code: issue.code.into(),
5343                path: issue.path.clone(),
5344                message: issue.message.clone(),
5345                next_action: issue.next_action.clone(),
5346            });
5347        }
5348        let contract: AutonomousServiceContract =
5349            serde_json::from_value(value.clone()).map_err(|error| ContractArtifactCheckError {
5350                code: ContractArtifactCheckErrorCode::InvalidArtifact,
5351                path: "$".to_owned(),
5352                message: error.to_string(),
5353                next_action: "Fix the reported contract field and run the check again.".to_owned(),
5354            })?;
5355        return Ok(ContractArtifactCheck {
5356            detected_protocol: protocol.to_owned(),
5357            artifact_kind: ContractArtifactKind::Service,
5358            semantic_kind: ContractSemanticKind::AutonomousService,
5359            provider_semantics: None,
5360            autonomous_service: Some(AutonomousServiceSummary {
5361                service_id: contract.service_id,
5362                workloads: {
5363                    let mut workloads = contract
5364                        .workloads
5365                        .into_iter()
5366                        .map(|workload| workload.workload_id)
5367                        .collect::<Vec<_>>();
5368                    workloads.sort();
5369                    workloads
5370                },
5371                modules: sorted_unique(contract.modules),
5372                service_contracts: sorted_unique(
5373                    contract
5374                        .service_contracts
5375                        .into_iter()
5376                        .map(|contract| contract.contract_id),
5377                ),
5378                event_contracts: sorted_unique(
5379                    contract
5380                        .event_contracts
5381                        .into_iter()
5382                        .map(|contract| contract.contract_id),
5383                ),
5384                has_config_contract: contract.config_contract.is_some(),
5385                has_reliability_contract: contract.reliability_contract.is_some(),
5386            }),
5387        });
5388    }
5389
5390    let (artifact_kind, semantic_kind, issues) = match protocol {
5391        SERVICE_CONTRACT_PROTOCOL => (
5392            ContractArtifactKind::Service,
5393            ContractSemanticKind::Provider,
5394            validate_service_contract_value(value),
5395        ),
5396        SERVICE_SYSTEM_PROTOCOL => (
5397            ContractArtifactKind::System,
5398            ContractSemanticKind::ProviderSystem,
5399            validate_service_system_value(value),
5400        ),
5401        _ => {
5402            return Err(ContractArtifactCheckError {
5403                code: ContractArtifactCheckErrorCode::UnsupportedProtocol,
5404                path: "$.protocol".to_owned(),
5405                message: format!("unsupported artifact protocol `{protocol}`"),
5406                next_action: "Use a supported protocol or upgrade Lenso for this artifact version."
5407                    .to_owned(),
5408            });
5409        }
5410    };
5411
5412    if let Some(issue) = issues.first() {
5413        return Err(ContractArtifactCheckError {
5414            code: ContractArtifactCheckErrorCode::InvalidArtifact,
5415            path: issue.path.clone(),
5416            message: issue.message.clone(),
5417            next_action: "Fix the reported contract field and run the check again.".to_owned(),
5418        });
5419    }
5420
5421    let mut providers: Vec<String> = match artifact_kind {
5422        ContractArtifactKind::Service => object
5423            .get("name")
5424            .and_then(Value::as_str)
5425            .map(ToOwned::to_owned)
5426            .into_iter()
5427            .collect(),
5428        ContractArtifactKind::System => object
5429            .get("services")
5430            .and_then(Value::as_array)
5431            .into_iter()
5432            .flatten()
5433            .filter_map(|service| service.get("name").and_then(Value::as_str))
5434            .map(ToOwned::to_owned)
5435            .collect(),
5436    };
5437    providers.sort();
5438    providers.dedup();
5439
5440    Ok(ContractArtifactCheck {
5441        detected_protocol: protocol.to_owned(),
5442        artifact_kind,
5443        semantic_kind,
5444        provider_semantics: Some(ProviderSemantics {
5445            providers,
5446            auth_owner: ContractOwner::Host,
5447            proxy_policy_owner: ContractOwner::Host,
5448            retry_owner: ContractOwner::Host,
5449            runtime_queue_owner: ContractOwner::Host,
5450            outbox_owner: ContractOwner::Host,
5451            story_owner: ContractOwner::Host,
5452        }),
5453        autonomous_service: None,
5454    })
5455}
5456
5457/// Checks an artifact and resolves its owned contract files against a packaged path set.
5458pub fn check_contract_artifact_value_with_artifacts(
5459    value: &Value,
5460    available_paths: &BTreeSet<String>,
5461) -> Result<ContractArtifactCheck, ContractArtifactCheckError> {
5462    let check = check_contract_artifact_value(value)?;
5463    if check.semantic_kind == ContractSemanticKind::AutonomousService
5464        && let Some(issue) =
5465            validate_autonomous_service_artifact_references(value, available_paths).first()
5466    {
5467        return Err(ContractArtifactCheckError {
5468            code: issue.code.into(),
5469            path: issue.path.clone(),
5470            message: issue.message.clone(),
5471            next_action: issue.next_action.clone(),
5472        });
5473    }
5474    Ok(check)
5475}
5476
5477fn sorted_unique(values: impl IntoIterator<Item = String>) -> Vec<String> {
5478    let mut values = values.into_iter().collect::<Vec<_>>();
5479    values.sort();
5480    values.dedup();
5481    values
5482}
5483
5484fn ambiguous_protocol_error(message: &str) -> ContractArtifactCheckError {
5485    ContractArtifactCheckError {
5486        code: ContractArtifactCheckErrorCode::AmbiguousProtocol,
5487        path: "$.protocol".to_owned(),
5488        message: message.to_owned(),
5489        next_action: "Set `protocol` to a supported Provider-era protocol or `lenso.service.v2`."
5490            .to_owned(),
5491    }
5492}
5493
5494#[must_use]
5495pub fn validate_autonomous_service_contract(
5496    contract: &AutonomousServiceContract,
5497) -> Vec<AutonomousServiceIssue> {
5498    validate_autonomous_service_contract_value(
5499        &serde_json::to_value(contract).expect("AutonomousServiceContract must serialize"),
5500    )
5501}
5502
5503#[must_use]
5504pub fn validate_autonomous_service_contract_value(value: &Value) -> Vec<AutonomousServiceIssue> {
5505    let mut issues = Vec::new();
5506    let Some(object) = value.as_object() else {
5507        push_autonomous_issue(
5508            &mut issues,
5509            AutonomousServiceIssueCode::InvalidServiceIdentity,
5510            "$",
5511            "service contract must be an object",
5512            "Use a JSON object for the Service declaration.",
5513        );
5514        return issues;
5515    };
5516    validate_unknown_fields(
5517        object,
5518        "$",
5519        &[
5520            "protocol",
5521            "serviceId",
5522            "version",
5523            "workloads",
5524            "modules",
5525            "stores",
5526            "serviceContracts",
5527            "eventContracts",
5528            "configContract",
5529            "reliabilityContract",
5530            "tenancyMode",
5531            "operatingRegions",
5532        ],
5533        &mut issues,
5534    );
5535    if object.get("protocol").and_then(Value::as_str) != Some(AUTONOMOUS_SERVICE_PROTOCOL) {
5536        push_autonomous_issue(
5537            &mut issues,
5538            AutonomousServiceIssueCode::InvalidProtocol,
5539            "$.protocol",
5540            "protocol must be `lenso.service.v2`",
5541            "Set `protocol` to `lenso.service.v2`.",
5542        );
5543    }
5544    let service_id = object
5545        .get("serviceId")
5546        .and_then(Value::as_str)
5547        .unwrap_or("");
5548    if service_id.trim().is_empty() {
5549        push_autonomous_issue(
5550            &mut issues,
5551            AutonomousServiceIssueCode::InvalidServiceIdentity,
5552            "$.serviceId",
5553            "serviceId must be a non-empty string",
5554            "Assign one stable logical Service identity.",
5555        );
5556    }
5557    if object.get("version").is_some_and(|version| {
5558        version
5559            .as_str()
5560            .is_none_or(|version| version.trim().is_empty())
5561    }) {
5562        push_autonomous_issue(
5563            &mut issues,
5564            AutonomousServiceIssueCode::InvalidVersion,
5565            "$.version",
5566            "version must be a non-empty string when present",
5567            "Set a non-empty Service version or remove the optional field.",
5568        );
5569    }
5570    let mut workload_ids = BTreeSet::new();
5571    match object.get("workloads").and_then(Value::as_array) {
5572        Some(workloads) if !workloads.is_empty() => {
5573            for (index, workload) in workloads.iter().enumerate() {
5574                let path = format!("$.workloads[{index}]");
5575                if let Some(object) = workload.as_object() {
5576                    validate_unknown_fields(
5577                        object,
5578                        &path,
5579                        &["workloadId", "serviceId", "role"],
5580                        &mut issues,
5581                    );
5582                }
5583                let id = workload
5584                    .get("workloadId")
5585                    .and_then(Value::as_str)
5586                    .unwrap_or("");
5587                if id.trim().is_empty() {
5588                    push_autonomous_issue(
5589                        &mut issues,
5590                        AutonomousServiceIssueCode::InvalidWorkloadIdentity,
5591                        format!("{path}.workloadId"),
5592                        "workloadId must be a non-empty string",
5593                        "Assign a unique identity to this Workload.",
5594                    );
5595                } else if !workload_ids.insert(id) {
5596                    push_autonomous_issue(
5597                        &mut issues,
5598                        AutonomousServiceIssueCode::DuplicateWorkloadIdentity,
5599                        format!("{path}.workloadId"),
5600                        "workloadId must be unique within the Service",
5601                        "Rename this Workload so each workloadId is unique.",
5602                    );
5603                }
5604                if workload.get("serviceId").and_then(Value::as_str) != Some(service_id) {
5605                    push_autonomous_issue(
5606                        &mut issues,
5607                        AutonomousServiceIssueCode::WorkloadOwnerMismatch,
5608                        format!("{path}.serviceId"),
5609                        "Workload owner must match the enclosing serviceId",
5610                        "Set the Workload serviceId to the enclosing Service identity.",
5611                    );
5612                }
5613                if workload
5614                    .get("role")
5615                    .and_then(Value::as_str)
5616                    .is_none_or(|role| role.trim().is_empty())
5617                {
5618                    push_autonomous_issue(
5619                        &mut issues,
5620                        AutonomousServiceIssueCode::InvalidWorkloadRole,
5621                        format!("{path}.role"),
5622                        "role must be a non-empty string",
5623                        "Use `api`, `worker`, `migration`, or a stable extension role.",
5624                    );
5625                }
5626            }
5627        }
5628        _ => push_autonomous_issue(
5629            &mut issues,
5630            AutonomousServiceIssueCode::InvalidWorkloadIdentity,
5631            "$.workloads",
5632            "workloads must contain at least one Workload",
5633            "Declare at least one API, Worker, Migration, or extension Workload.",
5634        ),
5635    }
5636    validate_owned_identities(
5637        object.get("stores"),
5638        "stores",
5639        "storeId",
5640        service_id,
5641        AutonomousServiceIssueCode::InvalidStoreIdentity,
5642        AutonomousServiceIssueCode::StoreOwnerMismatch,
5643        AutonomousServiceIssueCode::DuplicateStoreIdentity,
5644        &mut issues,
5645    );
5646    validate_unique_strings(
5647        object.get("modules"),
5648        "modules",
5649        AutonomousServiceIssueCode::InvalidModuleIdentity,
5650        AutonomousServiceIssueCode::DuplicateModuleIdentity,
5651        &mut issues,
5652    );
5653    let module_ids = object
5654        .get("modules")
5655        .and_then(Value::as_array)
5656        .into_iter()
5657        .flatten()
5658        .filter_map(Value::as_str)
5659        .collect::<BTreeSet<_>>();
5660    validate_contract_artifacts(
5661        object.get("serviceContracts"),
5662        "serviceContracts",
5663        &["openapi", "protobuf"],
5664        true,
5665        &module_ids,
5666        &mut issues,
5667    );
5668    validate_contract_artifacts(
5669        object.get("eventContracts"),
5670        "eventContracts",
5671        &["json_schema", "protobuf"],
5672        true,
5673        &module_ids,
5674        &mut issues,
5675    );
5676    validate_config_contract(object.get("configContract"), &mut issues);
5677    validate_reliability_contract(object.get("reliabilityContract"), &mut issues);
5678    match object.get("tenancyMode").and_then(Value::as_str) {
5679        Some("none" | "optional" | "required") => {}
5680        _ => push_autonomous_issue(
5681            &mut issues,
5682            AutonomousServiceIssueCode::InvalidTenancyMode,
5683            "$.tenancyMode",
5684            "tenancyMode must be `none`, `optional`, or `required`",
5685            "Choose one supported Tenancy Mode.",
5686        ),
5687    }
5688    validate_unique_strings(
5689        object.get("operatingRegions"),
5690        "operatingRegions",
5691        AutonomousServiceIssueCode::InvalidOperatingRegion,
5692        AutonomousServiceIssueCode::DuplicateOperatingRegion,
5693        &mut issues,
5694    );
5695    if object
5696        .get("operatingRegions")
5697        .and_then(Value::as_array)
5698        .is_none_or(Vec::is_empty)
5699    {
5700        push_autonomous_issue(
5701            &mut issues,
5702            AutonomousServiceIssueCode::InvalidOperatingRegion,
5703            "$.operatingRegions",
5704            "at least one Operating Region is required",
5705            "Declare at least one logical Operating Region.",
5706        );
5707    }
5708    issues
5709}
5710
5711/// Resolves every owned contract artifact against paths packaged by a caller such as the CLI.
5712#[must_use]
5713pub fn validate_autonomous_service_artifact_references(
5714    value: &Value,
5715    available_paths: &BTreeSet<String>,
5716) -> Vec<AutonomousServiceIssue> {
5717    let mut issues = Vec::new();
5718    for field in ["serviceContracts", "eventContracts"] {
5719        for (index, contract) in value
5720            .get(field)
5721            .and_then(Value::as_array)
5722            .into_iter()
5723            .flatten()
5724            .enumerate()
5725        {
5726            validate_available_artifact_path(
5727                contract
5728                    .get("artifact")
5729                    .and_then(|artifact| artifact.get("path")),
5730                format!("$.{field}[{index}].artifact.path"),
5731                available_paths,
5732                &mut issues,
5733            );
5734        }
5735    }
5736    for field in ["configContract", "reliabilityContract"] {
5737        if let Some(contract) = value.get(field) {
5738            validate_available_artifact_path(
5739                contract
5740                    .get("artifact")
5741                    .and_then(|artifact| artifact.get("path")),
5742                format!("$.{field}.artifact.path"),
5743                available_paths,
5744                &mut issues,
5745            );
5746        }
5747    }
5748    issues
5749}
5750
5751fn validate_available_artifact_path(
5752    value: Option<&Value>,
5753    path: String,
5754    available_paths: &BTreeSet<String>,
5755    issues: &mut Vec<AutonomousServiceIssue>,
5756) {
5757    if let Some(reference) = value.and_then(Value::as_str)
5758        && is_repository_relative_artifact_path(reference)
5759        && !available_paths.contains(reference)
5760    {
5761        push_autonomous_issue(
5762            issues,
5763            AutonomousServiceIssueCode::UnresolvedArtifactReference,
5764            path,
5765            format!("artifact `{reference}` is not present in the package"),
5766            "Package the referenced artifact or correct its repository-relative path.",
5767        );
5768    }
5769}
5770
5771fn validate_contract_artifacts(
5772    value: Option<&Value>,
5773    field: &str,
5774    formats: &[&str],
5775    has_tenancy: bool,
5776    module_ids: &BTreeSet<&str>,
5777    issues: &mut Vec<AutonomousServiceIssue>,
5778) {
5779    let Some(values) = value.and_then(Value::as_array) else {
5780        if value.is_some() {
5781            push_autonomous_issue(
5782                issues,
5783                AutonomousServiceIssueCode::InvalidContractIdentity,
5784                format!("$.{field}"),
5785                "contract declarations must be an array",
5786                "Declare contract artifacts as an array.",
5787            );
5788        }
5789        return;
5790    };
5791    let mut identities = BTreeSet::new();
5792    for (index, value) in values.iter().enumerate() {
5793        let base = format!("$.{field}[{index}]");
5794        let Some(object) = value.as_object() else {
5795            push_autonomous_issue(
5796                issues,
5797                AutonomousServiceIssueCode::InvalidContractIdentity,
5798                &base,
5799                "contract declaration must be an object",
5800                "Declare a versioned contract object.",
5801            );
5802            continue;
5803        };
5804        let mut allowed = vec!["contractId", "moduleId", "version", "artifact", "context"];
5805        if has_tenancy {
5806            allowed.push("tenancyMode");
5807        }
5808        validate_unknown_fields(object, &base, &allowed, issues);
5809        let contract_id = object
5810            .get("contractId")
5811            .and_then(Value::as_str)
5812            .unwrap_or("");
5813        let version = object.get("version").and_then(Value::as_str).unwrap_or("");
5814        if contract_id.trim().is_empty() || version.trim().is_empty() {
5815            let field_name = if contract_id.trim().is_empty() {
5816                "contractId"
5817            } else {
5818                "version"
5819            };
5820            push_autonomous_issue(
5821                issues,
5822                AutonomousServiceIssueCode::InvalidContractIdentity,
5823                format!("{base}.{field_name}"),
5824                "contractId and version must be non-empty strings",
5825                "Assign a stable contractId and Contract Version.",
5826            );
5827        }
5828        let artifact = object.get("artifact");
5829        let format = artifact
5830            .and_then(|value| value.get("format"))
5831            .and_then(Value::as_str)
5832            .unwrap_or("");
5833        if !formats.contains(&format) {
5834            push_autonomous_issue(
5835                issues,
5836                AutonomousServiceIssueCode::UnsupportedArtifactFormat,
5837                format!("{base}.artifact.format"),
5838                format!("unsupported artifact format `{format}`"),
5839                format!("Use one supported format: {}.", formats.join(", ")),
5840            );
5841        }
5842        let module_id = object.get("moduleId").and_then(Value::as_str).unwrap_or("");
5843        if !module_ids.contains(module_id) {
5844            push_autonomous_issue(
5845                issues,
5846                AutonomousServiceIssueCode::UnresolvedModuleReference,
5847                format!("{base}.moduleId"),
5848                "moduleId does not resolve to an owned Module",
5849                "Reference one Module identity declared in `modules`.",
5850            );
5851        }
5852        if !contract_id.is_empty() && !identities.insert(contract_id) {
5853            push_autonomous_issue(
5854                issues,
5855                AutonomousServiceIssueCode::DuplicateContractIdentity,
5856                format!("{base}.contractId"),
5857                "contractId must be unique within its contract kind",
5858                "Remove or rename the duplicate contractId.",
5859            );
5860        }
5861        if artifact
5862            .and_then(|value| value.get("path"))
5863            .and_then(Value::as_str)
5864            .is_none_or(|path| !is_repository_relative_artifact_path(path))
5865        {
5866            push_autonomous_issue(
5867                issues,
5868                AutonomousServiceIssueCode::InvalidArtifactReference,
5869                format!("{base}.artifact.path"),
5870                "artifact path must be a non-empty string",
5871                "Reference a packaged contract artifact using a repository-relative path.",
5872            );
5873        }
5874        if let Some(artifact) = artifact.and_then(Value::as_object) {
5875            validate_unknown_fields(
5876                artifact,
5877                &format!("{base}.artifact"),
5878                &["format", "path"],
5879                issues,
5880            );
5881        }
5882        if has_tenancy
5883            && !matches!(
5884                object.get("tenancyMode").and_then(Value::as_str),
5885                Some("none" | "optional" | "required")
5886            )
5887        {
5888            push_autonomous_issue(
5889                issues,
5890                AutonomousServiceIssueCode::InvalidTenancyMode,
5891                format!("{base}.tenancyMode"),
5892                "tenancyMode must be `none`, `optional`, or `required`",
5893                "Choose one supported Tenancy Mode for this Service Contract.",
5894            );
5895        }
5896        validate_context_requirements(object.get("context"), &base, issues);
5897    }
5898}
5899
5900fn validate_context_requirements(
5901    value: Option<&Value>,
5902    base: &str,
5903    issues: &mut Vec<AutonomousServiceIssue>,
5904) {
5905    let supported = [
5906        "story",
5907        "trace",
5908        "service_principal",
5909        "delegated_actor",
5910        "tenant",
5911        "deadline",
5912        "idempotency_key",
5913        "causation",
5914        "region",
5915    ];
5916    let valid = value.and_then(Value::as_object).is_some_and(|context| {
5917        context.get("protocol").and_then(Value::as_str) == Some(COMMON_CONTEXT_PROTOCOL)
5918            && context
5919                .get("required")
5920                .and_then(Value::as_array)
5921                .is_some_and(|required| {
5922                    let values = required
5923                        .iter()
5924                        .filter_map(Value::as_str)
5925                        .collect::<BTreeSet<_>>();
5926                    values.len() == required.len()
5927                        && values.iter().all(|value| supported.contains(value))
5928                })
5929    });
5930    if !valid {
5931        push_autonomous_issue(
5932            issues,
5933            AutonomousServiceIssueCode::InvalidContractIdentity,
5934            format!("{base}.context"),
5935            "context must reference `lenso.context.v1` with unique supported requirements",
5936            "Declare the common context required by this contract.",
5937        );
5938    }
5939    if let Some(context) = value.and_then(Value::as_object) {
5940        validate_unknown_fields(
5941            context,
5942            &format!("{base}.context"),
5943            &["protocol", "required"],
5944            issues,
5945        );
5946    }
5947}
5948
5949fn is_repository_relative_artifact_path(path: &str) -> bool {
5950    let path = std::path::Path::new(path);
5951    !path.as_os_str().is_empty()
5952        && !path.is_absolute()
5953        && !path
5954            .components()
5955            .any(|part| matches!(part, std::path::Component::ParentDir))
5956        && !path.to_string_lossy().contains("://")
5957}
5958
5959fn validate_config_contract(value: Option<&Value>, issues: &mut Vec<AutonomousServiceIssue>) {
5960    let Some(value) = value else {
5961        return;
5962    };
5963    let Some(object) = value.as_object() else {
5964        push_autonomous_issue(
5965            issues,
5966            AutonomousServiceIssueCode::InvalidConfigContract,
5967            "$.configContract",
5968            "configContract must be an object",
5969            "Declare one versioned Config Contract object.",
5970        );
5971        return;
5972    };
5973    validate_unknown_fields(
5974        object,
5975        "$.configContract",
5976        &["contractId", "version", "artifact", "fields"],
5977        issues,
5978    );
5979    validate_service_owned_contract_header(object, "$.configContract", issues);
5980    if object
5981        .get("version")
5982        .and_then(Value::as_str)
5983        .is_none_or(|v| v.trim().is_empty())
5984    {
5985        push_autonomous_issue(
5986            issues,
5987            AutonomousServiceIssueCode::InvalidConfigContract,
5988            "$.configContract.version",
5989            "Config Contract version must be a non-empty string",
5990            "Assign a stable Config Contract Version.",
5991        );
5992    }
5993    let Some(fields) = object.get("fields").and_then(Value::as_array) else {
5994        push_autonomous_issue(
5995            issues,
5996            AutonomousServiceIssueCode::InvalidConfigContract,
5997            "$.configContract.fields",
5998            "Config Contract fields must be an array",
5999            "Declare configuration field requirements.",
6000        );
6001        return;
6002    };
6003    let mut paths = BTreeSet::new();
6004    for (index, field) in fields.iter().enumerate() {
6005        let base = format!("$.configContract.fields[{index}]");
6006        let Some(object) = field.as_object() else {
6007            push_autonomous_issue(
6008                issues,
6009                AutonomousServiceIssueCode::InvalidConfigContract,
6010                &base,
6011                "Config field must be an object",
6012                "Declare all Config field requirements in an object.",
6013            );
6014            continue;
6015        };
6016        validate_unknown_fields(
6017            object,
6018            &base,
6019            &[
6020                "path",
6021                "shape",
6022                "sensitive",
6023                "scope",
6024                "mutability",
6025                "activation",
6026            ],
6027            issues,
6028        );
6029        let path = object.get("path").and_then(Value::as_str).unwrap_or("");
6030        let valid = !path.trim().is_empty()
6031            && object
6032                .get("shape")
6033                .and_then(Value::as_str)
6034                .is_some_and(|v| !v.trim().is_empty())
6035            && object.get("sensitive").is_some_and(Value::is_boolean)
6036            && matches!(
6037                object.get("scope").and_then(Value::as_str),
6038                Some("service" | "region" | "tenant")
6039            )
6040            && matches!(
6041                object.get("mutability").and_then(Value::as_str),
6042                Some("immutable" | "mutable")
6043            )
6044            && matches!(
6045                object.get("activation").and_then(Value::as_str),
6046                Some("hot" | "restart")
6047            );
6048        if !valid {
6049            push_autonomous_issue(
6050                issues,
6051                AutonomousServiceIssueCode::InvalidConfigContract,
6052                &base,
6053                "Config field must declare path, shape, sensitivity, scope, mutability, and activation",
6054                "Complete every Config Contract field declaration using supported values.",
6055            );
6056        }
6057        if !path.is_empty() && !paths.insert(path) {
6058            push_autonomous_issue(
6059                issues,
6060                AutonomousServiceIssueCode::DuplicateConfigField,
6061                format!("{base}.path"),
6062                "Config field path must be unique",
6063                "Remove the duplicate Config field path.",
6064            );
6065        }
6066    }
6067}
6068
6069fn validate_reliability_contract(value: Option<&Value>, issues: &mut Vec<AutonomousServiceIssue>) {
6070    let Some(value) = value else {
6071        return;
6072    };
6073    let Some(object) = value.as_object() else {
6074        push_autonomous_issue(
6075            issues,
6076            AutonomousServiceIssueCode::InvalidReliabilityContract,
6077            "$.reliabilityContract",
6078            "reliabilityContract must be an object",
6079            "Declare one versioned Reliability Contract object.",
6080        );
6081        return;
6082    };
6083    validate_unknown_fields(
6084        object,
6085        "$.reliabilityContract",
6086        &[
6087            "contractId",
6088            "version",
6089            "artifact",
6090            "profile",
6091            "overrides",
6092            "availabilityTarget",
6093            "latencyTargetMs",
6094            "dependencyCriticality",
6095            "healthSemantics",
6096            "degradedModes",
6097            "degradedModeByDependency",
6098            "backlogLimit",
6099            "errorBudget",
6100            "rolloutSafety",
6101        ],
6102        issues,
6103    );
6104    validate_service_owned_contract_header(object, "$.reliabilityContract", issues);
6105    let dependencies = object
6106        .get("dependencyCriticality")
6107        .and_then(Value::as_object);
6108    let degraded_mode_by_dependency = object
6109        .get("degradedModeByDependency")
6110        .and_then(Value::as_object);
6111    let degradable_dependencies = dependencies
6112        .into_iter()
6113        .flat_map(|dependencies| dependencies.iter())
6114        .filter_map(|(dependency, criticality)| {
6115            (criticality.as_str() == Some("degradable")).then_some(dependency.as_str())
6116        })
6117        .collect::<BTreeSet<_>>();
6118    let mapped_degradable_dependencies = degraded_mode_by_dependency
6119        .into_iter()
6120        .flat_map(|dependencies| dependencies.iter())
6121        .filter_map(|(dependency, mode)| {
6122            mode.as_str()
6123                .is_some_and(|mode| !mode.trim().is_empty())
6124                .then_some(dependency.as_str())
6125        })
6126        .collect::<BTreeSet<_>>();
6127    if object
6128        .get("version")
6129        .and_then(Value::as_str)
6130        .is_none_or(|v| v.trim().is_empty())
6131        || !matches!(
6132            object.get("profile").and_then(Value::as_str),
6133            Some("development" | "standard" | "critical")
6134        )
6135        || !valid_reliability_overrides(object.get("overrides"))
6136        || object
6137            .get("availabilityTarget")
6138            .and_then(Value::as_str)
6139            .and_then(parse_percentage_basis_points)
6140            .is_none()
6141        || !object.get("latencyTargetMs").is_some_and(Value::is_u64)
6142        || !object
6143            .get("dependencyCriticality")
6144            .and_then(Value::as_object)
6145            .is_some_and(|dependencies| {
6146                dependencies.iter().all(|(dependency, value)| {
6147                    !dependency.trim().is_empty()
6148                        && matches!(value.as_str(), Some("critical" | "degradable" | "optional"))
6149                })
6150            })
6151        || !is_string_array(object.get("healthSemantics"))
6152        || !is_string_array(object.get("degradedModes"))
6153        || !object
6154            .get("degradedModeByDependency")
6155            .and_then(Value::as_object)
6156            .is_some_and(|modes| {
6157                modes.iter().all(|(dependency, mode)| {
6158                    mode.as_str().is_some_and(|mode| !mode.trim().is_empty())
6159                        && dependencies.is_some_and(|dependencies| {
6160                            dependencies.get(dependency).and_then(Value::as_str)
6161                                == Some("degradable")
6162                        })
6163                })
6164            })
6165        || degradable_dependencies != mapped_degradable_dependencies
6166        || !object.get("backlogLimit").is_some_and(Value::is_u64)
6167        || object
6168            .get("errorBudget")
6169            .and_then(Value::as_str)
6170            .is_none_or(str::is_empty)
6171        || !is_string_array(object.get("rolloutSafety"))
6172    {
6173        push_autonomous_issue(
6174            issues,
6175            AutonomousServiceIssueCode::InvalidReliabilityContract,
6176            "$.reliabilityContract",
6177            "Reliability Contract must declare availability, latency, dependencies, health, degradation, backlog, error budget, and rollout safety",
6178            "Declare whole-Service reliability expectations using supported values.",
6179        );
6180    }
6181}
6182
6183fn valid_reliability_overrides(value: Option<&Value>) -> bool {
6184    let Some(overrides) = value.and_then(Value::as_object) else {
6185        return false;
6186    };
6187    const NUMERIC_FIELDS: &[&str] = &[
6188        "queueBacklogLimit",
6189        "workflowBacklogLimit",
6190        "timerLagLimitMs",
6191        "retryExhaustionLimit",
6192        "compensationPressureLimit",
6193    ];
6194    const ALLOWED_FIELDS: &[&str] = &[
6195        "queueBacklogLimit",
6196        "workflowBacklogLimit",
6197        "timerLagLimitMs",
6198        "retryExhaustionLimit",
6199        "compensationPressureLimit",
6200        "errorBudgetConsumedLimitBasisPoints",
6201        "readiness",
6202        "liveness",
6203    ];
6204    overrides
6205        .keys()
6206        .all(|field| ALLOWED_FIELDS.contains(&field.as_str()))
6207        && NUMERIC_FIELDS
6208            .iter()
6209            .all(|field| overrides.get(*field).is_none_or(Value::is_u64))
6210        && overrides
6211            .get("errorBudgetConsumedLimitBasisPoints")
6212            .is_none_or(|value| value.as_u64().is_some_and(|value| value <= 10_000))
6213        && overrides
6214            .get("readiness")
6215            .is_none_or(|value| matches!(value.as_str(), Some("serving" | "healthy")))
6216        && overrides.get("liveness").is_none_or(|value| {
6217            matches!(
6218                value.as_str(),
6219                Some("process_running" | "runtime_operational")
6220            )
6221        })
6222}
6223
6224fn is_string_array(value: Option<&Value>) -> bool {
6225    value.and_then(Value::as_array).is_some_and(|values| {
6226        values
6227            .iter()
6228            .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))
6229    })
6230}
6231
6232fn validate_service_owned_contract_header(
6233    object: &serde_json::Map<String, Value>,
6234    base: &str,
6235    issues: &mut Vec<AutonomousServiceIssue>,
6236) {
6237    for field in ["contractId", "version"] {
6238        if object
6239            .get(field)
6240            .and_then(Value::as_str)
6241            .is_none_or(|value| value.trim().is_empty())
6242        {
6243            push_autonomous_issue(
6244                issues,
6245                AutonomousServiceIssueCode::InvalidContractIdentity,
6246                format!("{base}.{field}"),
6247                format!("{field} must be a non-empty string"),
6248                "Assign a stable contract identity and version.",
6249            );
6250        }
6251    }
6252    if object
6253        .get("artifact")
6254        .and_then(|artifact| artifact.get("path"))
6255        .and_then(Value::as_str)
6256        .is_none_or(|path| !is_repository_relative_artifact_path(path))
6257    {
6258        push_autonomous_issue(
6259            issues,
6260            AutonomousServiceIssueCode::InvalidArtifactReference,
6261            format!("{base}.artifact.path"),
6262            "artifact path must be repository-relative without traversal",
6263            "Reference a packaged contract artifact using a repository-relative path.",
6264        );
6265    }
6266    if let Some(artifact) = object.get("artifact").and_then(Value::as_object) {
6267        validate_unknown_fields(artifact, &format!("{base}.artifact"), &["path"], issues);
6268    }
6269}
6270
6271fn push_autonomous_issue(
6272    issues: &mut Vec<AutonomousServiceIssue>,
6273    code: AutonomousServiceIssueCode,
6274    path: impl Into<String>,
6275    message: impl Into<String>,
6276    next_action: impl Into<String>,
6277) {
6278    issues.push(AutonomousServiceIssue {
6279        code,
6280        path: path.into(),
6281        message: message.into(),
6282        next_action: next_action.into(),
6283    });
6284}
6285
6286fn validate_unknown_fields(
6287    object: &serde_json::Map<String, Value>,
6288    path: &str,
6289    allowed: &[&str],
6290    issues: &mut Vec<AutonomousServiceIssue>,
6291) {
6292    let mut unknown = object
6293        .keys()
6294        .filter(|key| !allowed.contains(&key.as_str()))
6295        .collect::<Vec<_>>();
6296    unknown.sort();
6297    for field in unknown {
6298        push_autonomous_issue(
6299            issues,
6300            AutonomousServiceIssueCode::UnknownField,
6301            format!("{path}.{field}"),
6302            format!("unknown field `{field}`"),
6303            "Remove the field or upgrade to a contract version that declares it.",
6304        );
6305    }
6306}
6307
6308fn validate_unique_strings(
6309    value: Option<&Value>,
6310    field: &str,
6311    invalid: AutonomousServiceIssueCode,
6312    duplicate: AutonomousServiceIssueCode,
6313    issues: &mut Vec<AutonomousServiceIssue>,
6314) {
6315    let Some(values) = value.and_then(Value::as_array) else {
6316        return;
6317    };
6318    let mut seen = BTreeSet::new();
6319    for (index, value) in values.iter().enumerate() {
6320        let path = format!("$.{field}[{index}]");
6321        let Some(identity) = value
6322            .as_str()
6323            .filter(|identity| !identity.trim().is_empty())
6324        else {
6325            push_autonomous_issue(
6326                issues,
6327                invalid,
6328                path,
6329                format!("{field} identity must be a non-empty string"),
6330                format!("Assign a non-empty {field} identity."),
6331            );
6332            continue;
6333        };
6334        if !seen.insert(identity) {
6335            push_autonomous_issue(
6336                issues,
6337                duplicate,
6338                path,
6339                format!("{field} identities must be unique"),
6340                format!("Remove or rename the duplicate {field} identity."),
6341            );
6342        }
6343    }
6344}
6345
6346#[allow(clippy::too_many_arguments)]
6347fn validate_owned_identities(
6348    value: Option<&Value>,
6349    field: &str,
6350    identity_field: &str,
6351    service_id: &str,
6352    invalid: AutonomousServiceIssueCode,
6353    owner_mismatch: AutonomousServiceIssueCode,
6354    duplicate: AutonomousServiceIssueCode,
6355    issues: &mut Vec<AutonomousServiceIssue>,
6356) {
6357    let Some(values) = value.and_then(Value::as_array) else {
6358        return;
6359    };
6360    let mut seen = BTreeSet::new();
6361    for (index, value) in values.iter().enumerate() {
6362        let base = format!("$.{field}[{index}]");
6363        if let Some(object) = value.as_object() {
6364            validate_unknown_fields(object, &base, &[identity_field, "serviceId"], issues);
6365        }
6366        let identity = value
6367            .get(identity_field)
6368            .and_then(Value::as_str)
6369            .unwrap_or("");
6370        if identity.trim().is_empty() {
6371            push_autonomous_issue(
6372                issues,
6373                invalid,
6374                format!("{base}.{identity_field}"),
6375                "identity must be a non-empty string",
6376                "Assign a stable logical identity.",
6377            );
6378        }
6379        if value.get("serviceId").and_then(Value::as_str) != Some(service_id) {
6380            push_autonomous_issue(
6381                issues,
6382                owner_mismatch,
6383                format!("{base}.serviceId"),
6384                "owner must match the enclosing serviceId",
6385                "Set serviceId to the enclosing Service identity.",
6386            );
6387        }
6388        if !identity.is_empty() && !seen.insert(identity) {
6389            push_autonomous_issue(
6390                issues,
6391                duplicate,
6392                format!("{base}.{identity_field}"),
6393                "identity must be unique within the Service",
6394                "Rename the duplicate identity.",
6395            );
6396        }
6397    }
6398}
6399
6400#[must_use]
6401pub fn validate_service_contract_value(value: &Value) -> Vec<ServiceContractIssue> {
6402    let Some(object) = value.as_object() else {
6403        return vec![ServiceContractIssue::new(
6404            "$",
6405            "service contract must be an object",
6406        )];
6407    };
6408
6409    let mut issues = Vec::new();
6410    if let Some(protocol) = object.get("protocol") {
6411        match protocol.as_str() {
6412            Some(SERVICE_CONTRACT_PROTOCOL) => {}
6413            Some(_) => issues.push(ServiceContractIssue::new(
6414                "$.protocol",
6415                format!("protocol must be `{SERVICE_CONTRACT_PROTOCOL}`"),
6416            )),
6417            None => issues.push(ServiceContractIssue::new(
6418                "$.protocol",
6419                "field must be a non-empty string",
6420            )),
6421        }
6422    }
6423    require_non_empty_string(object.get("name"), "$.name", &mut issues);
6424    if let Some(version) = object.get("version") {
6425        require_non_empty_string(Some(version), "$.version", &mut issues);
6426    }
6427    validate_provider(object.get("provider"), &mut issues);
6428    validate_named_fields_array(object.get("config"), "$.config", "key", &mut issues);
6429    validate_named_fields_array(object.get("env"), "$.env", "name", &mut issues);
6430    validate_string_array(
6431        object
6432            .get("requiredEnv")
6433            .or_else(|| object.get("required_env")),
6434        "$.requiredEnv",
6435        &mut issues,
6436    );
6437    validate_compatibility(object.get("compatibility"), &mut issues);
6438    validate_local_process(
6439        object
6440            .get("localProcess")
6441            .or_else(|| object.get("local_process")),
6442        "$.localProcess",
6443        &mut issues,
6444    );
6445    validate_install(object.get("install"), &mut issues);
6446    validate_modules(object.get("modules"), &mut issues);
6447    issues
6448}
6449
6450#[must_use]
6451pub fn validate_service_package_value(value: &Value) -> Vec<ServiceContractIssue> {
6452    let Some(object) = value.as_object() else {
6453        return vec![ServiceContractIssue::new(
6454            "$",
6455            "service package must be an object",
6456        )];
6457    };
6458
6459    let mut issues = Vec::new();
6460    match object.get("protocol").and_then(Value::as_str) {
6461        Some(SERVICE_PACKAGE_PROTOCOL) => {}
6462        Some(_) => issues.push(ServiceContractIssue::new(
6463            "$.protocol",
6464            format!("protocol must be `{SERVICE_PACKAGE_PROTOCOL}`"),
6465        )),
6466        None => issues.push(ServiceContractIssue::new(
6467            "$.protocol",
6468            "field must be a non-empty string",
6469        )),
6470    }
6471    require_non_empty_string(object.get("name"), "$.name", &mut issues);
6472    require_non_empty_string(object.get("version"), "$.version", &mut issues);
6473    require_non_empty_string(
6474        object
6475            .get("serviceManifest")
6476            .or_else(|| object.get("service_manifest")),
6477        "$.serviceManifest",
6478        &mut issues,
6479    );
6480    validate_service_package_modules(object.get("modules"), &mut issues);
6481    issues
6482}
6483
6484#[must_use]
6485pub fn validate_service_workspace_value(value: &Value) -> Vec<ServiceContractIssue> {
6486    let Some(object) = value.as_object() else {
6487        return vec![ServiceContractIssue::new(
6488            "$",
6489            "service workspace must be an object",
6490        )];
6491    };
6492
6493    let mut issues = Vec::new();
6494    match object.get("protocol").and_then(Value::as_str) {
6495        Some(SERVICE_WORKSPACE_PROTOCOL) => {}
6496        Some(_) => issues.push(ServiceContractIssue::new(
6497            "$.protocol",
6498            format!("protocol must be `{SERVICE_WORKSPACE_PROTOCOL}`"),
6499        )),
6500        None => issues.push(ServiceContractIssue::new(
6501            "$.protocol",
6502            "field must be a non-empty string",
6503        )),
6504    }
6505    validate_workspace_services(object.get("services"), &mut issues);
6506    issues
6507}
6508
6509#[must_use]
6510pub fn validate_service_system_value(value: &Value) -> Vec<ServiceContractIssue> {
6511    let Some(object) = value.as_object() else {
6512        return vec![ServiceContractIssue::new(
6513            "$",
6514            "service system must be an object",
6515        )];
6516    };
6517
6518    let mut issues = Vec::new();
6519    match object.get("protocol").and_then(Value::as_str) {
6520        Some(SERVICE_SYSTEM_PROTOCOL) => {}
6521        Some(_) => issues.push(ServiceContractIssue::new(
6522            "$.protocol",
6523            format!("protocol must be `{SERVICE_SYSTEM_PROTOCOL}`"),
6524        )),
6525        None => issues.push(ServiceContractIssue::new(
6526            "$.protocol",
6527            "field must be a non-empty string",
6528        )),
6529    }
6530    require_non_empty_string(object.get("name"), "$.name", &mut issues);
6531    validate_string_array(object.get("environments"), "$.environments", &mut issues);
6532    validate_system_services(object.get("services"), &mut issues);
6533    validate_system_modules(object.get("modules"), &mut issues);
6534    validate_system_dependencies(object.get("dependencies"), &mut issues);
6535    issues
6536}
6537
6538#[must_use]
6539pub fn validate_module_manifest_value(value: &Value) -> Vec<ServiceContractIssue> {
6540    let manifest = match serde_json::from_value::<ModuleManifest>(value.clone()) {
6541        Ok(manifest) => manifest,
6542        Err(error) => return vec![ServiceContractIssue::new("$", error.to_string())],
6543    };
6544    lenso_contracts::lint_module_manifest(&manifest)
6545        .into_iter()
6546        .filter(|lint| {
6547            matches!(
6548                lint.severity,
6549                lenso_contracts::ModuleManifestLintSeverity::Error
6550            )
6551        })
6552        .map(|lint| ServiceContractIssue::new(format!("$.{}", lint.subject), lint.message))
6553        .collect()
6554}
6555
6556#[must_use]
6557pub fn validate_module_release_value(value: &Value) -> Vec<ServiceContractIssue> {
6558    match serde_json::from_value::<ModuleRelease>(value.clone()) {
6559        Ok(release) => release
6560            .validate()
6561            .into_iter()
6562            .map(|issue| ServiceContractIssue::new(issue.path, issue.message))
6563            .collect(),
6564        Err(error) => vec![ServiceContractIssue::new("$", error.to_string())],
6565    }
6566}
6567
6568fn validate_provider(value: Option<&Value>, issues: &mut Vec<ServiceContractIssue>) {
6569    let Some(value) = value else {
6570        return;
6571    };
6572    if !value.is_object() {
6573        issues.push(ServiceContractIssue::new(
6574            "$.provider",
6575            "provider must be an object",
6576        ));
6577        return;
6578    }
6579    require_non_empty_string(value.get("name"), "$.provider.name", issues);
6580}
6581
6582fn validate_compatibility(value: Option<&Value>, issues: &mut Vec<ServiceContractIssue>) {
6583    let Some(value) = value else {
6584        return;
6585    };
6586    let Some(object) = value.as_object() else {
6587        issues.push(ServiceContractIssue::new(
6588            "$.compatibility",
6589            "compatibility must be an object",
6590        ));
6591        return;
6592    };
6593    validate_string_array(
6594        object
6595            .get("requiredHostFeatures")
6596            .or_else(|| object.get("required_host_features")),
6597        "$.compatibility.requiredHostFeatures",
6598        issues,
6599    );
6600}
6601
6602fn validate_named_fields_array(
6603    value: Option<&Value>,
6604    path: &str,
6605    name_field: &str,
6606    issues: &mut Vec<ServiceContractIssue>,
6607) {
6608    let Some(value) = value else {
6609        return;
6610    };
6611    let Some(array) = value.as_array() else {
6612        issues.push(ServiceContractIssue::new(path, "field must be an array"));
6613        return;
6614    };
6615    for (index, item) in array.iter().enumerate() {
6616        if !item.is_object() {
6617            issues.push(ServiceContractIssue::new(
6618                format!("{path}[{index}]"),
6619                "entry must be an object",
6620            ));
6621            continue;
6622        }
6623        require_non_empty_string(
6624            item.get(name_field),
6625            &format!("{path}[{index}].{name_field}"),
6626            issues,
6627        );
6628    }
6629}
6630
6631fn validate_local_process(
6632    value: Option<&Value>,
6633    path: &str,
6634    issues: &mut Vec<ServiceContractIssue>,
6635) {
6636    let Some(value) = value else {
6637        return;
6638    };
6639    if !value.is_object() {
6640        issues.push(ServiceContractIssue::new(
6641            path,
6642            "localProcess must be an object",
6643        ));
6644        return;
6645    }
6646    require_non_empty_string(value.get("command"), &format!("{path}.command"), issues);
6647}
6648
6649fn validate_install(value: Option<&Value>, issues: &mut Vec<ServiceContractIssue>) {
6650    let Some(value) = value else {
6651        return;
6652    };
6653    let Some(object) = value.as_object() else {
6654        issues.push(ServiceContractIssue::new(
6655            "$.install",
6656            "install must be an object",
6657        ));
6658        return;
6659    };
6660    let Some(services) = object.get("services") else {
6661        return;
6662    };
6663    let Some(array) = services.as_array() else {
6664        issues.push(ServiceContractIssue::new(
6665            "$.install.services",
6666            "install services must be an array",
6667        ));
6668        return;
6669    };
6670    for (index, service) in array.iter().enumerate() {
6671        if !service.is_object() {
6672            issues.push(ServiceContractIssue::new(
6673                format!("$.install.services[{index}]"),
6674                "service must be an object",
6675            ));
6676            continue;
6677        }
6678        require_non_empty_string(
6679            service.get("name"),
6680            &format!("$.install.services[{index}].name"),
6681            issues,
6682        );
6683        require_non_empty_string(
6684            service.get("command"),
6685            &format!("$.install.services[{index}].command"),
6686            issues,
6687        );
6688    }
6689}
6690
6691fn validate_modules(value: Option<&Value>, issues: &mut Vec<ServiceContractIssue>) {
6692    let Some(value) = value else {
6693        issues.push(ServiceContractIssue::new(
6694            "$.modules",
6695            "modules must be an array",
6696        ));
6697        return;
6698    };
6699    let Some(array) = value.as_array() else {
6700        issues.push(ServiceContractIssue::new(
6701            "$.modules",
6702            "modules must be an array",
6703        ));
6704        return;
6705    };
6706    if array.is_empty() {
6707        issues.push(ServiceContractIssue::new(
6708            "$.modules",
6709            "modules must not be empty",
6710        ));
6711        return;
6712    }
6713
6714    let mut module_ids = BTreeSet::new();
6715    for (index, module) in array.iter().enumerate() {
6716        let manifest = match serde_json::from_value::<ModuleManifest>(module.clone()) {
6717            Ok(manifest) => manifest,
6718            Err(error) => {
6719                issues.push(ServiceContractIssue::new(
6720                    format!("$.modules[{index}]"),
6721                    error.to_string(),
6722                ));
6723                continue;
6724            }
6725        };
6726        if !module_ids.insert(manifest.module_id.clone()) {
6727            issues.push(ServiceContractIssue::new(
6728                format!("$.modules[{index}].module_id"),
6729                format!("module `{}` is declared more than once", manifest.module_id),
6730            ));
6731        }
6732        issues.extend(
6733            validate_module_manifest_value(module)
6734                .into_iter()
6735                .map(|issue| {
6736                    let path = issue.path.strip_prefix('$').unwrap_or(&issue.path);
6737                    ServiceContractIssue::new(format!("$.modules[{index}]{path}"), issue.message)
6738                }),
6739        );
6740    }
6741}
6742
6743fn validate_service_package_modules(value: Option<&Value>, issues: &mut Vec<ServiceContractIssue>) {
6744    let Some(value) = value else {
6745        issues.push(ServiceContractIssue::new(
6746            "$.modules",
6747            "modules must be an array",
6748        ));
6749        return;
6750    };
6751    let Some(array) = value.as_array() else {
6752        issues.push(ServiceContractIssue::new(
6753            "$.modules",
6754            "modules must be an array",
6755        ));
6756        return;
6757    };
6758    if array.is_empty() {
6759        issues.push(ServiceContractIssue::new(
6760            "$.modules",
6761            "modules must not be empty",
6762        ));
6763        return;
6764    }
6765    let mut names = BTreeSet::new();
6766    for (index, module) in array.iter().enumerate() {
6767        let Some(module_name) =
6768            non_empty_string(Some(module), &format!("$.modules[{index}]"), issues)
6769        else {
6770            continue;
6771        };
6772        if !names.insert(module_name.to_owned()) {
6773            issues.push(ServiceContractIssue::new(
6774                format!("$.modules[{index}]"),
6775                format!("module `{module_name}` is declared more than once"),
6776            ));
6777        }
6778    }
6779}
6780
6781fn validate_workspace_services(value: Option<&Value>, issues: &mut Vec<ServiceContractIssue>) {
6782    let Some(value) = value else {
6783        return;
6784    };
6785    let Some(array) = value.as_array() else {
6786        issues.push(ServiceContractIssue::new(
6787            "$.services",
6788            "services must be an array",
6789        ));
6790        return;
6791    };
6792    let mut names = BTreeSet::new();
6793    for (index, service) in array.iter().enumerate() {
6794        let Some(object) = service.as_object() else {
6795            issues.push(ServiceContractIssue::new(
6796                format!("$.services[{index}]"),
6797                "service must be an object",
6798            ));
6799            continue;
6800        };
6801        let name = non_empty_string(
6802            object.get("name"),
6803            &format!("$.services[{index}].name"),
6804            issues,
6805        );
6806        if let Some(name) = name {
6807            if !names.insert(name.to_owned()) {
6808                issues.push(ServiceContractIssue::new(
6809                    format!("$.services[{index}].name"),
6810                    format!("service `{name}` is declared more than once"),
6811                ));
6812            }
6813        }
6814        require_non_empty_string(
6815            object.get("lang"),
6816            &format!("$.services[{index}].lang"),
6817            issues,
6818        );
6819        require_non_empty_string(
6820            object.get("cwd"),
6821            &format!("$.services[{index}].cwd"),
6822            issues,
6823        );
6824        require_non_empty_string(
6825            object.get("manifest"),
6826            &format!("$.services[{index}].manifest"),
6827            issues,
6828        );
6829        require_non_empty_string(
6830            object.get("command"),
6831            &format!("$.services[{index}].command"),
6832            issues,
6833        );
6834        require_non_empty_string(
6835            object.get("readyUrl").or_else(|| object.get("ready_url")),
6836            &format!("$.services[{index}].readyUrl"),
6837            issues,
6838        );
6839        validate_string_array(
6840            object.get("modules"),
6841            &format!("$.services[{index}].modules"),
6842            issues,
6843        );
6844    }
6845}
6846
6847fn validate_system_services(value: Option<&Value>, issues: &mut Vec<ServiceContractIssue>) {
6848    let Some(value) = value else {
6849        return;
6850    };
6851    let Some(array) = value.as_array() else {
6852        issues.push(ServiceContractIssue::new(
6853            "$.services",
6854            "services must be an array",
6855        ));
6856        return;
6857    };
6858    let mut names = BTreeSet::new();
6859    for (index, service) in array.iter().enumerate() {
6860        let Some(object) = service.as_object() else {
6861            issues.push(ServiceContractIssue::new(
6862                format!("$.services[{index}]"),
6863                "service must be an object",
6864            ));
6865            continue;
6866        };
6867        if let Some(name) = non_empty_string(
6868            object.get("name"),
6869            &format!("$.services[{index}].name"),
6870            issues,
6871        ) && !names.insert(name.to_owned())
6872        {
6873            issues.push(ServiceContractIssue::new(
6874                format!("$.services[{index}].name"),
6875                format!("service `{name}` is declared more than once"),
6876            ));
6877        }
6878        require_non_empty_string(
6879            object.get("target"),
6880            &format!("$.services[{index}].target"),
6881            issues,
6882        );
6883        validate_string_array(
6884            object.get("modules"),
6885            &format!("$.services[{index}].modules"),
6886            issues,
6887        );
6888    }
6889}
6890
6891fn validate_system_modules(value: Option<&Value>, issues: &mut Vec<ServiceContractIssue>) {
6892    let Some(value) = value else {
6893        return;
6894    };
6895    let Some(array) = value.as_array() else {
6896        issues.push(ServiceContractIssue::new(
6897            "$.modules",
6898            "modules must be an array",
6899        ));
6900        return;
6901    };
6902    let mut names = BTreeSet::new();
6903    for (index, module) in array.iter().enumerate() {
6904        let Some(object) = module.as_object() else {
6905            issues.push(ServiceContractIssue::new(
6906                format!("$.modules[{index}]"),
6907                "module must be an object",
6908            ));
6909            continue;
6910        };
6911        if let Some(name) = non_empty_string(
6912            object.get("name"),
6913            &format!("$.modules[{index}].name"),
6914            issues,
6915        ) && !names.insert(name.to_owned())
6916        {
6917            issues.push(ServiceContractIssue::new(
6918                format!("$.modules[{index}].name"),
6919                format!("module `{name}` is declared more than once"),
6920            ));
6921        }
6922        if let Some(install_to) = object.get("installTo").or_else(|| object.get("install_to")) {
6923            require_non_empty_string(
6924                Some(install_to),
6925                &format!("$.modules[{index}].installTo"),
6926                issues,
6927            );
6928        }
6929        validate_string_array(
6930            object.get("capabilities"),
6931            &format!("$.modules[{index}].capabilities"),
6932            issues,
6933        );
6934        validate_string_array(
6935            object.get("dependencies"),
6936            &format!("$.modules[{index}].dependencies"),
6937            issues,
6938        );
6939    }
6940}
6941
6942fn validate_system_dependencies(value: Option<&Value>, issues: &mut Vec<ServiceContractIssue>) {
6943    let Some(value) = value else {
6944        return;
6945    };
6946    let Some(array) = value.as_array() else {
6947        issues.push(ServiceContractIssue::new(
6948            "$.dependencies",
6949            "dependencies must be an array",
6950        ));
6951        return;
6952    };
6953    for (index, dependency) in array.iter().enumerate() {
6954        let Some(object) = dependency.as_object() else {
6955            issues.push(ServiceContractIssue::new(
6956                format!("$.dependencies[{index}]"),
6957                "dependency must be an object",
6958            ));
6959            continue;
6960        };
6961        require_non_empty_string(
6962            object.get("from"),
6963            &format!("$.dependencies[{index}].from"),
6964            issues,
6965        );
6966        require_non_empty_string(
6967            object.get("capability"),
6968            &format!("$.dependencies[{index}].capability"),
6969            issues,
6970        );
6971        if let Some(to) = object.get("to") {
6972            require_non_empty_string(Some(to), &format!("$.dependencies[{index}].to"), issues);
6973        }
6974    }
6975}
6976
6977fn validate_string_array(
6978    value: Option<&Value>,
6979    path: &str,
6980    issues: &mut Vec<ServiceContractIssue>,
6981) {
6982    let Some(value) = value else {
6983        return;
6984    };
6985    let Some(array) = value.as_array() else {
6986        issues.push(ServiceContractIssue::new(path, "field must be an array"));
6987        return;
6988    };
6989    for (index, item) in array.iter().enumerate() {
6990        require_non_empty_string(Some(item), &format!("{path}[{index}]"), issues);
6991    }
6992}
6993
6994fn require_non_empty_string(
6995    value: Option<&Value>,
6996    path: &str,
6997    issues: &mut Vec<ServiceContractIssue>,
6998) {
6999    let _ = non_empty_string(value, path, issues);
7000}
7001
7002fn non_empty_string<'a>(
7003    value: Option<&'a Value>,
7004    path: &str,
7005    issues: &mut Vec<ServiceContractIssue>,
7006) -> Option<&'a str> {
7007    match value.and_then(Value::as_str).map(str::trim) {
7008        Some(value) if !value.is_empty() => Some(value),
7009        _ => {
7010            issues.push(ServiceContractIssue::new(
7011                path,
7012                "field must be a non-empty string",
7013            ));
7014            None
7015        }
7016    }
7017}
7018
7019fn service_base_url_from_url_suffix(value: &str, suffixes: &[&str]) -> Option<String> {
7020    let value = value.trim();
7021    if !(value.starts_with("http://") || value.starts_with("https://")) {
7022        return None;
7023    }
7024    let value = strip_query_fragment(value).trim_end_matches('/');
7025    suffixes.iter().find_map(|suffix| {
7026        value
7027            .strip_suffix(suffix)
7028            .map(|base_url| base_url.trim_end_matches('/'))
7029            .map(ToOwned::to_owned)
7030    })
7031}
7032
7033fn strip_query_fragment(value: &str) -> &str {
7034    let query_index = value.find('?').unwrap_or(value.len());
7035    let fragment_index = value.find('#').unwrap_or(value.len());
7036    &value[..query_index.min(fragment_index)]
7037}
7038
7039const fn service_release_risk_rank(risk: ServiceReleaseRisk) -> u8 {
7040    match risk {
7041        ServiceReleaseRisk::Safe => 0,
7042        ServiceReleaseRisk::NeedsAttention => 1,
7043        ServiceReleaseRisk::Breaking => 2,
7044        ServiceReleaseRisk::Blocked => 3,
7045    }
7046}
7047
7048const fn is_false(value: &bool) -> bool {
7049    !*value
7050}
7051
7052const fn default_service_auto_start() -> bool {
7053    true
7054}
7055
7056const fn default_service_ready_timeout_ms() -> u64 {
7057    30_000
7058}
7059
7060const fn default_workspace_service_ready_timeout_ms() -> u64 {
7061    10_000
7062}
7063
7064fn default_service_manifest() -> String {
7065    "lenso.service.json".to_owned()
7066}
7067
7068#[cfg(test)]
7069mod tests {
7070    use super::*;
7071    use serde_json::json;
7072
7073    #[test]
7074    fn service_package_new_uses_v1_protocol() {
7075        let package = ServicePackage::new(
7076            "support-suite-provider",
7077            "0.2.0",
7078            vec!["support-ticket".to_owned()],
7079        );
7080        let value = serde_json::to_value(package).unwrap();
7081
7082        assert_eq!(value["protocol"], SERVICE_PACKAGE_PROTOCOL);
7083        assert_eq!(value["serviceManifest"], "lenso.service.json");
7084        assert_eq!(value["modules"], json!(["support-ticket"]));
7085    }
7086
7087    #[test]
7088    fn service_release_plan_uses_delivery_policy() {
7089        let diff = ServiceReleaseDiff {
7090            capabilities: vec![ServiceReleaseModuleChangeSet {
7091                module: "support-ticket".to_owned(),
7092                added: Vec::new(),
7093                removed: vec!["support_ticket.tickets.write".to_owned()],
7094            }],
7095            config: ServiceReleaseChangeSet {
7096                added: vec!["support.mode".to_owned()],
7097                removed: Vec::new(),
7098            },
7099            env: ServiceReleaseChangeSet {
7100                added: vec!["SUPPORT_API_KEY".to_owned()],
7101                removed: Vec::new(),
7102            },
7103            operations: vec![ServiceReleaseModuleChangeSet {
7104                module: "support-ticket".to_owned(),
7105                added: Vec::new(),
7106                removed: vec!["route:DELETE /tickets/{id}".to_owned()],
7107            }],
7108            ..ServiceReleaseDiff::default()
7109        };
7110        let current = ServiceReleaseManifestSummary {
7111            name: "support-suite-provider".to_owned(),
7112            version: Some("0.1.0".to_owned()),
7113            manifest_reference: "./support/v1/lenso.service.json".to_owned(),
7114            package_reference: None,
7115            input_reference: None,
7116            modules: vec!["support-ticket".to_owned()],
7117            compatibility_issue: None,
7118        };
7119        let candidate = ServiceReleaseManifestSummary {
7120            name: "support-suite-provider".to_owned(),
7121            version: Some("0.2.0".to_owned()),
7122            manifest_reference: "./support/v2/lenso.service.json".to_owned(),
7123            package_reference: Some("./support/v2/lenso.service-package.json".to_owned()),
7124            input_reference: None,
7125            modules: vec!["support-ticket".to_owned()],
7126            compatibility_issue: None,
7127        };
7128
7129        let plan = ServiceReleasePlan::new("support-suite-provider", current, candidate, diff);
7130        let value = serde_json::to_value(plan).unwrap();
7131
7132        assert_eq!(value["protocol"], SERVICE_RELEASE_PLAN_PROTOCOL);
7133        assert_eq!(value["policy"]["risk"], "breaking");
7134        assert_eq!(value["restartRequired"], true);
7135        assert_eq!(
7136            evaluate_service_release_policy(
7137                &ServiceReleaseDiff::default(),
7138                Some("remote protocol is newer"),
7139            )
7140            .risk,
7141            ServiceReleaseRisk::Blocked
7142        );
7143    }
7144
7145    #[test]
7146    fn service_environment_round_trips_kubernetes_target() {
7147        let file = ServiceEnvironmentsFile {
7148            version: 1,
7149            environments: vec![ServiceEnvironment {
7150                namespace: Some("lenso-staging".to_owned()),
7151                kube_context: Some("staging".to_owned()),
7152                image: Some("ghcr.io/acme/support-suite-provider:0.4.0".to_owned()),
7153                public_base_url: Some("https://support-staging.example.com".to_owned()),
7154                release_track: Some("staging".to_owned()),
7155                config: Some(
7156                    KubernetesDeploymentConfig::new()
7157                        .replicas(2)
7158                        .port(4110)
7159                        .ingress_host("support-staging.example.com"),
7160                ),
7161                ..ServiceEnvironment::kubernetes("staging", "support-suite-provider")
7162            }],
7163        };
7164
7165        let value = serde_json::to_value(&file).unwrap();
7166        assert_eq!(value["environments"][0]["target"], "kubernetes");
7167        assert_eq!(
7168            value["environments"][0]["serviceName"],
7169            "support-suite-provider"
7170        );
7171        assert_eq!(
7172            value["environments"][0]["config"]["ingressHost"],
7173            "support-staging.example.com"
7174        );
7175
7176        let round_trip: ServiceEnvironmentsFile = serde_json::from_value(value).unwrap();
7177        assert_eq!(round_trip, file);
7178    }
7179
7180    #[test]
7181    fn service_deployment_observation_uses_stable_state_names() {
7182        let observation = ServiceDeploymentObservation {
7183            service_name: "support-suite-provider".to_owned(),
7184            environment: "staging".to_owned(),
7185            target: ServiceDeploymentTarget::Kubernetes,
7186            observed_at_unix_ms: 1_803_744_000_000,
7187            state: ServiceDeploymentState::Ready,
7188            drift: ServiceDeploymentDrift::InSync,
7189            cluster: Some(KubernetesDeploymentObservation {
7190                namespace: "lenso-staging".to_owned(),
7191                deployment: "support-suite-provider".to_owned(),
7192                ready_replicas: Some(2),
7193                desired_replicas: Some(2),
7194                available_replicas: Some(2),
7195                image: Some("ghcr.io/acme/support-suite-provider:0.4.0".to_owned()),
7196                release_id: Some("rel_staging".to_owned()),
7197                manifest_reference: Some(
7198                    "https://support-staging.example.com/lenso/service/v1/manifest".to_owned(),
7199                ),
7200                service_endpoint: Some(
7201                    "support-suite-provider.lenso-staging.svc.cluster.local".to_owned(),
7202                ),
7203                ingress_host: Some("support-staging.example.com".to_owned()),
7204            }),
7205            host: Some(ServiceDeploymentHostObservation {
7206                release_id: Some("rel_staging".to_owned()),
7207                candidate_version: Some("0.4.0".to_owned()),
7208            }),
7209            checks: vec![ServiceDeploymentCheck {
7210                name: "deployment_rollout".to_owned(),
7211                status: "ok".to_owned(),
7212                detail: Some("2/2 replicas ready".to_owned()),
7213            }],
7214            next_action: Some("monitor rollout and Remote Calls".to_owned()),
7215        };
7216
7217        let value = serde_json::to_value(&observation).unwrap();
7218        assert_eq!(value["state"], "ready");
7219        assert_eq!(value["drift"], "in_sync");
7220        assert_eq!(value["cluster"]["readyReplicas"], 2);
7221
7222        let round_trip: ServiceDeploymentObservation = serde_json::from_value(value).unwrap();
7223        assert_eq!(round_trip, observation);
7224    }
7225
7226    #[test]
7227    fn valid_service_package_has_no_issues() {
7228        let issues = validate_service_package_value(&json!({
7229            "protocol": "lenso.service-package.v1",
7230            "name": "support-suite-provider",
7231            "version": "0.2.0",
7232            "serviceManifest": "lenso.service.json",
7233            "modules": ["support-ticket", "support-inbox"]
7234        }));
7235
7236        assert!(issues.is_empty(), "{issues:?}");
7237    }
7238
7239    #[test]
7240    fn invalid_service_package_reports_protocol_and_modules() {
7241        let issues = validate_service_package_value(&json!({
7242            "protocol": "retired.delivery.v0",
7243            "name": "support-suite-provider",
7244            "version": "0.2.0",
7245            "serviceManifest": "lenso.service.json",
7246            "modules": ["support-ticket", "support-ticket", ""]
7247        }));
7248
7249        assert_eq!(
7250            issues
7251                .iter()
7252                .map(|issue| issue.path.as_str())
7253                .collect::<Vec<_>>(),
7254            vec!["$.protocol", "$.modules[1]", "$.modules[2]"]
7255        );
7256    }
7257
7258    #[test]
7259    fn valid_service_workspace_has_no_issues() {
7260        let issues = validate_service_workspace_value(&json!({
7261            "protocol": "lenso.service-workspace.v1",
7262            "services": [
7263                {
7264                    "name": "support-suite-provider",
7265                    "lang": "ts",
7266                    "cwd": "services/support-suite-provider",
7267                    "manifest": "lenso.service.json",
7268                    "command": "pnpm start",
7269                    "readyUrl": "http://127.0.0.1:4110/lenso/service/v1/status",
7270                    "modules": ["support-ticket"]
7271                }
7272            ]
7273        }));
7274
7275        assert!(issues.is_empty(), "{issues:?}");
7276    }
7277
7278    #[test]
7279    fn service_workspace_exports_module_service_start_file() {
7280        let workspace = ServiceWorkspace::new(vec![ServiceWorkspaceService {
7281            name: "support-suite-provider".to_owned(),
7282            lang: "ts".to_owned(),
7283            cwd: "services/support-suite-provider".to_owned(),
7284            manifest: "lenso.service.json".to_owned(),
7285            command: "pnpm start".to_owned(),
7286            ready_url: "http://127.0.0.1:4110/lenso/service/v1/status".to_owned(),
7287            auto_start: true,
7288            ready_timeout_ms: 10_000,
7289            modules: vec!["support-ticket".to_owned()],
7290        }]);
7291
7292        let value = serde_json::to_value(service_workspace_to_module_services(&workspace)).unwrap();
7293
7294        assert_eq!(value["version"], 1);
7295        assert_eq!(value["modules"][0]["moduleName"], "support-suite-provider");
7296        assert_eq!(value["modules"][0]["services"][0]["command"], "pnpm start");
7297        assert_eq!(
7298            value["modules"][0]["services"][0]["readyUrl"],
7299            "http://127.0.0.1:4110/lenso/service/v1/status"
7300        );
7301    }
7302
7303    #[test]
7304    fn service_workspace_infers_service_base_url() {
7305        assert_eq!(
7306            service_base_url_from_ready_url(
7307                "http://127.0.0.1:4110/lenso/service/v1/status?probe=1"
7308            )
7309            .as_deref(),
7310            Some("http://127.0.0.1:4110/lenso/service/v1")
7311        );
7312        assert_eq!(
7313            service_base_url_from_manifest_url("http://127.0.0.1:4110/lenso/service/v1/manifest")
7314                .as_deref(),
7315            Some("http://127.0.0.1:4110/lenso/service/v1")
7316        );
7317        assert_eq!(
7318            service_workspace_base_url(&ServiceWorkspaceService {
7319                name: "support-suite-provider".to_owned(),
7320                lang: "ts".to_owned(),
7321                cwd: "services/support-suite-provider".to_owned(),
7322                manifest: "lenso.service.json".to_owned(),
7323                command: "pnpm start".to_owned(),
7324                ready_url: "http://127.0.0.1:4110/lenso/service/v1/ready".to_owned(),
7325                auto_start: true,
7326                ready_timeout_ms: 10_000,
7327                modules: vec!["support-ticket".to_owned()],
7328            })
7329            .as_deref(),
7330            Some("http://127.0.0.1:4110/lenso/service/v1")
7331        );
7332        assert!(service_base_url_from_ready_url("not a url").is_none());
7333    }
7334
7335    #[test]
7336    fn invalid_service_workspace_reports_service_paths() {
7337        let issues = validate_service_workspace_value(&json!({
7338            "protocol": "lenso.workspace",
7339            "services": [
7340                {
7341                    "name": "",
7342                    "modules": ["support-ticket", 42]
7343                }
7344            ]
7345        }));
7346
7347        assert_eq!(
7348            issues
7349                .iter()
7350                .map(|issue| issue.path.as_str())
7351                .collect::<Vec<_>>(),
7352            vec![
7353                "$.protocol",
7354                "$.services[0].name",
7355                "$.services[0].lang",
7356                "$.services[0].cwd",
7357                "$.services[0].manifest",
7358                "$.services[0].command",
7359                "$.services[0].readyUrl",
7360                "$.services[0].modules[1]"
7361            ]
7362        );
7363    }
7364
7365    #[test]
7366    fn module_manifest_uses_canonical_protocol_and_identity() {
7367        let manifest = ModuleManifest::builder("acme/support-ticket")
7368            .capabilities(vec!["support_ticket.tickets.read".to_owned()])
7369            .build();
7370        let value = serde_json::to_value(manifest).unwrap();
7371
7372        assert_eq!(value["protocol"], MODULE_MANIFEST_PROTOCOL);
7373        assert_eq!(value["module_id"], "acme/support-ticket");
7374        assert!(value.get("source").is_none());
7375        assert!(value.get("bundled").is_none());
7376        assert!(validate_module_manifest_value(&value).is_empty());
7377    }
7378
7379    #[test]
7380    fn removed_module_contract_shape_is_rejected() {
7381        let issues = validate_module_manifest_value(&json!({
7382            "protocol": "lenso.module.v1",
7383            "name": "support-ticket",
7384            "source": "remote",
7385            "bundled": true
7386        }));
7387
7388        assert!(!issues.is_empty());
7389    }
7390
7391    #[test]
7392    fn linked_module_release_uses_strict_delivery_union() {
7393        let manifest = ModuleManifest::builder("acme/support-ticket").build();
7394        let release = ModuleRelease::new(
7395            "acme/support-ticket",
7396            "0.2.0",
7397            manifest,
7398            ModuleDelivery::Linked(LinkedModuleDelivery {
7399                package: "lenso-module-support-ticket".to_owned(),
7400                crate_version: "0.2.0".to_owned(),
7401                archive_checksum: format!("sha256:{}", "a".repeat(64)),
7402                default_features: false,
7403                features: Vec::new(),
7404                binding: "support_ticket::binding".to_owned(),
7405                attestations: Vec::new(),
7406                migrations: Vec::new(),
7407            }),
7408        )
7409        .unwrap();
7410        let value = serde_json::to_value(release).unwrap();
7411
7412        assert_eq!(value["protocol"], MODULE_RELEASE_PROTOCOL);
7413        assert_eq!(value["module_id"], "acme/support-ticket");
7414        assert_eq!(value["delivery"]["kind"], "linked");
7415        assert!(validate_module_release_value(&value).is_empty());
7416    }
7417}