chronon_core/context.rs
1//! Script execution context ports.
2//!
3//! When a job is scheduled, Chronon stores `actor_json` on the job record and copies it onto
4//! each run at enqueue. When a worker dispatches the script, it calls [`ContextFactory::build`]
5//! with the **run's** snapshotted `actor_json` (not the live job row), so identity cannot change
6//! under a queued run.
7//!
8//! # Choosing a factory
9//!
10//! | Approach | When to use |
11//! |----------|-------------|
12//! | [`JsonScriptContextFactory`] | Examples, smoke tests, and handlers that only need [`ScriptContext::label`] and [`ScriptContext::actor_json`] |
13//! | Custom [`ContextFactory`] | Production apps that map actor JSON to sessions, permissions, database access, or other application identity |
14//!
15//! Install the factory at runtime boot on `ChrononBuilder` (see `chronon-runtime`).
16//! On external HTTP APIs, Chronon rejects System-shaped `actor_json`
17//! ([`RejectExternalSystemActor`](crate::RejectExternalSystemActor)). Hosts should still
18//! implement factories that **fail closed** on other untrusted payloads.
19
20use serde_json::Value;
21
22use crate::error::{ChrononError, Result};
23
24/// Opaque execution context for script handlers.
25///
26/// The runtime passes this as the first argument to `#[chronon::script]` handlers and to
27/// registered invoke functions. Use [`Self::label`] for logs and [`Self::actor_json`] when the
28/// handler only needs the captured actor payload from [`crate::models::Job::actor_json`].
29///
30/// # Examples
31///
32/// ```
33/// use chronon_core::{ContextFactory, JsonScriptContextFactory, ScriptContext};
34/// use serde_json::json;
35///
36/// let ctx = JsonScriptContextFactory
37/// .build(&json!({"user": "alice"}))
38/// .unwrap();
39/// assert!(ctx.label().contains("alice"));
40/// assert_eq!(ctx.actor_json()["user"], "alice");
41/// ```
42pub trait ScriptContext: Send {
43 /// Debug label for logs and tests.
44 fn label(&self) -> &str;
45
46 /// Actor JSON captured at schedule time and restored at dispatch.
47 fn actor_json(&self) -> &Value;
48}
49
50/// Builds a [`ScriptContext`] from JSON captured at enqueue time on the run.
51///
52/// Install on `ChrononBuilder::context_factory` at boot. The executor/worker call [`Self::build`]
53/// for every dispatched run using the run's snapshotted `actor_json`.
54///
55/// | Implementation | When to use |
56/// |----------------|-------------|
57/// | [`JsonScriptContextFactory`] | Examples / handlers that only need label + actor JSON |
58/// | [`NoOpContextFactory`] | Tests and benches |
59/// | Custom | Production identity, sessions, permissions — prefer **fail closed** on invalid payloads |
60///
61/// # Examples
62///
63/// Custom factory sketch:
64///
65/// ```
66/// use chronon_core::{ContextFactory, ChrononError, Result, ScriptContext};
67/// use serde_json::Value;
68///
69/// struct AppCtx { label: String, actor_json: Value }
70/// impl ScriptContext for AppCtx {
71/// fn label(&self) -> &str { &self.label }
72/// fn actor_json(&self) -> &Value { &self.actor_json }
73/// }
74///
75/// struct AppFactory;
76/// impl ContextFactory for AppFactory {
77/// fn build(&self, actor_json: &Value) -> Result<Box<dyn ScriptContext>> {
78/// let Some(user) = actor_json.get("user").and_then(|v| v.as_str()) else {
79/// return Err(ChrononError::Identity("missing user".into()));
80/// };
81/// Ok(Box::new(AppCtx {
82/// label: user.into(),
83/// actor_json: actor_json.clone(),
84/// }))
85/// }
86/// }
87///
88/// let ctx = AppFactory.build(&serde_json::json!({"user": "bob"})).unwrap();
89/// assert_eq!(ctx.label(), "bob");
90/// assert!(AppFactory.build(&serde_json::json!({})).is_err());
91/// ```
92pub trait ContextFactory: Send + Sync {
93 /// Reconstruct handler context from actor JSON snapshotted on the run at enqueue.
94 ///
95 /// # Errors
96 ///
97 /// Returns [`IdentityError`] (mapped to [`ChrononError::Identity`]) when the payload
98 /// cannot be decoded into application identity. Hosts should fail closed on untrusted
99 /// or incomplete payloads.
100 fn build(&self, actor_json: &Value) -> Result<Box<dyn ScriptContext>>;
101}
102
103/// Identity reconstruction failure.
104#[derive(Debug, thiserror::Error)]
105#[error("identity error: {0}")]
106pub struct IdentityError(pub String);
107
108impl From<IdentityError> for ChrononError {
109 fn from(value: IdentityError) -> Self {
110 Self::Identity(value.0)
111 }
112}
113
114/// No-op context for tests and benchmarks.
115#[derive(Debug, Default)]
116pub struct NoOpScriptContext {
117 actor_json: Value,
118}
119
120impl ScriptContext for NoOpScriptContext {
121 fn label(&self) -> &'static str {
122 "noop"
123 }
124
125 fn actor_json(&self) -> &Value {
126 &self.actor_json
127 }
128}
129
130/// Factory that always returns [`NoOpScriptContext`].
131#[derive(Debug, Default, Clone, Copy)]
132pub struct NoOpContextFactory;
133
134impl ContextFactory for NoOpContextFactory {
135 fn build(&self, _actor_json: &Value) -> Result<Box<dyn ScriptContext>> {
136 Ok(Box::new(NoOpScriptContext::default()))
137 }
138}
139
140/// Default factory that wraps actor JSON in a labeled [`ScriptContext`].
141///
142/// Suitable for examples and handlers that only need [`ScriptContext::label`] and
143/// [`ScriptContext::actor_json`]. For application-specific identity (database sessions,
144/// permission checks, typed actors), implement [`ContextFactory`] instead.
145///
146/// # Examples
147///
148/// ```
149/// use chronon_core::{ContextFactory, JsonScriptContextFactory};
150/// use serde_json::json;
151///
152/// let factory = JsonScriptContextFactory;
153/// let ctx = factory.build(&json!({"user": "alice"})).unwrap();
154/// assert!(ctx.label().contains("alice"));
155/// assert_eq!(ctx.actor_json()["user"], "alice");
156/// ```
157#[derive(Debug, Default, Clone, Copy)]
158pub struct JsonScriptContextFactory;
159
160struct JsonContext {
161 actor_json: Value,
162 label: String,
163}
164
165impl ScriptContext for JsonContext {
166 fn label(&self) -> &str {
167 &self.label
168 }
169
170 fn actor_json(&self) -> &Value {
171 &self.actor_json
172 }
173}
174
175impl ContextFactory for JsonScriptContextFactory {
176 fn build(&self, actor_json: &Value) -> Result<Box<dyn ScriptContext>> {
177 Ok(Box::new(JsonContext {
178 actor_json: actor_json.clone(),
179 label: actor_json.to_string(),
180 }))
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use serde_json::json;
188
189 struct TestContext {
190 label: String,
191 actor_json: Value,
192 }
193
194 impl ScriptContext for TestContext {
195 fn label(&self) -> &str {
196 &self.label
197 }
198
199 fn actor_json(&self) -> &Value {
200 &self.actor_json
201 }
202 }
203
204 struct TestFactory;
205
206 impl ContextFactory for TestFactory {
207 fn build(&self, actor_json: &Value) -> Result<Box<dyn ScriptContext>> {
208 if actor_json.get("System").is_some() {
209 Ok(Box::new(TestContext {
210 label: "system".into(),
211 actor_json: actor_json.clone(),
212 }))
213 } else {
214 Err(IdentityError("missing System".into()).into())
215 }
216 }
217 }
218
219 #[test]
220 fn factory_builds_context() {
221 let factory = TestFactory;
222 let actor = json!({"System": {"operation": "t"}});
223 let ctx = factory.build(&actor).expect("ok");
224 assert_eq!(ctx.label(), "system");
225 assert_eq!(ctx.actor_json(), &actor);
226 }
227
228 #[test]
229 fn json_factory_stores_actor_json() {
230 let factory = JsonScriptContextFactory;
231 let actor = json!({"user": "alice"});
232 let ctx = factory.build(&actor).expect("ok");
233 assert_eq!(ctx.actor_json(), &actor);
234 assert!(ctx.label().contains("alice"));
235 }
236}