Skip to main content

fallow_output/
audit_brief.rs

1//! Audit brief output contracts.
2
3use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use fallow_types::envelope::{ElapsedMs, Meta, ToolVersion};
5use serde::Serialize;
6use serde_json::Value;
7
8/// Wire version for the `fallow audit --brief --format json` envelope.
9pub const REVIEW_BRIEF_SCHEMA_VERSION: u32 = 6;
10
11/// Independently-versioned wire-version newtype for the brief envelope.
12/// Serializes as the integer `REVIEW_BRIEF_SCHEMA_VERSION`.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15pub struct ReviewBriefSchemaVersion(pub u32);
16
17impl Default for ReviewBriefSchemaVersion {
18    fn default() -> Self {
19        Self(REVIEW_BRIEF_SCHEMA_VERSION)
20    }
21}
22
23/// Coarse risk classification for a changeset, a pure function of the change
24/// size (file count plus, once threaded, net lines).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[serde(rename_all = "snake_case")]
28pub enum RiskClass {
29    /// Small, contained change.
30    Low,
31    /// Moderately sized change.
32    Medium,
33    /// Large change spanning many files or lines.
34    High,
35}
36
37/// Suggested reviewer effort, a pure function of [`RiskClass`].
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40#[serde(rename_all = "snake_case")]
41pub enum ReviewEffort {
42    /// A quick scan is enough.
43    Glance,
44    /// A normal line-by-line review.
45    Review,
46    /// A careful, deep review is warranted.
47    DeepDive,
48}
49
50/// Stage 0 of the brief: triage facts derived purely from the diff size.
51///
52/// `hunks` and `net_lines` are populated when the caller supplies parsed diff
53/// evidence. They remain absent when no diff is available.
54#[derive(Debug, Clone, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56pub struct DiffTriage {
57    /// Number of changed files in the audit scope.
58    pub files: usize,
59    /// Number of diff hunks, or `None` when no diff evidence was supplied.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub hunks: Option<usize>,
62    /// Net added-minus-removed lines, or `None` without diff evidence.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub net_lines: Option<i64>,
65    /// Coarse risk class derived from the change size.
66    pub risk_class: RiskClass,
67    /// Suggested reviewer effort derived from `risk_class`.
68    pub review_effort: ReviewEffort,
69}
70
71/// Stage 1 of the brief: graph-derived orientation facts.
72///
73/// `boundaries_touched` is derived from the run's boundary-violation zones;
74/// `reachable_from` is populated by the impact closure (the affected-not-shown
75/// set: modules the changed code is reachable from / affects, none in the diff).
76/// `exports_added` and `api_width_delta` both report the exports-aware public API
77/// widening count. Removed exports are not represented in this widening-only
78/// signal.
79#[derive(Debug, Clone, Serialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct GraphFacts {
82    /// Number of public API exports added by the changeset. Zero means the
83    /// changeset adds no public API exports.
84    pub exports_added: usize,
85    /// Widening-only public API delta, currently equal to `exports_added`.
86    /// Removed exports are not represented, so zero means no public API exports
87    /// were added.
88    pub api_width_delta: i64,
89    /// Root-relative paths of modules the changed code is reachable from / affects
90    /// (the impact closure's affected-but-not-in-diff set), deduped and sorted.
91    /// Empty when no graph was retained or nothing depends on the changed files.
92    pub reachable_from: Vec<String>,
93    /// Architecture boundary zones touched by the changeset, deduped and sorted.
94    /// Derived from the run's boundary-violation findings.
95    pub boundaries_touched: Vec<String>,
96}
97
98/// Stage 3 of the brief: the impact closure. The transitive
99/// affected-but-not-in-diff set plus the coordination gap. The differentiator a
100/// diff tool fundamentally cannot do, because it has no graph.
101///
102/// Honest scope (ADR-001, syntactic): the coordination gap is an attention
103/// pointer at the exact inter-module failure mode, NOT a correctness proof.
104#[derive(Debug, Clone, Default, Serialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct ImpactClosureFacts {
107    /// Root-relative paths transitively affected by the changeset (reverse-deps +
108    /// re-export chains) that are NOT in the diff, deduped and sorted.
109    pub affected_not_shown: Vec<String>,
110    /// Coordination gaps: a changed file exports a contract consumed by a module
111    /// absent from the diff. One entry per (changed file, consumer) pair.
112    pub coordination_gap: Vec<CoordinationGapFact>,
113}
114
115/// One coordination-gap entry: a changed file exports symbols consumed by a
116/// `consumer_file` that is NOT in the diff. Deduped per (changed, consumer) pair
117/// (firing-precision rule R2).
118#[derive(Debug, Clone, Serialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct CoordinationGapFact {
121    /// Root-relative path of the changed file whose contract is consumed elsewhere.
122    pub changed_file: String,
123    /// Root-relative path of the consumer module that is NOT in the diff.
124    pub consumer_file: String,
125    /// The exported symbol names the consumer references, sorted.
126    pub consumed_symbols: Vec<String>,
127    /// Honest scope note: this is a syntactic attention pointer, not a proof.
128    pub note: String,
129}
130
131/// Stage 2 of the brief: the partition + order. The changed files split into
132/// coherent BY-MODULE units (the only byte-identical-deterministic clustering
133/// definition straight from the graph), plus a dependency-sensible review ORDER
134/// over those units (definitions before consumers, mechanical/leaf units last,
135/// ties broken by the path sort). Stage 2 sits UNDER the decision surface as a
136/// drill-down; it is the backbone the directed-review loop hands the agent.
137///
138/// Feature-cluster and concern partitioning are deferred (they need scoring
139/// heuristics whose tie-breaks are a fresh nondeterminism surface).
140#[derive(Debug, Clone, Default, Serialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct PartitionFacts {
143    /// The by-module units, sorted by module directory. Empty when no graph was
144    /// retained or no changed file maps to a known module.
145    pub units: Vec<ReviewUnitFact>,
146    /// The dependency-sensible review order: module-directory strings,
147    /// definitions before consumers, mechanical/leaf units last. A permutation of
148    /// the `units` module directories.
149    pub order: Vec<String>,
150}
151
152/// One review unit: a coherent by-module cluster of the changed set.
153#[derive(Debug, Clone, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155pub struct ReviewUnitFact {
156    /// The module directory the unit covers (root-relative, forward-slashed).
157    /// The empty string is the repository-root group.
158    pub module_dir: String,
159    /// The changed files in this unit, path-sorted.
160    pub files: Vec<String>,
161}
162
163/// Diff-aware deterministic deltas (6.A), framed new-vs-pre-existing against
164/// the audit base snapshot. Each entry is a brief summary/verdict line.
165///
166/// `public_api` is batch-consolidated to ONE decision per change (rule R1):
167/// the `added` list carries the introduced public-export keys as evidence, but a
168/// reviewer reads "the public surface widened by N", never one decision per
169/// symbol.
170#[derive(Debug, Clone, Default, Serialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172pub struct ReviewDeltas {
173    /// Cross-zone boundary EDGES introduced vs base (R2 first-edge-only: one per
174    /// `<from_zone>-><to_zone>` pair, never per import). New-vs-pre-existing.
175    pub boundary_introduced: Vec<String>,
176    /// Circular dependencies introduced vs base (canonical file-set keys).
177    pub cycle_introduced: Vec<String>,
178    /// Exports-aware public-API surface delta: the public-export keys
179    /// (`<rel_path>::<name>`) added vs base, resolved through `package.json`
180    /// `exports` + re-export reachability. A symbol re-exported only through an
181    /// internal barrel NOT in `exports` is absent here (zero delta); one
182    /// reachable through an `exports` path is present (exactly one).
183    pub public_api_added: Vec<String>,
184}
185
186/// The full `fallow audit --brief --format json` envelope. Carries the
187/// informational verdict, the triage and graph-facts orientation stages, plus
188/// the reused "subtract" section (the same dead-code / duplication / complexity
189/// payload `fallow audit --format json` emits).
190#[derive(Debug, Clone, Serialize)]
191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
192#[cfg_attr(
193    feature = "schema",
194    schemars(title = "fallow audit --brief --format json")
195)]
196pub struct ReviewBriefOutput<Focus, Weakening, Routing, Decisions> {
197    /// Independently-versioned brief schema version.
198    pub schema_version: ReviewBriefSchemaVersion,
199    /// Fallow CLI version that produced this output.
200    pub version: String,
201    /// Command discriminator singleton: always `"audit-brief"`.
202    pub command: String,
203    pub triage: DiffTriage,
204    /// Stage 1: graph orientation facts.
205    pub graph_facts: GraphFacts,
206    /// Stage 2: the partition + order (by-module units + dependency-sensible
207    /// review order). The backbone the directed-review loop hands the agent.
208    pub partition: PartitionFacts,
209    /// Stage 3: the impact closure (affected-not-shown + coordination gap).
210    pub impact_closure: ImpactClosureFacts,
211    /// Stage 4: the weighted focus map. A composite attention score per
212    /// changed-file unit (fan-in/out + security taint + risk zone + change shape),
213    /// with `review-here` / `not-prioritized` labels (NEVER `skip` in free mode),
214    /// a per-unit confidence flag, and the FULL `deprioritized` escape-hatch list
215    /// so every de-prioritized piece is reachable. Stage 4 sits UNDER the decision
216    /// surface as drill-down.
217    pub focus: Focus,
218    /// 6.A: diff-aware deterministic deltas (boundary/cycle introduced +
219    /// exports-aware public-API surface delta), new-vs-pre-existing.
220    pub deltas: ReviewDeltas,
221    /// 6.F, headline: reviewer-private weakening signals (tests
222    /// removed/skipped, thresholds lowered, suppressions added, security steps
223    /// removed). Advisory, never gates, never auto-posted.
224    pub weakening: Vec<Weakening>,
225    /// 6.D: ownership-aware reviewer routing (per-file expert + bus-factor).
226    pub routing: Routing,
227    /// 6.G, the APEX: the decision surface. The ranked, capped,
228    /// signal_id-anchored set of consequential structural decisions, each framed
229    /// as a judgment question with its routed expert. This is the only thing the
230    /// brief visibly leads with; the stages above are its drill-down derivation.
231    pub decisions: Decisions,
232}
233
234/// The standard audit brief payload shape used by the CLI, schema emitter,
235/// API, and agent-facing review surfaces.
236pub type StandardReviewBriefOutput = ReviewBriefOutput<
237    crate::audit_focus::FocusMap,
238    crate::audit_weakening::WeakeningSignal,
239    crate::audit_routing::RoutingFacts,
240    crate::audit_decision_surface::DecisionSurface,
241>;
242
243/// Informational audit metadata carried by the review brief wire envelope.
244#[derive(Debug, Clone, Serialize)]
245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
246pub struct ReviewBriefHeader<Verdict, Summary, Attribution> {
247    /// Fallow CLI version that produced this output.
248    pub version: ToolVersion,
249    /// Audit verdict, informational only on the brief path.
250    pub verdict: Verdict,
251    /// Number of changed files in the audit scope.
252    pub changed_files_count: u32,
253    /// Base ref used to determine the changeset.
254    pub base_ref: String,
255    /// Human-readable description of the resolved base, when available.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub base_description: Option<String>,
258    /// Head commit SHA, when the audit ran against a committed head.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub head_sha: Option<String>,
261    /// Analysis duration in milliseconds.
262    pub elapsed_ms: ElapsedMs,
263    /// Whether base-snapshot analysis was skipped for this run.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub base_snapshot_skipped: Option<bool>,
266    /// Per-category audit summary.
267    pub summary: Summary,
268    /// Introduced-versus-inherited issue attribution.
269    pub attribution: Attribution,
270}
271
272/// Complete `fallow audit --brief --format json` wire envelope.
273///
274/// This is distinct from [`ReviewBriefOutput`], which is the reusable review
275/// digest embedded in walkthrough output. The wire envelope also carries audit
276/// metadata, optional telemetry, and the subtract-style analysis subreports.
277#[derive(Debug, Clone, Serialize)]
278#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
279#[cfg_attr(
280    feature = "schema",
281    schemars(title = "fallow audit --brief --format json")
282)]
283pub struct ReviewBriefWireOutput<
284    Focus,
285    Weakening,
286    Routing,
287    Decisions,
288    Verdict,
289    Summary,
290    Attribution,
291    DeadCode,
292    Duplication,
293    Complexity,
294> {
295    /// Independently-versioned brief schema version.
296    pub schema_version: ReviewBriefSchemaVersion,
297    /// Fallow CLI version that produced this output.
298    pub version: ToolVersion,
299    /// Command discriminator singleton: always `"audit-brief"`.
300    pub command: String,
301    /// Audit verdict, informational only on the brief path.
302    pub verdict: Verdict,
303    /// Number of changed files in the audit scope.
304    pub changed_files_count: u32,
305    /// Base ref used to determine the changeset.
306    pub base_ref: String,
307    /// Human-readable description of the resolved base, when available.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub base_description: Option<String>,
310    /// Head commit SHA, when available.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub head_sha: Option<String>,
313    /// Analysis duration in milliseconds.
314    pub elapsed_ms: ElapsedMs,
315    /// Whether base-snapshot analysis was skipped for this run.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub base_snapshot_skipped: Option<bool>,
318    /// Per-category audit summary.
319    pub summary: Summary,
320    /// Introduced-versus-inherited issue attribution.
321    pub attribution: Attribution,
322    /// Optional metric definitions and local telemetry correlation metadata.
323    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
324    pub meta: Option<Meta>,
325    /// Ranked, capped review decisions.
326    pub decisions: Decisions,
327    /// Diff-size triage facts.
328    pub triage: DiffTriage,
329    /// Graph-derived orientation facts.
330    pub graph_facts: GraphFacts,
331    /// Changed-file partition and review order.
332    pub partition: PartitionFacts,
333    /// Transitive impact closure outside the diff.
334    pub impact_closure: ImpactClosureFacts,
335    /// Weighted focus map for changed-file units.
336    pub focus: Focus,
337    /// Deterministic introduced deltas against the base snapshot.
338    pub deltas: ReviewDeltas,
339    /// Reviewer-private weakening signals.
340    pub weakening: Vec<Weakening>,
341    /// Ownership-aware reviewer routing.
342    pub routing: Routing,
343    /// Dead-code findings scoped to the audit changeset.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub dead_code: Option<DeadCode>,
346    /// Duplication findings scoped to the audit changeset.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub duplication: Option<Duplication>,
349    /// Complexity findings scoped to the audit changeset.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub complexity: Option<Complexity>,
352}
353
354/// CLI-built audit subreports that are embedded in the audit brief envelope.
355///
356/// The brief envelope and field ordering belong to `fallow-output`; the
357/// underlying subreport payloads are still supplied by the CLI until their
358/// builders are fully command-neutral.
359#[derive(Debug, Clone, Default)]
360pub struct ReviewBriefSubtractSections<DeadCode = Value, Duplication = Value, Complexity = Value> {
361    pub dead_code: Option<DeadCode>,
362    pub duplication: Option<Duplication>,
363    pub complexity: Option<Complexity>,
364}
365
366/// Build the complete `fallow audit --brief --format json` value.
367///
368/// `header` carries informational audit scope fields such as verdict, base ref,
369/// summary, and attribution. The independent brief schema and command always
370/// come from the typed brief payload.
371pub fn build_review_brief_json_output<
372    Focus,
373    Weakening,
374    Routing,
375    Decisions,
376    Verdict,
377    Summary,
378    Attribution,
379    DeadCode,
380    Duplication,
381    Complexity,
382>(
383    brief: ReviewBriefOutput<Focus, Weakening, Routing, Decisions>,
384    header: ReviewBriefHeader<Verdict, Summary, Attribution>,
385    subtract: ReviewBriefSubtractSections<DeadCode, Duplication, Complexity>,
386) -> Result<Value, serde_json::Error>
387where
388    Focus: Serialize,
389    Weakening: Serialize,
390    Routing: Serialize,
391    Decisions: Serialize,
392    Verdict: Serialize,
393    Summary: Serialize,
394    Attribution: Serialize,
395    DeadCode: Serialize,
396    Duplication: Serialize,
397    Complexity: Serialize,
398{
399    serde_json::to_value(ReviewBriefWireOutput {
400        schema_version: brief.schema_version,
401        version: header.version,
402        command: brief.command,
403        verdict: header.verdict,
404        changed_files_count: header.changed_files_count,
405        base_ref: header.base_ref,
406        base_description: header.base_description,
407        head_sha: header.head_sha,
408        elapsed_ms: header.elapsed_ms,
409        base_snapshot_skipped: header.base_snapshot_skipped,
410        summary: header.summary,
411        attribution: header.attribution,
412        meta: None,
413        decisions: brief.decisions,
414        triage: brief.triage,
415        graph_facts: brief.graph_facts,
416        partition: brief.partition,
417        impact_closure: brief.impact_closure,
418        focus: brief.focus,
419        deltas: brief.deltas,
420        weakening: brief.weakening,
421        routing: brief.routing,
422        dead_code: subtract.dead_code,
423        duplication: subtract.duplication,
424        complexity: subtract.complexity,
425    })
426}
427
428fn serialize_agent_contract_json_output<T: Serialize>(
429    output: T,
430    kind: &'static str,
431    mode: RootEnvelopeMode,
432    analysis_run_id: Option<&str>,
433) -> Result<Value, serde_json::Error> {
434    let mut value = serialize_named_json_output(output, kind, mode)?;
435    attach_telemetry_meta(&mut value, analysis_run_id);
436    Ok(value)
437}
438
439/// Serialize the `fallow audit --brief --format json` envelope.
440///
441/// # Errors
442///
443/// Returns a serde error when the brief output cannot be converted to JSON.
444pub fn serialize_review_brief_json_output<T: Serialize>(
445    output: T,
446    mode: RootEnvelopeMode,
447    analysis_run_id: Option<&str>,
448) -> Result<Value, serde_json::Error> {
449    serialize_agent_contract_json_output(output, "audit-brief", mode, analysis_run_id)
450}
451
452/// Serialize the standalone decision-surface envelope.
453///
454/// # Errors
455///
456/// Returns a serde error when the decision-surface output cannot be converted
457/// to JSON.
458pub fn serialize_decision_surface_json_output<T: Serialize>(
459    output: T,
460    mode: RootEnvelopeMode,
461    analysis_run_id: Option<&str>,
462) -> Result<Value, serde_json::Error> {
463    serialize_agent_contract_json_output(output, "decision-surface", mode, analysis_run_id)
464}
465
466/// Serialize the review walkthrough guide envelope.
467///
468/// # Errors
469///
470/// Returns a serde error when the walkthrough guide cannot be converted to
471/// JSON.
472pub fn serialize_walkthrough_guide_json_output<T: Serialize>(
473    output: T,
474    mode: RootEnvelopeMode,
475    analysis_run_id: Option<&str>,
476) -> Result<Value, serde_json::Error> {
477    serialize_agent_contract_json_output(output, "review-walkthrough-guide", mode, analysis_run_id)
478}
479
480/// Serialize the review walkthrough validation envelope.
481///
482/// # Errors
483///
484/// Returns a serde error when the walkthrough validation cannot be converted
485/// to JSON.
486pub fn serialize_walkthrough_validation_json_output<T: Serialize>(
487    output: T,
488    mode: RootEnvelopeMode,
489    analysis_run_id: Option<&str>,
490) -> Result<Value, serde_json::Error> {
491    serialize_agent_contract_json_output(
492        output,
493        "review-walkthrough-validation",
494        mode,
495        analysis_run_id,
496    )
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use serde_json::json;
503
504    #[test]
505    fn review_brief_json_output_assembles_typed_wire_contract() {
506        let brief = ReviewBriefOutput {
507            schema_version: ReviewBriefSchemaVersion::default(),
508            version: "1.2.3".to_string(),
509            command: "audit-brief".to_string(),
510            triage: DiffTriage {
511                files: 1,
512                hunks: None,
513                net_lines: None,
514                risk_class: RiskClass::Low,
515                review_effort: ReviewEffort::Glance,
516            },
517            graph_facts: GraphFacts {
518                exports_added: 0,
519                api_width_delta: 0,
520                reachable_from: Vec::new(),
521                boundaries_touched: Vec::new(),
522            },
523            partition: PartitionFacts::default(),
524            impact_closure: ImpactClosureFacts::default(),
525            focus: json!({"units": []}),
526            deltas: ReviewDeltas::default(),
527            weakening: Vec::<Value>::new(),
528            routing: json!({"units": []}),
529            decisions: json!({"decisions": []}),
530        };
531        let header = ReviewBriefHeader {
532            version: ToolVersion("1.2.3".to_string()),
533            verdict: json!("fail"),
534            changed_files_count: 1,
535            base_ref: "main".to_string(),
536            base_description: Some("merge base".to_string()),
537            head_sha: Some("abc123".to_string()),
538            elapsed_ms: ElapsedMs(12),
539            base_snapshot_skipped: Some(false),
540            summary: json!({"dead_code_issues": 0}),
541            attribution: json!({"gate": "new_only"}),
542        };
543
544        let value = build_review_brief_json_output(
545            brief,
546            header,
547            ReviewBriefSubtractSections::<Value, Value, Value> {
548                dead_code: Some(json!({"issues": []})),
549                duplication: None,
550                complexity: None,
551            },
552        )
553        .expect("brief output should serialize");
554
555        assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
556        assert_eq!(value["command"], "audit-brief");
557        assert_eq!(value["verdict"], "fail");
558        assert_eq!(value["base_ref"], "main");
559        assert_eq!(value["summary"]["dead_code_issues"], 0);
560        assert_eq!(value["attribution"]["gate"], "new_only");
561        assert_eq!(value["dead_code"]["issues"], json!([]));
562    }
563
564    #[test]
565    fn review_brief_serializer_owns_root_contract() {
566        let value = serialize_review_brief_json_output(
567            json!({"command": "audit-brief"}),
568            RootEnvelopeMode::Tagged,
569            Some("run-brief"),
570        )
571        .expect("brief output should serialize");
572
573        assert_eq!(value["kind"], "audit-brief");
574        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-brief");
575    }
576
577    #[test]
578    fn decision_surface_serializer_owns_root_contract() {
579        let value = serialize_decision_surface_json_output(
580            json!({"decisions": []}),
581            RootEnvelopeMode::Tagged,
582            Some("run-decision"),
583        )
584        .expect("decision surface should serialize");
585
586        assert_eq!(value["kind"], "decision-surface");
587        assert_eq!(
588            value["_meta"]["telemetry"]["analysis_run_id"],
589            "run-decision"
590        );
591    }
592}