af-context 0.2.0

Transport-neutral tenant, subject, locale, and entitlement context.
Documentation
use std::collections::{BTreeMap, BTreeSet};

use async_trait::async_trait;

/// Caller identity and ambient product policy resolved from transport metadata.
/// It is deliberately absent from Agent and Docs request bodies.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RequestContext {
    pub tenant_id: String,
    pub subject_id: String,
    pub roles: BTreeSet<String>,
    pub locale: String,
    pub request_id: String,
    pub entitlements: BTreeSet<String>,
}

impl RequestContext {
    pub fn validate(&self) -> Result<(), ContextError> {
        for (name, value) in [
            ("tenant_id", self.tenant_id.as_str()),
            ("subject_id", self.subject_id.as_str()),
            ("request_id", self.request_id.as_str()),
        ] {
            if value.trim().is_empty() {
                return Err(ContextError::Missing(name));
            }
        }
        Ok(())
    }

    /// Durable records retain identity only. Roles and entitlements are
    /// re-resolved before every recovered execution.
    pub fn durable_identity(&self) -> Self {
        Self {
            tenant_id: self.tenant_id.clone(),
            subject_id: self.subject_id.clone(),
            roles: BTreeSet::new(),
            locale: self.locale.clone(),
            request_id: self.request_id.clone(),
            entitlements: BTreeSet::new(),
        }
    }
}

/// Transport metadata passed to a product context adapter.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestMetadata(pub BTreeMap<String, String>);

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ContextError {
    #[error("missing request context field '{0}'")]
    Missing(&'static str),
    #[error("request context rejected: {0}")]
    Rejected(String),
    #[error("request context dependency unavailable: {0}")]
    Unavailable(String),
}

#[async_trait]
pub trait PlatformContextProvider: Send + Sync {
    async fn resolve(&self, metadata: &RequestMetadata) -> Result<RequestContext, ContextError>;
}

#[async_trait]
pub trait ScheduledContextProvider: Send + Sync {
    async fn resolve_scheduled(
        &self,
        tenant_id: &str,
        subject_id: &str,
        request_id: &str,
        locale: &str,
    ) -> Result<RequestContext, ContextError>;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_missing_identity_fields() {
        let context = RequestContext {
            tenant_id: "tenant".into(),
            subject_id: "".into(),
            roles: BTreeSet::new(),
            locale: "en".into(),
            request_id: "request".into(),
            entitlements: BTreeSet::new(),
        };
        assert_eq!(context.validate(), Err(ContextError::Missing("subject_id")));
    }
}