Skip to main content

dataflow_rs/engine/
message.rs

1use crate::engine::error::{DataflowError, ErrorInfo};
2use chrono::{DateTime, Utc};
3use datavalue::OwnedDataValue;
4use serde::{Deserialize, Serialize};
5use serde_json::Value as JsonValue;
6use std::sync::Arc;
7use uuid::Uuid;
8
9/// A message flowing through the dataflow engine.
10///
11/// Construct via [`Message::builder`] for the full API, or use the shortcuts
12/// [`Message::new`] (already-owned `Arc<OwnedDataValue>` payload — the perf
13/// path) and [`Message::from_value`] (bridges from `serde_json::Value`).
14///
15/// `context` is held as an [`OwnedDataValue`] tree (not `serde_json::Value`)
16/// so the JSONLogic evaluator can borrow it into its arena via
17/// `OwnedDataValue::view_in` — one spine walk in, with strings and keys
18/// pointing into this tree rather than copied — and project the result back
19/// via `DataValue::to_owned` with a single deep walk out; no
20/// `serde_json::Value` round-trip in the hot path. The on-the-wire JSON
21/// shape is preserved by datavalue's native `Serialize` / `Deserialize`
22/// impls.
23///
24/// Every other field is encapsulated — read via `id()`, `payload()`,
25/// `audit_trail()`, `errors()`, `capture_changes()`; mutate `errors` via
26/// [`Message::add_error`]; mutate `context` via [`crate::TaskContext::set`].
27/// Direct mutation of `audit_trail` is engine-internal.
28/// Internal id storage for [`Message`]. The default UUID v7 id is written
29/// into a fixed inline buffer (36 hyphenated lowercase ASCII bytes) — no
30/// per-message heap allocation; a caller-supplied id keeps its `String`.
31/// Private repr: every public surface exposes it as `&str` via
32/// [`Message::id`], and the serialized wire shape (a JSON string) is
33/// unchanged.
34#[derive(Clone)]
35pub(crate) enum MessageId {
36    Uuid([u8; 36]),
37    Custom(String),
38}
39
40impl MessageId {
41    /// Generate a fresh UUID v7 id directly into the inline buffer.
42    fn new_uuid_v7() -> Self {
43        let mut buf = [0u8; 36];
44        Uuid::now_v7().hyphenated().encode_lower(&mut buf);
45        Self::Uuid(buf)
46    }
47
48    fn as_str(&self) -> &str {
49        match self {
50            // encode_lower fills all 36 bytes with hyphenated lowercase
51            // ASCII, so the buffer is always valid UTF-8.
52            Self::Uuid(buf) => std::str::from_utf8(buf).expect("uuid ids are ascii"),
53            Self::Custom(s) => s,
54        }
55    }
56}
57
58impl std::fmt::Debug for MessageId {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        std::fmt::Debug::fmt(self.as_str(), f)
61    }
62}
63
64#[derive(Debug, Clone)]
65pub struct Message {
66    pub(crate) id: MessageId,
67    pub(crate) payload: Arc<OwnedDataValue>,
68    /// Unified context containing `data`, `metadata`, and `temp_data` keys.
69    /// Always an `OwnedDataValue::Object`; the engine populates the three
70    /// top-level keys at construction. Public for read access (tests do
71    /// `message.context["data"]["x"]` lookups); inside handlers prefer
72    /// [`crate::TaskContext::set`] which records audit-trail changes.
73    pub context: OwnedDataValue,
74    pub(crate) audit_trail: Vec<AuditTrail>,
75    /// Errors that occurred during message processing. Read via
76    /// `errors()`, append via `add_error()`.
77    pub(crate) errors: Vec<ErrorInfo>,
78    /// When `true` (default), built-in functions emit per-write `Change`
79    /// entries into `audit_trail`, capturing `old_value` and `new_value` deep
80    /// clones. When `false`, `AuditTrail` entries are still recorded
81    /// (workflow_id, task_id, status, timestamp) but `changes` is empty —
82    /// the bulk-pipeline fast path. UI debug consumers should leave this at
83    /// `true`. Wire shape is unchanged either way.
84    pub(crate) capture_changes: bool,
85    /// Routing bucket `0..=99` for a workflow traffic split
86    /// ([`crate::Workflow::rollout`]).
87    ///
88    /// Deliberately **not** stored in `context`, so it never appears in `data` /
89    /// `metadata` / `temp_data` and never has to be stripped before the context
90    /// is serialized — that is the point of the field.
91    ///
92    /// Like `capture_changes`, an in-memory hint: the hand-written `Serialize` /
93    /// `Deserialize` impls keep the 5-field wire shape, so the bucket does not
94    /// survive a JSON round trip.
95    pub(crate) routing_bucket: Option<u8>,
96    /// Which audit entries the message keeps; see [`AuditMode`]. An in-memory
97    /// hint like `capture_changes`: never serialized.
98    pub(crate) audit_mode: AuditMode,
99}
100
101/// How many [`AuditTrail`] entries a [`Message`] keeps.
102///
103/// The engine records one entry per executed task, and in a looping workflow
104/// that is one per task per sweep, all kept until `process_message` returns.
105/// A host that never reads [`Message::audit_trail`] (because it traces, or
106/// reads `metadata.progress`) can bound or drop them. Set it with
107/// [`MessageBuilder::audit_mode`].
108///
109/// The mode changes only what is *kept*. Status classification, error
110/// records, `metadata.progress`, halting and the observer are identical in
111/// every mode.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113#[non_exhaustive]
114pub enum AuditMode {
115    /// Keep every entry. The default.
116    #[default]
117    Full,
118    /// Keep the most recent `n` entries. `Last(0)` keeps none, like
119    /// [`Self::Off`].
120    ///
121    /// Older entries are dropped in batches, so the message holds at most
122    /// `2n` entries in memory while [`Message::audit_trail`] always shows the
123    /// last `n`.
124    Last(usize),
125    /// Keep no entries.
126    ///
127    /// A trace's per-step `changes` are read from the task's own entry, so
128    /// they come back empty in this mode, as they do with `capture_changes`
129    /// off. Turn `capture_changes` off too: with no entry to hold them, the
130    /// captured copies would be made and dropped for nothing.
131    Off,
132}
133
134// Custom Serialize: stable wire format ({id, payload, context, audit_trail, errors}).
135// `capture_changes` is an in-memory hint only — never serialized.
136impl Serialize for Message {
137    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
138    where
139        S: serde::Serializer,
140    {
141        use serde::ser::SerializeStruct;
142        let mut state = serializer.serialize_struct("Message", 5)?;
143        state.serialize_field("id", &self.id.as_str())?;
144        state.serialize_field("payload", &self.payload)?;
145        state.serialize_field("context", &self.context)?;
146        state.serialize_field("audit_trail", self.audit_trail())?;
147        state.serialize_field("errors", &self.errors)?;
148        state.end()
149    }
150}
151
152// Custom Deserialize: mirrors the Serialize shape; no cache field to seed.
153// `capture_changes` defaults to `true` for back-compat.
154impl<'de> Deserialize<'de> for Message {
155    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
156    where
157        D: serde::Deserializer<'de>,
158    {
159        #[derive(Deserialize)]
160        struct MessageData {
161            id: String,
162            payload: Arc<OwnedDataValue>,
163            context: OwnedDataValue,
164            audit_trail: Vec<AuditTrail>,
165            errors: Vec<ErrorInfo>,
166        }
167
168        let data = MessageData::deserialize(deserializer)?;
169        Ok(Self {
170            id: MessageId::Custom(data.id),
171            payload: data.payload,
172            context: data.context,
173            audit_trail: data.audit_trail,
174            errors: data.errors,
175            capture_changes: true,
176            routing_bucket: None,
177            audit_mode: AuditMode::Full,
178        })
179    }
180}
181
182impl Message {
183    /// Start building a message. The recommended constructor — chains
184    /// `.id(...)`, `.payload(...)` / `.payload_json(...)`, and
185    /// `.capture_changes(...)` calls, then `.build()`.
186    pub fn builder() -> MessageBuilder {
187        MessageBuilder::new()
188    }
189
190    /// Construct a message from an already-owned payload `Arc`. The perf
191    /// path: zero `serde_json::Value` walk, one Arc refcount bump per
192    /// message. Use this from a hot loop with a payload `Arc` shared across
193    /// messages (e.g. a benchmark harness or an HTTP handler that receives
194    /// already-parsed payloads).
195    pub fn new(payload: Arc<OwnedDataValue>) -> Self {
196        Self {
197            // UUID v7: ms-precision timestamp in the high bits, random tail.
198            // Time-ordered and sortable — better for databases/logs than v4
199            // (random-only) and the same `rng` backend cost. Encoded into the
200            // inline id buffer — no `String` allocation per message.
201            id: MessageId::new_uuid_v7(),
202            payload,
203            context: empty_context(),
204            audit_trail: vec![],
205            errors: vec![],
206            capture_changes: true,
207            routing_bucket: None,
208            audit_mode: AuditMode::Full,
209        }
210    }
211
212    /// Construct a message from a `serde_json::Value` payload. Convenience
213    /// for code that already speaks serde_json; goes through the
214    /// `OwnedDataValue::from(&Value)` bridge (one deep walk).
215    pub fn from_value(payload: &JsonValue) -> Self {
216        Self::new(Arc::new(OwnedDataValue::from(payload)))
217    }
218
219    /// Construct a message from a JSON payload string. Parses with
220    /// `serde_json` and bridges into `OwnedDataValue`. Returns
221    /// `DataflowError::Deserialization` on parse failure.
222    pub fn from_json_str(payload: &str) -> crate::engine::error::Result<Self> {
223        let value: JsonValue = serde_json::from_str(payload).map_err(DataflowError::from_serde)?;
224        Ok(Self::from_value(&value))
225    }
226
227    /// A copy for one `for_each` call: the context, id, payload and routing
228    /// bucket, with an empty audit trail and error list, and change capture on.
229    ///
230    /// The fold reads only what the call *adds* (its `Change`s, its errors,
231    /// the `collect` value), so the parent's history is never needed, and it
232    /// is the part that grows every loop sweep (#67).
233    pub(crate) fn fork_for_element(&self) -> Self {
234        Self {
235            id: self.id.clone(),
236            payload: Arc::clone(&self.payload),
237            context: self.context.clone(),
238            audit_trail: Vec::new(),
239            errors: Vec::new(),
240            capture_changes: true,
241            routing_bucket: self.routing_bucket,
242            audit_mode: self.audit_mode,
243        }
244    }
245
246    /// Add an error to the message
247    pub fn add_error(&mut self, error: ErrorInfo) {
248        self.errors.push(error);
249    }
250
251    /// Check if message has errors
252    pub fn has_errors(&self) -> bool {
253        !self.errors.is_empty()
254    }
255
256    /// Message id (UUID v7 string by default; caller-supplied if set via
257    /// [`MessageBuilder::id`]).
258    #[inline]
259    pub fn id(&self) -> &str {
260        self.id.as_str()
261    }
262
263    /// Original payload as the engine received it. Immutable for the
264    /// lifetime of the message — the engine reads it through this Arc and
265    /// copies into `context` only as needed by handlers.
266    #[inline]
267    pub fn payload(&self) -> &OwnedDataValue {
268        &self.payload
269    }
270
271    /// The shared payload `Arc` itself. Useful when forwarding the same
272    /// payload to multiple messages without recloning the underlying
273    /// `OwnedDataValue` tree.
274    #[inline]
275    pub fn payload_arc(&self) -> &Arc<OwnedDataValue> {
276        &self.payload
277    }
278
279    /// Audit-trail entries recorded by the engine, one per task that ran
280    /// (skipped tasks are absent unless `Trace` mode is on). Under
281    /// [`AuditMode::Last`] only the most recent entries, and under
282    /// [`AuditMode::Off`] none.
283    #[inline]
284    pub fn audit_trail(&self) -> &[AuditTrail] {
285        match self.audit_mode {
286            AuditMode::Last(n) => &self.audit_trail[self.audit_trail.len().saturating_sub(n)..],
287            _ => &self.audit_trail,
288        }
289    }
290
291    /// Which audit entries this message keeps. Set it with
292    /// [`MessageBuilder::audit_mode`].
293    #[inline]
294    pub fn audit_mode(&self) -> AuditMode {
295        self.audit_mode
296    }
297
298    /// Record one audit entry under [`Self::audit_mode`]. The only way the
299    /// engine appends to the trail.
300    pub(crate) fn record_audit(&mut self, entry: AuditTrail) {
301        match self.audit_mode {
302            AuditMode::Full => self.audit_trail.push(entry),
303            AuditMode::Off | AuditMode::Last(0) => {}
304            AuditMode::Last(n) => {
305                self.audit_trail.push(entry);
306                // Trim in batches of `n`, so the shift is amortized to O(1)
307                // per entry; `audit_trail()` hides the surplus.
308                if self.audit_trail.len() >= n.saturating_mul(2) {
309                    let excess = self.audit_trail.len() - n;
310                    self.audit_trail.drain(..excess);
311                }
312            }
313        }
314    }
315
316    /// Errors collected while processing — both validation failures and
317    /// task errors that the workflow swallowed via `continue_on_error`.
318    #[inline]
319    pub fn errors(&self) -> &[ErrorInfo] {
320        &self.errors
321    }
322
323    /// Whether per-write `Change` capture is on. When `false`, audit-trail
324    /// entries are still emitted but their `changes` lists are empty —
325    /// the bulk-pipeline fast path.
326    ///
327    /// Set it with [`MessageBuilder::capture_changes`], which documents the
328    /// cost — including the memory a looping workflow accumulates.
329    #[inline]
330    pub fn capture_changes(&self) -> bool {
331        self.capture_changes
332    }
333
334    /// Routing bucket for the traffic-split gate, if one was set.
335    ///
336    /// `None` means every workflow admits this message regardless of its
337    /// [`crate::Workflow::rollout`].
338    #[inline]
339    pub fn routing_bucket(&self) -> Option<u8> {
340        self.routing_bucket
341    }
342
343    /// Get a reference to the `data` field in context. Returns
344    /// `&OwnedDataValue::Null` if missing (matches `serde_json::Value`'s
345    /// `Index` fallback semantics).
346    pub fn data(&self) -> &OwnedDataValue {
347        &self.context["data"]
348    }
349
350    /// Get a reference to the `metadata` field in context.
351    pub fn metadata(&self) -> &OwnedDataValue {
352        &self.context["metadata"]
353    }
354
355    /// Get a reference to the `temp_data` field in context.
356    pub fn temp_data(&self) -> &OwnedDataValue {
357        &self.context["temp_data"]
358    }
359}
360
361/// Builder for [`Message`]. Collapses the historical
362/// `new` / `with_id` / `from_value` / `without_change_capture` four-way
363/// constructor split into a single fluent shape.
364///
365/// ```
366/// use dataflow_rs::Message;
367/// use serde_json::json;
368///
369/// // Minimal: serde_json payload, default UUID id, capture on.
370/// let m = Message::builder()
371///     .payload_json(&json!({"order": {"total": 1500}}))
372///     .build();
373/// assert!(m.id().len() > 0);
374/// assert!(m.capture_changes());
375/// ```
376#[must_use = "MessageBuilder must be `.build()` to produce a Message"]
377#[derive(Default)]
378pub struct MessageBuilder {
379    id: Option<String>,
380    payload: Option<Arc<OwnedDataValue>>,
381    capture_changes: Option<bool>,
382    audit_mode: AuditMode,
383    data: Option<OwnedDataValue>,
384    metadata: Option<OwnedDataValue>,
385    temp_data: Option<OwnedDataValue>,
386    routing_bucket: Option<u8>,
387}
388
389impl MessageBuilder {
390    /// Create an empty builder. Equivalent to [`MessageBuilder::default`].
391    pub fn new() -> Self {
392        Self::default()
393    }
394
395    /// Caller-supplied id (typically a correlation id from upstream).
396    /// Defaults to a freshly-generated UUID v7.
397    pub fn id(mut self, id: impl Into<String>) -> Self {
398        self.id = Some(id.into());
399        self
400    }
401
402    /// Already-owned payload `Arc` — zero serde_json walk, refcount-only
403    /// share. Mutually exclusive with [`Self::payload_json`]; whichever is
404    /// called last wins.
405    pub fn payload(mut self, payload: Arc<OwnedDataValue>) -> Self {
406        self.payload = Some(payload);
407        self
408    }
409
410    /// Construct the payload from a `serde_json::Value`. Goes through the
411    /// `OwnedDataValue::from(&Value)` bridge (one deep walk).
412    pub fn payload_json(mut self, payload: &JsonValue) -> Self {
413        self.payload = Some(Arc::new(OwnedDataValue::from(payload)));
414        self
415    }
416
417    /// Seed `context.data`.
418    ///
419    /// Replaces the empty `Object` that [`Self::build`] would otherwise install;
420    /// the other two root fields are unaffected. Seeding records no audit-trail
421    /// entry and no `Change` — it is initial state, not a mutation.
422    ///
423    /// Keys are taken **literally**: unlike
424    /// [`crate::engine::utils::set_nested_value`], a key containing `.` stays a
425    /// single key and a leading `#` is not stripped.
426    ///
427    /// A non-`Object` value is **ignored**, preserving the crate-wide invariant
428    /// that the three root fields are always objects. Calling this twice keeps
429    /// the last value.
430    ///
431    /// ```
432    /// use dataflow_rs::Message;
433    /// use serde_json::json;
434    ///
435    /// let m = Message::builder().data_json(&json!({"order": {"total": 1500}})).build();
436    /// assert_eq!(m.data()["order"]["total"], json!(1500).into());
437    /// ```
438    pub fn data(mut self, data: OwnedDataValue) -> Self {
439        if data.is_object() {
440            self.data = Some(data);
441        }
442        self
443    }
444
445    /// [`Self::data`] from a `serde_json::Value` (one `OwnedDataValue::from`
446    /// deep walk).
447    pub fn data_json(self, data: &JsonValue) -> Self {
448        self.data(OwnedDataValue::from(data))
449    }
450
451    /// Seed `context.metadata` — request headers, correlation ids, routing
452    /// hints.
453    ///
454    /// `Engine::process_message` adds `processed_at` and `engine_version` on top
455    /// of whatever is seeded here; a seeded `channel` key is overwritten by
456    /// `process_message_for_channel`. Same literal-key and non-object rules as
457    /// [`Self::data`].
458    ///
459    /// ```
460    /// use dataflow_rs::Message;
461    /// use serde_json::json;
462    ///
463    /// let m = Message::builder().metadata_json(&json!({"source": "api"})).build();
464    /// assert_eq!(m.metadata()["source"], json!("api").into());
465    /// ```
466    pub fn metadata(mut self, metadata: OwnedDataValue) -> Self {
467        if metadata.is_object() {
468            self.metadata = Some(metadata);
469        }
470        self
471    }
472
473    /// [`Self::metadata`] from a `serde_json::Value`.
474    pub fn metadata_json(self, metadata: &JsonValue) -> Self {
475        self.metadata(OwnedDataValue::from(metadata))
476    }
477
478    /// Seed `context.temp_data` — scratch space for intermediate task output.
479    ///
480    /// Same literal-key and non-object rules as [`Self::data`].
481    ///
482    /// ```
483    /// use dataflow_rs::Message;
484    /// use serde_json::json;
485    ///
486    /// let m = Message::builder().temp_data_json(&json!({"scratch": 1})).build();
487    /// assert_eq!(m.temp_data()["scratch"], json!(1).into());
488    /// ```
489    pub fn temp_data(mut self, temp_data: OwnedDataValue) -> Self {
490        if temp_data.is_object() {
491            self.temp_data = Some(temp_data);
492        }
493        self
494    }
495
496    /// [`Self::temp_data`] from a `serde_json::Value`.
497    pub fn temp_data_json(self, temp_data: &JsonValue) -> Self {
498        self.temp_data(OwnedDataValue::from(temp_data))
499    }
500
501    /// Routing bucket `0..=99` for [`crate::Workflow::rollout`] matching.
502    ///
503    /// Values `>= 100` are clamped to `99`, keeping the builder infallible like
504    /// the rest of its methods. A message with no bucket is admitted by every
505    /// workflow, split or not.
506    ///
507    /// ```
508    /// use dataflow_rs::Message;
509    ///
510    /// assert_eq!(Message::builder().routing_bucket(7).build().routing_bucket(), Some(7));
511    /// assert_eq!(Message::builder().routing_bucket(200).build().routing_bucket(), Some(99));
512    /// assert_eq!(Message::builder().build().routing_bucket(), None);
513    /// ```
514    pub fn routing_bucket(mut self, bucket: u8) -> Self {
515        self.routing_bucket = Some(bucket.min(99));
516        self
517    }
518
519    /// When `false`, built-in functions and `TaskContext::set` skip per-write
520    /// `Change` capture: audit-trail entries are still recorded, with an empty
521    /// `changes` list. Defaults to `true`.
522    ///
523    /// Capture has two costs. Each write deep-copies its old and new value
524    /// (throughput), and the copies stay on the message until
525    /// `process_message` returns (memory). In a looping workflow the second
526    /// one grows with every sweep — see
527    /// [`LoopConfig`](crate::engine::workflow::LoopConfig). Turn it off unless
528    /// you read [`AuditTrail::changes`](crate::AuditTrail::changes), or trace
529    /// with `TraceOptions { changes: true }`: that option reports the captured
530    /// diff and does not turn capture on.
531    ///
532    /// ```
533    /// use dataflow_rs::Message;
534    ///
535    /// let m = Message::builder().capture_changes(false).build();
536    /// assert!(!m.capture_changes());
537    /// ```
538    pub fn capture_changes(mut self, on: bool) -> Self {
539        self.capture_changes = Some(on);
540        self
541    }
542
543    /// Which audit entries the message keeps. Defaults to
544    /// [`AuditMode::Full`].
545    ///
546    /// A looping workflow records one entry per task per sweep, all kept until
547    /// `process_message` returns. Bound them with [`AuditMode::Last`] or drop
548    /// them with [`AuditMode::Off`] when nothing reads
549    /// [`Message::audit_trail`]; control flow is the same in every mode.
550    ///
551    /// ```
552    /// use dataflow_rs::{AuditMode, Message};
553    ///
554    /// let m = Message::builder().audit_mode(AuditMode::Last(64)).build();
555    /// assert_eq!(m.audit_mode(), AuditMode::Last(64));
556    /// ```
557    pub fn audit_mode(mut self, mode: AuditMode) -> Self {
558        self.audit_mode = mode;
559        self
560    }
561
562    /// Finalize. Defaults: id = UUID v7, payload = `OwnedDataValue::Null`,
563    /// capture_changes = `true`.
564    pub fn build(self) -> Message {
565        Message {
566            id: self
567                .id
568                .map(MessageId::Custom)
569                .unwrap_or_else(MessageId::new_uuid_v7),
570            payload: self
571                .payload
572                .unwrap_or_else(|| Arc::new(OwnedDataValue::Null)),
573            context: context_from(self.data, self.metadata, self.temp_data),
574            audit_trail: vec![],
575            errors: vec![],
576            capture_changes: self.capture_changes.unwrap_or(true),
577            routing_bucket: self.routing_bucket,
578            audit_mode: self.audit_mode,
579        }
580    }
581}
582
583/// Build the canonical context object, substituting any caller-seeded root field
584/// for the empty `Object`.
585///
586/// Key order is fixed at `data, metadata, temp_data` so the serialized shape is
587/// identical whether or not a seed was supplied.
588fn context_from(
589    data: Option<OwnedDataValue>,
590    metadata: Option<OwnedDataValue>,
591    temp_data: Option<OwnedDataValue>,
592) -> OwnedDataValue {
593    fn slot(v: Option<OwnedDataValue>) -> OwnedDataValue {
594        v.unwrap_or_else(|| OwnedDataValue::Object(Vec::new()))
595    }
596    OwnedDataValue::Object(vec![
597        ("data".to_string(), slot(data)),
598        ("metadata".to_string(), slot(metadata)),
599        ("temp_data".to_string(), slot(temp_data)),
600    ])
601}
602
603/// Build the canonical empty context shape used by `Message::new`. One
604/// implementation of the shape, shared with the seeded path.
605fn empty_context() -> OwnedDataValue {
606    context_from(None, None, None)
607}
608
609#[derive(Serialize, Deserialize, Debug, Clone)]
610pub struct AuditTrail {
611    pub workflow_id: Arc<str>,
612    pub task_id: Arc<str>,
613    pub timestamp: DateTime<Utc>,
614    pub changes: Vec<Change>,
615    pub status: usize,
616    /// Loop counter value for the sweep that produced this entry, for
617    /// workflows carrying a [`crate::engine::workflow::LoopConfig`]; `None`
618    /// otherwise.
619    ///
620    /// The counter rather than a sweep ordinal: `increment >= 1` makes it
621    /// strictly increasing, so it identifies the iteration *and* carries the
622    /// business meaning — in the per-item pattern it is the array index this
623    /// entry refers to. Recorded even when the loop leaves its counter
624    /// unnamed, since the engine tracks the value either way.
625    ///
626    /// Skipped when `None`, so a non-looping workflow's audit JSON is
627    /// byte-identical to what it was before loops existed.
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub loop_counter: Option<i64>,
630    /// Index of the element this entry records, for a task carrying a
631    /// [`crate::engine::for_each::ForEach`]; `None` otherwise. A fan-out
632    /// records one entry per element, in element order.
633    ///
634    /// Also `None` on the single entry an empty or unevaluable `over`
635    /// records, since no element was involved. Skipped when `None`, so an
636    /// ordinary task's audit JSON is unchanged.
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub element_index: Option<usize>,
639}
640
641/// A single recorded mutation in the audit trail.
642///
643/// `old_value` and `new_value` are owned `OwnedDataValue`s rather than
644/// `Arc<OwnedDataValue>` — eliminates one heap allocation per Change on the
645/// hot path. External consumers that need to share a `Change` across threads
646/// can wrap it themselves; in-process pipelines (audit-on map mappings) don't
647/// pay the Arc cost they were never going to use.
648///
649/// A path that was absent before the write records `old_value` as `null`.
650#[derive(Serialize, Deserialize, Debug, Clone)]
651pub struct Change {
652    pub path: Arc<str>,
653    pub old_value: OwnedDataValue,
654    /// The value written. `null` when [`Self::removed`] is set — read that
655    /// flag rather than this value to tell a removal from a write.
656    pub new_value: OwnedDataValue,
657    /// The key at `path` was removed rather than written — a `map` mapping
658    /// with `unset`, or `on_null: "unset"` meeting a null result. `new_value`
659    /// is then `null`, which alone could not tell "now absent" from "now
660    /// holds null".
661    ///
662    /// Skipped when `false`, so every write's audit JSON is byte-identical to
663    /// what it was before removals existed, and a reader that predates the
664    /// field still loads a removal (as a write of `null`).
665    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
666    pub removed: bool,
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn audit_mode_last_holds_at_most_twice_n_and_shows_the_last_n() {
675        let mut m = Message::builder().audit_mode(AuditMode::Last(3)).build();
676        for i in 0..1000_i64 {
677            m.record_audit(AuditTrail {
678                timestamp: Utc::now(),
679                workflow_id: Arc::from("w"),
680                task_id: Arc::from("t"),
681                status: 200,
682                changes: vec![],
683                loop_counter: Some(i),
684                element_index: None,
685            });
686            assert!(m.audit_trail.len() < 6, "the batch trim bounds the Vec");
687            let shown: Vec<i64> = m
688                .audit_trail()
689                .iter()
690                .map(|e| e.loop_counter.unwrap())
691                .collect();
692            let from = (i - 2).max(0);
693            assert_eq!(shown, (from..=i).collect::<Vec<_>>());
694        }
695    }
696
697    #[test]
698    fn capture_changes_defaults_to_true_on_every_constructor() {
699        // Kept `true` deliberately (#63): the browser debugger, `TraceOptions
700        // { changes: true }` and hosts reading `AuditTrail::changes` all rely
701        // on it. Changing it is a behaviour change for all of them — read the
702        // CLAUDE.md note before touching this.
703        assert!(Message::builder().build().capture_changes());
704        assert!(Message::new(Arc::new(OwnedDataValue::Null)).capture_changes());
705        assert!(Message::from_value(&serde_json::json!({})).capture_changes());
706        let json = serde_json::to_value(Message::builder().build()).expect("serializes");
707        let back: Message = serde_json::from_value(json).expect("deserializes");
708        assert!(
709            back.capture_changes(),
710            "not serialized, so deserializing restores the default"
711        );
712    }
713
714    #[test]
715    fn audit_trail_loop_counter_is_absent_from_json_when_none() {
716        // A non-looping workflow must keep the historical wire shape byte for
717        // byte — dataflow-ui and any stored audit JSON depend on it.
718        let entry = AuditTrail {
719            workflow_id: Arc::from("w"),
720            task_id: Arc::from("t"),
721            timestamp: Utc::now(),
722            changes: vec![],
723            status: 200,
724            loop_counter: None,
725            element_index: None,
726        };
727        let json = serde_json::to_value(&entry).expect("should serialize");
728        assert!(json.get("loop_counter").is_none());
729
730        let with_counter = AuditTrail {
731            loop_counter: Some(7),
732            element_index: None,
733            ..entry
734        };
735        assert_eq!(
736            serde_json::to_value(&with_counter).expect("should serialize")["loop_counter"],
737            serde_json::json!(7)
738        );
739    }
740
741    #[test]
742    fn audit_trail_element_index_is_absent_from_json_when_none() {
743        let entry = AuditTrail {
744            workflow_id: Arc::from("w"),
745            task_id: Arc::from("t"),
746            timestamp: Utc::now(),
747            changes: vec![],
748            status: 200,
749            loop_counter: None,
750            element_index: None,
751        };
752        let json = serde_json::to_value(&entry).expect("should serialize");
753        assert!(
754            json.get("element_index").is_none(),
755            "an ordinary task keeps its wire shape"
756        );
757
758        // A reader that predates the field loads an entry without it.
759        let back: AuditTrail = serde_json::from_value(json).expect("should deserialize");
760        assert_eq!(back.element_index, None);
761
762        let fanned = AuditTrail {
763            element_index: Some(4),
764            ..entry
765        };
766        assert_eq!(
767            serde_json::to_value(&fanned).expect("should serialize")["element_index"],
768            serde_json::json!(4)
769        );
770    }
771
772    #[test]
773    fn a_write_change_keeps_its_historical_wire_shape() {
774        // `removed` is skipped when false, so writes serialize byte for byte as
775        // they did before removals existed.
776        let change = Change {
777            path: Arc::from("data.x"),
778            old_value: OwnedDataValue::Null,
779            new_value: OwnedDataValue::from_i64(1),
780            removed: false,
781        };
782        assert_eq!(
783            serde_json::to_value(&change).unwrap(),
784            serde_json::json!({"path": "data.x", "old_value": null, "new_value": 1})
785        );
786
787        // And a change written before the flag existed reads back as a write.
788        let legacy: Change = serde_json::from_value(
789            serde_json::json!({"path": "data.x", "old_value": null, "new_value": 1}),
790        )
791        .unwrap();
792        assert!(!legacy.removed);
793    }
794
795    #[test]
796    fn audit_trail_without_a_loop_counter_key_deserializes() {
797        // Audit JSON written before loops existed must still round-trip.
798        let entry: AuditTrail = serde_json::from_value(serde_json::json!({
799            "workflow_id": "w",
800            "task_id": "t",
801            "timestamp": "2026-08-11T00:00:00Z",
802            "changes": [],
803            "status": 200
804        }))
805        .expect("legacy audit JSON should deserialize");
806        assert_eq!(entry.loop_counter, None);
807    }
808
809    #[test]
810    fn from_json_str_parses_valid_payload() {
811        let msg =
812            Message::from_json_str(r#"{"order": {"total": 42}}"#).expect("valid JSON should parse");
813        let payload_json = serde_json::to_value(msg.payload()).unwrap();
814        assert_eq!(payload_json, serde_json::json!({"order": {"total": 42}}));
815    }
816
817    #[test]
818    fn from_json_str_rejects_malformed_payload() {
819        let err = Message::from_json_str("{ not json").expect_err("malformed input should fail");
820        assert!(matches!(err, DataflowError::Deserialization(_)));
821    }
822
823    #[test]
824    fn builder_with_no_seed_matches_the_historical_empty_context() {
825        let m = Message::builder().build();
826        let v = serde_json::to_value(&m).unwrap();
827        // Key order is part of the shape, so assert on the serialized object.
828        let ctx = v["context"].as_object().unwrap();
829        assert_eq!(
830            ctx.keys().collect::<Vec<_>>(),
831            vec!["data", "metadata", "temp_data"]
832        );
833        assert_eq!(
834            v["context"],
835            serde_json::json!({
836                "data": {}, "metadata": {}, "temp_data": {}
837            })
838        );
839    }
840
841    #[test]
842    fn each_setter_lands_in_its_own_root_field() {
843        let m = Message::builder()
844            .data_json(&serde_json::json!({"d": 1}))
845            .build();
846        assert_eq!(
847            serde_json::Value::from(m.data()),
848            serde_json::json!({"d": 1})
849        );
850        assert_eq!(serde_json::Value::from(m.metadata()), serde_json::json!({}));
851        assert_eq!(
852            serde_json::Value::from(m.temp_data()),
853            serde_json::json!({})
854        );
855
856        let m = Message::builder()
857            .metadata_json(&serde_json::json!({"m": 1}))
858            .build();
859        assert_eq!(
860            serde_json::Value::from(m.metadata()),
861            serde_json::json!({"m": 1})
862        );
863        assert_eq!(serde_json::Value::from(m.data()), serde_json::json!({}));
864
865        let m = Message::builder()
866            .temp_data_json(&serde_json::json!({"t": 1}))
867            .build();
868        assert_eq!(
869            serde_json::Value::from(m.temp_data()),
870            serde_json::json!({"t": 1})
871        );
872        assert_eq!(serde_json::Value::from(m.data()), serde_json::json!({}));
873    }
874
875    #[test]
876    fn the_owned_and_json_setter_forms_agree() {
877        let v = serde_json::json!({"a": {"b": [1, 2]}});
878        let via_json = Message::builder().data_json(&v).build();
879        let via_owned = Message::builder().data(OwnedDataValue::from(&v)).build();
880        assert_eq!(via_json.context, via_owned.context);
881    }
882
883    #[test]
884    fn seeding_records_no_audit_entry_or_change() {
885        for capture in [true, false] {
886            let m = Message::builder()
887                .capture_changes(capture)
888                .data_json(&serde_json::json!({"d": 1}))
889                .build();
890            assert!(
891                m.audit_trail().is_empty(),
892                "seeding is initial state, not a mutation"
893            );
894            assert_eq!(
895                serde_json::Value::from(m.data()),
896                serde_json::json!({"d": 1})
897            );
898        }
899    }
900
901    #[test]
902    fn calling_a_setter_twice_keeps_the_last_value() {
903        let m = Message::builder()
904            .data_json(&serde_json::json!({"first": 1}))
905            .data_json(&serde_json::json!({"second": 2}))
906            .build();
907        assert_eq!(
908            serde_json::Value::from(m.data()),
909            serde_json::json!({"second": 2})
910        );
911    }
912
913    #[test]
914    fn an_empty_object_seed_is_indistinguishable_from_no_seed() {
915        let seeded = Message::builder().data_json(&serde_json::json!({})).build();
916        let bare = Message::builder().build();
917        assert_eq!(seeded.context, bare.context);
918    }
919
920    #[test]
921    fn seed_keys_are_literal_not_paths() {
922        use crate::engine::utils::get_nested_value;
923
924        let m = Message::builder()
925            .metadata_json(&serde_json::json!({"a.b": 1}))
926            .build();
927        // One literal key, not nested.
928        assert_eq!(
929            serde_json::Value::from(m.metadata()),
930            serde_json::json!({"a.b": 1})
931        );
932        assert!(
933            get_nested_value(&m.context, "metadata.a.b").is_none(),
934            "a dotted key must not become a nested path"
935        );
936
937        // Leading `#` is not stripped.
938        let m = Message::builder()
939            .metadata_json(&serde_json::json!({"#20": 1}))
940            .build();
941        assert_eq!(
942            serde_json::Value::from(m.metadata()),
943            serde_json::json!({"#20": 1})
944        );
945
946        // A numeric-string key stays an object key, not an Array.
947        let m = Message::builder()
948            .data_json(&serde_json::json!({"0": 1}))
949            .build();
950        assert!(matches!(m.data(), OwnedDataValue::Object(_)));
951        assert_eq!(
952            serde_json::Value::from(m.data()),
953            serde_json::json!({"0": 1})
954        );
955    }
956
957    #[test]
958    fn non_ascii_keys_and_values_round_trip() {
959        let v = serde_json::json!({"régión": "東京"});
960        let m = Message::builder().data_json(&v).build();
961        assert_eq!(serde_json::Value::from(m.data()), v);
962        assert_eq!(serde_json::to_value(&m).unwrap()["context"]["data"], v);
963    }
964
965    #[test]
966    fn nested_seeds_resolve_through_the_path_api() {
967        use crate::engine::utils::get_nested_value;
968
969        let m = Message::builder()
970            .data_json(&serde_json::json!({"order": {"items": [1, 2]}}))
971            .build();
972        assert_eq!(
973            get_nested_value(&m.context, "data.order.items.1"),
974            Some(&OwnedDataValue::from(&serde_json::json!(2)))
975        );
976    }
977
978    #[test]
979    fn non_object_seeds_are_ignored_to_preserve_the_context_invariant() {
980        // The recorded decision: the three root fields are always Objects.
981        // Storing a scalar verbatim would make `set_processing_metadata` bail out
982        // and silently drop `processed_at` / `engine_version`.
983        for bad in [
984            serde_json::json!("scalar"),
985            serde_json::json!([1, 2]),
986            serde_json::json!(null),
987            serde_json::json!(7),
988        ] {
989            let m = Message::builder()
990                .data_json(&bad)
991                .metadata_json(&bad)
992                .temp_data_json(&bad)
993                .build();
994            assert_eq!(
995                serde_json::Value::from(&m.context),
996                serde_json::json!({"data": {}, "metadata": {}, "temp_data": {}}),
997                "non-object seed {bad} must be ignored"
998            );
999        }
1000    }
1001}