Skip to main content

lean_ctx_protocol/
runtime_exchange.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Strict payloads for the optional local Intelligence peer, not a second Engine.
3//! Transport authenticates exact bytes before decoding and owns replay state.
4//! Only explicitly policy-approved projections belong here; never log payloads.
5
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7
8use crate::{
9    EngineInvocationV1, EngineObservationV1, EnginePolicyDecisionV1, Sha256Digest, ValidationError,
10    receipt_document::digest_bytes, validate_bounded_opaque_identifier,
11};
12
13/// Independent successor to the legacy, one-way runtime notification.
14pub const RUNTIME_EXCHANGE_VERSION: &str = "leanctx.runtime-exchange/v1";
15/// Encoded JSON limit; transport must check the length prefix before allocation.
16pub const MAX_RUNTIME_EXCHANGE_BYTES: usize = 1_048_576;
17/// A peer cannot extend an invocation beyond this host-admitted time budget.
18pub const MAX_RUNTIME_DEADLINE_MS: u64 = 60_000;
19
20/// The size ceilings of one exchange: input and output content, and the
21/// encoded request and response that carry them.
22///
23/// There are exactly the profiles named here; none can be built from numbers.
24/// Each side selects one from what it admitted locally — the host from the
25/// authenticated package and the exact operation, the peer from its protected
26/// bootstrap and a live entitlement — and keeps it for the session. Nothing
27/// in a message selects or widens a profile. Every function without a profile
28/// argument uses [`Self::ORDINARY`], exactly as before profiles existed.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct RuntimeExchangeProfile {
31    input: usize,
32    output: usize,
33    request: usize,
34    response: usize,
35}
36
37/// Allowance for the typed metadata around the bulk of a verifier exchange.
38const VERIFIER_METADATA_BYTES: usize = 16 * 1024;
39
40const fn base64_bytes(bytes: usize) -> usize {
41    bytes.div_ceil(3) * 4
42}
43
44/// The parts of one shared-artifact reader exchange, as decoded bytes. The
45/// first three are the bounds of the artifact wire contract (manifest, body
46/// envelope, room around an authorization); a part larger than its bound is
47/// refused by both sides, never shortened.
48pub const MAX_ARTIFACT_READER_MANIFEST_BYTES: usize = 64 * 1024;
49pub const MAX_ARTIFACT_READER_BODY_BYTES: usize = 4 * 1024 * 1024;
50pub const MAX_ARTIFACT_READER_AUTHORIZATION_BYTES: usize = 16 * 1024;
51/// Allowance for the typed fields around those parts.
52pub const ARTIFACT_READER_METADATA_BYTES: usize = 16 * 1024;
53/// Most source files one artifact may be derived from, and the longest path.
54pub const MAX_ARTIFACT_READER_SOURCE_FILES: usize = 4096;
55pub const MAX_ARTIFACT_READER_SOURCE_PATH_BYTES: usize = 1024;
56/// Width of one serialized byte in the widest admitted JSON encoding.
57const JSON_ESCAPE_WIDTH: usize = 6;
58/// A `sha256:<64 hex>` digest.
59const DIGEST_TEXT_BYTES: usize = 71;
60/// The reader's captured checkout state as typed JSON: per file its escaped
61/// path, its digest and the field names and punctuation around them.
62pub const MAX_ARTIFACT_READER_LOCAL_BYTES: usize = MAX_ARTIFACT_READER_SOURCE_FILES
63    * (JSON_ESCAPE_WIDTH * MAX_ARTIFACT_READER_SOURCE_PATH_BYTES
64        + JSON_ESCAPE_WIDTH * DIGEST_TEXT_BYTES
65        + 128)
66    + ARTIFACT_READER_METADATA_BYTES;
67/// Largest input of one reader step: the three byte parts in base64 at the
68/// widest serialization, the local state and the fields around them. These
69/// are the adapter's own widths (`ee/intelligence/src/artifact_reader.rs`),
70/// which a build-time equality there holds to this profile.
71pub const MAX_ARTIFACT_READER_INPUT_BYTES: usize = JSON_ESCAPE_WIDTH
72    * (base64_bytes(MAX_ARTIFACT_READER_BODY_BYTES)
73        + base64_bytes(MAX_ARTIFACT_READER_MANIFEST_BYTES)
74        + base64_bytes(MAX_ARTIFACT_READER_AUTHORIZATION_BYTES))
75    + MAX_ARTIFACT_READER_LOCAL_BYTES
76    + ARTIFACT_READER_METADATA_BYTES;
77/// Largest answer of one reader step: a decision or the list of source paths
78/// to capture. An answer never carries artifact content.
79pub const MAX_ARTIFACT_READER_OUTPUT_BYTES: usize = MAX_ARTIFACT_READER_SOURCE_FILES
80    * (JSON_ESCAPE_WIDTH * MAX_ARTIFACT_READER_SOURCE_PATH_BYTES + 3)
81    + ARTIFACT_READER_METADATA_BYTES;
82/// Largest content one shared artifact carries, before it is put into a body.
83pub const MAX_ARTIFACT_AUTHOR_CONTENT_BYTES: usize = 1024 * 1024;
84/// Largest input of the authoring step: the content in base64 at the widest
85/// serialization, the source state it was derived from and the fields
86/// around them (`ee/intelligence/src/artifact_author.rs`).
87pub const MAX_ARTIFACT_AUTHOR_INPUT_BYTES: usize = JSON_ESCAPE_WIDTH
88    * base64_bytes(MAX_ARTIFACT_AUTHOR_CONTENT_BYTES)
89    + MAX_ARTIFACT_READER_LOCAL_BYTES
90    + ARTIFACT_READER_METADATA_BYTES;
91/// Largest answer of the authoring step: the body envelope and the manifest
92/// in base64 with the fields around them.
93pub const MAX_ARTIFACT_AUTHOR_OUTPUT_BYTES: usize = base64_bytes(MAX_ARTIFACT_READER_BODY_BYTES)
94    + base64_bytes(MAX_ARTIFACT_READER_MANIFEST_BYTES)
95    + ARTIFACT_READER_METADATA_BYTES;
96// The numbers the adapters' author stated; a change of either side alone stops the build.
97const _: () = {
98    assert!(MAX_ARTIFACT_READER_LOCAL_BYTES == 27_451_392);
99    assert!(MAX_ARTIFACT_READER_INPUT_BYTES == 61_677_616);
100    assert!(MAX_ARTIFACT_READER_OUTPUT_BYTES == 25_194_496);
101    assert!(MAX_ARTIFACT_AUTHOR_INPUT_BYTES == 35_856_400);
102    assert!(MAX_ARTIFACT_AUTHOR_OUTPUT_BYTES == 5_696_176);
103};
104
105impl RuntimeExchangeProfile {
106    /// Every operation unless another profile was admitted for it.
107    pub const ORDINARY: Self = Self {
108        input: MAX_RUNTIME_EXCHANGE_BYTES,
109        output: MAX_RUNTIME_EXCHANGE_BYTES,
110        request: MAX_RUNTIME_EXCHANGE_BYTES,
111        response: MAX_RUNTIME_EXCHANGE_BYTES,
112    };
113
114    /// The organization policy verifier: a signed artifact whose pack may be
115    /// JSON-escaped up to sixfold, a trust store and a marker, each base64 in
116    /// one input; and an answer that carries the pack once more, escaped
117    /// through two JSON layers (at most sevenfold).
118    pub const ORG_POLICY_VERIFIER: Self = {
119        let pack = crate::org_policy_verifier::MAX_ORG_POLICY_VERIFIED_PACK_BYTES;
120        let snapshot = crate::org_policy_verifier::MAX_ORG_REQUIRED_MARKER_BYTES;
121        let artifact = 6 * pack + VERIFIER_METADATA_BYTES;
122        let input = base64_bytes(artifact) + 2 * base64_bytes(snapshot) + VERIFIER_METADATA_BYTES;
123        Self {
124            input,
125            output: artifact,
126            request: input + 2 * VERIFIER_METADATA_BYTES,
127            response: 7 * pack + 3 * VERIFIER_METADATA_BYTES,
128        }
129    };
130
131    /// The shared-artifact reader: a manifest, a body envelope and an
132    /// authorization in base64 with the reader's checkout state, and an
133    /// answer without content. Input and answer travel as one JSON string
134    /// inside the request and the response; the frames add the room for the
135    /// quotes of the typed part being escaped once more, and for the
136    /// invocation around them.
137    pub const ARTIFACT_READER: Self = Self {
138        input: MAX_ARTIFACT_READER_INPUT_BYTES,
139        output: MAX_ARTIFACT_READER_OUTPUT_BYTES,
140        request: MAX_ARTIFACT_READER_INPUT_BYTES
141            + MAX_ARTIFACT_READER_LOCAL_BYTES
142            + 2 * ARTIFACT_READER_METADATA_BYTES,
143        response: 2 * MAX_ARTIFACT_READER_OUTPUT_BYTES + 2 * ARTIFACT_READER_METADATA_BYTES,
144    };
145
146    /// The shared-artifact author: content and source state in, manifest and
147    /// body out. The frames add the room for the typed part being escaped
148    /// once more inside the request and for the answer inside the response.
149    pub const ARTIFACT_AUTHOR: Self = Self {
150        input: MAX_ARTIFACT_AUTHOR_INPUT_BYTES,
151        output: MAX_ARTIFACT_AUTHOR_OUTPUT_BYTES,
152        request: MAX_ARTIFACT_AUTHOR_INPUT_BYTES
153            + MAX_ARTIFACT_READER_LOCAL_BYTES
154            + 2 * ARTIFACT_READER_METADATA_BYTES,
155        response: 2 * MAX_ARTIFACT_AUTHOR_OUTPUT_BYTES + 2 * ARTIFACT_READER_METADATA_BYTES,
156    };
157
158    /// Largest input content, before encoding.
159    #[must_use]
160    pub const fn max_input_bytes(self) -> usize {
161        self.input
162    }
163
164    /// Largest output content, before encoding.
165    #[must_use]
166    pub const fn max_output_bytes(self) -> usize {
167        self.output
168    }
169
170    /// Largest encoded request: the plaintext of a request frame.
171    #[must_use]
172    pub const fn max_request_bytes(self) -> usize {
173        self.request
174    }
175
176    /// Largest encoded response: the plaintext of a response frame.
177    #[must_use]
178    pub const fn max_response_bytes(self) -> usize {
179        self.response
180    }
181}
182
183/// One admitted invocation plus its exact bounded input projection.
184/// No Debug implementation: the projection may contain private user content.
185#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct RuntimeRequestV1 {
188    pub protocol_version: String,
189    pub session_id: String,
190    pub request_id: String,
191    /// Strictly increasing within the authenticated session; zero is reserved.
192    pub sequence: u64,
193    pub deadline_unix_ms: u64,
194    pub invocation: EngineInvocationV1,
195    pub input: String,
196    /// The Hive runtime lease (`leanctx.hive.runtime-lease/v1`, standard
197    /// base64), sent only to a package that declares lease support. Absent,
198    /// the request is byte-identical to one without leases.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub lease_b64: Option<String>,
201}
202
203/// Largest encoded runtime lease a request may carry.
204pub const MAX_RUNTIME_LEASE_B64_BYTES: usize = 16 * 1024;
205
206fn validate_lease_b64(lease: &str) -> Result<(), ValidationError> {
207    let padding = lease.bytes().rev().take_while(|b| *b == b'=').count();
208    let body = &lease[..lease.len() - padding];
209    if lease.is_empty()
210        || lease.len() > MAX_RUNTIME_LEASE_B64_BYTES
211        || !lease.len().is_multiple_of(4)
212        || padding > 2
213        || !body
214            .bytes()
215            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/'))
216    {
217        return Err(ValidationError::new(
218            "runtime lease must be padded base64 of at most 16 KiB",
219        ));
220    }
221    Ok(())
222}
223
224impl RuntimeRequestV1 {
225    /// Validate structure and input binding; this does not grant authorization.
226    pub fn validate(&self) -> Result<(), ValidationError> {
227        self.validate_in(RuntimeExchangeProfile::ORDINARY)
228    }
229
230    /// [`Self::validate`] under a locally admitted profile.
231    pub fn validate_in(&self, profile: RuntimeExchangeProfile) -> Result<(), ValidationError> {
232        validate_version(&self.protocol_version)?;
233        validate_bounded_opaque_identifier(&self.session_id, "runtime session_id")?;
234        validate_bounded_opaque_identifier(&self.request_id, "runtime request_id")?;
235        self.invocation.validate()?;
236        if self.sequence == 0 || self.deadline_unix_ms == 0 {
237            return Err(ValidationError::new(
238                "runtime sequence/deadline must be nonzero",
239            ));
240        }
241        if self.invocation.policy_admission.decision != EnginePolicyDecisionV1::Admitted {
242            return Err(ValidationError::new(
243                "runtime input requires host policy admission",
244            ));
245        }
246        if let Some(lease) = &self.lease_b64 {
247            validate_lease_b64(lease)?;
248        }
249        validate_content(
250            self.input.as_bytes(),
251            &self.invocation.input_digest,
252            profile.input,
253        )
254    }
255
256    /// Deterministically check time admission using the receiver's clock.
257    /// The transport also enforces a monotonic I/O deadline for the entire call.
258    pub fn validate_at(&self, now_unix_ms: u64) -> Result<(), ValidationError> {
259        self.validate_at_in(now_unix_ms, RuntimeExchangeProfile::ORDINARY)
260    }
261
262    /// [`Self::validate_at`] under a locally admitted profile.
263    pub fn validate_at_in(
264        &self,
265        now_unix_ms: u64,
266        profile: RuntimeExchangeProfile,
267    ) -> Result<(), ValidationError> {
268        self.validate_in(profile)?;
269        let remaining = self.deadline_unix_ms.saturating_sub(now_unix_ms);
270        if remaining == 0 || remaining > MAX_RUNTIME_DEADLINE_MS {
271            return Err(ValidationError::new(
272                "runtime deadline expired or exceeds budget",
273            ));
274        }
275        Ok(())
276    }
277
278    /// Encode the exact stable representation used by response correlation.
279    pub fn to_bytes(&self) -> Result<Vec<u8>, ValidationError> {
280        self.to_bytes_in(RuntimeExchangeProfile::ORDINARY)
281    }
282
283    /// [`Self::to_bytes`] under a locally admitted profile: the same bytes,
284    /// refused once they would pass that profile's request ceiling.
285    pub fn to_bytes_in(&self, profile: RuntimeExchangeProfile) -> Result<Vec<u8>, ValidationError> {
286        self.validate_in(profile)?;
287        encode(self, profile.request)
288    }
289
290    /// Decode bounded JSON, rejecting unknown fields, duplicates and bad bindings.
291    /// Equivalent JSON whitespace/key order is accepted after authentication of
292    /// the exact received bytes; decoding is not transport authentication.
293    pub fn from_bytes(bytes: &[u8], now_unix_ms: u64) -> Result<Self, ValidationError> {
294        Self::from_bytes_in(bytes, now_unix_ms, RuntimeExchangeProfile::ORDINARY)
295    }
296
297    /// [`Self::from_bytes`] under a locally admitted profile.
298    pub fn from_bytes_in(
299        bytes: &[u8],
300        now_unix_ms: u64,
301        profile: RuntimeExchangeProfile,
302    ) -> Result<Self, ValidationError> {
303        let request: Self = decode(bytes, profile.request)?;
304        request.validate_at_in(now_unix_ms, profile)?;
305        Ok(request)
306    }
307
308    /// Bind all request fields, not merely a caller-provided invocation ID.
309    /// This is a semantic correlation digest of stable re-encoding, not a MAC
310    /// of the received representation. Transport authenticates bytes separately.
311    pub fn digest(&self) -> Result<Sha256Digest, ValidationError> {
312        self.digest_in(RuntimeExchangeProfile::ORDINARY)
313    }
314
315    /// [`Self::digest`] under a locally admitted profile. The digest is of
316    /// the same bytes whatever the profile; the profile only bounds them.
317    pub fn digest_in(
318        &self,
319        profile: RuntimeExchangeProfile,
320    ) -> Result<Sha256Digest, ValidationError> {
321        digest_bytes(&self.to_bytes_in(profile)?)
322    }
323}
324
325/// Peer result bound to the whole request; receipts remain host-owned.
326#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct RuntimeResponseV1 {
329    pub protocol_version: String,
330    pub request_digest: Sha256Digest,
331    pub observation: EngineObservationV1,
332    pub output: Option<String>,
333}
334
335impl RuntimeResponseV1 {
336    /// Reuse canonical Engine lineage/status validation and check exact content.
337    pub fn validate_for(&self, request: &RuntimeRequestV1) -> Result<(), ValidationError> {
338        self.validate_for_in(request, RuntimeExchangeProfile::ORDINARY)
339    }
340
341    /// [`Self::validate_for`] under a locally admitted profile.
342    pub fn validate_for_in(
343        &self,
344        request: &RuntimeRequestV1,
345        profile: RuntimeExchangeProfile,
346    ) -> Result<(), ValidationError> {
347        validate_version(&self.protocol_version)?;
348        if self.request_digest != request.digest_in(profile)? {
349            return Err(ValidationError::new(
350                "runtime response belongs to another request",
351            ));
352        }
353        self.observation.validate_for(&request.invocation)?;
354        if self.observation.receipt_link.is_some() {
355            return Err(ValidationError::new(
356                "runtime peer cannot issue a host receipt",
357            ));
358        }
359        match (&self.output, &self.observation.output_digest) {
360            (Some(output), Some(digest)) => {
361                validate_content(output.as_bytes(), digest, profile.output)
362            }
363            (None, None) => Ok(()),
364            _ => Err(ValidationError::new(
365                "runtime output and digest must be present together",
366            )),
367        }
368    }
369
370    /// Encode only a result that is bound to the admitted request.
371    pub fn to_bytes(&self, request: &RuntimeRequestV1) -> Result<Vec<u8>, ValidationError> {
372        self.to_bytes_in(request, RuntimeExchangeProfile::ORDINARY)
373    }
374
375    /// [`Self::to_bytes`] under a locally admitted profile.
376    pub fn to_bytes_in(
377        &self,
378        request: &RuntimeRequestV1,
379        profile: RuntimeExchangeProfile,
380    ) -> Result<Vec<u8>, ValidationError> {
381        self.validate_for_in(request, profile)?;
382        encode(self, profile.response)
383    }
384
385    /// Reject late, mismatched, malformed or oversized replies before use.
386    pub fn from_bytes(
387        bytes: &[u8],
388        request: &RuntimeRequestV1,
389        now_unix_ms: u64,
390    ) -> Result<Self, ValidationError> {
391        Self::from_bytes_in(
392            bytes,
393            request,
394            now_unix_ms,
395            RuntimeExchangeProfile::ORDINARY,
396        )
397    }
398
399    /// [`Self::from_bytes`] under a locally admitted profile.
400    pub fn from_bytes_in(
401        bytes: &[u8],
402        request: &RuntimeRequestV1,
403        now_unix_ms: u64,
404        profile: RuntimeExchangeProfile,
405    ) -> Result<Self, ValidationError> {
406        request.validate_at_in(now_unix_ms, profile)?;
407        let response: Self = decode(bytes, profile.response)?;
408        response.validate_for_in(request, profile)?;
409        Ok(response)
410    }
411}
412
413fn validate_version(version: &str) -> Result<(), ValidationError> {
414    if version != RUNTIME_EXCHANGE_VERSION {
415        return Err(ValidationError::new("unsupported runtime exchange version"));
416    }
417    Ok(())
418}
419
420fn validate_content(
421    bytes: &[u8],
422    digest: &Sha256Digest,
423    limit: usize,
424) -> Result<(), ValidationError> {
425    if bytes.len() > limit || digest_bytes(bytes)? != *digest {
426        return Err(ValidationError::new(
427            "runtime content exceeds limit or differs from digest",
428        ));
429    }
430    Ok(())
431}
432
433/// Collects encoded bytes and fails the write that would pass `limit`, so an
434/// oversized message is refused while it is produced, not after it was built.
435struct Bounded {
436    bytes: Vec<u8>,
437    limit: usize,
438}
439
440impl std::io::Write for Bounded {
441    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
442        if buffer.len() > self.limit - self.bytes.len() {
443            return Err(std::io::ErrorKind::FileTooLarge.into());
444        }
445        self.bytes.extend_from_slice(buffer);
446        Ok(buffer.len())
447    }
448
449    fn flush(&mut self) -> std::io::Result<()> {
450        Ok(())
451    }
452}
453
454fn encode(value: &impl Serialize, limit: usize) -> Result<Vec<u8>, ValidationError> {
455    let mut bounded = Bounded {
456        bytes: Vec::new(),
457        limit,
458    };
459    serde_json::to_writer(&mut bounded, value).map_err(|error| {
460        ValidationError::new(if error.is_io() {
461            "runtime message exceeds encoded limit"
462        } else {
463            "runtime JSON encoding failed"
464        })
465    })?;
466    Ok(bounded.bytes)
467}
468
469fn decode<T: DeserializeOwned>(bytes: &[u8], limit: usize) -> Result<T, ValidationError> {
470    if bytes.is_empty() || bytes.len() > limit {
471        return Err(ValidationError::new("runtime message length is invalid"));
472    }
473    serde_json::from_slice(bytes).map_err(|_| ValidationError::new("invalid runtime JSON"))
474}
475
476#[cfg(test)]
477mod tests;