Skip to main content

lean_ctx/core/
engine_interface.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Internal proof bridge from one native OCLA capability to Engine Interface v1.
4//!
5//! This module is deliberately crate-private. It proves the local Engine
6//! contract without promoting an SDK façade, an agent loop, or Cloud semantics.
7
8use std::path::{Path, PathBuf};
9
10pub mod context_plan;
11pub mod planning;
12pub mod receipt_sink;
13pub mod source_execution;
14
15#[cfg(test)]
16use lean_ctx_protocol::EngineInterfaceV1;
17use lean_ctx_protocol::{
18    CapabilityId, EngineFailureCodeV1, EngineFailureV1, EngineInvocationIdV1, EngineInvocationV1,
19    EngineMeasurementV1, EngineObservationStatusV1, EngineObservationV1, EngineOperationV1,
20    EnginePolicyAdmissionV1, EnginePolicyDecisionV1, EngineReceiptLinkV1,
21    EngineValueClassificationV1, ProtocolReference, ReceiptId, ResolvedLocalEngineIdentityV1,
22    SemanticVersion, Sha256Digest, V1_SCHEMA_VERSION,
23};
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26
27use crate::core::canonical;
28use crate::core::ocla::adapters::native_context::{
29    NativeContextAdapter, NativeContextInvocationFailure,
30};
31use crate::core::ocla::invocation::{CapabilityInput, CapabilityInvocation, PolicyConstraints};
32
33const ENGINE_ID: &str = "lean-ctx-local";
34pub const CAPABILITY_ID: &str = "capability://leanctx/context-optimization";
35pub const CAPABILITY_VERSION: &str = "1.0.0";
36const RECEIPT_DIRECTORY: &str = "engine-interface/v1/receipts";
37const OUTPUT_DIRECTORY: &str = "engine-interface/v1/outputs";
38const RECOVERY_DIRECTORY: &str = "engine-interface/v1/recovery";
39pub const ENGINE_TRANSPORT_VERSION: u32 = 1;
40pub const ENGINE_INTERFACE_VERSION: &str = "1.0.0";
41pub const ENGINE_TRANSPORT_POLICY_REF: &str = "policy:engine-transport-v1:admitted";
42const INPUT_REF_PREFIX: &str = "input:ctx-read-snapshot-sha256:";
43const SOURCE_REF_PREFIX: &str = "source:canonical-path-sha256:";
44const MAX_TRANSPORT_VIEW_BYTES: usize = 1024 * 1024;
45pub const MAX_TRANSPORT_BUDGET_TOKENS: u64 = MAX_TRANSPORT_VIEW_BYTES as u64;
46
47use super::engine_artifact as artifact_store;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum EngineTransportError {
51    InvalidRequest,
52    UnsupportedMode,
53    UnsafeRoot,
54    SourceOutsideRoot,
55    SourceSymlink,
56    SourceUnavailable,
57    UnsupportedInput,
58    SourceChanged,
59    MalformedRecoveryRef,
60    ReceiptMismatch,
61    ObservationMismatch,
62    ViewTooLarge,
63    Internal,
64}
65
66impl EngineTransportError {
67    pub const fn code(self) -> &'static str {
68        match self {
69            Self::InvalidRequest => "invalid_request",
70            Self::UnsupportedMode => "unsupported_mode",
71            Self::UnsafeRoot => "unsafe_root",
72            Self::SourceOutsideRoot => "source_outside_root",
73            Self::SourceSymlink => "source_symlink",
74            Self::SourceUnavailable => "source_unavailable",
75            Self::UnsupportedInput => "unsupported_input",
76            Self::SourceChanged => "source_changed",
77            Self::MalformedRecoveryRef => "malformed_recovery_ref",
78            Self::ReceiptMismatch => "receipt_mismatch",
79            Self::ObservationMismatch => "observation_mismatch",
80            Self::ViewTooLarge => "view_too_large",
81            Self::Internal => "internal",
82        }
83    }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct EngineTransportRecoveryDescriptor {
88    pub recovery_ref: ProtocolReference,
89    pub source_ref: ProtocolReference,
90    pub source_digest: Sha256Digest,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct EngineTransportView {
95    pub text: String,
96    pub output_ref: Option<ProtocolReference>,
97    pub output_digest: Option<Sha256Digest>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct EngineTransportResult {
102    pub view: EngineTransportView,
103    pub invocation: Option<EngineInvocationV1>,
104    pub observation: Option<EngineObservationV1>,
105    pub recovery: EngineTransportRecoveryDescriptor,
106}
107
108pub fn persist_engine_artifact_content(
109    directory: &str,
110    digest: &str,
111    extension: &str,
112    bytes: &[u8],
113) -> Result<std::fs::File, String> {
114    artifact_store::persist_content(directory, digest, extension, bytes)
115}
116
117#[cfg(windows)]
118pub(crate) fn open_engine_artifact_lock(
119    directory: &str,
120    digest: &str,
121) -> Result<std::fs::File, String> {
122    artifact_store::open_lock(directory, digest)
123}
124
125/// Inputs intentionally retained inside the local Engine proof boundary.
126///
127/// The caller provides source and input identities before dispatch. The bridge
128/// verifies the expected input digest against the bytes read through the native
129/// rooted adapter, so an observation can never claim a different source input.
130#[derive(Clone, Debug)]
131struct NativeContextEngineRequest {
132    invocation_id: EngineInvocationIdV1,
133    input_ref: ProtocolReference,
134    input_digest: Sha256Digest,
135    source_refs: Vec<ProtocolReference>,
136    policy_admission: EnginePolicyAdmissionV1,
137    paths: Vec<String>,
138    mode: String,
139    budget_tokens: Option<u64>,
140    timeout_ms: u64,
141}
142
143impl NativeContextEngineRequest {
144    #[cfg(test)]
145    fn ctx_read_snapshot(
146        path: &str,
147        raw_input: &str,
148        timeout_ms: u64,
149        policy_admission: EnginePolicyAdmissionV1,
150    ) -> Result<(Self, String), String> {
151        let canonical_path = std::fs::canonicalize(path)
152            .map_err(|error| format!("resolve ctx_read Engine source: {error}"))?;
153        Self::ctx_read_snapshot_canonical(&canonical_path, raw_input, timeout_ms, policy_admission)
154    }
155
156    fn ctx_read_snapshot_canonical(
157        canonical_path: &Path,
158        raw_input: &str,
159        timeout_ms: u64,
160        policy_admission: EnginePolicyAdmissionV1,
161    ) -> Result<(Self, String), String> {
162        if !canonical_path.is_absolute() {
163            return Err("ctx_read Engine canonical source must be absolute".to_owned());
164        }
165        Self::ctx_read_identified(
166            canonical_path.to_string_lossy().into_owned(),
167            "source:canonical-path-sha256",
168            raw_input,
169            timeout_ms,
170            policy_admission,
171        )
172    }
173
174    fn ctx_read_rejection(
175        requested_path: &str,
176        timeout_ms: u64,
177        policy_admission: EnginePolicyAdmissionV1,
178    ) -> Result<Self, String> {
179        if !Path::new(requested_path).is_absolute() {
180            return Err("ctx_read Engine requested source must be absolute".to_owned());
181        }
182        Self::ctx_read_identified(
183            requested_path.to_owned(),
184            "source:requested-path-sha256",
185            "",
186            timeout_ms,
187            policy_admission,
188        )
189        .map(|(request, _)| request)
190    }
191
192    fn ctx_read_identified(
193        path_identity: String,
194        source_prefix: &str,
195        raw_input: &str,
196        timeout_ms: u64,
197        policy_admission: EnginePolicyAdmissionV1,
198    ) -> Result<(Self, String), String> {
199        let input = crate::core::redaction::redact_text_if_enabled(raw_input);
200        let raw_input_digest = sha256_digest(raw_input.as_bytes())?;
201        let input_digest = sha256_digest(input.as_bytes())?;
202        let input_ref = ProtocolReference::new(format!(
203            "input:ctx-read-snapshot-sha256:{}",
204            raw_input_digest.hex()
205        ))
206        .map_err(|error| error.to_string())?;
207        let path_digest = sha256_digest(path_identity.as_bytes())?;
208        let source_ref = ProtocolReference::new(format!("{source_prefix}:{}", path_digest.hex()))
209            .map_err(|error| error.to_string())?;
210        let invocation_seed = canonical::canonical_serialize(&CtxReadInvocationIdentityV1 {
211            engine_id: ENGINE_ID,
212            engine_version: env!("CARGO_PKG_VERSION"),
213            capability_id: CAPABILITY_ID,
214            capability_version: CAPABILITY_VERSION,
215            input_ref: input_ref.as_str(),
216            source_ref: source_ref.as_str(),
217            input_digest: input_digest.as_str(),
218            policy_ref: policy_admission.policy_ref.as_str(),
219            policy_decision: policy_admission.decision,
220            mode: "aggressive",
221            timeout_ms,
222        });
223        let invocation_digest = sha256_digest(&invocation_seed)?;
224        let request = Self {
225            invocation_id: EngineInvocationIdV1::new(format!(
226                "engine-invocation-{}",
227                &invocation_digest.hex()[..32]
228            ))
229            .map_err(|error| error.to_string())?,
230            input_ref: input_ref.clone(),
231            input_digest,
232            source_refs: vec![input_ref, source_ref],
233            policy_admission,
234            paths: vec![path_identity],
235            mode: "aggressive".to_owned(),
236            budget_tokens: None,
237            timeout_ms,
238        };
239        Ok((request, input))
240    }
241}
242
243#[derive(Serialize)]
244struct CtxReadInvocationIdentityV1<'a> {
245    engine_id: &'a str,
246    engine_version: &'a str,
247    capability_id: &'a str,
248    capability_version: &'a str,
249    input_ref: &'a str,
250    source_ref: &'a str,
251    input_digest: &'a str,
252    policy_ref: &'a str,
253    policy_decision: EnginePolicyDecisionV1,
254    mode: &'a str,
255    timeout_ms: u64,
256}
257
258/// One strictly local implementation of the published Engine records.
259pub struct NativeContextEngine {
260    adapter: NativeContextAdapter,
261}
262
263/// Stored without a receipt link so its digest cannot self-reference.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub(crate) struct EngineReceiptArtifactV1 {
267    schema_version: u32,
268    invocation: EngineInvocationV1,
269    observation: EngineObservationV1,
270}
271
272/// Receipt contents returned only after the on-disk artifact and both expected
273/// records have passed the local verification boundary.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub(crate) struct VerifiedEngineReceiptV1 {
276    artifact: EngineReceiptArtifactV1,
277    digest: Sha256Digest,
278}
279
280impl VerifiedEngineReceiptV1 {
281    pub(crate) fn invocation(&self) -> &EngineInvocationV1 {
282        &self.artifact.invocation
283    }
284
285    pub(crate) fn observation(&self) -> &EngineObservationV1 {
286        &self.artifact.observation
287    }
288
289    pub(crate) fn digest(&self) -> &Sha256Digest {
290        &self.digest
291    }
292}
293
294/// Build the exact canonical bytes persisted for one Engine receipt.
295pub(crate) fn canonical_engine_receipt_artifact_bytes(
296    invocation: &EngineInvocationV1,
297    observation: &EngineObservationV1,
298) -> Vec<u8> {
299    canonical::canonical_serialize(&EngineReceiptArtifactV1 {
300        schema_version: V1_SCHEMA_VERSION,
301        invocation: invocation.clone(),
302        observation: observation.clone(),
303    })
304}
305
306/// Read and verify one Engine receipt artifact against its expected records.
307pub(crate) fn read_verified_engine_receipt(
308    digest: &Sha256Digest,
309    expected_invocation: &EngineInvocationV1,
310    expected_observation_without_link: &EngineObservationV1,
311) -> Result<VerifiedEngineReceiptV1, String> {
312    if expected_observation_without_link.receipt_link.is_some() {
313        return Err("expected Engine observation must omit receipt_link".to_owned());
314    }
315    expected_invocation
316        .validate()
317        .map_err(|error| format!("invalid expected Engine invocation: {error}"))?;
318    expected_observation_without_link
319        .validate_for(expected_invocation)
320        .map_err(|error| format!("invalid expected Engine observation: {error}"))?;
321
322    let bytes = artifact_store::read_content(RECEIPT_DIRECTORY, digest.hex(), "json")?;
323    let actual_digest = sha256_digest(&bytes)?;
324    if actual_digest != *digest {
325        return Err("Engine receipt artifact digest mismatch".to_owned());
326    }
327    let artifact = decode_canonical_engine_receipt_artifact(&bytes)?;
328    if artifact.schema_version != V1_SCHEMA_VERSION {
329        return Err("Engine receipt artifact schema_version must be 1".to_owned());
330    }
331    if artifact.observation.receipt_link.is_some() {
332        return Err("Engine receipt artifact must omit receipt_link".to_owned());
333    }
334    artifact
335        .invocation
336        .validate()
337        .map_err(|error| format!("invalid Engine receipt invocation: {error}"))?;
338    artifact
339        .observation
340        .validate_for(&artifact.invocation)
341        .map_err(|error| format!("invalid Engine receipt observation: {error}"))?;
342    if artifact.invocation != *expected_invocation {
343        return Err("Engine receipt invocation does not match expectation".to_owned());
344    }
345    if artifact.observation != *expected_observation_without_link {
346        return Err("Engine receipt observation does not match expectation".to_owned());
347    }
348    Ok(VerifiedEngineReceiptV1 {
349        artifact,
350        digest: digest.clone(),
351    })
352}
353
354fn decode_canonical_engine_receipt_artifact(
355    bytes: &[u8],
356) -> Result<EngineReceiptArtifactV1, String> {
357    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
358    let artifact = EngineReceiptArtifactV1::deserialize(&mut deserializer)
359        .map_err(|error| format!("decode Engine receipt artifact: {error}"))?;
360    deserializer
361        .end()
362        .map_err(|error| format!("trailing Engine receipt artifact data: {error}"))?;
363    if canonical_engine_receipt_artifact_bytes(&artifact.invocation, &artifact.observation) != bytes
364    {
365        return Err("Engine receipt artifact is not canonical JSON".to_owned());
366    }
367    Ok(artifact)
368}
369
370#[derive(Serialize)]
371#[serde(deny_unknown_fields)]
372struct EngineRecoveryArtifactV1 {
373    schema_version: u32,
374    invocation: EngineInvocationV1,
375    observation: EngineObservationV1,
376    failure_class: &'static str,
377}
378
379impl NativeContextEngine {
380    pub fn with_root(root: impl AsRef<Path>) -> Result<Self, String> {
381        let root = crate::core::pathutil::canonicalize_secure(root.as_ref())
382            .map_err(|_| "ctx_read Engine root cannot be bound securely".to_owned())?;
383        Ok(Self {
384            adapter: NativeContextAdapter::with_root(root),
385        })
386    }
387
388    #[cfg(test)]
389    fn interface(&self) -> Result<EngineInterfaceV1, String> {
390        let interface = EngineInterfaceV1 {
391            schema_version: V1_SCHEMA_VERSION,
392            interface_version: SemanticVersion::new("1.0.0").map_err(|error| error.to_string())?,
393            engine: engine_identity()?,
394            supported_operations: vec![native_operation()?],
395        };
396        interface.validate().map_err(|error| error.to_string())?;
397        Ok(interface)
398    }
399
400    /// Execute the one admitted local capability and return its factual record.
401    ///
402    /// Rejections never enter the adapter. Successful output is persisted only
403    /// as a local integrity-addressed artifact; the observation exposes its
404    /// reference and SHA-256, never the payload.
405    fn execute(
406        &self,
407        request: NativeContextEngineRequest,
408    ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
409        self.execute_inner(request, None)
410    }
411
412    /// Execute against a caller-owned immutable source snapshot.
413    ///
414    /// The digest is still verified by the adapter; the only difference from
415    /// `execute` is that no second disk read can race the production caller.
416    fn execute_materialized(
417        &self,
418        request: NativeContextEngineRequest,
419        input: &str,
420    ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
421        self.execute_inner(request, Some(input))
422    }
423
424    /// Bind and execute the production `ctx_read` snapshot as one operation.
425    #[cfg(test)]
426    pub(crate) fn execute_ctx_read_snapshot(
427        &self,
428        path: &str,
429        raw_input: &str,
430        policy_admission: EnginePolicyAdmissionV1,
431    ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
432        let rooted_path = crate::core::pathjail::jail_path(Path::new(path), self.adapter.root())
433            .map_err(|_| "ctx_read Engine source is outside its rooted boundary".to_owned())?;
434        self.execute_ctx_read_rooted_snapshot(
435            &rooted_path.to_string_lossy(),
436            raw_input,
437            policy_admission,
438        )
439    }
440
441    pub fn execute_ctx_read_rooted_snapshot(
442        &self,
443        rooted_path: &str,
444        raw_input: &str,
445        policy_admission: EnginePolicyAdmissionV1,
446    ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
447        self.execute_ctx_read_rooted_snapshot_with_budget(
448            rooted_path,
449            raw_input,
450            policy_admission,
451            None,
452            None,
453        )
454    }
455
456    pub fn execute_ctx_read_rooted_snapshot_with_plan(
457        &self,
458        rooted_path: &str,
459        raw_input: &str,
460        policy_admission: EnginePolicyAdmissionV1,
461        task: &lean_ctx_protocol::TaskEnvelopeV1,
462        plan: &lean_ctx_protocol::ExecutionPlanV1,
463    ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
464        planning::validate_native_plan(task, plan, &policy_admission)?;
465        self.execute_ctx_read_rooted_snapshot_with_budget(
466            rooted_path,
467            raw_input,
468            policy_admission,
469            plan.context_token_limit(),
470            Some((task, plan)),
471        )
472    }
473
474    fn execute_ctx_read_rooted_snapshot_with_budget(
475        &self,
476        rooted_path: &str,
477        raw_input: &str,
478        policy_admission: EnginePolicyAdmissionV1,
479        budget_tokens: Option<u64>,
480        binding: Option<(
481            &lean_ctx_protocol::TaskEnvelopeV1,
482            &lean_ctx_protocol::ExecutionPlanV1,
483        )>,
484    ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
485        let root = self.adapter.root();
486        let rooted_path = Path::new(rooted_path);
487        if !rooted_path.is_absolute() {
488            return Err("ctx_read Engine source is outside its rooted boundary".to_owned());
489        }
490        if contains_symlink_component(rooted_path, root) {
491            return Err("ctx_read Engine source is outside its rooted boundary".to_owned());
492        }
493        // The Engine binds its root through `canonicalize_secure` before building
494        // the adapter. Bring caller-supplied paths into that same form before
495        // comparing: on Windows std::fs::canonicalize may return a `\\?\` path, while pathjail and
496        // canonicalize_secure strip that prefix. Resolving here also collapses
497        // short-name, case, and reparse-point aliases before the component-boundary
498        // check; it does not broaden the root.
499        let rooted_path = crate::core::pathutil::canonicalize_secure(rooted_path)
500            .or_else(|error| {
501                if error.kind() == std::io::ErrorKind::NotFound {
502                    unresolved_rooted_path(rooted_path, root).ok_or(error)
503                } else {
504                    Err(error)
505                }
506            })
507            .map_err(|_| "ctx_read Engine source is outside its rooted boundary".to_owned())?;
508        if !rooted_path.starts_with(root) {
509            return Err("ctx_read Engine source is outside its rooted boundary".to_owned());
510        }
511        let (mut request, input) = NativeContextEngineRequest::ctx_read_snapshot_canonical(
512            &rooted_path,
513            raw_input,
514            30_000,
515            policy_admission,
516        )?;
517        if let Some(budget) = budget_tokens {
518            if budget == 0 {
519                return Err("ctx_read Engine budget must be positive".to_owned());
520            }
521            let identity = sha256_digest(&canonical::canonical_serialize(&(
522                "ctx-read-budget-v1",
523                &request.invocation_id,
524                budget,
525            )))?;
526            request.invocation_id =
527                EngineInvocationIdV1::new(format!("engine-invocation-{}", &identity.hex()[..32]))
528                    .map_err(|error| error.to_string())?;
529            request.budget_tokens = Some(budget);
530        }
531        if let Some((task, plan)) = binding {
532            // Persist and bind the admitted documents before the adapter can execute.
533            planning::persist_evidence(task)?;
534            planning::persist_evidence(plan)?;
535            let refs = planning::binding_refs(task, plan)?;
536            let identity = sha256_digest(&canonical::canonical_serialize(&(
537                "ctx-read-plan-v1",
538                &request.invocation_id,
539                &refs,
540            )))?;
541            request.invocation_id =
542                EngineInvocationIdV1::new(format!("engine-invocation-{}", &identity.hex()[..32]))
543                    .map_err(|error| error.to_string())?;
544            request.source_refs.extend(refs);
545        }
546        self.execute_materialized(request, &input)
547    }
548
549    pub fn execute_ctx_read_rejection(
550        &self,
551        path: &str,
552        policy_admission: EnginePolicyAdmissionV1,
553    ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
554        if policy_admission.decision != EnginePolicyDecisionV1::Rejected {
555            return Err("ctx_read rejection requires a rejected policy admission".to_owned());
556        }
557        let request =
558            NativeContextEngineRequest::ctx_read_rejection(path, 30_000, policy_admission)?;
559        self.execute(request)
560    }
561
562    fn execute_inner(
563        &self,
564        request: NativeContextEngineRequest,
565        materialized_input: Option<&str>,
566    ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
567        let invocation = self.invocation_for(&request)?;
568        if request.policy_admission.decision == EnginePolicyDecisionV1::Rejected {
569            let observation = EngineObservationV1 {
570                schema_version: V1_SCHEMA_VERSION,
571                invocation_id: invocation.invocation_id.clone(),
572                status: EngineObservationStatusV1::Rejected,
573                output_ref: None,
574                output_digest: None,
575                source_lineage: invocation.source_refs.clone(),
576                measurements: Vec::new(),
577                failure: Some(EngineFailureV1 {
578                    code: EngineFailureCodeV1::PolicyRejected,
579                    retryable_by_host: false,
580                    recovery_ref: None,
581                }),
582                receipt_link: None,
583            };
584            return self.persist_terminal(invocation, observation);
585        }
586
587        let native_invocation = CapabilityInvocation {
588            task_id: request.invocation_id.as_str().to_owned(),
589            capability_id: CAPABILITY_ID.to_owned(),
590            capability_version: CAPABILITY_VERSION.to_owned(),
591            input: CapabilityInput::ContextRequest {
592                paths: request.paths,
593                mode: request.mode,
594                budget_tokens: request.budget_tokens,
595            },
596            policy_constraints: PolicyConstraints::default(),
597            timeout_ms: request.timeout_ms,
598        };
599
600        let native_result = match materialized_input {
601            Some(input) => self
602                .adapter
603                .invoke_materialized_bounded(native_invocation, input.to_owned()),
604            None => self.adapter.invoke_with_output_identity(&native_invocation),
605        };
606        let observation = match native_result {
607            Ok(result) if result.input_digest == request.input_digest.as_str() => {
608                let output_digest = Sha256Digest::new(result.output_digest)
609                    .map_err(|error| format!("native output digest is invalid: {error}"))?;
610                let output_ref = ProtocolReference::new(format!("output:{}", output_digest.hex()))
611                    .map_err(|error| error.to_string())?;
612                match persist_output(output_digest.hex(), &result.output) {
613                    Ok(()) => EngineObservationV1 {
614                        schema_version: V1_SCHEMA_VERSION,
615                        invocation_id: invocation.invocation_id.clone(),
616                        status: EngineObservationStatusV1::Succeeded,
617                        output_ref: Some(output_ref),
618                        output_digest: Some(output_digest),
619                        source_lineage: invocation.source_refs.clone(),
620                        measurements: measured_observations(&result.result),
621                        failure: None,
622                        receipt_link: None,
623                    },
624                    Err(error) => {
625                        tracing::warn!(%error, "failed to persist Engine output artifact");
626                        storage_failure_observation(&invocation)
627                    }
628                }
629            }
630            Ok(_) => source_integrity_failure(&invocation),
631            Err(failure) => adapter_failure(&invocation, &failure),
632        };
633        self.persist_terminal(invocation, observation)
634    }
635
636    fn invocation_for(
637        &self,
638        request: &NativeContextEngineRequest,
639    ) -> Result<EngineInvocationV1, String> {
640        let invocation = EngineInvocationV1 {
641            schema_version: V1_SCHEMA_VERSION,
642            invocation_id: request.invocation_id.clone(),
643            engine: engine_identity()?,
644            operation: native_operation()?,
645            input_ref: request.input_ref.clone(),
646            input_digest: request.input_digest.clone(),
647            source_refs: request.source_refs.clone(),
648            policy_admission: request.policy_admission.clone(),
649        };
650        invocation.validate().map_err(|error| error.to_string())?;
651        Ok(invocation)
652    }
653
654    fn persist_terminal(
655        &self,
656        invocation: EngineInvocationV1,
657        observation: EngineObservationV1,
658    ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
659        observation
660            .validate_for(&invocation)
661            .map_err(|error| format!("invalid Engine observation: {error}"))?;
662        let receipt_link = match persist_receipt(&invocation, &observation) {
663            Ok(receipt_link) => receipt_link,
664            Err(error) => {
665                let recovery_ref = persist_recovery(
666                    &invocation,
667                    &observation,
668                    "receipt-persistence-failed",
669                )
670                .map_err(|recovery_error| {
671                    format!(
672                        "persist Engine receipt: {error}; persist Engine recovery artifact: {recovery_error}"
673                    )
674                })?;
675                return Err(format!(
676                    "persist Engine receipt: {error}; recovery_ref={}",
677                    recovery_ref.as_str()
678                ));
679            }
680        };
681        // The registered sink queues it for Hive only when receipt uploads
682        // are on (a marker check).
683        receipt_sink::note_receipt(
684            self.adapter.root(),
685            receipt_link.receipt_id.as_str(),
686            &invocation,
687            &observation,
688            chrono::Utc::now(),
689        );
690        let mut observation = observation;
691        observation.receipt_link = Some(receipt_link);
692        observation
693            .validate_for(&invocation)
694            .map_err(|error| format!("invalid Engine observation receipt link: {error}"))?;
695        Ok((invocation, observation))
696    }
697}
698
699/// Execute the standalone, versioned local Engine transport operation.
700pub fn execute_transport_context_view(
701    root: &Path,
702    path: &str,
703) -> Result<EngineTransportResult, EngineTransportError> {
704    execute_transport_context_view_with_budget(root, path, None)
705}
706
707pub fn execute_transport_context_view_with_budget(
708    root: &Path,
709    path: &str,
710    budget_tokens: Option<u64>,
711) -> Result<EngineTransportResult, EngineTransportError> {
712    execute_transport_context_view_bound(root, path, budget_tokens, None)
713}
714
715pub fn execute_transport_context_view_with_plan(
716    root: &Path,
717    path: &str,
718    task: &lean_ctx_protocol::TaskEnvelopeV1,
719    plan: &lean_ctx_protocol::ExecutionPlanV1,
720) -> Result<EngineTransportResult, EngineTransportError> {
721    execute_transport_context_view_bound(root, path, plan.context_token_limit(), Some((task, plan)))
722}
723
724fn execute_transport_context_view_bound(
725    root: &Path,
726    path: &str,
727    budget_tokens: Option<u64>,
728    binding: Option<(
729        &lean_ctx_protocol::TaskEnvelopeV1,
730        &lean_ctx_protocol::ExecutionPlanV1,
731    )>,
732) -> Result<EngineTransportResult, EngineTransportError> {
733    if budget_tokens.is_some_and(|budget| budget == 0 || budget > MAX_TRANSPORT_BUDGET_TOKENS) {
734        return Err(EngineTransportError::InvalidRequest);
735    }
736    let root = bind_transport_root(root)?;
737    let (input, canonical_path) = read_transport_source(&root, path)?;
738    if input.len() > MAX_TRANSPORT_VIEW_BYTES {
739        return Err(EngineTransportError::ViewTooLarge);
740    }
741
742    let source_digest =
743        sha256_digest(input.as_bytes()).map_err(|_| EngineTransportError::Internal)?;
744    let source_ref = source_reference(&canonical_path)?;
745    let policy_admission = EnginePolicyAdmissionV1 {
746        policy_ref: ProtocolReference::new(ENGINE_TRANSPORT_POLICY_REF)
747            .map_err(|_| EngineTransportError::Internal)?,
748        decision: EnginePolicyDecisionV1::Admitted,
749    };
750    let engine =
751        NativeContextEngine::with_root(&root).map_err(|_| EngineTransportError::UnsafeRoot)?;
752    if let Some((task, plan)) = binding {
753        planning::validate_native_plan(task, plan, &policy_admission)
754            .map_err(|_| EngineTransportError::InvalidRequest)?;
755    }
756    let (invocation, observation) = engine
757        .execute_ctx_read_rooted_snapshot_with_budget(
758            &canonical_path,
759            &input,
760            policy_admission,
761            budget_tokens,
762            binding,
763        )
764        .map_err(|_| EngineTransportError::Internal)?;
765
766    let recovered_digest = parse_input_recovery_ref(&invocation.input_ref)?;
767    if recovered_digest != source_digest {
768        return Err(EngineTransportError::ObservationMismatch);
769    }
770    if !invocation.source_refs.contains(&source_ref) {
771        return Err(EngineTransportError::ObservationMismatch);
772    }
773
774    let view = verified_output_view(&invocation, &observation)?;
775    let recovery_ref = invocation.input_ref.clone();
776    Ok(EngineTransportResult {
777        view,
778        invocation: Some(invocation),
779        observation: Some(observation),
780        recovery: EngineTransportRecoveryDescriptor {
781            recovery_ref,
782            source_ref,
783            source_digest,
784        },
785    })
786}
787
788/// Read only the content-addressed output bound by the verified terminal receipt.
789pub fn verified_output_view(
790    invocation: &EngineInvocationV1,
791    observation: &EngineObservationV1,
792) -> Result<EngineTransportView, EngineTransportError> {
793    if observation.status != EngineObservationStatusV1::Succeeded {
794        return Err(EngineTransportError::ObservationMismatch);
795    }
796    let receipt_link = observation
797        .receipt_link
798        .as_ref()
799        .ok_or(EngineTransportError::ReceiptMismatch)?;
800    if receipt_link.receipt_ref.as_str()
801        != format!("receipt:sha256:{}", receipt_link.receipt_digest.hex())
802    {
803        return Err(EngineTransportError::ReceiptMismatch);
804    }
805    let mut expected_observation = observation.clone();
806    expected_observation.receipt_link = None;
807    read_verified_engine_receipt(
808        &receipt_link.receipt_digest,
809        invocation,
810        &expected_observation,
811    )
812    .map_err(|_| EngineTransportError::ReceiptMismatch)?;
813
814    let output_digest = observation
815        .output_digest
816        .clone()
817        .ok_or(EngineTransportError::ObservationMismatch)?;
818    let expected_output_ref = format!("output:{}", output_digest.hex());
819    if observation
820        .output_ref
821        .as_ref()
822        .map(ProtocolReference::as_str)
823        != Some(expected_output_ref.as_str())
824    {
825        return Err(EngineTransportError::ObservationMismatch);
826    }
827    let output = artifact_store::read_content(OUTPUT_DIRECTORY, output_digest.hex(), "txt")
828        .map_err(|_| EngineTransportError::Internal)?;
829    if output.len() > MAX_TRANSPORT_VIEW_BYTES {
830        return Err(EngineTransportError::ViewTooLarge);
831    }
832    let output_text =
833        String::from_utf8(output).map_err(|_| EngineTransportError::UnsupportedInput)?;
834    Ok(EngineTransportView {
835        text: output_text,
836        output_ref: observation.output_ref.clone(),
837        output_digest: Some(output_digest),
838    })
839}
840
841/// Re-read the exact source snapshot named by a prior Engine input reference.
842pub fn recover_transport_source(
843    root: &Path,
844    path: &str,
845    recovery_ref: &ProtocolReference,
846    source_ref: &ProtocolReference,
847    source_digest: &Sha256Digest,
848) -> Result<EngineTransportResult, EngineTransportError> {
849    let root = bind_transport_root(root)?;
850    let expected_digest = parse_input_recovery_ref(recovery_ref)?;
851    if &expected_digest != source_digest {
852        return Err(EngineTransportError::MalformedRecoveryRef);
853    }
854
855    let (input, canonical_path) = read_transport_source(&root, path)?;
856    let actual_source_ref = source_reference(&canonical_path)?;
857    if &actual_source_ref != source_ref {
858        return Err(EngineTransportError::SourceChanged);
859    }
860    let actual_digest =
861        sha256_digest(input.as_bytes()).map_err(|_| EngineTransportError::Internal)?;
862    if &actual_digest != source_digest || actual_digest != expected_digest {
863        return Err(EngineTransportError::SourceChanged);
864    }
865    if input.len() > MAX_TRANSPORT_VIEW_BYTES {
866        return Err(EngineTransportError::ViewTooLarge);
867    }
868
869    Ok(EngineTransportResult {
870        view: EngineTransportView {
871            text: input,
872            output_ref: None,
873            output_digest: Some(actual_digest.clone()),
874        },
875        invocation: None,
876        observation: None,
877        recovery: EngineTransportRecoveryDescriptor {
878            recovery_ref: recovery_ref.clone(),
879            source_ref: source_ref.clone(),
880            source_digest: actual_digest,
881        },
882    })
883}
884
885pub fn bind_transport_root(root: &Path) -> Result<PathBuf, EngineTransportError> {
886    if root.as_os_str().is_empty() || crate::core::pathutil::is_broad_or_unsafe_root(root) {
887        return Err(EngineTransportError::UnsafeRoot);
888    }
889    let canonical = crate::core::pathutil::canonicalize_secure(root)
890        .map_err(|_| EngineTransportError::UnsafeRoot)?;
891    if !canonical.is_dir() || crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
892        return Err(EngineTransportError::UnsafeRoot);
893    }
894    Ok(canonical)
895}
896
897fn read_transport_source(
898    root: &Path,
899    path: &str,
900) -> Result<(String, String), EngineTransportError> {
901    if path.trim().is_empty() || path.as_bytes().contains(&0) {
902        return Err(EngineTransportError::InvalidRequest);
903    }
904    let requested = Path::new(path);
905    let candidate = if requested.is_absolute() {
906        requested.to_path_buf()
907    } else {
908        root.join(requested)
909    };
910    let jailed = crate::core::pathjail::jail_path(&candidate, root)
911        .map_err(|_| EngineTransportError::SourceOutsideRoot)?;
912    // PathJail also admits its allowed extra roots (e.g. the temp dir); the
913    // Engine transport is bound to `root` only. Classify "outside" before the
914    // symlink walk, which treats any path it cannot place under root as unsafe.
915    if relative_path_under_root(&candidate, root).is_none() && !jailed.starts_with(root) {
916        return Err(EngineTransportError::SourceOutsideRoot);
917    }
918    if contains_symlink_component(&candidate, root) {
919        return Err(EngineTransportError::SourceSymlink);
920    }
921    // PathJail validates the caller's spelling. The transport's component walk
922    // rejects symlink/reparse points, then the same canonicalizer used to bind
923    // the Engine root prevents verbatim prefixes, drive-letter case, and
924    // short-name aliases from looking outside the root.
925    if !jailed.starts_with(root) {
926        return Err(EngineTransportError::SourceOutsideRoot);
927    }
928    let rooted = crate::core::rooted_read::read_file_lossy_rooted(
929        &jailed.to_string_lossy(),
930        &root.to_string_lossy(),
931    )
932    .map_err(|error| classify_source_error(&error))?;
933    let canonical = crate::core::pathutil::canonicalize_secure(Path::new(&rooted.canonical_path))
934        .map_err(|_| EngineTransportError::SourceOutsideRoot)?;
935    if !canonical.starts_with(root) {
936        return Err(EngineTransportError::SourceOutsideRoot);
937    }
938    Ok((rooted.content, canonical.to_string_lossy().into_owned()))
939}
940
941fn classify_source_error(error: &std::io::Error) -> EngineTransportError {
942    if error
943        .to_string()
944        .to_ascii_lowercase()
945        .contains("binary file")
946    {
947        EngineTransportError::UnsupportedInput
948    } else {
949        EngineTransportError::SourceUnavailable
950    }
951}
952
953fn contains_symlink_component(path: &Path, root: &Path) -> bool {
954    let Some(relative) = relative_path_under_root(path, root) else {
955        return true;
956    };
957    let mut current = root.to_path_buf();
958    for component in relative.components() {
959        match component {
960            std::path::Component::CurDir => {}
961            std::path::Component::ParentDir => {
962                if !current.pop() {
963                    return true;
964                }
965            }
966            std::path::Component::Normal(name) => {
967                current.push(name);
968                if std::fs::symlink_metadata(&current)
969                    .is_ok_and(|metadata| crate::core::pathutil::is_symlink_or_reparse(&metadata))
970                {
971                    return true;
972                }
973            }
974            std::path::Component::Prefix(_) | std::path::Component::RootDir => return true,
975        }
976    }
977    false
978}
979
980fn relative_path_under_root(path: &Path, root: &Path) -> Option<PathBuf> {
981    if let Ok(relative) = path.strip_prefix(root) {
982        return Some(relative.to_path_buf());
983    }
984
985    #[cfg(windows)]
986    {
987        use std::path::Component;
988
989        // Absolute Windows aliases can spell the same root with a verbatim
990        // prefix, separator, or case variation. Compare path components (rather
991        // than a string prefix) to retain a safe root boundary, then walk the
992        // original root plus the relative components to inspect reparse points.
993        let normalized_path =
994            crate::core::pathutil::normalize_tool_path_lexical(&path.to_string_lossy())
995                .to_lowercase();
996        let normalized_root =
997            crate::core::pathutil::normalize_tool_path_lexical(&root.to_string_lossy())
998                .to_lowercase();
999        let path_components: Vec<_> = Path::new(&normalized_path).components().collect();
1000        let root_components: Vec<_> = Path::new(&normalized_root).components().collect();
1001        if path_components.len() < root_components.len()
1002            || !path_components
1003                .iter()
1004                .zip(&root_components)
1005                .all(|(path, root)| path.as_os_str() == root.as_os_str())
1006        {
1007            // Short-name aliases do not have the root's lexical component
1008            // spelling. Resolve them before the reparse walk; the caller still
1009            // performs the strict canonical component-boundary check.
1010            let canonical = crate::core::pathutil::canonicalize_secure(path).ok()?;
1011            if !canonical.starts_with(root) {
1012                return None;
1013            }
1014            return canonical.strip_prefix(root).ok().map(Path::to_path_buf);
1015        }
1016
1017        let mut relative = PathBuf::new();
1018        for component in path_components.into_iter().skip(root_components.len()) {
1019            match component {
1020                Component::CurDir => {}
1021                Component::ParentDir => relative.push(".."),
1022                Component::Normal(name) => relative.push(name),
1023                Component::Prefix(_) | Component::RootDir => return None,
1024            }
1025        }
1026        Some(relative)
1027    }
1028
1029    #[cfg(not(windows))]
1030    {
1031        None
1032    }
1033}
1034
1035fn unresolved_rooted_path(path: &Path, root: &Path) -> Option<PathBuf> {
1036    for ancestor in path.ancestors() {
1037        if let Ok(mut rooted) = crate::core::pathutil::canonicalize_secure(ancestor) {
1038            if !rooted.starts_with(root) {
1039                return None;
1040            }
1041            for component in path.strip_prefix(ancestor).ok()?.components() {
1042                match component {
1043                    std::path::Component::CurDir => {}
1044                    std::path::Component::ParentDir => {
1045                        if rooted == root || !rooted.pop() {
1046                            return None;
1047                        }
1048                    }
1049                    std::path::Component::Normal(name) => rooted.push(name),
1050                    std::path::Component::Prefix(_) | std::path::Component::RootDir => {
1051                        return None;
1052                    }
1053                }
1054            }
1055            return rooted.starts_with(root).then_some(rooted);
1056        }
1057    }
1058    None
1059}
1060
1061fn source_reference(canonical_path: &str) -> Result<ProtocolReference, EngineTransportError> {
1062    let digest =
1063        sha256_digest(canonical_path.as_bytes()).map_err(|_| EngineTransportError::Internal)?;
1064    ProtocolReference::new(format!("{SOURCE_REF_PREFIX}{}", digest.hex()))
1065        .map_err(|_| EngineTransportError::Internal)
1066}
1067
1068fn parse_input_recovery_ref(
1069    reference: &ProtocolReference,
1070) -> Result<Sha256Digest, EngineTransportError> {
1071    let Some(hex) = reference.as_str().strip_prefix(INPUT_REF_PREFIX) else {
1072        return Err(EngineTransportError::MalformedRecoveryRef);
1073    };
1074    Sha256Digest::new(format!("sha256:{hex}"))
1075        .map_err(|_| EngineTransportError::MalformedRecoveryRef)
1076}
1077
1078fn engine_identity() -> Result<ResolvedLocalEngineIdentityV1, String> {
1079    let identity = ResolvedLocalEngineIdentityV1 {
1080        engine_id: ENGINE_ID.to_owned(),
1081        engine_version: SemanticVersion::new(env!("CARGO_PKG_VERSION"))
1082            .map_err(|error| error.to_string())?,
1083    };
1084    identity.validate().map_err(|error| error.to_string())?;
1085    Ok(identity)
1086}
1087
1088fn native_operation() -> Result<EngineOperationV1, String> {
1089    Ok(EngineOperationV1 {
1090        capability_id: CapabilityId::new(CAPABILITY_ID).map_err(|error| error.to_string())?,
1091        capability_version: SemanticVersion::new(CAPABILITY_VERSION)
1092            .map_err(|error| error.to_string())?,
1093    })
1094}
1095
1096fn measured_observations(
1097    result: &crate::core::ocla::invocation::CapabilityResult,
1098) -> Vec<EngineMeasurementV1> {
1099    let mut measurements = vec![
1100        measured("input_tokens", "token", result.observation.input_tokens),
1101        measured("output_tokens", "token", result.output_tokens),
1102    ];
1103    for name in ["compression_saved_tokens", "compression_rate_milli"] {
1104        if let Some(value) = result.observation.metrics.get(name) {
1105            let unit = if name == "compression_saved_tokens" {
1106                "token"
1107            } else {
1108                "milliunit"
1109            };
1110            measurements.push(measured(name, unit, *value));
1111        }
1112    }
1113    measurements
1114}
1115
1116fn measured(name: &str, unit: &str, value: u64) -> EngineMeasurementV1 {
1117    EngineMeasurementV1 {
1118        name: name.to_owned(),
1119        unit: unit.to_owned(),
1120        classification: EngineValueClassificationV1::Measured,
1121        value: Some(value),
1122    }
1123}
1124
1125fn source_integrity_failure(invocation: &EngineInvocationV1) -> EngineObservationV1 {
1126    failed_observation(
1127        invocation,
1128        EngineFailureCodeV1::SourceIntegrityMismatch,
1129        Some(invocation.input_ref.clone()),
1130    )
1131}
1132
1133fn adapter_failure(
1134    invocation: &EngineInvocationV1,
1135    failure: &NativeContextInvocationFailure,
1136) -> EngineObservationV1 {
1137    let code = match failure {
1138        NativeContextInvocationFailure::SourceUnavailable(_) => {
1139            EngineFailureCodeV1::SourceUnavailable
1140        }
1141        NativeContextInvocationFailure::ResourceLimit(_) => EngineFailureCodeV1::ResourceLimit,
1142        NativeContextInvocationFailure::UnsupportedInput => {
1143            EngineFailureCodeV1::UnsupportedOperation
1144        }
1145        NativeContextInvocationFailure::InvalidRequest(_) => EngineFailureCodeV1::Internal,
1146    };
1147    let recovery_ref = matches!(code, EngineFailureCodeV1::SourceUnavailable)
1148        .then(|| invocation.input_ref.clone());
1149    failed_observation(invocation, code, recovery_ref)
1150}
1151
1152fn failed_observation(
1153    invocation: &EngineInvocationV1,
1154    code: EngineFailureCodeV1,
1155    recovery_ref: Option<ProtocolReference>,
1156) -> EngineObservationV1 {
1157    EngineObservationV1 {
1158        schema_version: V1_SCHEMA_VERSION,
1159        invocation_id: invocation.invocation_id.clone(),
1160        status: EngineObservationStatusV1::Failed,
1161        output_ref: None,
1162        output_digest: None,
1163        source_lineage: invocation.source_refs.clone(),
1164        measurements: Vec::new(),
1165        failure: Some(EngineFailureV1 {
1166            code,
1167            retryable_by_host: false,
1168            recovery_ref,
1169        }),
1170        receipt_link: None,
1171    }
1172}
1173
1174fn storage_failure_observation(invocation: &EngineInvocationV1) -> EngineObservationV1 {
1175    let mut observation = failed_observation(
1176        invocation,
1177        EngineFailureCodeV1::Internal,
1178        Some(invocation.input_ref.clone()),
1179    );
1180    if let Some(failure) = observation.failure.as_mut() {
1181        failure.retryable_by_host = true;
1182    }
1183    observation
1184}
1185
1186fn persist_output(digest: &str, bytes: &[u8]) -> Result<(), String> {
1187    persist_content(OUTPUT_DIRECTORY, digest, "txt", bytes)
1188}
1189
1190fn persist_receipt(
1191    invocation: &EngineInvocationV1,
1192    observation: &EngineObservationV1,
1193) -> Result<EngineReceiptLinkV1, String> {
1194    let bytes = canonical_engine_receipt_artifact_bytes(invocation, observation);
1195    let digest = sha256_digest(&bytes)?;
1196    persist_content(RECEIPT_DIRECTORY, digest.hex(), "json", &bytes)?;
1197    Ok(EngineReceiptLinkV1 {
1198        schema_version: V1_SCHEMA_VERSION,
1199        receipt_id: ReceiptId::new(format!("engine-receipt-{}", &digest.hex()[..32]))
1200            .map_err(|error| error.to_string())?,
1201        receipt_ref: ProtocolReference::new(format!("receipt:sha256:{}", digest.hex()))
1202            .map_err(|error| error.to_string())?,
1203        receipt_digest: digest,
1204        invocation_id: invocation.invocation_id.clone(),
1205    })
1206}
1207
1208fn persist_recovery(
1209    invocation: &EngineInvocationV1,
1210    observation: &EngineObservationV1,
1211    failure_class: &'static str,
1212) -> Result<ProtocolReference, String> {
1213    let artifact = EngineRecoveryArtifactV1 {
1214        schema_version: V1_SCHEMA_VERSION,
1215        invocation: invocation.clone(),
1216        observation: observation.clone(),
1217        failure_class,
1218    };
1219    let bytes = canonical::canonical_serialize(&artifact);
1220    let digest = sha256_digest(&bytes)?;
1221    persist_content(RECOVERY_DIRECTORY, digest.hex(), "json", &bytes)?;
1222    ProtocolReference::new(format!("recovery:sha256:{}", digest.hex()))
1223        .map_err(|error| error.to_string())
1224}
1225
1226fn persist_content(
1227    directory: &str,
1228    digest: &str,
1229    extension: &str,
1230    bytes: &[u8],
1231) -> Result<(), String> {
1232    artifact_store::persist_content(directory, digest, extension, bytes).map(|_| ())
1233}
1234
1235fn sha256_digest(bytes: &[u8]) -> Result<Sha256Digest, String> {
1236    Sha256Digest::new(format!(
1237        "sha256:{}",
1238        crate::core::agent_identity::hex_encode(&Sha256::digest(bytes))
1239    ))
1240    .map_err(|error| error.to_string())
1241}
1242
1243#[cfg(test)]
1244mod tests;