Skip to main content

af_context/
lib.rs

1//! Transport-neutral caller context and the branded identifiers every Factory
2//! crate shares. Nothing here knows about HTTP, gRPC, Postgres or a product.
3
4#![deny(missing_docs)]
5#![deny(rustdoc::broken_intra_doc_links)]
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use async_trait::async_trait;
10
11pub mod ids;
12pub mod testing;
13pub use ids::{
14    ActionIntentId, EmptyId, InputId, InstanceId, InteractionId, NodeId, ProfileRevisionId,
15    RequestId, RunId, SessionId, SpaceId, SubjectId, TenantId, ToolCallId,
16};
17
18/// Caller identity and ambient product policy resolved from transport metadata.
19/// It is deliberately absent from Agent and Docs request bodies.
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21pub struct RequestContext {
22    /// Tenant that owns this record.
23    pub tenant_id: TenantId,
24    /// Subject (user or service principal) acting on or owning this record.
25    pub subject_id: SubjectId,
26    /// Roles granted to the subject.
27    pub roles: BTreeSet<String>,
28    /// BCP 47 locale for user-facing text.
29    pub locale: String,
30    /// Transport request identity for tracing and idempotency.
31    pub request_id: RequestId,
32    /// Product entitlements granted to the subject.
33    pub entitlements: BTreeSet<String>,
34}
35
36impl RequestContext {
37    /// Reject a context whose identity fields are blank; roles and entitlements may be empty.
38    pub fn validate(&self) -> Result<(), ContextError> {
39        for (name, value) in [
40            ("tenant_id", self.tenant_id.as_str()),
41            ("subject_id", self.subject_id.as_str()),
42            ("request_id", self.request_id.as_str()),
43        ] {
44            if value.trim().is_empty() {
45                return Err(ContextError::Missing(name));
46            }
47        }
48        Ok(())
49    }
50
51    /// Durable records retain identity only. Roles and entitlements are
52    /// re-resolved before every recovered execution.
53    pub fn durable_identity(&self) -> Self {
54        Self {
55            tenant_id: self.tenant_id.clone(),
56            subject_id: self.subject_id.clone(),
57            roles: BTreeSet::new(),
58            locale: self.locale.clone(),
59            request_id: self.request_id.clone(),
60            entitlements: BTreeSet::new(),
61        }
62    }
63}
64
65/// Transport metadata passed to a product context adapter.
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct RequestMetadata(pub BTreeMap<String, String>);
68
69/// Why a request context could not be resolved or accepted.
70#[derive(Debug, thiserror::Error, PartialEq, Eq)]
71pub enum ContextError {
72    /// Missing request context field ''.
73    #[error("missing request context field '{0}'")]
74    Missing(&'static str),
75    /// Request context rejected.
76    #[error("request context rejected: {0}")]
77    Rejected(String),
78    /// Request context dependency unavailable.
79    #[error("request context dependency unavailable: {0}")]
80    Unavailable(String),
81}
82
83/// Resolves transport metadata (headers, bearer tokens) into a verified [`RequestContext`].
84/// Hosts own this seam; the platform never trusts caller-supplied identity claims.
85#[async_trait]
86pub trait PlatformContextProvider: Send + Sync {
87    /// Resolve one request. Fail closed: an unknown or revoked principal is an error, not an anonymous context.
88    async fn resolve(&self, metadata: &RequestMetadata) -> Result<RequestContext, ContextError>;
89}
90
91/// Re-resolves the identity persisted with scheduled work before it runs, so revoked principals cannot resume.
92#[async_trait]
93pub trait ScheduledContextProvider: Send + Sync {
94    /// Resolve a durable identity into a fresh context with current roles and entitlements.
95    async fn resolve_scheduled(
96        &self,
97        tenant_id: &TenantId,
98        subject_id: &SubjectId,
99        request_id: &RequestId,
100        locale: &str,
101    ) -> Result<RequestContext, ContextError>;
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn identity_fields_cannot_be_blank() {
110        assert_eq!(SubjectId::try_from(""), Err(EmptyId("SubjectId")));
111        let context = RequestContext {
112            tenant_id: "tenant".parse().unwrap(),
113            subject_id: "subject".parse().unwrap(),
114            roles: BTreeSet::new(),
115            locale: "en".into(),
116            request_id: "request".parse().unwrap(),
117            entitlements: BTreeSet::new(),
118        };
119        assert_eq!(context.validate(), Ok(()));
120        assert_eq!(context.durable_identity().tenant_id, "tenant");
121    }
122}