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