Skip to main content

lean_ctx_protocol/
runtime_session.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Bounded replay state for one pre-admitted, capability-scoped local session.
3//! The host supplies trusted scope and a fresh session/key, never scope learned
4//! from an incoming request. This mechanism does not decide policy or trust peers.
5
6use std::fmt;
7
8use crate::{
9    EngineInvocationV1, EngineOperationV1, EnginePolicyDecisionV1, ProtocolReference,
10    ResolvedLocalEngineIdentityV1,
11    runtime_exchange::{RuntimeExchangeProfile, RuntimeRequestV1},
12    runtime_frame::{RuntimeFrameDirection, RuntimeFrameError, decode_runtime_frame_in},
13    validate_bounded_opaque_identifier,
14};
15
16/// No Clone, Debug or serialization: neither the borrowed key nor session state
17/// may be copied into another receiver to reset its replay counter.
18pub struct RuntimeRequestSession<'key> {
19    key: &'key [u8; 32],
20    session_id: String,
21    engine: ResolvedLocalEngineIdentityV1,
22    operation: EngineOperationV1,
23    policy_ref: ProtocolReference,
24    /// Fixed when the session was admitted; no request can change it.
25    profile: RuntimeExchangeProfile,
26    next_sequence: Option<u64>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum RuntimeSessionError {
31    InvalidScope,
32    Frame(RuntimeFrameError),
33    InvalidRequest,
34    ScopeMismatch,
35    SequenceMismatch,
36    SequenceExhausted,
37}
38
39impl fmt::Display for RuntimeSessionError {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        formatter.write_str(match self {
42            Self::InvalidScope => "invalid runtime session scope",
43            Self::Frame(_) => "invalid authenticated runtime frame",
44            Self::InvalidRequest => "invalid runtime request",
45            Self::ScopeMismatch => "runtime request outside session scope",
46            Self::SequenceMismatch => "runtime request sequence mismatch",
47            Self::SequenceExhausted => "runtime session sequence exhausted",
48        })
49    }
50}
51
52impl std::error::Error for RuntimeSessionError {}
53
54impl<'key> RuntimeRequestSession<'key> {
55    /// Bind locally trusted admission, not a peer-supplied assertion of admission.
56    /// The owner provisions and protects the key, serializes access to this
57    /// receiver, and retires it on reconnect/revocation. Never reuse a session key.
58    pub fn new(
59        key: &'key [u8; 32],
60        session_id: String,
61        admitted: &EngineInvocationV1,
62    ) -> Result<Self, RuntimeSessionError> {
63        Self::new_in(key, session_id, admitted, RuntimeExchangeProfile::ORDINARY)
64    }
65
66    /// [`Self::new`] with the size profile the owner admitted for this
67    /// session from its own trusted state. The profile belongs to the
68    /// session like its scope: what a request says about itself never
69    /// selects it.
70    pub fn new_in(
71        key: &'key [u8; 32],
72        session_id: String,
73        admitted: &EngineInvocationV1,
74        profile: RuntimeExchangeProfile,
75    ) -> Result<Self, RuntimeSessionError> {
76        validate_bounded_opaque_identifier(&session_id, "runtime session_id")
77            .and_then(|()| admitted.validate())
78            .map_err(|_| RuntimeSessionError::InvalidScope)?;
79        if admitted.policy_admission.decision != EnginePolicyDecisionV1::Admitted {
80            return Err(RuntimeSessionError::InvalidScope);
81        }
82        Ok(Self {
83            key,
84            session_id,
85            engine: admitted.engine.clone(),
86            operation: admitted.operation.clone(),
87            policy_ref: admitted.policy_admission.policy_ref.clone(),
88            profile,
89            next_sequence: Some(1),
90        })
91    }
92
93    /// Authenticate before JSON parsing; admit exact scope and consecutive sequence.
94    /// Invalid input never advances state. A successful request is consumed even
95    /// if later execution fails, so it cannot be replayed to repeat side effects.
96    /// Callers still enforce a monotonic I/O deadline and live policy revocation.
97    pub fn accept(
98        &mut self,
99        frame: &[u8],
100        now_unix_ms: u64,
101    ) -> Result<RuntimeRequestV1, RuntimeSessionError> {
102        let sequence = self
103            .next_sequence
104            .ok_or(RuntimeSessionError::SequenceExhausted)?;
105        let payload = decode_runtime_frame_in(
106            self.key,
107            RuntimeFrameDirection::Request,
108            frame,
109            self.profile,
110        )
111        .map_err(RuntimeSessionError::Frame)?;
112        let request = RuntimeRequestV1::from_bytes_in(&payload, now_unix_ms, self.profile)
113            .map_err(|_| RuntimeSessionError::InvalidRequest)?;
114        if request.session_id != self.session_id
115            || request.invocation.engine != self.engine
116            || request.invocation.operation != self.operation
117            || request.invocation.policy_admission.policy_ref != self.policy_ref
118        {
119            return Err(RuntimeSessionError::ScopeMismatch);
120        }
121        if request.sequence != sequence {
122            return Err(RuntimeSessionError::SequenceMismatch);
123        }
124        self.next_sequence = sequence.checked_add(1);
125        Ok(request)
126    }
127}
128
129#[cfg(test)]
130mod tests;