lean_ctx_protocol/
engine_context_materialization_v2.rs1use serde::{Deserialize, Serialize};
17
18use crate::{
19 EngineContextSourceMaterializationRequestV1, EngineContextSourceMaterializationResponseV1,
20 ProtocolReference, ValidationError,
21};
22
23pub const ENGINE_CONTEXT_MATERIALIZATION_V2_SCHEMA_ID: &str =
25 "leanctx.engine-context-materialization/v2";
26pub const ENGINE_PRESENTER_REVOCATION_V1_SCHEMA_ID: &str = "leanctx.engine-presenter-revocation/v1";
28pub const ENGINE_PRESENTER_CAPABILITY_MIN_ENGINE_VERSION: &str = "3.11.0";
30pub const MAX_ENGINE_PRESENTER_TTL_SECONDS: u32 = 24 * 60 * 60;
32pub const ENGINE_PRESENTER_CAPABILITY_PREFIX: &str = "lcp1_";
34pub const ENGINE_PRESENTER_REF_PREFIX: &str = "presenter:";
36
37const CAPABILITY_HEX_CHARS: usize = 64;
38const PRESENTER_REF_HEX_CHARS: usize = 32;
39
40fn lower_hex(text: &str, length: usize) -> bool {
41 text.len() == length
42 && text
43 .bytes()
44 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
45}
46
47#[must_use]
50pub fn is_engine_presenter_capability_form(text: &str) -> bool {
51 text.strip_prefix(ENGINE_PRESENTER_CAPABILITY_PREFIX)
52 .is_some_and(|hex| lower_hex(hex, CAPABILITY_HEX_CHARS))
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "mode")]
58pub enum EnginePresenterRequestV1 {
59 Issue { ttl_seconds: u32 },
61 Renew,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct EngineContextSourceMaterializationRequestV2 {
71 pub schema_version: u32,
72 pub materialization: EngineContextSourceMaterializationRequestV1,
73 pub presenter: EnginePresenterRequestV1,
74}
75
76impl EngineContextSourceMaterializationRequestV2 {
77 pub fn validate_payload(&self) -> Result<(), ValidationError> {
79 if self.schema_version != 2 {
80 return Err(invalid());
81 }
82 match self.presenter {
83 EnginePresenterRequestV1::Issue { ttl_seconds }
84 if !(1..=MAX_ENGINE_PRESENTER_TTL_SECONDS).contains(&ttl_seconds) =>
85 {
86 Err(invalid())
87 }
88 _ => self.materialization.validate_payload(),
89 }
90 }
91}
92
93#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct EnginePresenterGrantV1 {
97 pub presenter_ref: ProtocolReference,
101 pub expires_at_unix: u64,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub capability: Option<String>,
106}
107
108impl std::fmt::Debug for EnginePresenterGrantV1 {
109 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 formatter
111 .debug_struct("EnginePresenterGrantV1")
112 .field("presenter_ref", &self.presenter_ref)
113 .field("expires_at_unix", &self.expires_at_unix)
114 .field(
115 "capability",
116 &self.capability.as_ref().map(|_| "<redacted>"),
117 )
118 .finish()
119 }
120}
121
122impl EnginePresenterGrantV1 {
123 fn validate(&self) -> Result<(), ValidationError> {
124 let reference_ok = self
125 .presenter_ref
126 .as_str()
127 .strip_prefix(ENGINE_PRESENTER_REF_PREFIX)
128 .is_some_and(|hex| lower_hex(hex, PRESENTER_REF_HEX_CHARS));
129 let capability_ok = self
130 .capability
131 .as_deref()
132 .is_none_or(is_engine_presenter_capability_form);
133 if reference_ok && capability_ok {
134 Ok(())
135 } else {
136 Err(invalid())
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct EngineContextSourceMaterializationResponseV2 {
145 pub schema_version: u32,
146 pub materialization: EngineContextSourceMaterializationResponseV1,
147 pub presenter: EnginePresenterGrantV1,
148}
149
150impl EngineContextSourceMaterializationResponseV2 {
151 pub fn validate(&self) -> Result<(), ValidationError> {
154 if self.schema_version != 2 {
155 return Err(invalid());
156 }
157 self.presenter.validate()?;
158 let leaked = self
159 .presenter
160 .capability
161 .as_deref()
162 .is_some_and(|capability| self.materialization.content.contains(capability));
163 if leaked {
164 return Err(invalid());
165 }
166 self.materialization.validate()
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct EnginePresenterRevocationRequestV1 {
175 pub schema_version: u32,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct EnginePresenterRevocationResponseV1 {
182 pub schema_version: u32,
183 pub presenter_ref: ProtocolReference,
184 pub revoked: bool,
185}
186
187fn invalid() -> ValidationError {
188 ValidationError::new("invalid Engine materialization v2 envelope")
189}