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