Skip to main content

cloacina_workflow/
context.rs

1/*
2 *  Copyright 2025-2026 Colliery Software
3 *
4 *  Licensed under the Apache License, Version 2.0 (the "License");
5 *  you may not use this file except in compliance with the License.
6 *  You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 *  Unless required by applicable law or agreed to in writing, software
11 *  distributed under the License is distributed on an "AS IS" BASIS,
12 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 *  See the License for the specific language governing permissions and
14 *  limitations under the License.
15 */
16
17//! # Minimal Context for Workflow Authoring
18//!
19//! This module provides a minimal `Context` type for sharing data between tasks.
20//! It contains only the core data operations without runtime-specific features
21//! like database persistence or dependency loading.
22
23use crate::error::ContextError;
24use crate::secret::{SecretAccessError, SecretResolver, SecretResolverError};
25use serde::{Deserialize, Serialize};
26use std::collections::{BTreeMap, HashMap};
27use std::fmt::Debug;
28use std::sync::Arc;
29use tracing::{debug, warn};
30
31/// A context that holds data for pipeline execution.
32///
33/// The context is a type-safe, serializable container that flows through your pipeline,
34/// allowing tasks to share data. It supports JSON serialization and provides key-value
35/// access patterns with comprehensive error handling.
36///
37/// ## Type Parameter
38///
39/// - `T`: The type of values stored in the context. Must implement `Serialize`, `Deserialize`, and `Debug`.
40///
41/// ## Examples
42///
43/// ```rust
44/// use cloacina_workflow::Context;
45/// use serde_json::Value;
46///
47/// // Create a context for JSON values
48/// let mut context = Context::<Value>::new();
49///
50/// // Insert and retrieve data
51/// context.insert("user_id", serde_json::json!(123)).unwrap();
52/// let user_id = context.get("user_id").unwrap();
53/// ```
54pub struct Context<T = serde_json::Value>
55where
56    T: Serialize + for<'de> Deserialize<'de> + Debug,
57{
58    data: HashMap<String, T>,
59
60    /// Secret resolution side channel (CLOACI-I-0133 / T-0858, design D-1).
61    ///
62    /// A runtime-only handle used by [`Context::secret`]. It is **never**
63    /// serialized: [`Context::to_json`] writes only `data`, and this field has
64    /// no `Serialize`/`Deserialize` — the moral equivalent of `#[serde(skip)]`.
65    /// That structural exclusion is exactly what keeps a resolved secret out of
66    /// the durable context / `schedules.params` / fires log (NFR-001). It is
67    /// likewise redacted from [`Debug`] below so it cannot leak through logs.
68    secrets: Option<Arc<dyn SecretResolver>>,
69}
70
71// Manual `Debug` (the struct can no longer derive it because
72// `Arc<dyn SecretResolver>` is not `Debug`). The resolver handle is redacted so
73// neither its address nor any backend state can leak through a `{:?}` render.
74impl<T> Debug for Context<T>
75where
76    T: Serialize + for<'de> Deserialize<'de> + Debug,
77{
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("Context")
80            .field("data", &self.data)
81            .field(
82                "secrets",
83                &self.secrets.as_ref().map(|_| "<redacted resolver>"),
84            )
85            .finish()
86    }
87}
88
89impl<T> Context<T>
90where
91    T: Serialize + for<'de> Deserialize<'de> + Debug,
92{
93    /// Creates a new empty context.
94    ///
95    /// # Examples
96    ///
97    /// ```rust
98    /// use cloacina_workflow::Context;
99    ///
100    /// let context = Context::<i32>::new();
101    /// assert!(context.get("any_key").is_none());
102    /// ```
103    pub fn new() -> Self {
104        debug!("Creating new empty context");
105        Self {
106            data: HashMap::new(),
107            secrets: None,
108        }
109    }
110
111    /// Creates a clone of this context's data.
112    ///
113    /// # Performance
114    ///
115    /// - Time complexity: O(n) where n is the number of key-value pairs
116    /// - Space complexity: O(n) for the cloned data
117    pub fn clone_data(&self) -> Self
118    where
119        T: Clone,
120    {
121        debug!("Cloning context data");
122        Self {
123            data: self.data.clone(),
124            // Carry the resolver handle (cheap Arc clone) so a cloned execution
125            // scope can still resolve secrets.
126            secrets: self.secrets.clone(),
127        }
128    }
129
130    /// Inserts a value into the context.
131    ///
132    /// # Arguments
133    ///
134    /// * `key` - The key to insert (can be any type that converts to String)
135    /// * `value` - The value to store
136    ///
137    /// # Returns
138    ///
139    /// * `Ok(())` - If the insertion was successful
140    /// * `Err(ContextError::KeyExists)` - If the key already exists
141    ///
142    /// # Examples
143    ///
144    /// ```rust
145    /// use cloacina_workflow::{Context, ContextError};
146    ///
147    /// let mut context = Context::<i32>::new();
148    ///
149    /// // First insertion succeeds
150    /// assert!(context.insert("count", 42).is_ok());
151    ///
152    /// // Duplicate insertion fails
153    /// assert!(matches!(context.insert("count", 43), Err(ContextError::KeyExists(_))));
154    /// ```
155    pub fn insert(&mut self, key: impl Into<String>, value: T) -> Result<(), ContextError> {
156        let key = key.into();
157        if self.data.contains_key(&key) {
158            warn!("Attempted to insert duplicate key: {}", key);
159            return Err(ContextError::KeyExists(key));
160        }
161        debug!("Inserting value for key: {}", key);
162        self.data.insert(key, value);
163        Ok(())
164    }
165
166    /// Updates an existing value in the context.
167    ///
168    /// # Arguments
169    ///
170    /// * `key` - The key to update
171    /// * `value` - The new value
172    ///
173    /// # Returns
174    ///
175    /// * `Ok(())` - If the update was successful
176    /// * `Err(ContextError::KeyNotFound)` - If the key doesn't exist
177    ///
178    /// # Examples
179    ///
180    /// ```rust
181    /// use cloacina_workflow::{Context, ContextError};
182    ///
183    /// let mut context = Context::<i32>::new();
184    /// context.insert("count", 42).unwrap();
185    ///
186    /// // Update existing key
187    /// assert!(context.update("count", 100).is_ok());
188    /// assert_eq!(context.get("count"), Some(&100));
189    ///
190    /// // Update non-existent key fails
191    /// assert!(matches!(context.update("missing", 1), Err(ContextError::KeyNotFound(_))));
192    /// ```
193    pub fn update(&mut self, key: impl Into<String>, value: T) -> Result<(), ContextError> {
194        let key = key.into();
195        if !self.data.contains_key(&key) {
196            warn!("Attempted to update non-existent key: {}", key);
197            return Err(ContextError::KeyNotFound(key));
198        }
199        debug!("Updating value for key: {}", key);
200        self.data.insert(key, value);
201        Ok(())
202    }
203
204    /// Gets a reference to a value from the context.
205    ///
206    /// # Arguments
207    ///
208    /// * `key` - The key to look up
209    ///
210    /// # Returns
211    ///
212    /// * `Some(&T)` - If the key exists
213    /// * `None` - If the key doesn't exist
214    ///
215    /// # Examples
216    ///
217    /// ```rust
218    /// use cloacina_workflow::Context;
219    ///
220    /// let mut context = Context::<String>::new();
221    /// context.insert("message", "Hello".to_string()).unwrap();
222    ///
223    /// assert_eq!(context.get("message"), Some(&"Hello".to_string()));
224    /// assert_eq!(context.get("missing"), None);
225    /// ```
226    pub fn get(&self, key: &str) -> Option<&T> {
227        debug!("Getting value for key: {}", key);
228        self.data.get(key)
229    }
230
231    /// Removes and returns a value from the context.
232    ///
233    /// # Arguments
234    ///
235    /// * `key` - The key to remove
236    ///
237    /// # Returns
238    ///
239    /// * `Some(T)` - If the key existed and was removed
240    /// * `None` - If the key didn't exist
241    ///
242    /// # Examples
243    ///
244    /// ```rust
245    /// use cloacina_workflow::Context;
246    ///
247    /// let mut context = Context::<i32>::new();
248    /// context.insert("temp", 42).unwrap();
249    ///
250    /// assert_eq!(context.remove("temp"), Some(42));
251    /// assert_eq!(context.get("temp"), None);
252    /// assert_eq!(context.remove("missing"), None);
253    /// ```
254    pub fn remove(&mut self, key: &str) -> Option<T> {
255        debug!("Removing value for key: {}", key);
256        self.data.remove(key)
257    }
258
259    /// Gets a reference to the underlying data HashMap.
260    ///
261    /// This method provides direct access to the internal data structure
262    /// for advanced use cases that need to iterate over all key-value pairs.
263    ///
264    /// # Returns
265    ///
266    /// A reference to the HashMap containing all context data
267    ///
268    /// # Examples
269    ///
270    /// ```rust
271    /// use cloacina_workflow::Context;
272    ///
273    /// let mut context = Context::<i32>::new();
274    /// context.insert("a", 1).unwrap();
275    /// context.insert("b", 2).unwrap();
276    ///
277    /// for (key, value) in context.data() {
278    ///     println!("{}: {}", key, value);
279    /// }
280    /// ```
281    pub fn data(&self) -> &HashMap<String, T> {
282        &self.data
283    }
284
285    /// Consumes the context and returns the underlying data HashMap.
286    ///
287    /// # Returns
288    ///
289    /// The HashMap containing all context data
290    pub fn into_data(self) -> HashMap<String, T> {
291        self.data
292    }
293
294    /// Creates a Context from a HashMap.
295    ///
296    /// # Arguments
297    ///
298    /// * `data` - The HashMap to use as context data
299    ///
300    /// # Returns
301    ///
302    /// A new Context with the provided data
303    pub fn from_data(data: HashMap<String, T>) -> Self {
304        Self {
305            data,
306            secrets: None,
307        }
308    }
309
310    /// Serializes the context to a JSON string.
311    ///
312    /// # Returns
313    ///
314    /// * `Ok(String)` - The JSON representation of the context
315    /// * `Err(ContextError)` - If serialization fails
316    pub fn to_json(&self) -> Result<String, ContextError> {
317        debug!("Serializing context to JSON");
318        let json = serde_json::to_string(&self.data)?;
319        debug!("Context serialized successfully");
320        Ok(json)
321    }
322
323    /// Deserializes a context from a JSON string.
324    ///
325    /// # Arguments
326    ///
327    /// * `json` - The JSON string to deserialize
328    ///
329    /// # Returns
330    ///
331    /// * `Ok(Context<T>)` - The deserialized context
332    /// * `Err(ContextError)` - If deserialization fails
333    pub fn from_json(json: String) -> Result<Self, ContextError> {
334        debug!("Deserializing context from JSON");
335        let data = serde_json::from_str(&json)?;
336        debug!("Context deserialized successfully");
337        // A deserialized context is a durable snapshot: it never carries a
338        // resolver. The runtime re-attaches one at fire time if needed.
339        Ok(Self {
340            data,
341            secrets: None,
342        })
343    }
344
345    // ── Secret resolution side channel (CLOACI-I-0133 / T-0858, D-1) ─────────
346
347    /// Attach a secret resolver, builder-style.
348    ///
349    /// The resolver is a runtime-only side channel: it is NEVER serialized (see
350    /// [`Context::to_json`], which writes only `data`), which is what keeps a
351    /// resolved secret structurally out of the durable context.
352    pub fn with_secret_resolver(mut self, resolver: Arc<dyn SecretResolver>) -> Self {
353        self.secrets = Some(resolver);
354        self
355    }
356
357    /// Attach (or replace) the secret resolver on this scope.
358    pub fn set_secret_resolver(&mut self, resolver: Arc<dyn SecretResolver>) {
359        self.secrets = Some(resolver);
360    }
361
362    /// Whether a secret resolver is configured on this execution scope.
363    pub fn has_secret_resolver(&self) -> bool {
364        self.secrets.is_some()
365    }
366
367    /// Resolve a named secret into its decrypted `{field: value}` map.
368    ///
369    /// `name` may be either the concrete secret name OR a **declared local
370    /// binding name** that an instance mapped to a secret via a
371    /// `{"$secret": "..."}` reference (CLOACI-I-0133 / T-0859). When the
372    /// context carries a `{"$secret"}` alias map (under
373    /// [`secret::SECRET_REFS_KEY`](crate::secret::SECRET_REFS_KEY)) and `name`
374    /// appears as a local binding there, the mapped secret name is resolved
375    /// instead — so a task can read either the declared name it authored against
376    /// or the concrete secret the instance chose.
377    ///
378    /// The returned map is handed to the task and is NEVER inserted into the
379    /// context's serialized `data`. Errors clearly when no resolver is
380    /// configured ([`SecretAccessError::NotConfigured`]) or the name is absent
381    /// ([`SecretAccessError::NotFound`]).
382    pub async fn secret(&self, name: &str) -> Result<BTreeMap<String, String>, SecretAccessError> {
383        let resolver = self
384            .secrets
385            .as_ref()
386            .ok_or(SecretAccessError::NotConfigured)?;
387        let effective = self.resolve_secret_alias(name);
388        resolver.resolve(&effective).await.map_err(|e| match e {
389            SecretResolverError::NotFound(n) => SecretAccessError::NotFound(n),
390            SecretResolverError::NotGranted(n) => SecretAccessError::NotGranted(n),
391            SecretResolverError::Backend(m) => SecretAccessError::Backend(m),
392        })
393    }
394
395    /// Map a task-supplied secret name through the instance's `{"$secret"}` alias
396    /// map (if any), returning the concrete secret name to resolve.
397    ///
398    /// The alias map lives under [`secret::SECRET_REFS_KEY`] as a NAME→NAME
399    /// object of `local_binding_name -> secret_name`. It carries no secret
400    /// values, so reading it here cannot leak plaintext. A name absent from the
401    /// map (or the absence of a map) passes through unchanged.
402    fn resolve_secret_alias(&self, name: &str) -> String {
403        if let Some(v) = self.data.get(crate::secret::SECRET_REFS_KEY) {
404            // `T: Serialize`, so any backing value can be viewed as JSON; the map
405            // is a plain `{local: secret}` object of strings.
406            if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(v) {
407                if let Some(serde_json::Value::String(target)) = map.get(name) {
408                    return target.clone();
409                }
410            }
411        }
412        name.to_string()
413    }
414
415    /// Resolve one field of a named secret.
416    ///
417    /// Convenience over [`Context::secret`]; errors with
418    /// [`SecretAccessError::FieldNotFound`] when the secret exists but lacks the
419    /// requested field.
420    pub async fn secret_field(&self, name: &str, field: &str) -> Result<String, SecretAccessError> {
421        let fields = self.secret(name).await?;
422        fields
423            .get(field)
424            .cloned()
425            .ok_or_else(|| SecretAccessError::FieldNotFound {
426                secret: name.to_string(),
427                field: field.to_string(),
428            })
429    }
430}
431
432/// Typed accessors for the task context (`Context<serde_json::Value>`).
433///
434/// Task bodies operate on a `Context<serde_json::Value>`, so reading an input
435/// otherwise means `get(...).and_then(|v| v.as_*()).ok_or_else(...)?` plus a
436/// `serde_json::from_value` round-trip, and writing means wrapping every value
437/// in `serde_json::json!(...)`. These helpers fold that boilerplate and return
438/// a [`TaskError`] so they compose with `?` in a task body (CLOACI-T-0733).
439///
440/// This mirrors the ergonomics Python authors already get from
441/// `context.get(key, default)` / `context.set(key, value)`.
442impl Context<serde_json::Value> {
443    /// Get a value by key and deserialize it into `V`.
444    ///
445    /// Returns `Ok(None)` when the key is absent, `Ok(Some(value))` when it is
446    /// present and deserializes cleanly, and `Err(TaskError::ValidationFailed)`
447    /// when the stored JSON does not match `V` (the message names the key and
448    /// target type).
449    ///
450    /// # Examples
451    ///
452    /// ```rust
453    /// use cloacina_workflow::Context;
454    ///
455    /// let mut ctx = Context::new();
456    /// ctx.insert("count", serde_json::json!(7)).unwrap();
457    /// let n: Option<i64> = ctx.get_as("count").unwrap();
458    /// assert_eq!(n, Some(7));
459    /// assert_eq!(ctx.get_as::<i64>("missing").unwrap(), None);
460    /// ```
461    pub fn get_as<V>(&self, key: &str) -> Result<Option<V>, crate::error::TaskError>
462    where
463        V: serde::de::DeserializeOwned,
464    {
465        match self.data.get(key) {
466            None => Ok(None),
467            Some(value) => serde_json::from_value(value.clone())
468                .map(Some)
469                .map_err(|e| crate::error::TaskError::ValidationFailed {
470                    message: format!(
471                        "context key '{}' could not be read as {}: {}",
472                        key,
473                        std::any::type_name::<V>(),
474                        e
475                    ),
476                }),
477        }
478    }
479
480    /// Get a value by key, deserialize it into `V`, and error if the key is
481    /// missing.
482    ///
483    /// `Err(TaskError::ValidationFailed)` when the key is absent or the stored
484    /// JSON does not match `V`.
485    ///
486    /// # Examples
487    ///
488    /// ```rust
489    /// use cloacina_workflow::Context;
490    ///
491    /// let mut ctx = Context::new();
492    /// ctx.insert("name", serde_json::json!("ada")).unwrap();
493    /// let name: String = ctx.get_required("name").unwrap();
494    /// assert_eq!(name, "ada");
495    /// assert!(ctx.get_required::<String>("missing").is_err());
496    /// ```
497    pub fn get_required<V>(&self, key: &str) -> Result<V, crate::error::TaskError>
498    where
499        V: serde::de::DeserializeOwned,
500    {
501        match self.get_as(key)? {
502            Some(value) => Ok(value),
503            None => Err(crate::error::TaskError::ValidationFailed {
504                message: format!(
505                    "required context key '{}' is missing (expected {})",
506                    key,
507                    std::any::type_name::<V>()
508                ),
509            }),
510        }
511    }
512
513    /// Serialize a value and write it under `key`, **upserting** (insert or
514    /// overwrite).
515    ///
516    /// Folds the `serde_json::json!(...)` / `to_value` wrapping — and the
517    /// "exists? update : insert" dance — that every context write otherwise
518    /// repeats. Upsert semantics mirror Python's `context.set(key, value)`
519    /// (unlike the lower-level [`Context::insert`], which errors on an existing
520    /// key). Errors with `TaskError::ValidationFailed` only if the value cannot
521    /// be serialized.
522    ///
523    /// # Examples
524    ///
525    /// ```rust
526    /// use cloacina_workflow::Context;
527    ///
528    /// let mut ctx = Context::new();
529    /// ctx.insert_as("total", 42u32).unwrap();
530    /// assert_eq!(ctx.get_as::<u32>("total").unwrap(), Some(42));
531    /// // Upserts — overwriting an existing key is fine.
532    /// ctx.insert_as("total", 100u32).unwrap();
533    /// assert_eq!(ctx.get_as::<u32>("total").unwrap(), Some(100));
534    /// ```
535    pub fn insert_as<V>(
536        &mut self,
537        key: impl Into<String>,
538        value: V,
539    ) -> Result<(), crate::error::TaskError>
540    where
541        V: serde::Serialize,
542    {
543        let key = key.into();
544        let json =
545            serde_json::to_value(value).map_err(|e| crate::error::TaskError::ValidationFailed {
546                message: format!("context key '{}' could not be serialized: {}", key, e),
547            })?;
548        // Upsert: overwrite if present, insert otherwise.
549        self.data.insert(key, json);
550        Ok(())
551    }
552}
553
554impl<T> Default for Context<T>
555where
556    T: Serialize + for<'de> Deserialize<'de> + Debug,
557{
558    fn default() -> Self {
559        Self::new()
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn setup_test_context() -> Context<i32> {
568        Context::new()
569    }
570
571    #[test]
572    fn test_context_operations() {
573        let mut context = setup_test_context();
574
575        // Test empty context
576        assert!(context.data.is_empty());
577
578        // Test insert and get
579        context.insert("test", 42).unwrap();
580        assert_eq!(context.get("test"), Some(&42));
581
582        // Test duplicate insert fails
583        assert!(matches!(
584            context.insert("test", 43),
585            Err(ContextError::KeyExists(_))
586        ));
587
588        // Test update
589        context.update("test", 43).unwrap();
590        assert_eq!(context.get("test"), Some(&43));
591
592        // Test update nonexistent key fails
593        assert!(matches!(
594            context.update("nonexistent", 42),
595            Err(ContextError::KeyNotFound(_))
596        ));
597    }
598
599    #[test]
600    fn test_context_serialization() {
601        let mut context = setup_test_context();
602        context.insert("test", 42).unwrap();
603
604        let json = context.to_json().unwrap();
605        let deserialized = Context::<i32>::from_json(json).unwrap();
606
607        assert_eq!(deserialized.get("test"), Some(&42));
608    }
609
610    #[test]
611    fn test_context_clone_data() {
612        let mut context = Context::<i32>::new();
613        context.insert("a", 1).unwrap();
614        context.insert("b", 2).unwrap();
615
616        let cloned = context.clone_data();
617        assert_eq!(cloned.get("a"), Some(&1));
618        assert_eq!(cloned.get("b"), Some(&2));
619    }
620
621    #[test]
622    fn test_context_from_data() {
623        let mut data = HashMap::new();
624        data.insert("key".to_string(), 42);
625
626        let context = Context::from_data(data);
627        assert_eq!(context.get("key"), Some(&42));
628    }
629
630    #[test]
631    fn test_context_into_data() {
632        let mut context = Context::<i32>::new();
633        context.insert("key", 42).unwrap();
634
635        let data = context.into_data();
636        assert_eq!(data.get("key"), Some(&42));
637    }
638
639    // CLOACI-T-0733: typed accessors on Context<serde_json::Value>.
640    #[test]
641    fn test_typed_accessors_roundtrip() {
642        let mut ctx = Context::new();
643        ctx.insert_as("count", 7u32).unwrap();
644        ctx.insert_as("name", "ada").unwrap();
645
646        // get_as: present + absent
647        assert_eq!(ctx.get_as::<u32>("count").unwrap(), Some(7));
648        assert_eq!(ctx.get_as::<String>("missing").unwrap(), None);
649
650        // get_required: present
651        let name: String = ctx.get_required("name").unwrap();
652        assert_eq!(name, "ada");
653
654        // insert_as upserts (overwrites) without erroring
655        ctx.insert_as("count", 100u32).unwrap();
656        assert_eq!(ctx.get_as::<u32>("count").unwrap(), Some(100));
657    }
658
659    // CLOACI-T-0858: secret resolution side channel.
660    // (`SecretResolver`, `SecretResolverError`, `SecretAccessError`, `Arc`,
661    // `BTreeMap`, `HashMap` all come in via `use super::*`.)
662    use async_trait::async_trait;
663
664    /// A stub resolver holding an in-memory `{name -> {field: value}}` map.
665    struct StubResolver {
666        secrets: HashMap<String, BTreeMap<String, String>>,
667    }
668
669    #[async_trait]
670    impl SecretResolver for StubResolver {
671        async fn resolve(
672            &self,
673            name: &str,
674        ) -> Result<BTreeMap<String, String>, SecretResolverError> {
675            self.secrets
676                .get(name)
677                .cloned()
678                .ok_or_else(|| SecretResolverError::NotFound(name.to_string()))
679        }
680    }
681
682    fn stub_resolver() -> Arc<dyn SecretResolver> {
683        let mut db = BTreeMap::new();
684        db.insert("host".to_string(), "db.internal".to_string());
685        db.insert("password".to_string(), "s3cr3t-p@ss".to_string());
686        let mut secrets = HashMap::new();
687        secrets.insert("db_prod".to_string(), db);
688        Arc::new(StubResolver { secrets })
689    }
690
691    #[test]
692    fn test_secret_resolver_field_is_not_serialized() {
693        // A Context carrying a resolver must serialize to exactly the same JSON
694        // as one without: the resolver is structurally outside `data`.
695        let mut ctx = Context::<serde_json::Value>::new();
696        ctx.insert("visible", serde_json::json!("in-context"))
697            .unwrap();
698        ctx.set_secret_resolver(stub_resolver());
699        assert!(ctx.has_secret_resolver());
700
701        let json = ctx.to_json().unwrap();
702        // The serialized form is just the data map — no resolver, no plaintext.
703        assert!(json.contains("visible"));
704        assert!(
705            !json.contains("s3cr3t-p@ss"),
706            "secret leaked into serialized Context: {json}"
707        );
708        assert!(!json.contains("secrets"));
709        assert!(!json.contains("resolver"));
710
711        // Round-tripping drops the resolver (a durable snapshot never carries one).
712        let restored = Context::<serde_json::Value>::from_json(json).unwrap();
713        assert!(!restored.has_secret_resolver());
714    }
715
716    #[test]
717    fn test_debug_redacts_resolver_and_never_prints_plaintext() {
718        let mut ctx = Context::<serde_json::Value>::new();
719        ctx.set_secret_resolver(stub_resolver());
720        let dbg = format!("{:?}", ctx);
721        assert!(dbg.contains("<redacted resolver>"), "debug: {dbg}");
722        assert!(
723            !dbg.contains("s3cr3t-p@ss"),
724            "secret leaked into Debug: {dbg}"
725        );
726    }
727
728    #[tokio::test]
729    async fn test_secret_accessor_not_configured_errors_clearly() {
730        let ctx = Context::<serde_json::Value>::new();
731        let err = ctx.secret("db_prod").await.unwrap_err();
732        assert!(matches!(err, SecretAccessError::NotConfigured));
733    }
734
735    #[tokio::test]
736    async fn test_secret_accessor_happy_path() {
737        let ctx = Context::<serde_json::Value>::new().with_secret_resolver(stub_resolver());
738        let fields = ctx.secret("db_prod").await.unwrap();
739        assert_eq!(fields.get("password").unwrap(), "s3cr3t-p@ss");
740        assert_eq!(
741            ctx.secret_field("db_prod", "host").await.unwrap(),
742            "db.internal"
743        );
744    }
745
746    // CLOACI-T-0859: the `{"$secret"}` alias map redirects a task's declared
747    // local binding name to the concrete secret the instance chose.
748    #[tokio::test]
749    async fn test_secret_accessor_resolves_through_alias_map() {
750        let mut ctx = Context::<serde_json::Value>::new().with_secret_resolver(stub_resolver());
751        // The instance mapped the declared name `dst` → concrete secret `db_prod`.
752        ctx.insert(
753            crate::secret::SECRET_REFS_KEY,
754            serde_json::json!({"dst": "db_prod"}),
755        )
756        .unwrap();
757
758        // Reading via the DECLARED local binding name resolves the mapped secret.
759        let fields = ctx.secret("dst").await.unwrap();
760        assert_eq!(fields.get("password").unwrap(), "s3cr3t-p@ss");
761        assert_eq!(
762            ctx.secret_field("dst", "host").await.unwrap(),
763            "db.internal"
764        );
765
766        // Reading via the concrete secret name still works (no alias → passthrough).
767        assert_eq!(
768            ctx.secret("db_prod")
769                .await
770                .unwrap()
771                .get("password")
772                .unwrap(),
773            "s3cr3t-p@ss"
774        );
775
776        // A name with no alias and no matching secret is NotFound.
777        assert!(matches!(
778            ctx.secret("unmapped").await.unwrap_err(),
779            SecretAccessError::NotFound(_)
780        ));
781
782        // The resolved plaintext never entered the serialized context.
783        let json = ctx.to_json().unwrap();
784        assert!(!json.contains("s3cr3t-p@ss"), "secret leaked: {json}");
785    }
786
787    #[tokio::test]
788    async fn test_secret_accessor_missing_name_and_field() {
789        let ctx = Context::<serde_json::Value>::new().with_secret_resolver(stub_resolver());
790        assert!(matches!(
791            ctx.secret("absent").await.unwrap_err(),
792            SecretAccessError::NotFound(_)
793        ));
794        assert!(matches!(
795            ctx.secret_field("db_prod", "absent_field")
796                .await
797                .unwrap_err(),
798            SecretAccessError::FieldNotFound { .. }
799        ));
800    }
801
802    #[test]
803    fn test_typed_accessor_errors_are_actionable() {
804        let mut ctx = Context::new();
805        ctx.insert("count", serde_json::json!("not-a-number"))
806            .unwrap();
807
808        // Type mismatch names the key and target type.
809        let err = ctx.get_as::<u32>("count").unwrap_err();
810        let msg = err.to_string();
811        assert!(msg.contains("count"), "msg should name the key: {msg}");
812
813        // Missing required key errors and names the key.
814        let err = ctx.get_required::<u32>("absent").unwrap_err();
815        let msg = err.to_string();
816        assert!(msg.contains("absent"), "msg should name the key: {msg}");
817    }
818}