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::acquisition::AcquisitionRequest;
5use crate::definition::{ProvenanceClass, TypeDecl};
6use crate::grade::Grade;
7use crate::ids::{AcquisitionRequestId, ContentDigest, SourceId};
8use crate::remote::{CancelSupport, RemoteCode, TransportPhase, WirePrincipal};
9use crate::run::Charge;
10use crate::serve::Embedded;
11use crate::snapshot::Snapshot;
12use crate::source::SourceDescriptor;
13use crate::{Document, DocumentError, Identified};
14use serde::{Deserialize, Serialize};
15
16pub const PROTOCOL: &str = "rustev.remote-context/1";
17pub const DESCRIBE: &str = "rustev.remote-context-describe/1";
18pub const FETCH: &str = "rustev.remote-context-fetch/1";
19pub const CANCEL: &str = "rustev.remote-context-cancel/1";
20pub const INVALIDATION: &str = "rustev.remote-context-invalidation/1";
21pub const INVALIDATIONS: &str = "rustev.remote-context-invalidations/1";
22pub const EXCHANGE: &str = "rustev.remote-context-exchange/1";
23pub const ACQUIRE: &str = "rustev.remote-context-acquire/1";
24
25pub(crate) fn invalid(message: impl Into<String>) -> DocumentError {
26    DocumentError::Parse(crate::bounded::ParseError::Schema {
27        message: message.into(),
28    })
29}
30
31/// A schema check and document-specific validation after the bounded scan.
32macro_rules! checked_document {
33    ($ty:ty, $schema:expr, $limits:expr, $check:expr) => {
34        impl crate::Document for $ty {
35            const SCHEMA: &'static str = $schema;
36            const LIMITS: crate::bounded::ParseLimits = $limits;
37            fn schema(&self) -> &str {
38                &self.schema
39            }
40            fn parse(bytes: &[u8]) -> Result<Self, crate::DocumentError> {
41                let doc: Self = crate::bounded::parse_bounded(bytes, &Self::LIMITS)
42                    .map_err(crate::DocumentError::Parse)?;
43                if doc.schema != Self::SCHEMA {
44                    return Err(crate::DocumentError::Schema {
45                        expected: Self::SCHEMA,
46                        found: doc.schema.chars().take(80).collect(),
47                    });
48                }
49                ($check)(&doc)?;
50                Ok(doc)
51            }
52        }
53    };
54}
55pub(crate) use checked_document;
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct DescribeRequest {
60    pub schema: String,
61    pub versions: Vec<u32>,
62}
63crate::document::document!(DescribeRequest, DESCRIBE, crate::limits::REMOTE_V1);
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
66#[serde(deny_unknown_fields)]
67pub struct DescribeResponse {
68    pub schema: String,
69    pub version: u32,
70    pub sources: Vec<HostedSource>,
71}
72checked_document!(
73    DescribeResponse,
74    DESCRIBE,
75    crate::limits::REMOTE_V1,
76    |d: &DescribeResponse| {
77        for source in &d.sources {
78            if let Some(hosted) = &source.descriptor {
79                hosted.check()?;
80            }
81        }
82        Ok(())
83    }
84);
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
87#[serde(deny_unknown_fields)]
88pub struct HostedSource {
89    pub source_id: String,
90    pub fields: Vec<ContextField>,
91    pub terms: ContextTerms,
92    /// The source's spec 036 descriptor, absent when it declares none.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub descriptor: Option<HostedDescriptor>,
95}
96
97/// A `rustev.source/1` descriptor carried verbatim with its `SourceId`
98/// (3.10.1). The id must be the identity of exactly these bytes.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
100#[serde(deny_unknown_fields)]
101pub struct HostedDescriptor {
102    pub id: SourceId,
103    pub document: Embedded<SourceDescriptor>,
104}
105impl HostedDescriptor {
106    pub fn new(descriptor: &SourceDescriptor) -> Result<Self, DocumentError> {
107        let bytes = descriptor.canonical().map_err(|e| invalid(e.to_string()))?;
108        Ok(Self {
109            id: descriptor.id().map_err(|e| invalid(e.to_string()))?,
110            document: Embedded::parse(&bytes)?,
111        })
112    }
113    fn check(&self) -> Result<(), DocumentError> {
114        let descriptor = self.document.document()?;
115        let canonical = descriptor.canonical().map_err(|e| invalid(e.to_string()))?;
116        if canonical != self.document.bytes() {
117            return Err(invalid("source descriptor bytes are not canonical"));
118        }
119        if descriptor.id().map_err(|e| invalid(e.to_string()))? != self.id {
120            return Err(invalid("source descriptor id does not match its bytes"));
121        }
122        Ok(())
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
127#[serde(deny_unknown_fields)]
128pub struct ContextField {
129    pub name: String,
130    #[serde(rename = "type")]
131    pub ty: TypeDecl,
132    pub provenance: ProvenanceClass,
133    pub freshness: Freshness,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub grade: Option<Grade>,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
139#[serde(rename_all = "snake_case", deny_unknown_fields)]
140pub enum Freshness {
141    Observation { staleness: Staleness },
142    Revision { staleness: Staleness },
143}
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
145#[serde(rename_all = "snake_case", deny_unknown_fields)]
146pub enum Staleness {
147    Unknown,
148    Bounded { max_age_ms: u64 },
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
152#[serde(deny_unknown_fields)]
153pub struct ContextTerms {
154    pub cancellation: CancelSupport,
155    pub idempotency_window_ms: u64,
156    pub max_request_bytes: u64,
157    pub max_response_bytes: u64,
158    pub invalidation: InvalidationSupport,
159    pub endpoint_class: EndpointClass,
160}
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
162#[serde(rename_all = "snake_case")]
163pub enum EndpointClass {
164    HostOperated,
165}
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
167#[serde(rename_all = "snake_case", deny_unknown_fields)]
168pub enum InvalidationSupport {
169    None,
170    Push,
171    Poll { interval_ms: u64, max_lag_ms: u64 },
172}
173
174/// The existing closed remote taxonomy, with identity mismatch renamed for describe.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub struct ContextCode(pub RemoteCode);
177impl Serialize for ContextCode {
178    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
179        s.serialize_str(if self.0 == RemoteCode::IdentityMismatch {
180            "describe_mismatch"
181        } else {
182            self.0.as_str()
183        })
184    }
185}
186impl ContextCode {
187    fn name(self) -> &'static str {
188        if self.0 == RemoteCode::IdentityMismatch {
189            "describe_mismatch"
190        } else {
191            self.0.as_str()
192        }
193    }
194    fn from_name(name: &str) -> Option<Self> {
195        RemoteCode::ALL
196            .into_iter()
197            .map(Self)
198            .find(|c| c.name() == name)
199    }
200}
201impl<'de> Deserialize<'de> for ContextCode {
202    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
203        let name = String::deserialize(d)?;
204        Self::from_name(&name)
205            .ok_or_else(|| serde::de::Error::custom("unknown context failure code"))
206    }
207}
208impl schemars::JsonSchema for ContextCode {
209    fn schema_name() -> std::borrow::Cow<'static, str> {
210        "ContextCode".into()
211    }
212    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
213        let codes: Vec<_> = RemoteCode::ALL
214            .into_iter()
215            .map(|c| ContextCode(c).name())
216            .collect();
217        schemars::json_schema!({ "type": "string", "enum": codes })
218    }
219}
220/// The acquire failure taxonomy (3.10.3): the context codes and `denied`,
221/// the source refusing the lookup for this principal. An exchange record
222/// carries either.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum AcquireCode {
225    Context(ContextCode),
226    Denied,
227}
228impl From<RemoteCode> for AcquireCode {
229    fn from(code: RemoteCode) -> Self {
230        Self::Context(ContextCode(code))
231    }
232}
233impl Serialize for AcquireCode {
234    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
235        match self {
236            Self::Context(c) => c.serialize(s),
237            Self::Denied => s.serialize_str("denied"),
238        }
239    }
240}
241impl<'de> Deserialize<'de> for AcquireCode {
242    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
243        let name = String::deserialize(d)?;
244        if name == "denied" {
245            return Ok(Self::Denied);
246        }
247        ContextCode::from_name(&name)
248            .map(Self::Context)
249            .ok_or_else(|| serde::de::Error::custom("unknown acquire failure code"))
250    }
251}
252impl schemars::JsonSchema for AcquireCode {
253    fn schema_name() -> std::borrow::Cow<'static, str> {
254        "AcquireCode".into()
255    }
256    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
257        let mut codes: Vec<_> = RemoteCode::ALL
258            .into_iter()
259            .map(|c| ContextCode(c).name())
260            .collect();
261        codes.push("denied");
262        schemars::json_schema!({ "type": "string", "enum": codes })
263    }
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
267#[serde(deny_unknown_fields)]
268pub struct FetchRequest {
269    pub schema: String,
270    pub version: u32,
271    pub source_id: String,
272    pub describe_digest: ContentDigest,
273    pub attempt_id: String,
274    pub budget_ms: u64,
275    pub trace_id: String,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub traceparent: Option<String>,
278    pub principal: WirePrincipal,
279    pub fields: Vec<String>,
280}
281checked_document!(
282    FetchRequest,
283    FETCH,
284    crate::limits::REMOTE_V1,
285    |d: &FetchRequest| {
286        if !d.principal.is_canonical() {
287            return Err(invalid("invalid principal reference"));
288        }
289        validate_traceparent(d.traceparent.as_deref())?;
290        if !valid_fetch_attempt(&d.attempt_id) {
291            return Err(invalid("invalid context attempt id"));
292        }
293        Ok(())
294    }
295);
296
297pub(crate) fn validate_traceparent(value: Option<&str>) -> Result<(), DocumentError> {
298    let Some(value) = value else {
299        return Ok(());
300    };
301    let b = value.as_bytes();
302    if b.len() != 55
303        || &b[..3] != b"00-"
304        || b[35] != b'-'
305        || b[52] != b'-'
306        || !b.iter().enumerate().all(|(i, c)| {
307            [2, 35, 52].contains(&i) || c.is_ascii_digit() || (b'a'..=b'f').contains(c)
308        })
309        || b[3..35].iter().all(|c| *c == b'0')
310        || b[36..52].iter().all(|c| *c == b'0')
311    {
312        return Err(invalid("invalid_trace_context"));
313    }
314    Ok(())
315}
316
317// Attempt ids are generated by the runtime (spec 003 3.9.2), which escapes
318// `%`, `/` and `,` as `%25`, `%2F` and `%2C`. Only that spelling is accepted,
319// so one attempt has exactly one id and idempotency keys cannot alias.
320fn valid_fetch_attempt(value: &str) -> bool {
321    let Some((decision, n)) = value.split_once("/context/") else {
322        return false;
323    };
324    number(n) && escaped(decision)
325}
326// A positive decimal with no leading zero.
327fn number(n: &str) -> bool {
328    !n.is_empty() && !n.starts_with('0') && n.bytes().all(|c| c.is_ascii_digit())
329}
330fn index(n: &str) -> bool {
331    n == "0" || number(n)
332}
333fn escaped(component: &str) -> bool {
334    let mut decoded = 0;
335    let mut rest = component.as_bytes();
336    while !rest.is_empty() {
337        match rest[0] {
338            b'%' => {
339                if rest.len() < 3 || !matches!(&rest[1..3], b"25" | b"2F" | b"2C") {
340                    return false;
341                }
342                rest = &rest[3..];
343            }
344            b'/' | b',' => return false,
345            _ => rest = &rest[1..],
346        }
347        decoded += 1;
348    }
349    (1..=crate::scope::MAX_HANDLE_BYTES).contains(&decoded)
350}
351// The id spec 036 assigns an acquisition attempt:
352// `<decision id>/<step id>/<tier>,<page>/<n>`, with `r<n>` on a rerun.
353fn valid_acquire_attempt(value: &str) -> bool {
354    let parts: Vec<&str> = value.split('/').collect();
355    let [decision, step, position, n] = parts.as_slice() else {
356        return false;
357    };
358    let Some((tier, page)) = position.split_once(',') else {
359        return false;
360    };
361    escaped(decision)
362        && escaped(step)
363        && index(tier)
364        && index(page)
365        && number(n.strip_prefix('r').unwrap_or(n))
366}
367
368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
369#[serde(deny_unknown_fields)]
370pub struct FetchResponse {
371    pub schema: String,
372    pub result: FetchResult,
373}
374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
375#[serde(rename_all = "snake_case", deny_unknown_fields)]
376pub enum FetchResult {
377    Snapshot(Snapshot),
378    Failure { code: ContextCode, detail: String },
379}
380// A snapshot retains its own parser limits, including its prohibition on fractions.
381checked_document!(
382    FetchResponse,
383    FETCH,
384    crate::limits::RECORD_V1,
385    |d: &FetchResponse| {
386        if let FetchResult::Snapshot(snapshot) = &d.result {
387            let bytes = serde_json::to_vec(snapshot).map_err(|e| invalid(e.to_string()))?;
388            Snapshot::parse(&bytes)?;
389        }
390        Ok(())
391    }
392);
393
394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
395#[serde(deny_unknown_fields)]
396pub struct CancelRequest {
397    pub schema: String,
398    pub attempt_ids: Vec<String>,
399}
400crate::document::document!(CancelRequest, CANCEL, crate::limits::REMOTE_V1);
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
402#[serde(deny_unknown_fields)]
403pub struct CancelResponse {
404    pub schema: String,
405    pub answers: Vec<CancelItem>,
406}
407crate::document::document!(CancelResponse, CANCEL, crate::limits::REMOTE_V1);
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
409#[serde(deny_unknown_fields)]
410pub struct CancelItem {
411    pub attempt_id: String,
412    pub answer: CancelAnswer,
413}
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
415#[serde(rename_all = "snake_case")]
416pub enum CancelAnswer {
417    Stopped,
418    Unconfirmed,
419    UnknownAttempt,
420}
421
422#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
423#[serde(deny_unknown_fields)]
424pub struct Invalidation {
425    pub schema: String,
426    pub source_id: String,
427    pub sequence: u64,
428    pub kind: InvalidationKind,
429    pub fields: Vec<String>,
430    pub scope: InvalidationScope,
431}
432checked_document!(
433    Invalidation,
434    INVALIDATION,
435    crate::limits::REMOTE_V1,
436    |d: &Invalidation| {
437        if let InvalidationScope::Principal(p) = &d.scope
438            && !p.is_canonical()
439        {
440            return Err(invalid("invalid principal reference"));
441        }
442        Ok(())
443    }
444);
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
446#[serde(rename_all = "snake_case")]
447pub enum InvalidationKind {
448    Correction,
449    Revocation,
450    Erasure,
451}
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
453#[serde(rename_all = "snake_case", deny_unknown_fields)]
454pub enum InvalidationScope {
455    Principal(WirePrincipal),
456    AllScopes,
457}
458
459#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
460#[serde(deny_unknown_fields)]
461pub struct InvalidationsRequest {
462    pub schema: String,
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub sequence: Option<u64>,
465}
466crate::document::document!(
467    InvalidationsRequest,
468    INVALIDATIONS,
469    crate::limits::REMOTE_V1
470);
471#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
472#[serde(deny_unknown_fields)]
473pub struct InvalidationsResponse {
474    pub schema: String,
475    pub sequence: u64,
476    pub items: Vec<Invalidation>,
477}
478checked_document!(
479    InvalidationsResponse,
480    INVALIDATIONS,
481    crate::limits::REMOTE_V1,
482    |d: &InvalidationsResponse| {
483        for item in &d.items {
484            Invalidation::parse(&serde_json::to_vec(item).map_err(|e| invalid(e.to_string()))?)?;
485        }
486        Ok(())
487    }
488);
489
490/// Digest-only evidence, never a copy of fetched context or principal bytes.
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
492#[serde(deny_unknown_fields)]
493pub struct ExchangeRecord {
494    pub schema: String,
495    pub attempt_id: String,
496    pub request_digest: Option<ContentDigest>,
497    pub response_digest: Option<ContentDigest>,
498    pub code: Option<AcquireCode>,
499    pub phases: Vec<TransportPhase>,
500    pub late: bool,
501}
502crate::document::document!(ExchangeRecord, EXCHANGE, crate::limits::EXCHANGE_V1);
503
504/// An acquisition attempt sent to a remote source (3.10.3): spec 036's
505/// request document and its identity, carried verbatim.
506#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
507#[serde(deny_unknown_fields)]
508pub struct AcquireRequest {
509    pub schema: String,
510    pub version: u32,
511    pub source: SourceId,
512    pub operation: String,
513    pub request: Embedded<AcquisitionRequest>,
514    pub request_id: AcquisitionRequestId,
515    pub attempt_id: String,
516    pub budget_ms: u64,
517    pub trace_id: String,
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub traceparent: Option<String>,
520    pub principal: WirePrincipal,
521}
522checked_document!(
523    AcquireRequest,
524    ACQUIRE,
525    crate::limits::REMOTE_V1,
526    |d: &AcquireRequest| {
527        if !d.principal.is_canonical() {
528            return Err(invalid("invalid principal reference"));
529        }
530        validate_traceparent(d.traceparent.as_deref())?;
531        if !valid_acquire_attempt(&d.attempt_id) {
532            return Err(invalid("invalid acquisition attempt id"));
533        }
534        let request = d.request.document()?;
535        if request.canonical().map_err(|e| invalid(e.to_string()))? != d.request.bytes() {
536            return Err(invalid("acquisition request bytes are not canonical"));
537        }
538        if request.id().map_err(|e| invalid(e.to_string()))? != d.request_id {
539            return Err(invalid("request_id does not match the request bytes"));
540        }
541        if request.source != d.source || request.operation != d.operation {
542            return Err(invalid("source or operation differs from the request"));
543        }
544        Ok(())
545    }
546);
547
548/// A source's answer to one acquisition attempt, with its charge.
549#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
550#[serde(deny_unknown_fields)]
551pub struct AcquireResponse {
552    pub schema: String,
553    pub result: AcquireResult,
554    pub charge: Charge,
555}
556#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
557#[serde(rename_all = "snake_case", deny_unknown_fields)]
558pub enum AcquireResult {
559    Page(PageBytes),
560    Failure { code: AcquireCode, detail: String },
561}
562// The envelope is bounded as a fetch response is; the page inside is
563// validated by the runtime (spec 036 3.3.4), never by the client.
564crate::document::document!(AcquireResponse, ACQUIRE, crate::limits::RECORD_V1);
565
566/// A `rustev.acquisition-page/1` document's bytes exactly as the source sent
567/// them. Only its being a JSON object is checked here: the runtime parses
568/// and validates the page, so an invalid page reaches it unchanged and is
569/// classified there (spec 036 3.13.4).
570#[derive(Debug, Clone, Serialize)]
571#[serde(transparent)]
572pub struct PageBytes(Box<serde_json::value::RawValue>);
573impl PageBytes {
574    pub fn new(bytes: &[u8]) -> Result<Self, DocumentError> {
575        let raw: Box<serde_json::value::RawValue> =
576            serde_json::from_slice(bytes).map_err(|e| invalid(e.to_string()))?;
577        Self::object(raw).map_err(invalid)
578    }
579    fn object(raw: Box<serde_json::value::RawValue>) -> Result<Self, &'static str> {
580        if raw.get().starts_with('{') {
581            Ok(Self(raw))
582        } else {
583            Err("an acquisition page must be an object")
584        }
585    }
586    /// The page bytes exactly as received.
587    pub fn bytes(&self) -> &[u8] {
588        self.0.get().as_bytes()
589    }
590}
591impl PartialEq for PageBytes {
592    fn eq(&self, other: &Self) -> bool {
593        self.0.get() == other.0.get()
594    }
595}
596impl<'de> Deserialize<'de> for PageBytes {
597    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
598        Self::object(Box::<serde_json::value::RawValue>::deserialize(d)?)
599            .map_err(serde::de::Error::custom)
600    }
601}
602impl schemars::JsonSchema for PageBytes {
603    fn schema_name() -> std::borrow::Cow<'static, str> {
604        "PageBytes".into()
605    }
606    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
607        schemars::json_schema!({
608            "type": "object",
609            "description": "A `rustev.acquisition-page/1` document carried as its original bytes; the runtime parses and validates it."
610        })
611    }
612}