Skip to main content

rustev_contract/
remote_context.rs

1//! Transport-neutral context callback documents (spec 042, 3.10).
2//! Protocol names identify a family; request and response documents carry schemas.
3
4use crate::DocumentError;
5use crate::definition::{ProvenanceClass, TypeDecl};
6use crate::grade::Grade;
7use crate::ids::ContentDigest;
8use crate::remote::{CancelSupport, RemoteCode, TransportPhase, WirePrincipal};
9use crate::snapshot::Snapshot;
10use serde::{Deserialize, Serialize};
11
12pub const PROTOCOL: &str = "rustev.remote-context/1";
13pub const DESCRIBE: &str = "rustev.remote-context-describe/1";
14pub const FETCH: &str = "rustev.remote-context-fetch/1";
15pub const CANCEL: &str = "rustev.remote-context-cancel/1";
16pub const INVALIDATION: &str = "rustev.remote-context-invalidation/1";
17pub const INVALIDATIONS: &str = "rustev.remote-context-invalidations/1";
18pub const EXCHANGE: &str = "rustev.remote-context-exchange/1";
19// Extension point: spec 036 (acquire request, response, and denied failure).
20
21pub(crate) fn invalid(message: impl Into<String>) -> DocumentError {
22    DocumentError::Parse(crate::bounded::ParseError::Schema {
23        message: message.into(),
24    })
25}
26
27/// A schema check and document-specific validation after the bounded scan.
28macro_rules! checked_document {
29    ($ty:ty, $schema:expr, $limits:expr, $check:expr) => {
30        impl crate::Document for $ty {
31            const SCHEMA: &'static str = $schema;
32            const LIMITS: crate::bounded::ParseLimits = $limits;
33            fn schema(&self) -> &str {
34                &self.schema
35            }
36            fn parse(bytes: &[u8]) -> Result<Self, crate::DocumentError> {
37                let doc: Self = crate::bounded::parse_bounded(bytes, &Self::LIMITS)
38                    .map_err(crate::DocumentError::Parse)?;
39                if doc.schema != Self::SCHEMA {
40                    return Err(crate::DocumentError::Schema {
41                        expected: Self::SCHEMA,
42                        found: doc.schema.chars().take(80).collect(),
43                    });
44                }
45                ($check)(&doc)?;
46                Ok(doc)
47            }
48        }
49    };
50}
51pub(crate) use checked_document;
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
54#[serde(deny_unknown_fields)]
55pub struct DescribeRequest {
56    pub schema: String,
57    pub versions: Vec<u32>,
58}
59crate::document::document!(DescribeRequest, DESCRIBE, crate::limits::REMOTE_V1);
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
62#[serde(deny_unknown_fields)]
63pub struct DescribeResponse {
64    pub schema: String,
65    pub version: u32,
66    pub sources: Vec<HostedSource>,
67}
68crate::document::document!(DescribeResponse, DESCRIBE, crate::limits::REMOTE_V1);
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
71#[serde(deny_unknown_fields)]
72pub struct HostedSource {
73    pub source_id: String,
74    pub fields: Vec<ContextField>,
75    pub terms: ContextTerms,
76    // Extension point: spec 036 (source descriptor and its SourceId).
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
80#[serde(deny_unknown_fields)]
81pub struct ContextField {
82    pub name: String,
83    #[serde(rename = "type")]
84    pub ty: TypeDecl,
85    pub provenance: ProvenanceClass,
86    pub freshness: Freshness,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub grade: Option<Grade>,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
92#[serde(rename_all = "snake_case", deny_unknown_fields)]
93pub enum Freshness {
94    Observation { staleness: Staleness },
95    Revision { staleness: Staleness },
96}
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
98#[serde(rename_all = "snake_case", deny_unknown_fields)]
99pub enum Staleness {
100    Unknown,
101    Bounded { max_age_ms: u64 },
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
105#[serde(deny_unknown_fields)]
106pub struct ContextTerms {
107    pub cancellation: CancelSupport,
108    pub idempotency_window_ms: u64,
109    pub max_request_bytes: u64,
110    pub max_response_bytes: u64,
111    pub invalidation: InvalidationSupport,
112    pub endpoint_class: EndpointClass,
113}
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
115#[serde(rename_all = "snake_case")]
116pub enum EndpointClass {
117    HostOperated,
118}
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
120#[serde(rename_all = "snake_case", deny_unknown_fields)]
121pub enum InvalidationSupport {
122    None,
123    Push,
124    Poll { interval_ms: u64, max_lag_ms: u64 },
125}
126
127/// The existing closed remote taxonomy, with identity mismatch renamed for describe.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub struct ContextCode(pub RemoteCode);
130impl Serialize for ContextCode {
131    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
132        s.serialize_str(if self.0 == RemoteCode::IdentityMismatch {
133            "describe_mismatch"
134        } else {
135            self.0.as_str()
136        })
137    }
138}
139impl<'de> Deserialize<'de> for ContextCode {
140    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
141        let name = String::deserialize(d)?;
142        if name == "describe_mismatch" {
143            return Ok(Self(RemoteCode::IdentityMismatch));
144        }
145        RemoteCode::ALL
146            .into_iter()
147            .find(|c| *c != RemoteCode::IdentityMismatch && c.as_str() == name)
148            .map(Self)
149            .ok_or_else(|| serde::de::Error::custom("unknown context failure code"))
150    }
151}
152impl schemars::JsonSchema for ContextCode {
153    fn schema_name() -> std::borrow::Cow<'static, str> {
154        "ContextCode".into()
155    }
156    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
157        let codes: Vec<_> = RemoteCode::ALL
158            .into_iter()
159            .map(|c| {
160                if c == RemoteCode::IdentityMismatch {
161                    "describe_mismatch"
162                } else {
163                    c.as_str()
164                }
165            })
166            .collect();
167        schemars::json_schema!({ "type": "string", "enum": codes })
168    }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
172#[serde(deny_unknown_fields)]
173pub struct FetchRequest {
174    pub schema: String,
175    pub version: u32,
176    pub source_id: String,
177    pub describe_digest: ContentDigest,
178    pub attempt_id: String,
179    pub budget_ms: u64,
180    pub trace_id: String,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub traceparent: Option<String>,
183    pub principal: WirePrincipal,
184    pub fields: Vec<String>,
185}
186checked_document!(
187    FetchRequest,
188    FETCH,
189    crate::limits::REMOTE_V1,
190    |d: &FetchRequest| {
191        if !d.principal.is_canonical() {
192            return Err(invalid("invalid principal reference"));
193        }
194        validate_traceparent(d.traceparent.as_deref())?;
195        if !valid_fetch_attempt(&d.attempt_id) {
196            return Err(invalid("invalid context attempt id"));
197        }
198        Ok(())
199    }
200);
201
202pub(crate) fn validate_traceparent(value: Option<&str>) -> Result<(), DocumentError> {
203    let Some(value) = value else {
204        return Ok(());
205    };
206    let b = value.as_bytes();
207    if b.len() != 55
208        || &b[..3] != b"00-"
209        || b[35] != b'-'
210        || b[52] != b'-'
211        || !b.iter().enumerate().all(|(i, c)| {
212            [2, 35, 52].contains(&i) || c.is_ascii_digit() || (b'a'..=b'f').contains(c)
213        })
214        || b[3..35].iter().all(|c| *c == b'0')
215        || b[36..52].iter().all(|c| *c == b'0')
216    {
217        return Err(invalid("invalid_trace_context"));
218    }
219    Ok(())
220}
221
222// Attempt ids are generated by the runtime (spec 003 3.9.2), which escapes
223// `%`, `/` and `,` as `%25`, `%2F` and `%2C`. Only that spelling is accepted,
224// so one attempt has exactly one id and idempotency keys cannot alias.
225fn valid_fetch_attempt(value: &str) -> bool {
226    let Some((decision, n)) = value.split_once("/context/") else {
227        return false;
228    };
229    if n.is_empty() || n.starts_with('0') || !n.bytes().all(|c| c.is_ascii_digit()) {
230        return false;
231    }
232    let mut decoded = 0;
233    let mut rest = decision.as_bytes();
234    while !rest.is_empty() {
235        match rest[0] {
236            b'%' => {
237                if rest.len() < 3 || !matches!(&rest[1..3], b"25" | b"2F" | b"2C") {
238                    return false;
239                }
240                rest = &rest[3..];
241            }
242            b'/' | b',' => return false,
243            _ => rest = &rest[1..],
244        }
245        decoded += 1;
246    }
247    (1..=crate::scope::MAX_HANDLE_BYTES).contains(&decoded)
248}
249
250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
251#[serde(deny_unknown_fields)]
252pub struct FetchResponse {
253    pub schema: String,
254    pub result: FetchResult,
255}
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
257#[serde(rename_all = "snake_case", deny_unknown_fields)]
258pub enum FetchResult {
259    Snapshot(Snapshot),
260    Failure { code: ContextCode, detail: String },
261}
262// A snapshot retains its own parser limits, including its prohibition on fractions.
263checked_document!(
264    FetchResponse,
265    FETCH,
266    crate::limits::RECORD_V1,
267    |d: &FetchResponse| {
268        if let FetchResult::Snapshot(snapshot) = &d.result {
269            let bytes = serde_json::to_vec(snapshot).map_err(|e| invalid(e.to_string()))?;
270            Snapshot::parse(&bytes)?;
271        }
272        Ok(())
273    }
274);
275
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
277#[serde(deny_unknown_fields)]
278pub struct CancelRequest {
279    pub schema: String,
280    pub attempt_ids: Vec<String>,
281}
282crate::document::document!(CancelRequest, CANCEL, crate::limits::REMOTE_V1);
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
284#[serde(deny_unknown_fields)]
285pub struct CancelResponse {
286    pub schema: String,
287    pub answers: Vec<CancelItem>,
288}
289crate::document::document!(CancelResponse, CANCEL, crate::limits::REMOTE_V1);
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
291#[serde(deny_unknown_fields)]
292pub struct CancelItem {
293    pub attempt_id: String,
294    pub answer: CancelAnswer,
295}
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
297#[serde(rename_all = "snake_case")]
298pub enum CancelAnswer {
299    Stopped,
300    Unconfirmed,
301    UnknownAttempt,
302}
303
304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
305#[serde(deny_unknown_fields)]
306pub struct Invalidation {
307    pub schema: String,
308    pub source_id: String,
309    pub sequence: u64,
310    pub kind: InvalidationKind,
311    pub fields: Vec<String>,
312    pub scope: InvalidationScope,
313}
314checked_document!(
315    Invalidation,
316    INVALIDATION,
317    crate::limits::REMOTE_V1,
318    |d: &Invalidation| {
319        if let InvalidationScope::Principal(p) = &d.scope
320            && !p.is_canonical()
321        {
322            return Err(invalid("invalid principal reference"));
323        }
324        Ok(())
325    }
326);
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
328#[serde(rename_all = "snake_case")]
329pub enum InvalidationKind {
330    Correction,
331    Revocation,
332    Erasure,
333}
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
335#[serde(rename_all = "snake_case", deny_unknown_fields)]
336pub enum InvalidationScope {
337    Principal(WirePrincipal),
338    AllScopes,
339}
340
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
342#[serde(deny_unknown_fields)]
343pub struct InvalidationsRequest {
344    pub schema: String,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub sequence: Option<u64>,
347}
348crate::document::document!(
349    InvalidationsRequest,
350    INVALIDATIONS,
351    crate::limits::REMOTE_V1
352);
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
354#[serde(deny_unknown_fields)]
355pub struct InvalidationsResponse {
356    pub schema: String,
357    pub sequence: u64,
358    pub items: Vec<Invalidation>,
359}
360checked_document!(
361    InvalidationsResponse,
362    INVALIDATIONS,
363    crate::limits::REMOTE_V1,
364    |d: &InvalidationsResponse| {
365        for item in &d.items {
366            Invalidation::parse(&serde_json::to_vec(item).map_err(|e| invalid(e.to_string()))?)?;
367        }
368        Ok(())
369    }
370);
371
372/// Digest-only evidence, never a copy of fetched context or principal bytes.
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
374#[serde(deny_unknown_fields)]
375pub struct ExchangeRecord {
376    pub schema: String,
377    pub attempt_id: String,
378    pub request_digest: Option<ContentDigest>,
379    pub response_digest: Option<ContentDigest>,
380    pub code: Option<ContextCode>,
381    pub phases: Vec<TransportPhase>,
382    pub late: bool,
383}
384crate::document::document!(ExchangeRecord, EXCHANGE, crate::limits::EXCHANGE_V1);