1use crate::{
8 CapabilityId, ProtocolReference, ReceiptId, SemanticVersion, Sha256Digest, ValidationError,
9 deserialize_schema_version, validate_bounded_opaque_identifier, validate_schema_version,
10};
11use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
12
13const MAX_SOURCE_REFS: usize = 32;
14const MAX_MEASUREMENTS: usize = 32;
15const MAX_SUPPORTED_OPERATIONS: usize = 32;
16const MAX_MEASUREMENT_NAME_LENGTH: usize = 64;
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
20#[serde(transparent)]
21pub struct EngineInvocationIdV1(String);
22
23impl EngineInvocationIdV1 {
24 pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
26 let value = value.into();
27 validate_bounded_opaque_identifier(&value, "EngineInvocationIdV1")?;
28 Ok(Self(value))
29 }
30
31 #[must_use]
33 pub fn as_str(&self) -> &str {
34 &self.0
35 }
36}
37
38impl TryFrom<String> for EngineInvocationIdV1 {
39 type Error = ValidationError;
40
41 fn try_from(value: String) -> Result<Self, Self::Error> {
42 Self::new(value)
43 }
44}
45
46impl TryFrom<&str> for EngineInvocationIdV1 {
47 type Error = ValidationError;
48
49 fn try_from(value: &str) -> Result<Self, Self::Error> {
50 Self::new(value)
51 }
52}
53
54impl<'de> Deserialize<'de> for EngineInvocationIdV1 {
55 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
56 where
57 D: Deserializer<'de>,
58 {
59 Self::new(String::deserialize(deserializer)?).map_err(DeError::custom)
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ResolvedLocalEngineIdentityV1 {
67 pub engine_id: String,
68 pub engine_version: SemanticVersion,
69}
70
71impl ResolvedLocalEngineIdentityV1 {
72 pub fn validate(&self) -> Result<(), ValidationError> {
74 validate_bounded_opaque_identifier(
75 &self.engine_id,
76 "ResolvedLocalEngineIdentityV1 engine_id",
77 )
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct EngineOperationV1 {
85 pub capability_id: CapabilityId,
86 pub capability_version: SemanticVersion,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum EnginePolicyDecisionV1 {
93 Admitted,
94 Rejected,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct EnginePolicyAdmissionV1 {
101 pub policy_ref: ProtocolReference,
102 pub decision: EnginePolicyDecisionV1,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct EngineInvocationV1 {
109 #[serde(deserialize_with = "deserialize_schema_version")]
110 pub schema_version: u32,
111 pub invocation_id: EngineInvocationIdV1,
112 pub engine: ResolvedLocalEngineIdentityV1,
113 pub operation: EngineOperationV1,
114 pub input_ref: ProtocolReference,
116 pub input_digest: Sha256Digest,
118 pub source_refs: Vec<ProtocolReference>,
120 pub policy_admission: EnginePolicyAdmissionV1,
121}
122
123impl EngineInvocationV1 {
124 pub fn validate(&self) -> Result<(), ValidationError> {
126 validate_schema_version(self.schema_version)?;
127 self.engine.validate()?;
128 validate_nonempty_unique_refs(&self.source_refs, "EngineInvocationV1 source_refs")?;
129 if !self
130 .source_refs
131 .iter()
132 .any(|reference| reference == &self.input_ref)
133 {
134 return Err(ValidationError::new(
135 "EngineInvocationV1 source_refs must contain input_ref",
136 ));
137 }
138 Ok(())
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum EngineValueClassificationV1 {
147 Measured,
148 Estimated,
149 Unavailable,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct EngineMeasurementV1 {
156 pub name: String,
157 pub unit: String,
158 pub classification: EngineValueClassificationV1,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub value: Option<u64>,
161}
162
163impl EngineMeasurementV1 {
164 pub fn validate(&self) -> Result<(), ValidationError> {
166 validate_measurement_name(&self.name, "EngineMeasurementV1 name")?;
167 validate_measurement_name(&self.unit, "EngineMeasurementV1 unit")?;
168 match (self.classification, self.value) {
169 (EngineValueClassificationV1::Unavailable, None)
170 | (
171 EngineValueClassificationV1::Measured | EngineValueClassificationV1::Estimated,
172 Some(_),
173 ) => Ok(()),
174 (EngineValueClassificationV1::Unavailable, Some(_)) => Err(ValidationError::new(
175 "EngineMeasurementV1 unavailable values must be omitted",
176 )),
177 (
178 EngineValueClassificationV1::Measured | EngineValueClassificationV1::Estimated,
179 None,
180 ) => Err(ValidationError::new(
181 "EngineMeasurementV1 measured and estimated values require a number",
182 )),
183 }
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum EngineFailureCodeV1 {
191 PolicyRejected,
192 SourceUnavailable,
193 SourceIntegrityMismatch,
194 ResourceLimit,
195 UnsupportedOperation,
196 Internal,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct EngineFailureV1 {
203 pub code: EngineFailureCodeV1,
204 pub retryable_by_host: bool,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub recovery_ref: Option<ProtocolReference>,
207}
208
209impl EngineFailureV1 {
210 pub fn validate(&self) -> Result<(), ValidationError> {
212 if self.code == EngineFailureCodeV1::PolicyRejected
213 && (self.retryable_by_host || self.recovery_ref.is_some())
214 {
215 return Err(ValidationError::new(
216 "EngineFailureV1 policy_rejected must not request a host retry or recovery route",
217 ));
218 }
219 if matches!(
220 self.code,
221 EngineFailureCodeV1::SourceUnavailable | EngineFailureCodeV1::SourceIntegrityMismatch
222 ) && self.recovery_ref.is_none()
223 {
224 return Err(ValidationError::new(
225 "EngineFailureV1 source failures require a recovery_ref",
226 ));
227 }
228 Ok(())
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum EngineObservationStatusV1 {
236 Succeeded,
237 Degraded,
238 Rejected,
239 Failed,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(deny_unknown_fields)]
245pub struct EngineReceiptLinkV1 {
246 #[serde(deserialize_with = "deserialize_schema_version")]
247 pub schema_version: u32,
248 pub receipt_id: ReceiptId,
249 pub receipt_ref: ProtocolReference,
250 pub receipt_digest: Sha256Digest,
251 pub invocation_id: EngineInvocationIdV1,
252}
253
254impl EngineReceiptLinkV1 {
255 pub fn validate_for(&self, invocation: &EngineInvocationV1) -> Result<(), ValidationError> {
257 validate_schema_version(self.schema_version)?;
258 if self.invocation_id != invocation.invocation_id {
259 return Err(ValidationError::new(
260 "EngineReceiptLinkV1 invocation_id must match EngineInvocationV1",
261 ));
262 }
263 Ok(())
264 }
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(deny_unknown_fields)]
270pub struct EngineObservationV1 {
271 #[serde(deserialize_with = "deserialize_schema_version")]
272 pub schema_version: u32,
273 pub invocation_id: EngineInvocationIdV1,
274 pub status: EngineObservationStatusV1,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub output_ref: Option<ProtocolReference>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub output_digest: Option<Sha256Digest>,
279 pub source_lineage: Vec<ProtocolReference>,
280 pub measurements: Vec<EngineMeasurementV1>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub failure: Option<EngineFailureV1>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub receipt_link: Option<EngineReceiptLinkV1>,
285}
286
287impl EngineObservationV1 {
288 pub fn validate(&self) -> Result<(), ValidationError> {
290 validate_schema_version(self.schema_version)?;
291 if self.output_ref.is_some() != self.output_digest.is_some() {
292 return Err(ValidationError::new(
293 "EngineObservationV1 output_ref and output_digest must be present together",
294 ));
295 }
296 validate_nonempty_unique_refs(&self.source_lineage, "EngineObservationV1 source_lineage")?;
297 if self.measurements.len() > MAX_MEASUREMENTS {
298 return Err(ValidationError::new(format!(
299 "EngineObservationV1 measurements exceeds {MAX_MEASUREMENTS}",
300 )));
301 }
302 for measurement in &self.measurements {
303 measurement.validate()?;
304 }
305 if self
306 .measurements
307 .iter()
308 .enumerate()
309 .any(|(index, measurement)| {
310 self.measurements[..index]
311 .iter()
312 .any(|existing| existing.name == measurement.name)
313 })
314 {
315 return Err(ValidationError::new(
316 "EngineObservationV1 measurements contains duplicate names",
317 ));
318 }
319 if let Some(failure) = &self.failure {
320 failure.validate()?;
321 }
322 match (
323 self.status,
324 self.output_ref.is_some(),
325 self.failure.as_ref(),
326 ) {
327 (EngineObservationStatusV1::Succeeded, true, None)
328 | (EngineObservationStatusV1::Degraded, true, Some(_))
329 | (EngineObservationStatusV1::Failed, false, Some(_)) => Ok(()),
330 (EngineObservationStatusV1::Rejected, false, Some(failure))
331 if failure.code == EngineFailureCodeV1::PolicyRejected =>
332 {
333 Ok(())
334 }
335 _ => Err(ValidationError::new(
336 "EngineObservationV1 status is inconsistent with output and failure",
337 )),
338 }
339 }
340
341 pub fn validate_for(&self, invocation: &EngineInvocationV1) -> Result<(), ValidationError> {
343 invocation.validate()?;
344 self.validate()?;
345 if self.invocation_id != invocation.invocation_id {
346 return Err(ValidationError::new(
347 "EngineObservationV1 invocation_id must match EngineInvocationV1",
348 ));
349 }
350 if self
351 .source_lineage
352 .iter()
353 .any(|reference| !invocation.source_refs.contains(reference))
354 {
355 return Err(ValidationError::new(
356 "EngineObservationV1 source_lineage must be a subset of invocation source_refs",
357 ));
358 }
359 match invocation.policy_admission.decision {
360 EnginePolicyDecisionV1::Admitted
361 if self.status == EngineObservationStatusV1::Rejected =>
362 {
363 return Err(ValidationError::new(
364 "EngineObservationV1 admitted invocation cannot be rejected",
365 ));
366 }
367 EnginePolicyDecisionV1::Rejected
368 if self.status != EngineObservationStatusV1::Rejected =>
369 {
370 return Err(ValidationError::new(
371 "EngineObservationV1 rejected invocation must produce a rejected observation",
372 ));
373 }
374 _ => {}
375 }
376 if let Some(receipt_link) = &self.receipt_link {
377 receipt_link.validate_for(invocation)?;
378 }
379 Ok(())
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(deny_unknown_fields)]
386pub struct EngineInterfaceV1 {
387 #[serde(deserialize_with = "deserialize_schema_version")]
388 pub schema_version: u32,
389 pub interface_version: SemanticVersion,
390 pub engine: ResolvedLocalEngineIdentityV1,
391 pub supported_operations: Vec<EngineOperationV1>,
392}
393
394impl EngineInterfaceV1 {
395 pub fn validate(&self) -> Result<(), ValidationError> {
397 validate_schema_version(self.schema_version)?;
398 self.engine.validate()?;
399 if self.supported_operations.is_empty()
400 || self.supported_operations.len() > MAX_SUPPORTED_OPERATIONS
401 {
402 return Err(ValidationError::new(format!(
403 "EngineInterfaceV1 supported_operations must contain 1..={MAX_SUPPORTED_OPERATIONS} entries",
404 )));
405 }
406 if self
407 .supported_operations
408 .iter()
409 .enumerate()
410 .any(|(index, operation)| {
411 self.supported_operations[..index].iter().any(|existing| {
412 existing.capability_id == operation.capability_id
413 && existing.capability_version == operation.capability_version
414 })
415 })
416 {
417 return Err(ValidationError::new(
418 "EngineInterfaceV1 supported_operations contains duplicate operations",
419 ));
420 }
421 Ok(())
422 }
423}
424
425fn validate_nonempty_unique_refs(
426 references: &[ProtocolReference],
427 field: &str,
428) -> Result<(), ValidationError> {
429 if references.is_empty() || references.len() > MAX_SOURCE_REFS {
430 return Err(ValidationError::new(format!(
431 "{field} must contain 1..={MAX_SOURCE_REFS} entries",
432 )));
433 }
434 if references.iter().enumerate().any(|(index, reference)| {
435 references[..index]
436 .iter()
437 .any(|existing| existing == reference)
438 }) {
439 return Err(ValidationError::new(format!("{field} contains duplicates")));
440 }
441 Ok(())
442}
443
444fn validate_measurement_name(value: &str, field: &str) -> Result<(), ValidationError> {
445 if value.is_empty()
446 || value.len() > MAX_MEASUREMENT_NAME_LENGTH
447 || !value.bytes().all(|byte| {
448 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-'
449 })
450 {
451 return Err(ValidationError::new(format!(
452 "{field} must contain lowercase ASCII letters, digits, '_' or '-' and fit {MAX_MEASUREMENT_NAME_LENGTH} bytes",
453 )));
454 }
455 Ok(())
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use serde_json::{Value, json};
462
463 const GOLDEN_INVOCATION: &str = r#"{
464 "schema_version":1,
465 "invocation_id":"engine-invocation-1",
466 "engine":{"engine_id":"lean-ctx-local","engine_version":"1.0.0"},
467 "operation":{"capability_id":"source-read","capability_version":"1.0.0"},
468 "input_ref":"file:src/lib.rs",
469 "input_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
470 "source_refs":["file:src/lib.rs","index:project-v1"],
471 "policy_admission":{"policy_ref":"policy:local-default","decision":"admitted"}
472 }"#;
473
474 const GOLDEN_OBSERVATION: &str = r#"{
475 "schema_version":1,
476 "invocation_id":"engine-invocation-1",
477 "status":"succeeded",
478 "output_ref":"view:source-read-1",
479 "output_digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
480 "source_lineage":["file:src/lib.rs"],
481 "measurements":[
482 {"name":"input_bytes","unit":"byte","classification":"measured","value":420},
483 {"name":"saved_tokens","unit":"token","classification":"unavailable"}
484 ],
485 "receipt_link":{
486 "schema_version":1,
487 "receipt_id":"receipt-engine-1",
488 "receipt_ref":"receipt:engine-1",
489 "receipt_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
490 "invocation_id":"engine-invocation-1"
491 }
492 }"#;
493
494 const GOLDEN_INTERFACE: &str = r#"{
495 "schema_version":1,
496 "interface_version":"1.0.0",
497 "engine":{"engine_id":"lean-ctx-local","engine_version":"1.0.0"},
498 "supported_operations":[
499 {"capability_id":"source-read","capability_version":"1.0.0"}
500 ]
501 }"#;
502
503 fn invocation() -> EngineInvocationV1 {
504 serde_json::from_str(GOLDEN_INVOCATION).expect("golden invocation must decode")
505 }
506
507 fn observation() -> EngineObservationV1 {
508 serde_json::from_str(GOLDEN_OBSERVATION).expect("golden observation must decode")
509 }
510
511 fn interface() -> EngineInterfaceV1 {
512 serde_json::from_str(GOLDEN_INTERFACE).expect("golden interface must decode")
513 }
514
515 #[test]
516 fn golden_records_round_trip_and_bind_lineage() {
517 let interface = interface();
518 let invocation = invocation();
519 let observation = observation();
520 interface.validate().expect("golden interface is valid");
521 invocation.validate().expect("golden invocation is valid");
522 observation
523 .validate_for(&invocation)
524 .expect("golden observation binds the invocation");
525 assert_eq!(
526 serde_json::to_value(&interface).expect("serialize interface"),
527 serde_json::from_str::<Value>(GOLDEN_INTERFACE).expect("parse golden value")
528 );
529 assert_eq!(
530 serde_json::to_value(&invocation).expect("serialize invocation"),
531 serde_json::from_str::<Value>(GOLDEN_INVOCATION).expect("parse golden value")
532 );
533 assert_eq!(
534 serde_json::to_value(&observation).expect("serialize observation"),
535 serde_json::from_str::<Value>(GOLDEN_OBSERVATION).expect("parse golden value")
536 );
537 }
538
539 #[test]
540 fn strict_deserialization_rejects_unknown_fields_and_bad_digests() {
541 let mut unknown: Value = serde_json::from_str(GOLDEN_INVOCATION).expect("golden JSON");
542 unknown["cloud_tenant"] = json!("forbidden");
543 assert!(serde_json::from_value::<EngineInvocationV1>(unknown).is_err());
544
545 let mut bad_digest: Value = serde_json::from_str(GOLDEN_INVOCATION).expect("golden JSON");
546 bad_digest["input_digest"] = json!("sha256:ABC");
547 assert!(serde_json::from_value::<EngineInvocationV1>(bad_digest).is_err());
548 }
549
550 #[test]
551 fn mutation_rejects_missing_lineage_and_invalid_measurement_state() {
552 let mut no_input_ref = invocation();
553 no_input_ref.source_refs = vec![ProtocolReference::try_from("index:project-v1").unwrap()];
554 assert!(no_input_ref.validate().is_err());
555
556 let mut invalid_measurement = observation();
557 invalid_measurement.measurements[1].value = Some(1);
558 assert!(invalid_measurement.validate().is_err());
559 }
560
561 #[test]
562 fn mutation_rejects_policy_and_receipt_cross_bindings() {
563 let mut rejected_invocation = invocation();
564 rejected_invocation.policy_admission.decision = EnginePolicyDecisionV1::Rejected;
565 let successful_observation = observation();
566 assert!(
567 successful_observation
568 .validate_for(&rejected_invocation)
569 .is_err()
570 );
571
572 let invocation = invocation();
573 let mut observation = observation();
574 observation.receipt_link.as_mut().unwrap().invocation_id =
575 EngineInvocationIdV1::try_from("other-invocation").unwrap();
576 assert!(observation.validate_for(&invocation).is_err());
577 }
578
579 #[test]
580 fn interface_rejects_duplicate_operations_and_unlabelled_cloud_surface() {
581 let mut duplicate = interface();
582 duplicate
583 .supported_operations
584 .push(duplicate.supported_operations[0].clone());
585 assert!(duplicate.validate().is_err());
586
587 let mut cloud: Value = serde_json::from_str(GOLDEN_INTERFACE).expect("golden JSON");
588 cloud["tenant_id"] = json!("forbidden");
589 assert!(serde_json::from_value::<EngineInterfaceV1>(cloud).is_err());
590 }
591}