Skip to main content

af_context/
lib.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use async_trait::async_trait;
4
5/// Caller identity and ambient product policy resolved from transport metadata.
6/// It is deliberately absent from Agent and Docs request bodies.
7#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8pub struct RequestContext {
9    pub tenant_id: String,
10    pub subject_id: String,
11    pub roles: BTreeSet<String>,
12    pub locale: String,
13    pub request_id: String,
14    pub entitlements: BTreeSet<String>,
15}
16
17impl RequestContext {
18    pub fn validate(&self) -> Result<(), ContextError> {
19        for (name, value) in [
20            ("tenant_id", self.tenant_id.as_str()),
21            ("subject_id", self.subject_id.as_str()),
22            ("request_id", self.request_id.as_str()),
23        ] {
24            if value.trim().is_empty() {
25                return Err(ContextError::Missing(name));
26            }
27        }
28        Ok(())
29    }
30
31    /// Durable records retain identity only. Roles and entitlements are
32    /// re-resolved before every recovered execution.
33    pub fn durable_identity(&self) -> Self {
34        Self {
35            tenant_id: self.tenant_id.clone(),
36            subject_id: self.subject_id.clone(),
37            roles: BTreeSet::new(),
38            locale: self.locale.clone(),
39            request_id: self.request_id.clone(),
40            entitlements: BTreeSet::new(),
41        }
42    }
43}
44
45/// Transport metadata passed to a product context adapter.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct RequestMetadata(pub BTreeMap<String, String>);
48
49#[derive(Debug, thiserror::Error, PartialEq, Eq)]
50pub enum ContextError {
51    #[error("missing request context field '{0}'")]
52    Missing(&'static str),
53    #[error("request context rejected: {0}")]
54    Rejected(String),
55    #[error("request context dependency unavailable: {0}")]
56    Unavailable(String),
57}
58
59#[async_trait]
60pub trait PlatformContextProvider: Send + Sync {
61    async fn resolve(&self, metadata: &RequestMetadata) -> Result<RequestContext, ContextError>;
62}
63
64#[async_trait]
65pub trait ScheduledContextProvider: Send + Sync {
66    async fn resolve_scheduled(
67        &self,
68        tenant_id: &str,
69        subject_id: &str,
70        request_id: &str,
71        locale: &str,
72    ) -> Result<RequestContext, ContextError>;
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn rejects_missing_identity_fields() {
81        let context = RequestContext {
82            tenant_id: "tenant".into(),
83            subject_id: "".into(),
84            roles: BTreeSet::new(),
85            locale: "en".into(),
86            request_id: "request".into(),
87            entitlements: BTreeSet::new(),
88        };
89        assert_eq!(context.validate(), Err(ContextError::Missing("subject_id")));
90    }
91}