Skip to main content

lean_ctx_protocol/
engine_interface.rs

1//! Narrow, local-only Engine interface records.
2//!
3//! These records describe one deterministic Engine operation. They deliberately
4//! exclude agent sessions, Profiles, Kits, planning, tenancy, Cloud transport,
5//! and retry orchestration: a host or future SDK owns those concerns.
6
7use 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/// Opaque identity for one Engine invocation, scoped to the local Engine.
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
20#[serde(transparent)]
21pub struct EngineInvocationIdV1(String);
22
23impl EngineInvocationIdV1 {
24    /// Construct a bounded opaque invocation identifier.
25    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    /// Borrow the wire representation.
32    #[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/// Resolved local Engine identity; it is not a tenant, user, or agent identity.
64#[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    /// Validate local Engine identity fields.
73    pub fn validate(&self) -> Result<(), ValidationError> {
74        validate_bounded_opaque_identifier(
75            &self.engine_id,
76            "ResolvedLocalEngineIdentityV1 engine_id",
77        )
78    }
79}
80
81/// One named, versioned Engine capability selected by the caller.
82#[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/// The result of local policy admission before an Engine operation runs.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum EnginePolicyDecisionV1 {
93    Admitted,
94    Rejected,
95}
96
97/// Policy reference and admission decision for a single invocation.
98#[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/// A deterministic local Engine request after identity and policy resolution.
106#[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    /// Addressable input retained by the host; raw input is intentionally absent.
115    pub input_ref: ProtocolReference,
116    /// Digest of the exact bounded input consumed by the Engine.
117    pub input_digest: Sha256Digest,
118    /// Source lineage available to recovery; includes `input_ref` exactly once.
119    pub source_refs: Vec<ProtocolReference>,
120    pub policy_admission: EnginePolicyAdmissionV1,
121}
122
123impl EngineInvocationV1 {
124    /// Validate schema, source lineage, and deterministic input invariants.
125    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/// Whether an observation value was directly measured, derived as an estimate,
143/// or unavailable for the invocation.
144#[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/// One bounded, named numerical Engine observation.
153#[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    /// Validate named-measurement and classification invariants.
165    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/// Stable failure taxonomy for a local Engine operation.
188#[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/// Structured, non-secret failure information with an optional recovery route.
200#[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    /// Validate recovery semantics without prescribing host retries.
211    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/// Terminal status of one Engine operation; it never controls the host loop.
233#[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/// Immutable link from an Engine observation to an integrity-addressed receipt.
243#[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    /// Validate a receipt link and bind it to the supplied invocation.
256    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/// Structured output, measurements, failure state, and optional receipt lineage.
268#[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    /// Validate this observation independently of an invocation record.
289    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    /// Validate linkage, source lineage, policy admission, and receipt binding.
342    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/// Published local Engine interface declaration and supported operation set.
384#[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    /// Validate the declared Engine interface without widening it into an SDK.
396    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}