af-context 0.4.0

Transport-neutral tenant, subject, locale, and entitlement context.
Documentation
//! Transport-neutral caller context and the branded identifiers every Factory
//! crate shares. Nothing here knows about HTTP, gRPC, Postgres or a product.

#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]

use std::collections::{BTreeMap, BTreeSet};

use async_trait::async_trait;

pub mod ids;
pub mod testing;
pub use ids::{
    ActionIntentId, AssetId, CommandId, EmptyId, InputId, InstanceId, InteractionId, InviteId,
    NodeId, ProfileRevisionId, ReleaseId, RequestId, RevisionId, RunId, SessionId, SpaceId,
    SubjectId, TenantId, ToolCallId,
};

/// 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 {
    /// Tenant that owns this record.
    pub tenant_id: TenantId,
    /// Subject (user or service principal) acting on or owning this record.
    pub subject_id: SubjectId,
    /// Roles granted to the subject.
    pub roles: BTreeSet<String>,
    /// BCP 47 locale for user-facing text.
    pub locale: String,
    /// Transport request identity for tracing and idempotency.
    pub request_id: RequestId,
    /// Product entitlements granted to the subject.
    pub entitlements: BTreeSet<String>,
}

impl RequestContext {
    /// Reject a context whose identity fields are blank; roles and entitlements may be empty.
    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>);

/// Why a request context could not be resolved or accepted.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ContextError {
    /// Missing request context field ''.
    #[error("missing request context field '{0}'")]
    Missing(&'static str),
    /// Request context rejected.
    #[error("request context rejected: {0}")]
    Rejected(String),
    /// Request context dependency unavailable.
    #[error("request context dependency unavailable: {0}")]
    Unavailable(String),
}

/// Resolves transport metadata (headers, bearer tokens) into a verified [`RequestContext`].
/// Hosts own this seam; the platform never trusts caller-supplied identity claims.
#[async_trait]
pub trait PlatformContextProvider: Send + Sync {
    /// Resolve one request. Fail closed: an unknown or revoked principal is an error, not an anonymous context.
    async fn resolve(&self, metadata: &RequestMetadata) -> Result<RequestContext, ContextError>;
}

/// Re-resolves the identity persisted with scheduled work before it runs, so revoked principals cannot resume.
#[async_trait]
pub trait ScheduledContextProvider: Send + Sync {
    /// Resolve a durable identity into a fresh context with current roles and entitlements.
    async fn resolve_scheduled(
        &self,
        tenant_id: &TenantId,
        subject_id: &SubjectId,
        request_id: &RequestId,
        locale: &str,
    ) -> Result<RequestContext, ContextError>;
}

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

    #[test]
    fn identity_fields_cannot_be_blank() {
        assert_eq!(SubjectId::try_from(""), Err(EmptyId("SubjectId")));
        let context = RequestContext {
            tenant_id: "tenant".parse().unwrap(),
            subject_id: "subject".parse().unwrap(),
            roles: BTreeSet::new(),
            locale: "en".into(),
            request_id: "request".parse().unwrap(),
            entitlements: BTreeSet::new(),
        };
        assert_eq!(context.validate(), Ok(()));
        assert_eq!(context.durable_identity().tenant_id, "tenant");
    }
}