Skip to main content

reddb_server/application/
operation_context.rs

1//! Per-request `OperationContext` — the single bag of state every
2//! port receives.
3//!
4//! Today, ports take `&self` only and reach into hidden runtime
5//! state (transaction map keyed by connection id, audit principal
6//! resolved via thread-local hacks, request id missing entirely).
7//! That spreading makes multi-port invariants — "this request's
8//! port_a call and port_b call must share an xid" — invisible to
9//! the type system and untestable.
10//!
11//! `OperationContext` flips that around: handlers build it once at
12//! request entry and pass it through every port call. Forgetting to
13//! propagate it is a compile error; sharing it across two ports is
14//! a single move.
15//!
16//! `WriteConsent` is a sealed token: it can only be constructed by
17//! `WriteGate::check`, so a port's mutating method that demands
18//! `ctx.write_consent.is_some()` is statically guaranteed to have
19//! passed the gate. Forgetting the gate is impossible at the type
20//! level.
21//!
22//! Migration is gated behind the `ctx-ports` feature flag while the
23//! 9 ports are converted one PR at a time. `OperationContext::implicit()`
24//! returns a no-op context that lets unmigrated callers keep
25//! compiling.
26
27use std::sync::atomic::{AtomicU64, Ordering};
28
29/// Snapshot identifier used for MVCC reads. `None` means autocommit
30/// — the port allocates a fresh snapshot per call (current default
31/// for unwrapped paths).
32pub type Xid = u64;
33
34/// Sealed write-permission token. Construct via
35/// `WriteGate::check`; cannot be assembled by application code,
36/// even within this crate, because the inner field is private and
37/// marked `PhantomData<*const ()>` — and the `_seal` field can only
38/// be created inside `runtime::write_gate`.
39#[derive(Debug, Clone)]
40pub struct WriteConsent {
41    pub(crate) kind: crate::runtime::write_gate::WriteKind,
42    pub(crate) _seal: WriteConsentSeal,
43}
44
45/// Module-private marker. Public type, but the only constructor lives
46/// in `runtime::write_gate::WriteConsentSeal::new()`, which is only
47/// callable from the write-gate module. Code outside that module can
48/// pattern-match on this struct but cannot construct it, so building
49/// a `WriteConsent` requires going through the gate.
50#[derive(Debug, Clone)]
51pub struct WriteConsentSeal {
52    _private: std::marker::PhantomData<*const ()>,
53}
54
55// SAFETY: `WriteConsentSeal` carries no owned state; the
56// `PhantomData<*const ()>` marker is unsendable by default to
57// discourage cross-thread token sharing without explicit auditing.
58// We re-add Send+Sync because `WriteConsent` itself is Clone and
59// gets stored on `OperationContext`, which crosses async/Tokio
60// task boundaries on every request. The marker only exists to
61// gate construction; thread-safety of the runtime gate it
62// represents is not affected by sending the token.
63unsafe impl Send for WriteConsentSeal {}
64unsafe impl Sync for WriteConsentSeal {}
65
66impl WriteConsentSeal {
67    /// Create the sealed marker. Pub only within the crate so the
68    /// write-gate module can mint tokens; everything else must
69    /// call `WriteGate::check`.
70    pub(crate) fn new() -> Self {
71        Self {
72            _private: std::marker::PhantomData,
73        }
74    }
75}
76
77/// Per-request context plumbed through every port method.
78#[derive(Debug, Clone)]
79pub struct OperationContext {
80    /// MVCC snapshot id when the request opened a transaction;
81    /// `None` for autocommit reads/writes.
82    pub xid: Option<Xid>,
83    /// Connection identifier the request arrived on; ties this
84    /// context back to per-connection state (current transaction,
85    /// session variables) when needed.
86    pub connection_id: Option<u64>,
87    /// Identity recorded in audit logs. `"anonymous"` when the
88    /// caller did not authenticate.
89    pub audit_principal: String,
90    /// Stable per-request id used for log correlation. Either
91    /// supplied by the caller via `X-Request-Id` or minted as a
92    /// monotonic ULID-like string at request entry.
93    pub request_id: String,
94    /// Sealed gate token, present only when `WriteGate::check`
95    /// granted permission. Mutating port methods demand this is
96    /// `Some(...)`; missing it is a runtime error (and during the
97    /// migration window, a structural reminder that the call site
98    /// hasn't been threaded yet).
99    pub write_consent: Option<WriteConsent>,
100    /// Optional tenant override. `None` falls back to the
101    /// connection's default tenant.
102    pub tenant: Option<String>,
103}
104
105/// Transport-neutral inputs collected at any request boundary.
106#[derive(Debug, Clone, Default)]
107pub struct OperationContextInput {
108    pub request_id: Option<String>,
109    pub principal: Option<String>,
110    pub tenant: Option<String>,
111    pub write_consent: Option<WriteConsent>,
112    pub connection_id: Option<u64>,
113    pub xid: Option<Xid>,
114}
115
116/// Canonical request-context factory shared by HTTP, gRPC, MCP, stdio, and
117/// wire adapters.
118pub struct OperationContextFactory;
119
120impl OperationContextFactory {
121    pub fn build(input: OperationContextInput) -> OperationContext {
122        let request_id = input
123            .request_id
124            .filter(|request_id| !request_id.is_empty())
125            .unwrap_or_else(mint_request_id);
126        let audit_principal = input
127            .principal
128            .filter(|principal| !principal.is_empty())
129            .unwrap_or_else(|| "anonymous".to_string());
130
131        OperationContext {
132            xid: input.xid,
133            connection_id: input.connection_id,
134            audit_principal,
135            request_id,
136            write_consent: input.write_consent,
137            tenant: input.tenant,
138        }
139    }
140}
141
142impl OperationContext {
143    /// Anonymous, no-write-consent context. The default for any
144    /// caller that hasn't been migrated to construct an explicit
145    /// context yet — keeps the migration window compilable.
146    pub fn implicit() -> Self {
147        Self {
148            xid: None,
149            connection_id: None,
150            audit_principal: "anonymous".to_string(),
151            request_id: mint_request_id(),
152            write_consent: None,
153            tenant: None,
154        }
155    }
156
157    /// Read-only context bound to a stable request id. Use when the
158    /// handler knows it is dispatching a query that never mutates.
159    pub fn read_only(request_id: impl Into<String>) -> Self {
160        Self {
161            xid: None,
162            connection_id: None,
163            audit_principal: "anonymous".to_string(),
164            request_id: request_id.into(),
165            write_consent: None,
166            tenant: None,
167        }
168    }
169
170    /// Writing context with an attached gate token. Construct
171    /// `WriteConsent` via `WriteGate::check` first; passing the
172    /// token here is the single point that proves the request
173    /// passed the policy.
174    pub fn writing(consent: WriteConsent, request_id: impl Into<String>) -> Self {
175        Self {
176            xid: None,
177            connection_id: None,
178            audit_principal: "anonymous".to_string(),
179            request_id: request_id.into(),
180            write_consent: Some(consent),
181            tenant: None,
182        }
183    }
184
185    pub fn with_principal(mut self, principal: impl Into<String>) -> Self {
186        self.audit_principal = principal.into();
187        self
188    }
189
190    pub fn with_connection(mut self, connection_id: u64) -> Self {
191        self.connection_id = Some(connection_id);
192        self
193    }
194
195    pub fn with_xid(mut self, xid: Xid) -> Self {
196        self.xid = Some(xid);
197        self
198    }
199
200    pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
201        self.tenant = Some(tenant.into());
202        self
203    }
204
205    pub fn require_write_consent(&self) -> Result<&WriteConsent, crate::api::RedDBError> {
206        self.write_consent.as_ref().ok_or_else(|| {
207            crate::api::RedDBError::ReadOnly(
208                "operation context is missing WriteConsent — handler must call WriteGate::check"
209                    .to_string(),
210            )
211        })
212    }
213}
214
215/// Monotonic request-id minter used when the caller doesn't supply
216/// one. Format: `req-<unix_micros>-<seq>` — sortable, unique,
217/// human-readable. Not a true ULID (no need to pull in another
218/// crate just for this).
219fn mint_request_id() -> String {
220    static SEQ: AtomicU64 = AtomicU64::new(0);
221    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
222    let now_us = std::time::SystemTime::now()
223        .duration_since(std::time::UNIX_EPOCH)
224        .map(|d| d.as_micros() as u64)
225        .unwrap_or(0);
226    format!("req-{now_us}-{seq}")
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn implicit_has_no_write_consent() {
235        let ctx = OperationContext::implicit();
236        assert!(ctx.write_consent.is_none());
237        assert_eq!(ctx.audit_principal, "anonymous");
238        assert!(!ctx.request_id.is_empty());
239    }
240
241    #[test]
242    fn read_only_constructor_carries_supplied_request_id() {
243        let ctx = OperationContext::read_only("req-abc");
244        assert_eq!(ctx.request_id, "req-abc");
245        assert!(ctx.write_consent.is_none());
246    }
247
248    #[test]
249    fn require_write_consent_errors_when_missing() {
250        let ctx = OperationContext::implicit();
251        let err = ctx.require_write_consent().unwrap_err();
252        assert!(matches!(err, crate::api::RedDBError::ReadOnly(_)));
253    }
254
255    #[test]
256    fn request_ids_are_monotonic_within_process() {
257        let a = mint_request_id();
258        let b = mint_request_id();
259        assert_ne!(a, b);
260    }
261
262    #[test]
263    fn builder_setters_compose() {
264        let ctx = OperationContext::read_only("req-1")
265            .with_principal("operator")
266            .with_connection(42)
267            .with_xid(7)
268            .with_tenant("acme");
269        assert_eq!(ctx.audit_principal, "operator");
270        assert_eq!(ctx.connection_id, Some(42));
271        assert_eq!(ctx.xid, Some(7));
272        assert_eq!(ctx.tenant.as_deref(), Some("acme"));
273    }
274
275    #[test]
276    fn factory_builds_the_same_context_for_every_transport() {
277        let ctx = OperationContextFactory::build(OperationContextInput {
278            request_id: Some("request-transport".to_string()),
279            principal: Some("acme/alice".to_string()),
280            tenant: Some("acme".to_string()),
281            write_consent: None,
282            connection_id: Some(42),
283            xid: Some(7),
284        });
285
286        assert_eq!(ctx.request_id, "request-transport");
287        assert_eq!(ctx.audit_principal, "acme/alice");
288        assert_eq!(ctx.tenant.as_deref(), Some("acme"));
289        assert_eq!(ctx.connection_id, Some(42));
290        assert_eq!(ctx.xid, Some(7));
291        assert!(ctx.write_consent.is_none());
292    }
293}