Skip to main content

agent_first_data/
protocol.rs

1use serde::Serialize;
2use serde_json::Value;
3use std::fmt;
4
5// ═══════════════════════════════════════════
6// Event Type and Build Errors (0.16 API)
7// ═══════════════════════════════════════════
8
9/// A typed, strict-valid AFDATA protocol v1 event.
10///
11/// Wraps the complete JSON envelope and provides access to the underlying value.
12/// Events produced by builders are guaranteed to pass `validate_protocol_event(_, true)`.
13#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
14pub struct Event(Value);
15
16impl Event {
17    /// Access the underlying JSON value.
18    pub fn as_value(&self) -> &Value {
19        &self.0
20    }
21
22    /// Convert into the underlying JSON value.
23    pub fn into_value(self) -> Value {
24        self.0
25    }
26}
27
28impl From<Event> for Value {
29    fn from(event: Event) -> Self {
30        event.0
31    }
32}
33
34impl fmt::Display for Event {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(f, "{}", self.0)
37    }
38}
39
40/// Error type for builder failures.
41///
42/// Errors occur when:
43/// - A reserved error field is overwritten (code, message, hint, retryable)
44/// - An object field (.fields or .extend) is not a JSON object
45/// - A required field (code/message for convenience functions) is empty
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub enum BuildError {
48    ReservedField(String),
49    NonObjectField(String),
50    EmptyRequiredField(String),
51}
52
53impl fmt::Display for BuildError {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            Self::ReservedField(msg) => write!(f, "reserved field: {msg}"),
57            Self::NonObjectField(msg) => write!(f, "non-object field: {msg}"),
58            Self::EmptyRequiredField(msg) => write!(f, "empty required field: {msg}"),
59        }
60    }
61}
62
63impl std::error::Error for BuildError {}
64
65/// Log level enumeration (serialized as lowercase).
66#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
67#[serde(rename_all = "lowercase")]
68pub enum LogLevel {
69    Debug,
70    Info,
71    Warn,
72    Error,
73}
74
75impl LogLevel {
76    /// Only the CLI emitter renders a level as text, so without `cli` this is
77    /// dead code — and the `--no-default-features` build is held to
78    /// `-D warnings` like every other.
79    #[cfg(feature = "cli")]
80    pub(crate) fn as_str(&self) -> &'static str {
81        match self {
82            Self::Debug => "debug",
83            Self::Info => "info",
84            Self::Warn => "warn",
85            Self::Error => "error",
86        }
87    }
88}
89
90// ═══════════════════════════════════════════
91// Result Builder
92// ═══════════════════════════════════════════
93
94/// Builder for result events.
95pub struct ResultBuilder {
96    payload: Value,
97    trace: Option<Value>,
98}
99
100impl ResultBuilder {
101    /// Set the trace object.
102    pub fn trace(mut self, trace: Value) -> Self {
103        self.trace = Some(trace);
104        self
105    }
106
107    /// Build the event. This builder cannot fail.
108    pub fn build(self) -> Event {
109        let trace = self
110            .trace
111            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
112        let mut obj = serde_json::Map::new();
113        obj.insert("kind".to_string(), Value::String("result".to_string()));
114        obj.insert("result".to_string(), self.payload);
115        obj.insert("trace".to_string(), trace);
116        Event(Value::Object(obj))
117    }
118}
119
120/// Fluent builder: start building a result event.
121pub fn json_result(payload: Value) -> ResultBuilder {
122    ResultBuilder {
123        payload,
124        trace: None,
125    }
126}
127
128// ═══════════════════════════════════════════
129// Error Builder
130// ═══════════════════════════════════════════
131
132/// Builder for error events.
133pub struct ErrorBuilder {
134    code: String,
135    message: String,
136    retryable: bool,
137    hint: Option<String>,
138    fields: serde_json::Map<String, Value>,
139    trace: Option<Value>,
140    build_error: Option<BuildError>,
141}
142
143impl ErrorBuilder {
144    /// Mark the error as retryable.
145    pub fn retryable(mut self) -> Self {
146        self.retryable = true;
147        self
148    }
149
150    /// Set retryable based on a boolean condition.
151    pub fn retryable_if(mut self, should_retry: bool) -> Self {
152        self.retryable = should_retry;
153        self
154    }
155
156    /// Set the hint.
157    pub fn hint(mut self, hint: &str) -> Self {
158        if !hint.is_empty() {
159            self.hint = Some(hint.to_string());
160        }
161        self
162    }
163
164    /// Set the hint if present.
165    pub fn hint_if_some(mut self, hint: Option<&str>) -> Self {
166        if let Some(h) = hint.filter(|hint| !hint.is_empty()) {
167            self.hint = Some(h.to_string());
168        }
169        self
170    }
171
172    /// Add an extension field.
173    pub fn field(mut self, name: &str, value: Value) -> Self {
174        if self.build_error.is_none() {
175            match name {
176                "code" | "message" | "hint" | "retryable" => {
177                    self.build_error = Some(BuildError::ReservedField(format!(
178                        "cannot write reserved field {name:?} to error payload"
179                    )));
180                }
181                _ => {
182                    self.fields.insert(name.to_string(), value);
183                }
184            }
185        }
186        self
187    }
188
189    /// Add multiple extension fields from a JSON object.
190    pub fn fields(mut self, fields: Value) -> Self {
191        if self.build_error.is_none() {
192            match fields {
193                Value::Object(map) => {
194                    for (k, v) in map {
195                        match k.as_str() {
196                            "code" | "message" | "hint" | "retryable" => {
197                                self.build_error = Some(BuildError::ReservedField(format!(
198                                    "cannot write reserved field {k:?} to error payload"
199                                )));
200                                return self;
201                            }
202                            _ => {
203                                self.fields.insert(k, v);
204                            }
205                        }
206                    }
207                }
208                _ => {
209                    self.build_error = Some(BuildError::NonObjectField(
210                        "fields() argument must be a JSON object".to_string(),
211                    ));
212                }
213            }
214        }
215        self
216    }
217
218    /// Extend with a serializable value (must serialize to JSON object).
219    pub fn extend<T: Serialize>(mut self, value: T) -> Self {
220        if self.build_error.is_none() {
221            match serde_json::to_value(&value) {
222                Ok(Value::Object(map)) => {
223                    for (k, v) in map {
224                        match k.as_str() {
225                            "code" | "message" | "hint" | "retryable" => {
226                                self.build_error = Some(BuildError::ReservedField(format!(
227                                    "cannot write reserved field {k:?} to error payload"
228                                )));
229                                return self;
230                            }
231                            _ => {
232                                self.fields.insert(k, v);
233                            }
234                        }
235                    }
236                }
237                Ok(_) => {
238                    self.build_error = Some(BuildError::NonObjectField(
239                        "extend() argument must serialize to a JSON object".to_string(),
240                    ));
241                }
242                Err(_) => {
243                    self.build_error = Some(BuildError::NonObjectField(
244                        "extend() argument serialization failed".to_string(),
245                    ));
246                }
247            }
248        }
249        self
250    }
251
252    /// Set the trace object.
253    pub fn trace(mut self, trace: Value) -> Self {
254        self.trace = Some(trace);
255        self
256    }
257
258    /// Build the event.
259    pub fn build(self) -> Result<Event, BuildError> {
260        if let Some(err) = self.build_error {
261            return Err(err);
262        }
263        if self.trace.as_ref().is_some_and(|trace| !trace.is_object()) {
264            return Err(BuildError::NonObjectField(
265                "trace() argument must be a JSON object".to_string(),
266            ));
267        }
268
269        let mut error_obj = self.fields;
270        error_obj.insert("code".to_string(), Value::String(self.code));
271        error_obj.insert("message".to_string(), Value::String(self.message));
272        error_obj.insert("retryable".to_string(), Value::Bool(self.retryable));
273        if let Some(h) = self.hint {
274            error_obj.insert("hint".to_string(), Value::String(h));
275        }
276
277        let trace = self
278            .trace
279            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
280        let mut obj = serde_json::Map::new();
281        obj.insert("kind".to_string(), Value::String("error".to_string()));
282        obj.insert("error".to_string(), Value::Object(error_obj));
283        obj.insert("trace".to_string(), trace);
284
285        Ok(Event(Value::Object(obj)))
286    }
287}
288
289/// Fluent builder: start building an error event.
290///
291/// An empty `code` or `message` does not panic here; it seeds a deferred
292/// [`BuildError::EmptyRequiredField`] that is surfaced when [`ErrorBuilder::build`]
293/// is called.
294pub fn json_error(code: &str, message: &str) -> ErrorBuilder {
295    let build_error = if code.is_empty() {
296        Some(BuildError::EmptyRequiredField(
297            "error code must not be empty".to_string(),
298        ))
299    } else if message.is_empty() {
300        Some(BuildError::EmptyRequiredField(
301            "error message must not be empty".to_string(),
302        ))
303    } else {
304        None
305    };
306    ErrorBuilder {
307        code: code.to_string(),
308        message: message.to_string(),
309        retryable: false,
310        hint: None,
311        fields: serde_json::Map::new(),
312        trace: None,
313        build_error,
314    }
315}
316
317// ═══════════════════════════════════════════
318// Progress Builder
319// ═══════════════════════════════════════════
320
321/// Builder for progress events.
322pub struct ProgressBuilder {
323    payload: Value,
324    trace: Option<Value>,
325}
326
327impl ProgressBuilder {
328    /// Set the trace object.
329    pub fn trace(mut self, trace: Value) -> Self {
330        self.trace = Some(trace);
331        self
332    }
333
334    /// Build the event. This builder cannot fail.
335    pub fn build(self) -> Event {
336        let trace = self
337            .trace
338            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
339        let mut obj = serde_json::Map::new();
340        obj.insert("kind".to_string(), Value::String("progress".to_string()));
341        obj.insert("progress".to_string(), self.payload);
342        obj.insert("trace".to_string(), trace);
343
344        Event(Value::Object(obj))
345    }
346}
347
348/// Fluent builder: start building a progress event.
349///
350pub fn json_progress(payload: Value) -> ProgressBuilder {
351    ProgressBuilder {
352        payload,
353        trace: None,
354    }
355}
356
357// ═══════════════════════════════════════════
358// Log Builder
359// ═══════════════════════════════════════════
360
361/// Builder for log events.
362pub struct LogBuilder {
363    payload: Value,
364    trace: Option<Value>,
365}
366
367impl LogBuilder {
368    /// Set the trace object.
369    pub fn trace(mut self, trace: Value) -> Self {
370        self.trace = Some(trace);
371        self
372    }
373
374    /// Build the event. This builder cannot fail.
375    pub fn build(self) -> Event {
376        let trace = self
377            .trace
378            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
379        let mut obj = serde_json::Map::new();
380        obj.insert("kind".to_string(), Value::String("log".to_string()));
381        obj.insert("log".to_string(), self.payload);
382        obj.insert("trace".to_string(), trace);
383
384        Event(Value::Object(obj))
385    }
386}
387
388/// Fluent builder: start building a log event.
389///
390pub fn json_log(payload: Value) -> LogBuilder {
391    LogBuilder {
392        payload,
393        trace: None,
394    }
395}
396
397// ═══════════════════════════════════════════
398// CLI Helper
399// ═══════════════════════════════════════════
400
401/// Build a CLI error event with optional hint.
402///
403/// Always returns a strict-valid `kind:"error"` event with code `"cli_error"`,
404/// `retryable: false`, and an empty `trace`. An empty `message` is replaced with
405/// a generic placeholder so the returned event stays strict-valid without panicking.
406pub fn build_cli_error(message: &str, hint: Option<&str>) -> Event {
407    let message = if message.is_empty() {
408        "unspecified error"
409    } else {
410        message
411    };
412    match json_error("cli_error", message).hint_if_some(hint).build() {
413        Ok(event) => event,
414        // Unreachable in practice (non-empty code+message, no reserved fields);
415        // construct a minimal valid envelope directly rather than panic.
416        Err(_) => {
417            let mut error_obj = serde_json::Map::new();
418            error_obj.insert("code".to_string(), Value::String("cli_error".to_string()));
419            error_obj.insert(
420                "message".to_string(),
421                Value::String("unspecified error".to_string()),
422            );
423            error_obj.insert("retryable".to_string(), Value::Bool(false));
424            let mut obj = serde_json::Map::new();
425            obj.insert("kind".to_string(), Value::String("error".to_string()));
426            obj.insert("error".to_string(), Value::Object(error_obj));
427            obj.insert("trace".to_string(), Value::Object(serde_json::Map::new()));
428            Event(Value::Object(obj))
429        }
430    }
431}
432
433// ═══════════════════════════════════════════
434// Validation
435// ═══════════════════════════════════════════
436
437/// A single protocol-validation violation: a stable machine-readable `rule`
438/// slug, a JSON pointer to the offending location (`""` = the whole event),
439/// and a human-readable `message`.
440///
441/// The `rule` slugs are a stable contract (the CLI maps them 1:1 onto its
442/// `validate` finding `rule_id`s). The full set:
443/// `event_not_object`, `kind_invalid`, `kind_unsupported`, `payload_missing`,
444/// `unexpected_field`, `trace_not_object`, `trace_required`, `error_not_object`,
445/// `error_code_invalid`, `error_message_invalid`, `error_hint_invalid`,
446/// `error_retryable_invalid`, `stream_non_terminal_after_terminal`,
447/// `stream_duplicate_terminal`, `stream_missing_terminal`.
448#[derive(Clone, Debug, PartialEq, Eq)]
449pub struct ProtocolViolation {
450    pub rule: &'static str,
451    pub pointer: String,
452    pub message: String,
453}
454
455impl std::fmt::Display for ProtocolViolation {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        write!(f, "{}", self.message)
458    }
459}
460
461impl std::error::Error for ProtocolViolation {}
462
463fn violation(
464    rule: &'static str,
465    pointer: impl Into<String>,
466    message: impl Into<String>,
467) -> ProtocolViolation {
468    ProtocolViolation {
469        rule,
470        pointer: pointer.into(),
471        message: message.into(),
472    }
473}
474
475/// Validate one protocol v1 event envelope.
476///
477/// `strict` additionally enforces the recommended strict profile: `trace` is
478/// required, and kind-specific payload shapes are checked (see
479/// [`validate_protocol_event_strict_payload`]).
480///
481/// Returns the first [`ProtocolViolation`] found, or `Ok(())`.
482pub fn validate_protocol_event(event: &Value, strict: bool) -> Result<(), ProtocolViolation> {
483    validate_protocol_event_base(event)?;
484    if strict {
485        validate_protocol_event_strict_payload(event)?;
486    }
487    Ok(())
488}
489
490fn validate_protocol_event_base(event: &Value) -> Result<(), ProtocolViolation> {
491    let Some(obj) = event.as_object() else {
492        return Err(violation(
493            "event_not_object",
494            "",
495            "event must be a JSON object",
496        ));
497    };
498    let Some(kind) = obj.get("kind").and_then(Value::as_str) else {
499        return Err(violation(
500            "kind_invalid",
501            "/kind",
502            "event.kind must be one of result, error, progress, log",
503        ));
504    };
505    if !matches!(kind, "result" | "error" | "progress" | "log") {
506        return Err(violation(
507            "kind_unsupported",
508            "/kind",
509            format!("unsupported event kind {kind:?}"),
510        ));
511    }
512    if !obj.contains_key(kind) {
513        return Err(violation(
514            "payload_missing",
515            format!("/{kind}"),
516            format!("event payload field {kind:?} is required"),
517        ));
518    }
519    for key in obj.keys() {
520        if key != "kind" && key != kind && key != "trace" {
521            return Err(violation(
522                "unexpected_field",
523                format!("/{key}"),
524                format!("unexpected top-level field {key:?}"),
525            ));
526        }
527    }
528    if let Some(trace) = obj.get("trace")
529        && !trace.is_object()
530    {
531        return Err(violation(
532            "trace_not_object",
533            "/trace",
534            "event.trace must be a JSON object when present",
535        ));
536    }
537    if kind == "error" {
538        validate_error_payload(obj.get("error"))?;
539    }
540    Ok(())
541}
542
543fn validate_error_payload(error: Option<&Value>) -> Result<(), ProtocolViolation> {
544    let Some(error) = error.and_then(Value::as_object) else {
545        return Err(violation(
546            "error_not_object",
547            "/error",
548            "event.error must be a JSON object",
549        ));
550    };
551    match error.get("code").and_then(Value::as_str) {
552        Some(code) if !code.is_empty() => {}
553        _ => {
554            return Err(violation(
555                "error_code_invalid",
556                "/error/code",
557                "event.error.code must be a non-empty string",
558            ));
559        }
560    }
561    match error.get("message").and_then(Value::as_str) {
562        Some(message) if !message.is_empty() => {}
563        _ => {
564            return Err(violation(
565                "error_message_invalid",
566                "/error/message",
567                "event.error.message must be a non-empty string",
568            ));
569        }
570    }
571    if error.get("hint").is_some_and(|hint| !hint.is_string()) {
572        return Err(violation(
573            "error_hint_invalid",
574            "/error/hint",
575            "event.error.hint must be a string when present",
576        ));
577    }
578    Ok(())
579}
580
581/// Validate a finite structured CLI event stream:
582/// `(log | progress)* -> exactly one (result | error) -> end`.
583///
584/// `strict` is forwarded to [`validate_protocol_event`] for every event.
585///
586/// Unlike [`validate_protocol_event`], this collects every violation across
587/// the whole stream instead of failing fast on the first one; per-event
588/// violation pointers are prefixed with the event's index (`/{idx}{pointer}`).
589pub fn validate_protocol_stream(
590    events: &[Value],
591    strict: bool,
592) -> Result<(), Vec<ProtocolViolation>> {
593    let mut violations = Vec::new();
594    let mut terminal_seen = false;
595    for (idx, event) in events.iter().enumerate() {
596        if let Err(v) = validate_protocol_event(event, strict) {
597            violations.push(ProtocolViolation {
598                rule: v.rule,
599                pointer: format!("/{idx}{}", v.pointer),
600                message: v.message,
601            });
602        }
603        match event.get("kind").and_then(Value::as_str) {
604            Some("log") | Some("progress") => {
605                if terminal_seen {
606                    violations.push(violation(
607                        "stream_non_terminal_after_terminal",
608                        format!("/{idx}"),
609                        "non-terminal event after terminal",
610                    ));
611                }
612            }
613            Some("result") | Some("error") => {
614                if terminal_seen {
615                    violations.push(violation(
616                        "stream_duplicate_terminal",
617                        format!("/{idx}"),
618                        "duplicate terminal event",
619                    ));
620                } else {
621                    terminal_seen = true;
622                }
623            }
624            _ => {} // an invalid/absent kind is already recorded by validate_protocol_event above
625        }
626    }
627    if !terminal_seen {
628        violations.push(violation(
629            "stream_missing_terminal",
630            String::new(),
631            "event stream must contain exactly one terminal result or error",
632        ));
633    }
634    if violations.is_empty() {
635        Ok(())
636    } else {
637        Err(violations)
638    }
639}
640
641/// Validate one protocol v1 event's payload against the recommended strict profile.
642///
643/// Assumes the base envelope shape (see [`validate_protocol_event_base`]) already passed.
644fn validate_protocol_event_strict_payload(event: &Value) -> Result<(), ProtocolViolation> {
645    if !event.get("trace").is_some_and(Value::is_object) {
646        return Err(violation(
647            "trace_required",
648            "/trace",
649            "event.trace is required by the strict profile",
650        ));
651    }
652    match event.get("kind").and_then(Value::as_str) {
653        Some("error") => validate_strict_error_payload(event.get("error")),
654        _ => Ok(()),
655    }
656}
657
658fn validate_strict_error_payload(error: Option<&Value>) -> Result<(), ProtocolViolation> {
659    let Some(error) = error.and_then(Value::as_object) else {
660        return Err(violation(
661            "error_not_object",
662            "/error",
663            "event.error must be a JSON object in the strict profile",
664        ));
665    };
666    require_non_empty_string(
667        error,
668        "code",
669        "error_code_invalid",
670        "/error/code",
671        "event.error",
672    )?;
673    require_non_empty_string(
674        error,
675        "message",
676        "error_message_invalid",
677        "/error/message",
678        "event.error",
679    )?;
680    if error.get("retryable").and_then(Value::as_bool).is_none() {
681        return Err(violation(
682            "error_retryable_invalid",
683            "/error/retryable",
684            "event.error.retryable must be a boolean in the strict profile",
685        ));
686    }
687    if error.contains_key("hint") && !error.get("hint").is_some_and(Value::is_string) {
688        return Err(violation(
689            "error_hint_invalid",
690            "/error/hint",
691            "event.error.hint must be a string when present",
692        ));
693    }
694    Ok(())
695}
696
697fn require_non_empty_string(
698    payload: &serde_json::Map<String, Value>,
699    field: &str,
700    rule: &'static str,
701    pointer: &'static str,
702    path: &str,
703) -> Result<(), ProtocolViolation> {
704    if payload
705        .get(field)
706        .and_then(Value::as_str)
707        .is_some_and(|value| !value.is_empty())
708    {
709        return Ok(());
710    }
711    Err(violation(
712        rule,
713        pointer,
714        format!("{path}.{field} must be a non-empty string in the strict profile"),
715    ))
716}
717
718// ═══════════════════════════════════════════
719// Reader API: decode_protocol_event
720// ═══════════════════════════════════════════
721
722/// A decoded, strict-valid AFDATA protocol v1 event, typed by kind.
723#[derive(Clone, Debug, PartialEq)]
724pub enum DecodedEvent {
725    Result(DecodedResult),
726    Error(DecodedError),
727    Progress(DecodedProgress),
728    Log(DecodedLog),
729}
730
731/// Decoded `kind:"result"` event.
732#[derive(Clone, Debug, PartialEq)]
733pub struct DecodedResult {
734    /// The raw `result` payload value.
735    pub result: Value,
736    /// The raw `trace` object, when present.
737    pub trace: Option<Value>,
738}
739
740/// Decoded `kind:"error"` event.
741#[derive(Clone, Debug, PartialEq)]
742pub struct DecodedError {
743    pub code: String,
744    pub message: String,
745    pub retryable: bool,
746    pub hint: Option<String>,
747    /// Payload keys beyond `code`, `message`, `retryable`, `hint`.
748    pub fields: serde_json::Map<String, Value>,
749    /// The raw `trace` object, when present.
750    pub trace: Option<Value>,
751}
752
753/// Decoded `kind:"progress"` event.
754#[derive(Clone, Debug, PartialEq)]
755pub struct DecodedProgress {
756    /// The raw `progress` payload value.
757    pub progress: Value,
758    /// The raw `trace` object, when present.
759    pub trace: Option<Value>,
760}
761
762/// Decoded `kind:"log"` event.
763#[derive(Clone, Debug, PartialEq)]
764pub struct DecodedLog {
765    /// The raw `log` payload value.
766    pub log: Value,
767    /// The raw `trace` object, when present.
768    pub trace: Option<Value>,
769}
770
771/// Error returned by [`decode_protocol_event`].
772#[derive(Clone, Debug, PartialEq, Eq)]
773pub enum EventDecodeError {
774    /// `text` is not valid JSON.
775    InvalidJson(String),
776    /// The parsed JSON value failed strict protocol validation.
777    InvalidEvent(String),
778}
779
780impl fmt::Display for EventDecodeError {
781    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
782        match self {
783            Self::InvalidJson(err) => write!(f, "invalid JSON: {err}"),
784            Self::InvalidEvent(err) => write!(f, "invalid protocol event: {err}"),
785        }
786    }
787}
788
789impl std::error::Error for EventDecodeError {}
790
791/// Parse one protocol v1 line, strict-validate it, and return a typed decoded event.
792///
793/// `text` is a single JSON text value (one protocol line), not a JSONL stream.
794pub fn decode_protocol_event(text: &str) -> Result<DecodedEvent, EventDecodeError> {
795    let value: Value =
796        serde_json::from_str(text).map_err(|err| EventDecodeError::InvalidJson(err.to_string()))?;
797    validate_protocol_event(&value, true)
798        .map_err(|v| EventDecodeError::InvalidEvent(v.to_string()))?;
799
800    // Strict validation above guarantees the envelope is an object with a
801    // recognized `kind`; the `ok_or_else` fallbacks below are defensive only.
802    let malformed = || {
803        EventDecodeError::InvalidEvent(
804            "event passed strict validation but has an unexpected shape".to_string(),
805        )
806    };
807    let obj = value.as_object().ok_or_else(malformed)?;
808    let trace = obj.get("trace").cloned();
809    match obj.get("kind").and_then(Value::as_str) {
810        Some("result") => Ok(DecodedEvent::Result(DecodedResult {
811            result: obj.get("result").cloned().unwrap_or(Value::Null),
812            trace,
813        })),
814        Some("error") => {
815            let mut fields = obj
816                .get("error")
817                .and_then(Value::as_object)
818                .ok_or_else(malformed)?
819                .clone();
820            let code = fields
821                .remove("code")
822                .and_then(|v| v.as_str().map(str::to_string))
823                .ok_or_else(malformed)?;
824            let message = fields
825                .remove("message")
826                .and_then(|v| v.as_str().map(str::to_string))
827                .ok_or_else(malformed)?;
828            let retryable = fields
829                .remove("retryable")
830                .and_then(|v| v.as_bool())
831                .ok_or_else(malformed)?;
832            let hint = fields
833                .remove("hint")
834                .and_then(|v| v.as_str().map(str::to_string));
835            Ok(DecodedEvent::Error(DecodedError {
836                code,
837                message,
838                retryable,
839                hint,
840                fields,
841                trace,
842            }))
843        }
844        Some("progress") => Ok(DecodedEvent::Progress(DecodedProgress {
845            progress: obj.get("progress").cloned().unwrap_or(Value::Null),
846            trace,
847        })),
848        Some("log") => Ok(DecodedEvent::Log(DecodedLog {
849            log: obj.get("log").cloned().unwrap_or(Value::Null),
850            trace,
851        })),
852        _ => Err(malformed()),
853    }
854}