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