1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use std::collections::{BTreeSet, HashSet};
5use utoipa::ToSchema;
6
7pub mod enrollment;
8pub mod module_operations;
9pub mod runtime_observability;
10pub mod runtime_operations;
11
12pub use enrollment::*;
13pub use module_operations::*;
14pub use runtime_observability::*;
15pub use runtime_operations::*;
16
17pub const CORE_PROTOCOL: &str = "lenso.system-plane.v1";
18pub const CORE_PATH: &str = "/system-plane/v1";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22pub struct CoreDocument {
23 pub protocol: String,
24 #[schema(min_length = 1)]
25 pub service_id: String,
26 #[schema(min_length = 1)]
27 pub service_principal: String,
28 #[schema(min_length = 1)]
29 pub service_revision: String,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub capabilities: Vec<CapabilityAdvertisement>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct CapabilityAdvertisement {
37 #[schema(pattern = r"^lenso\.system-plane\.[a-z0-9]+(?:[.-][a-z0-9]+)*\.v[1-9][0-9]*$")]
38 pub contract_id: String,
39 #[schema(minimum = 1)]
40 pub major_version: u32,
41 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
42 pub feature_ids: BTreeSet<String>,
43 #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
44 pub schema_digest: String,
45 #[schema(pattern = r"^/system-plane/v1/[a-z0-9]+(?:[/-][a-z0-9]+)*$")]
46 pub endpoint: String,
47}
48
49#[derive(
50 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
51)]
52#[serde(rename_all = "snake_case")]
53pub enum CoreIssueCode {
54 InvalidProtocol,
55 MissingServiceIdentity,
56 MissingServicePrincipal,
57 MissingServiceRevision,
58 InvalidCapabilityContractId,
59 InvalidCapabilityMajorVersion,
60 CapabilityMajorVersionMismatch,
61 InvalidFeatureId,
62 InvalidSchemaDigest,
63 InvalidEndpointReference,
64 DuplicateCapability,
65}
66
67#[derive(
68 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
69)]
70#[serde(rename_all = "camelCase", deny_unknown_fields)]
71pub struct CoreIssue {
72 pub code: CoreIssueCode,
73 pub path: String,
74 pub message: String,
75 pub next_action: String,
76}
77
78#[must_use]
79pub fn validate_core_document(document: &CoreDocument) -> Vec<CoreIssue> {
80 let mut issues = Vec::new();
81
82 if document.protocol != CORE_PROTOCOL {
83 push_issue(
84 &mut issues,
85 CoreIssueCode::InvalidProtocol,
86 "$.protocol",
87 format!("protocol must be `{CORE_PROTOCOL}`"),
88 "Use the supported System Plane Core Protocol identifier.",
89 );
90 }
91 validate_present(
92 &document.service_id,
93 CoreIssueCode::MissingServiceIdentity,
94 "$.serviceId",
95 "serviceId must identify the managed Service",
96 "Publish the stable logical Service identity.",
97 &mut issues,
98 );
99 validate_present(
100 &document.service_principal,
101 CoreIssueCode::MissingServicePrincipal,
102 "$.servicePrincipal",
103 "servicePrincipal must identify the managed Service authority",
104 "Publish the stable Service Principal independently from endpoint and Workload identity.",
105 &mut issues,
106 );
107 validate_present(
108 &document.service_revision,
109 CoreIssueCode::MissingServiceRevision,
110 "$.serviceRevision",
111 "serviceRevision must identify the advertised Service state",
112 "Publish a stable revision that changes when the Core advertisement changes.",
113 &mut issues,
114 );
115
116 let mut advertised_contracts = HashSet::new();
117 for (index, capability) in document.capabilities.iter().enumerate() {
118 let base = format!("$.capabilities[{index}]");
119 let contract_major = capability_major_version(&capability.contract_id);
120
121 if contract_major.is_none() {
122 push_issue(
123 &mut issues,
124 CoreIssueCode::InvalidCapabilityContractId,
125 format!("{base}.contractId"),
126 "contractId must match `lenso.system-plane.<capability>.v<major>`",
127 "Publish a stable capability-specific System Plane Contract identifier.",
128 );
129 }
130 if capability.major_version == 0 {
131 push_issue(
132 &mut issues,
133 CoreIssueCode::InvalidCapabilityMajorVersion,
134 format!("{base}.majorVersion"),
135 "majorVersion must be greater than zero",
136 "Publish the supported major version for this Capability Contract.",
137 );
138 } else if contract_major.is_some_and(|major| major != capability.major_version) {
139 push_issue(
140 &mut issues,
141 CoreIssueCode::CapabilityMajorVersionMismatch,
142 format!("{base}.majorVersion"),
143 "majorVersion must match the version suffix in contractId",
144 "Make the advertised major version and Contract identifier agree.",
145 );
146 }
147
148 for feature_id in &capability.feature_ids {
149 if !valid_dotted_id(feature_id) {
150 push_issue(
151 &mut issues,
152 CoreIssueCode::InvalidFeatureId,
153 format!("{base}.featureIds"),
154 format!("feature identifier `{feature_id}` is not canonical"),
155 "Use lowercase dot-separated identifiers with alphanumeric or hyphenated segments.",
156 );
157 }
158 }
159 if !valid_sha256_digest(&capability.schema_digest) {
160 push_issue(
161 &mut issues,
162 CoreIssueCode::InvalidSchemaDigest,
163 format!("{base}.schemaDigest"),
164 "schemaDigest must be a lowercase `sha256:<64 hex>` digest",
165 "Publish the digest of the exact Capability Contract schema.",
166 );
167 }
168 if !valid_capability_endpoint(&capability.endpoint) {
169 push_issue(
170 &mut issues,
171 CoreIssueCode::InvalidEndpointReference,
172 format!("{base}.endpoint"),
173 "endpoint must be a relative subpath below `/system-plane/v1/`",
174 "Publish a capability endpoint inside the managed Service System Plane namespace.",
175 );
176 }
177 if !advertised_contracts.insert(capability.contract_id.as_str()) {
178 push_issue(
179 &mut issues,
180 CoreIssueCode::DuplicateCapability,
181 format!("{base}.contractId"),
182 format!(
183 "Capability Contract `{}` is advertised more than once",
184 capability.contract_id
185 ),
186 "Publish each exact Capability Contract identity once.",
187 );
188 }
189 }
190
191 issues
192}
193
194#[must_use]
195pub fn core_document_schema() -> Value {
196 let mut schema = serde_json::to_value(schemars::schema_for!(CoreDocument))
197 .expect("System Plane Core schema serializes");
198 schema["$id"] = Value::String(
199 "https://contracts.lenso.local/system-plane/lenso.system-plane.v1.schema.json".to_owned(),
200 );
201 schema["title"] = Value::String("Lenso System Plane Core Document".to_owned());
202 schema["properties"]["protocol"] = json!({ "const": CORE_PROTOCOL });
203 for field in ["serviceId", "servicePrincipal", "serviceRevision"] {
204 schema["properties"][field]["minLength"] = json!(1);
205 }
206 schema["$defs"]["CapabilityAdvertisement"]["properties"]["contractId"]["pattern"] =
207 json!(r"^lenso\.system-plane\.[a-z0-9]+(?:[.-][a-z0-9]+)*\.v[1-9][0-9]*$");
208 schema["$defs"]["CapabilityAdvertisement"]["properties"]["majorVersion"]["minimum"] = json!(1);
209 schema["$defs"]["CapabilityAdvertisement"]["properties"]["featureIds"]["items"] = json!({
210 "type": "string",
211 "pattern": r"^[a-z0-9]+(?:[.-][a-z0-9]+)*$"
212 });
213 schema["$defs"]["CapabilityAdvertisement"]["properties"]["schemaDigest"]["pattern"] =
214 json!(r"^sha256:[0-9a-f]{64}$");
215 schema["$defs"]["CapabilityAdvertisement"]["properties"]["endpoint"]["pattern"] =
216 json!(r"^/system-plane/v1/[a-z0-9]+(?:[/-][a-z0-9]+)*$");
217 schema
218}
219
220fn validate_present(
221 value: &str,
222 code: CoreIssueCode,
223 path: &str,
224 message: &str,
225 next_action: &str,
226 issues: &mut Vec<CoreIssue>,
227) {
228 if value.trim().is_empty() {
229 push_issue(issues, code, path, message, next_action);
230 }
231}
232
233fn capability_major_version(contract_id: &str) -> Option<u32> {
234 let remainder = contract_id.strip_prefix("lenso.system-plane.")?;
235 let (capability, version) = remainder.rsplit_once(".v")?;
236 if !valid_dotted_id(capability) {
237 return None;
238 }
239 let major = version.parse::<u32>().ok()?;
240 (major > 0 && version == major.to_string()).then_some(major)
241}
242
243fn valid_dotted_id(value: &str) -> bool {
244 !value.is_empty() && value.split('.').all(valid_id_segment)
245}
246
247fn valid_id_segment(segment: &str) -> bool {
248 !segment.is_empty()
249 && !segment.starts_with('-')
250 && !segment.ends_with('-')
251 && !segment.contains("--")
252 && segment
253 .bytes()
254 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
255}
256
257fn valid_sha256_digest(value: &str) -> bool {
258 value.strip_prefix("sha256:").is_some_and(|digest| {
259 digest.len() == 64
260 && digest
261 .bytes()
262 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
263 })
264}
265
266fn valid_capability_endpoint(value: &str) -> bool {
267 value
268 .strip_prefix("/system-plane/v1/")
269 .is_some_and(|suffix| {
270 !suffix.is_empty()
271 && !suffix.starts_with('/')
272 && !suffix.contains("//")
273 && !suffix.contains(['?', '#'])
274 && suffix.split('/').all(valid_id_segment)
275 })
276}
277
278fn push_issue(
279 issues: &mut Vec<CoreIssue>,
280 code: CoreIssueCode,
281 path: impl Into<String>,
282 message: impl Into<String>,
283 next_action: impl Into<String>,
284) {
285 issues.push(CoreIssue {
286 code,
287 path: path.into(),
288 message: message.into(),
289 next_action: next_action.into(),
290 });
291}