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}