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