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