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