Skip to main content

mz_catalog_decode/
audit_log.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Reshape proto `audit_log_event_v1::Details` JSON back into the shape
11//! `mz_audit_log::EventDetails::as_json` produces.
12//!
13//! The `mz_audit_events` MV reads audit events from `mz_catalog_raw` as
14//! their durable proto twin (`mz_catalog_protos::objects::audit_log_event_v1`)
15//! and projects `details` through the SQL scalar
16//! `parse_catalog_audit_log_details`. Its output must equal what the prior
17//! `pack_audit_log_update` populator wrote, i.e.
18//! `mz_audit_log::EventDetails::as_json`.
19//!
20//! The reshape is driven by five rule tables below plus two structural
21//! rewrites (`StringWrapper` unwrap and the `ResetAllV1` null case). See the
22//! individual table docstrings for their shapes. The reciprocal is
23//! `EventDetails::as_json` combined with the proto `RustType` conversion in
24//! `src/catalog-protos/src/audit_log.rs`. The round-trip property test at
25//! `src/catalog/tests/audit_log_details.rs` samples every `Arbitrary`
26//! variant and catches drift when either side gains a new variant, field,
27//! or serde attribute.
28
29use mz_repr::adt::jsonb::{Jsonb, JsonbRef};
30use mz_repr::{Datum, Row, RowPacker};
31
32/// Reshapes proto `audit_log_event_v1::Details` JSON from `mz_catalog_raw`
33/// (e.g. `{"IdFullNameV1": {"id": "u1", "name": {...}}}`) into the shape
34/// `mz_audit_log::EventDetails::as_json` produces: the format the prior
35/// `pack_audit_log_update` populator wrote to `mz_audit_events`.
36///
37/// See the module docstring for the mechanism and the reciprocal side. Rule
38/// changes must be paired with tests; the property test at
39/// `src/catalog/tests/audit_log_details.rs` is the safety net.
40pub fn details(a: JsonbRef<'_>) -> Result<Jsonb, String> {
41    /// `(variant, path, field, sub_variant)`: `#[serde(flatten)]` sites.
42    /// `sub_variant` sets the context for rules on the hoisted content
43    /// when the flattened struct itself has a `#[serde(flatten)]`.
44    const FLATTENED_FIELDS: &[(&str, &str, &str, Option<&str>)] = &[
45        ("IdFullNameV1", "", "name", None),
46        ("CreateSourceSinkV1", "", "name", None),
47        ("CreateSourceSinkV2", "", "name", None),
48        ("CreateSourceSinkV3", "", "name", None),
49        ("CreateSourceSinkV4", "", "name", None),
50        ("CreateIndexV1", "", "name", None),
51        ("CreateMaterializedViewV1", "", "name", None),
52        ("AlterSourceSinkV1", "", "name", None),
53        ("AlterSetClusterV1", "", "name", None),
54        ("UpdateItemV1", "", "name", None),
55        // `AlterApplyReplacementV1.target: IdFullNameV1`, which itself
56        // flattens `name: FullNameV1`.
57        (
58            "AlterApplyReplacementV1",
59            "",
60            "target",
61            Some("IdFullNameV1"),
62        ),
63    ];
64
65    /// `(variant, path, proto_key, audit_key)`: covers `#[serde(rename)]`
66    /// on the audit-log side and Rust field-name diffs between the crates.
67    const RENAMES: &[(&str, &str, &str, &str)] = &[
68        ("CreateSourceSinkV2", "", "external_type", "type"),
69        ("CreateSourceSinkV3", "", "external_type", "type"),
70        ("CreateSourceSinkV4", "", "external_type", "type"),
71        (
72            "RefreshDecisionWithReasonV1",
73            "",
74            "rehydration_time_estimate",
75            "hydration_time_estimate",
76        ),
77        (
78            "RefreshDecisionWithReasonV2",
79            "",
80            "rehydration_time_estimate",
81            "hydration_time_estimate",
82        ),
83    ];
84
85    /// `(variant, path, field)`: drop when null. Keyed on the variant
86    /// because the same field skips in V1 but is nullable in V2+
87    /// (e.g. `replica_id`).
88    const DROP_NULL: &[(&str, &str, &str)] = &[
89        ("CreateClusterReplicaV1", "", "replica_id"),
90        ("DropClusterReplicaV1", "", "replica_id"),
91        ("CreateClusterReplicaV2", "", "scheduling_policies"),
92        ("CreateClusterReplicaV3", "", "scheduling_policies"),
93        ("CreateClusterReplicaV4", "", "scheduling_policies"),
94        ("DropClusterReplicaV2", "", "scheduling_policies"),
95        ("DropClusterReplicaV3", "", "scheduling_policies"),
96        ("CreateMaterializedViewV1", "", "replacement_target_id"),
97    ];
98
99    /// Kebab map for `CreateOrDropClusterReplicaReasonV1`, shared across
100    /// cluster-replica create and drop events.
101    const REASON_MAP: &[(&str, &str)] = &[
102        ("Manual", "manual"),
103        ("Schedule", "schedule"),
104        ("System", "system"),
105        ("Reconfiguration", "reconfiguration"),
106        ("HydrationBurst", "hydration-burst"),
107        ("Retired", "retired"),
108    ];
109
110    /// `(variant, path, field, wrap, kebab_map)`: collapse a proto enum-
111    /// with-`Empty`-payload into a kebab string. `Single` is `{"K": {}}`;
112    /// `Double` is `{"<field>": {"K": {}}}`, where the outer key matches
113    /// the field name in the current schemas (`reason`, `transition`).
114    /// Unlisted variants error (fail-fast).
115    #[allow(dead_code)]
116    #[derive(Copy, Clone)]
117    enum WrapKind {
118        Single,
119        Double,
120    }
121    const ENUM_COLLAPSE: &[(&str, &str, &str, WrapKind, &[(&str, &str)])] = &[
122        (
123            "RefreshDecisionWithReasonV1",
124            "",
125            "decision",
126            WrapKind::Single,
127            &[("On", "on"), ("Off", "off")],
128        ),
129        (
130            "RefreshDecisionWithReasonV2",
131            "",
132            "decision",
133            WrapKind::Single,
134            &[("On", "on"), ("Off", "off")],
135        ),
136        (
137            "CreateClusterReplicaV2",
138            "",
139            "reason",
140            WrapKind::Double,
141            REASON_MAP,
142        ),
143        (
144            "CreateClusterReplicaV3",
145            "",
146            "reason",
147            WrapKind::Double,
148            REASON_MAP,
149        ),
150        (
151            "CreateClusterReplicaV4",
152            "",
153            "reason",
154            WrapKind::Double,
155            REASON_MAP,
156        ),
157        (
158            "DropClusterReplicaV2",
159            "",
160            "reason",
161            WrapKind::Double,
162            REASON_MAP,
163        ),
164        (
165            "DropClusterReplicaV3",
166            "",
167            "reason",
168            WrapKind::Double,
169            REASON_MAP,
170        ),
171        (
172            "AlterClusterReconfigurationV1",
173            "",
174            "transition",
175            WrapKind::Double,
176            &[
177                ("Started", "started"),
178                ("Finalized", "finalized"),
179                ("TimedOut", "timed-out"),
180                ("Cancelled", "cancelled"),
181                ("ResourceExhausted", "resource-exhausted"),
182            ],
183        ),
184        (
185            "ClusterHydrationBurstV1",
186            "",
187            "transition",
188            WrapKind::Double,
189            &[("Started", "started"), ("Finished", "finished")],
190        ),
191        (
192            "ClusterHydrationBurstV1",
193            "",
194            "finish_cause",
195            WrapKind::Double,
196            &[
197                ("LingerElapsed", "linger-elapsed"),
198                ("NoLongerWarranted", "no-longer-warranted"),
199            ],
200        ),
201    ];
202
203    /// `(variant, path, field, sub_variant)`: recurse with `sub_variant` as
204    /// the new context so rules declared from its root fire. Used for
205    /// versioned nested wrappers (`SchedulingDecisionsWithReasonsV1/V2`)
206    /// and by-value `IdFullNameV1` fields whose inner `name: FullNameV1`
207    /// needs flattening.
208    const DESCEND: &[(&str, &str, &str, &str)] = &[
209        ("AlterApplyReplacementV1", "", "replacement", "IdFullNameV1"),
210        (
211            "CreateClusterReplicaV2",
212            "",
213            "scheduling_policies",
214            "SchedulingDecisionsWithReasonsV1",
215        ),
216        (
217            "CreateClusterReplicaV3",
218            "",
219            "scheduling_policies",
220            "SchedulingDecisionsWithReasonsV2",
221        ),
222        (
223            "CreateClusterReplicaV4",
224            "",
225            "scheduling_policies",
226            "SchedulingDecisionsWithReasonsV2",
227        ),
228        (
229            "DropClusterReplicaV2",
230            "",
231            "scheduling_policies",
232            "SchedulingDecisionsWithReasonsV1",
233        ),
234        (
235            "DropClusterReplicaV3",
236            "",
237            "scheduling_policies",
238            "SchedulingDecisionsWithReasonsV2",
239        ),
240        (
241            "SchedulingDecisionsWithReasonsV1",
242            "",
243            "on_refresh",
244            "RefreshDecisionWithReasonV1",
245        ),
246        (
247            "SchedulingDecisionsWithReasonsV2",
248            "",
249            "on_refresh",
250            "RefreshDecisionWithReasonV2",
251        ),
252    ];
253
254    fn lookup_flatten(variant: &str, path: &str, field: &str) -> Option<Option<&'static str>> {
255        FLATTENED_FIELDS
256            .iter()
257            .find(|(v, p, f, _)| *v == variant && *p == path && *f == field)
258            .map(|(_, _, _, sv)| *sv)
259    }
260
261    fn lookup_rename(variant: &str, path: &str, field: &str) -> Option<&'static str> {
262        RENAMES
263            .iter()
264            .find(|(v, p, k, _)| *v == variant && *p == path && *k == field)
265            .map(|(_, _, _, out)| *out)
266    }
267
268    fn is_drop_null(variant: &str, path: &str, field: &str) -> bool {
269        DROP_NULL
270            .iter()
271            .any(|(v, p, f)| *v == variant && *p == path && *f == field)
272    }
273
274    fn lookup_enum_collapse(
275        variant: &str,
276        path: &str,
277        field: &str,
278    ) -> Option<(WrapKind, &'static [(&'static str, &'static str)])> {
279        ENUM_COLLAPSE
280            .iter()
281            .find(|(v, p, f, _, _)| *v == variant && *p == path && *f == field)
282            .map(|(_, _, _, wrap, map)| (*wrap, *map))
283    }
284
285    fn lookup_descend(variant: &str, path: &str, field: &str) -> Option<&'static str> {
286        DESCEND
287            .iter()
288            .find(|(v, p, f, _)| *v == variant && *p == path && *f == field)
289            .map(|(_, _, _, sv)| *sv)
290    }
291
292    /// Unwrap a proto externally-tagged enum-with-`Empty`-payload and return
293    /// its kebab form from `map`. Errors on unexpected shapes or unknown
294    /// variants.
295    fn collapse_enum(
296        datum: Datum,
297        wrap: WrapKind,
298        map: &[(&'static str, &'static str)],
299        context: &str,
300    ) -> Result<&'static str, String> {
301        let inner = match wrap {
302            WrapKind::Single => datum,
303            WrapKind::Double => {
304                let Datum::Map(outer) = datum else {
305                    return Err(format!(
306                        "{context}: double-wrap enum expected outer object, got {datum:?}"
307                    ));
308                };
309                let mut iter = outer.iter();
310                let (Some((_, v)), None) = (iter.next(), iter.next()) else {
311                    return Err(format!(
312                        "{context}: double-wrap enum expected single-key outer object"
313                    ));
314                };
315                v
316            }
317        };
318        let Datum::Map(dict) = inner else {
319            return Err(format!(
320                "{context}: enum collapse expected object, got {inner:?}"
321            ));
322        };
323        let mut iter = dict.iter();
324        let (Some((variant_key, payload)), None) = (iter.next(), iter.next()) else {
325            return Err(format!(
326                "{context}: enum collapse expected single-key object"
327            ));
328        };
329        // The payload must be `{}` (proto `Empty`); fail if a variant
330        // grows a real payload in the future.
331        if let Datum::Map(payload_dict) = payload {
332            if payload_dict.iter().next().is_some() {
333                return Err(format!(
334                    "{context}: enum variant {variant_key} carries non-empty payload"
335                ));
336            }
337        } else {
338            return Err(format!(
339                "{context}: enum variant {variant_key} payload is not an object"
340            ));
341        }
342        map.iter()
343            .find(|(k, _)| *k == variant_key)
344            .map(|(_, kebab)| *kebab)
345            .ok_or_else(|| format!("{context}: unknown enum variant {variant_key}"))
346    }
347
348    fn extend_path(path: &str, field: &str) -> String {
349        if path.is_empty() {
350            field.to_string()
351        } else {
352            format!("{path}.{field}")
353        }
354    }
355
356    /// Recursively rewrite `datum` under `(variant, path)` into `out`.
357    fn rewrite(datum: Datum, variant: &str, path: &str, out: &mut RowPacker) -> Result<(), String> {
358        match datum {
359            Datum::Map(dict) => {
360                // Structural: `{"inner": v}` (proto `StringWrapper`) -> `v`.
361                let mut iter = dict.iter();
362                if let (Some((k, v)), None) = (iter.next(), iter.next()) {
363                    if k == "inner" {
364                        return rewrite(v, variant, path, out);
365                    }
366                }
367                rewrite_map(dict, variant, path, out)
368            }
369            Datum::List(list) => {
370                let mut result: Result<(), String> = Ok(());
371                out.push_list_with(|out| {
372                    for v in list.iter() {
373                        if let Err(e) = rewrite(v, variant, path, out) {
374                            result = Err(e);
375                            break;
376                        }
377                    }
378                });
379                result
380            }
381            other => {
382                out.push(other);
383                Ok(())
384            }
385        }
386    }
387
388    fn rewrite_map(
389        dict: mz_repr::DatumMap,
390        variant: &str,
391        path: &str,
392        out: &mut RowPacker,
393    ) -> Result<(), String> {
394        let mut entries = collect_entries(dict, variant, path)?;
395        // `Datum::Map` requires keys in ascending order.
396        entries.sort_by(|a, b| a.0.cmp(&b.0));
397        out.push_dict_with(|out| {
398            for (k, temp) in &entries {
399                out.push(Datum::String(k));
400                out.push(temp.unpack_first());
401            }
402        });
403        Ok(())
404    }
405
406    /// Produce the transformed `(key, temp_row)` pairs for `dict` under
407    /// `(variant, path)`. Split from `rewrite_map` so the flatten path can
408    /// hoist a fully-processed sub-entry list into the parent.
409    fn collect_entries(
410        dict: mz_repr::DatumMap,
411        variant: &str,
412        path: &str,
413    ) -> Result<Vec<(String, Row)>, String> {
414        let mut entries: Vec<(String, Row)> = Vec::new();
415
416        for (k, v) in dict.iter() {
417            if is_drop_null(variant, path, k) && matches!(v, Datum::JsonNull) {
418                continue;
419            }
420
421            // Flatten: hoist the sub-object's entries into the parent.
422            if let Some(sub_variant_opt) = lookup_flatten(variant, path, k) {
423                let sub_variant = sub_variant_opt.unwrap_or("");
424                let Datum::Map(sub) = v else {
425                    return Err(format!(
426                        "expected flatten target {variant}.{k} to be an object"
427                    ));
428                };
429                entries.extend(collect_entries(sub, sub_variant, "")?);
430                continue;
431            }
432
433            let out_key = lookup_rename(variant, path, k).unwrap_or(k).to_string();
434
435            // Enum collapse produces a computed string. An optional enum
436            // field (e.g. `finish_cause`) serializes as JSON null on both the
437            // proto and the audit-log side, so null passes through unchanged.
438            if let Some((wrap, map)) = lookup_enum_collapse(variant, path, k) {
439                if matches!(v, Datum::JsonNull) {
440                    let mut temp = Row::default();
441                    temp.packer().push(Datum::JsonNull);
442                    entries.push((out_key, temp));
443                    continue;
444                }
445                let kebab = collapse_enum(v, wrap, map, &format!("{variant}.{k}"))?;
446                let mut temp = Row::default();
447                temp.packer().push(Datum::String(kebab));
448                entries.push((out_key, temp));
449                continue;
450            }
451
452            // Descend: recurse with a fresh variant context (path resets;
453            // rules on the sub-variant are declared from its root).
454            if let Some(sub_variant) = lookup_descend(variant, path, k) {
455                let mut temp = Row::default();
456                rewrite(v, sub_variant, "", &mut temp.packer())?;
457                entries.push((out_key, temp));
458                continue;
459            }
460
461            // Default: recurse with the same variant and an extended path.
462            let child_path = extend_path(path, k);
463            let mut temp = Row::default();
464            rewrite(v, variant, &child_path, &mut temp.packer())?;
465            entries.push((out_key, temp));
466        }
467
468        Ok(entries)
469    }
470
471    let Datum::Map(dict) = a.into_datum() else {
472        return Err("expected object".into());
473    };
474    let mut iter = dict.iter();
475    let (variant, inner) = iter
476        .next()
477        .ok_or_else(|| "empty details enum".to_string())?;
478    if iter.next().is_some() {
479        return Err("details enum had multiple keys".into());
480    }
481    let mut row = Row::default();
482    // `ResetAllV1` is the only variant `as_json` maps to null (the
483    // proto `Empty` payload serializes to `{}`).
484    if variant == "ResetAllV1" {
485        row.packer().push(Datum::JsonNull);
486        return Ok(Jsonb::from_row(row));
487    }
488    let Datum::Map(_) = inner else {
489        return Err(format!("expected inner object for variant {variant}"));
490    };
491    rewrite(inner, variant, "", &mut row.packer())?;
492    Ok(Jsonb::from_row(row))
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    /// Round-trip a JSON `input` through `details` and
500    /// assert the resulting JSON parses equal to `expected`.
501    fn check(input: &str, expected: &str) {
502        let input: Jsonb = input.parse().expect("valid input JSONB");
503        let actual = details(input.as_ref())
504            .expect("helper succeeded")
505            .to_string();
506        let actual_value: serde_json::Value =
507            serde_json::from_str(&actual).expect("valid output JSON");
508        let expected_value: serde_json::Value =
509            serde_json::from_str(expected).expect("valid expected JSON");
510        assert_eq!(actual_value, expected_value);
511    }
512
513    /// Run `details` on `input` and assert it returns an error containing
514    /// `expected_substr`.
515    fn check_err(input: &str, expected_substr: &str) {
516        let input: Jsonb = input.parse().expect("valid input JSONB");
517        let err = details(input.as_ref()).expect_err("helper should error");
518        let msg = format!("{err:?}");
519        assert!(
520            msg.contains(expected_substr),
521            "error did not contain {expected_substr:?}: {msg}",
522        );
523    }
524
525    /// Variant with no nested struct and no `#[serde(flatten)]`. The helper
526    /// just strips the wrapper.
527    #[mz_ore::test]
528    fn variant_strip() {
529        check(
530            r#"{"IdNameV1": {"id": "u1", "name": "foo"}}"#,
531            r#"{"id": "u1", "name": "foo"}"#,
532        );
533    }
534
535    /// `IdFullNameV1` has `#[serde(flatten)] name: FullNameV1`. The helper
536    /// must hoist `database`/`schema`/`item` to the top level.
537    #[mz_ore::test]
538    fn flatten_name() {
539        check(
540            r#"{"IdFullNameV1": {
541                "id": "u1",
542                "name": {"database": "materialize", "schema": "public", "item": "t"}
543            }}"#,
544            r#"{"id": "u1", "database": "materialize", "schema": "public", "item": "t"}"#,
545        );
546    }
547
548    /// `AlterApplyReplacementV1` flattens `target: IdFullNameV1`, which itself
549    /// flattens `name: FullNameV1`. Exercises the recursive sub-variant lookup.
550    /// The non-flattened `replacement: IdFullNameV1` field has its inner
551    /// `name` hoisted via a `DESCEND` entry pointing at `IdFullNameV1`.
552    #[mz_ore::test]
553    fn flatten_recursive_and_nested_full_name() {
554        check(
555            r#"{"AlterApplyReplacementV1": {
556                "target": {
557                    "id": "u1",
558                    "name": {"database": "materialize", "schema": "public", "item": "mv"}
559                },
560                "replacement": {
561                    "id": "u2",
562                    "name": {"database": "materialize", "schema": "public", "item": "rp"}
563                }
564            }}"#,
565            r#"{
566                "id": "u1",
567                "database": "materialize",
568                "schema": "public",
569                "item": "mv",
570                "replacement": {
571                    "id": "u2",
572                    "database": "materialize",
573                    "schema": "public",
574                    "item": "rp"
575                }
576            }"#,
577        );
578    }
579
580    /// `Option<StringWrapper>` fields serialize as `{"inner": "..."}` in the
581    /// proto, where the audit-log crate uses a plain `String`. The helper must
582    /// unwrap recursively (including on optional fields nested under flatten).
583    #[mz_ore::test]
584    fn string_wrapper_unwrap() {
585        check(
586            r#"{"AlterDefaultPrivilegeV1": {
587                "role_id": "u1",
588                "database_id": {"inner": "u2"},
589                "schema_id": {"inner": "u3"},
590                "grantee_id": "p",
591                "privileges": "r"
592            }}"#,
593            r#"{
594                "role_id": "u1",
595                "database_id": "u2",
596                "schema_id": "u3",
597                "grantee_id": "p",
598                "privileges": "r"
599            }"#,
600        );
601    }
602
603    /// Null `Option<StringWrapper>` fields are passed through as JSON null.
604    #[mz_ore::test]
605    fn null_option() {
606        check(
607            r#"{"AlterDefaultPrivilegeV1": {
608                "role_id": "u1",
609                "database_id": null,
610                "schema_id": null,
611                "grantee_id": "p",
612                "privileges": "r"
613            }}"#,
614            r#"{
615                "role_id": "u1",
616                "database_id": null,
617                "schema_id": null,
618                "grantee_id": "p",
619                "privileges": "r"
620            }"#,
621        );
622    }
623
624    /// Non-flattened nested objects pass through untouched. Guards against
625    /// re-introducing a structural "hoist any `FullNameV1`-shaped object"
626    /// heuristic: `RenameItemV1.old_name`/`new_name` are `FullNameV1` fields
627    /// the audit-log side keeps nested, and any generic structural hoist
628    /// would collide their `database`/`schema`/`item` keys.
629    #[mz_ore::test]
630    fn nested_full_name_stays_nested() {
631        check(
632            r#"{"RenameItemV1": {
633                "id": "u1",
634                "old_name": {"database": "d", "schema": "s", "item": "a"},
635                "new_name": {"database": "d", "schema": "s", "item": "b"}
636            }}"#,
637            r#"{
638                "id": "u1",
639                "old_name": {"database": "d", "schema": "s", "item": "a"},
640                "new_name": {"database": "d", "schema": "s", "item": "b"}
641            }"#,
642        );
643    }
644
645    /// Sources V2+ rename proto `external_type` to audit `type`. Value
646    /// unchanged.
647    #[mz_ore::test]
648    fn rename_external_type() {
649        check(
650            r#"{"CreateSourceSinkV2": {
651                "id": "u1",
652                "name": {"database": "d", "schema": "s", "item": "src"},
653                "size": {"inner": "small"},
654                "external_type": "kafka"
655            }}"#,
656            r#"{
657                "id": "u1",
658                "database": "d",
659                "schema": "s",
660                "item": "src",
661                "size": "small",
662                "type": "kafka"
663            }"#,
664        );
665    }
666
667    /// The proto field `rehydration_time_estimate` becomes the audit field
668    /// `hydration_time_estimate` — an invisible rename (no `#[serde(rename)]`
669    /// on either side; just a genuine field-name diff between the crates).
670    /// Nested under `scheduling_policies.on_refresh`, so the descent chain
671    /// must land in the `RefreshDecisionWithReasonV1` variant context.
672    #[mz_ore::test]
673    fn rename_hydration_time_estimate_under_scheduling() {
674        check(
675            r#"{"CreateClusterReplicaV3": {
676                "cluster_id": "u1",
677                "cluster_name": "c",
678                "replica_id": {"inner": "r1"},
679                "replica_name": "n",
680                "logical_size": "small",
681                "disk": false,
682                "billed_as": null,
683                "internal": false,
684                "reason": {"reason": {"Manual": {}}},
685                "scheduling_policies": {
686                    "on_refresh": {
687                        "decision": {"On": {}},
688                        "objects_needing_refresh": [],
689                        "objects_needing_compaction": [],
690                        "rehydration_time_estimate": "00:00:07"
691                    }
692                }
693            }}"#,
694            r#"{
695                "cluster_id": "u1",
696                "cluster_name": "c",
697                "replica_id": "r1",
698                "replica_name": "n",
699                "logical_size": "small",
700                "disk": false,
701                "billed_as": null,
702                "internal": false,
703                "reason": "manual",
704                "scheduling_policies": {
705                    "on_refresh": {
706                        "decision": "on",
707                        "objects_needing_refresh": [],
708                        "objects_needing_compaction": [],
709                        "hydration_time_estimate": "00:00:07"
710                    }
711                }
712            }"#,
713        );
714    }
715
716    /// `CreateClusterReplicaV1.replica_id` uses
717    /// `#[serde(skip_serializing_if = "Option::is_none")]` in the audit-log
718    /// struct: a null proto value must be dropped from the output.
719    #[mz_ore::test]
720    fn drop_null_replica_id_v1() {
721        check(
722            r#"{"CreateClusterReplicaV1": {
723                "cluster_id": "u1",
724                "cluster_name": "c",
725                "replica_id": null,
726                "replica_name": "n",
727                "logical_size": "small",
728                "disk": false,
729                "billed_as": null,
730                "internal": false
731            }}"#,
732            r#"{
733                "cluster_id": "u1",
734                "cluster_name": "c",
735                "replica_name": "n",
736                "logical_size": "small",
737                "disk": false,
738                "billed_as": null,
739                "internal": false
740            }"#,
741        );
742    }
743
744    /// V2+ struct keeps `replica_id` as a plain `Option<StringWrapper>` — no
745    /// `skip_serializing_if`. A null value must round-trip as JSON null.
746    /// This is the paired negative: the drop rule keys on (variant, field),
747    /// not name alone.
748    #[mz_ore::test]
749    fn drop_null_replica_id_v2_kept() {
750        check(
751            r#"{"CreateClusterReplicaV2": {
752                "cluster_id": "u1",
753                "cluster_name": "c",
754                "replica_id": null,
755                "replica_name": "n",
756                "logical_size": "small",
757                "disk": false,
758                "billed_as": null,
759                "internal": false,
760                "reason": {"reason": {"System": {}}},
761                "scheduling_policies": null
762            }}"#,
763            r#"{
764                "cluster_id": "u1",
765                "cluster_name": "c",
766                "replica_id": null,
767                "replica_name": "n",
768                "logical_size": "small",
769                "disk": false,
770                "billed_as": null,
771                "internal": false,
772                "reason": "system"
773            }"#,
774        );
775    }
776
777    /// Single-wrap enum collapse: `{"On":{}}` → `"on"`, applied under the
778    /// `RefreshDecisionWithReasonV1` variant context.
779    #[mz_ore::test]
780    fn enum_single_wrap_decision() {
781        check(
782            r#"{"CreateClusterReplicaV2": {
783                "cluster_id": "u1",
784                "cluster_name": "c",
785                "replica_id": {"inner": "r1"},
786                "replica_name": "n",
787                "logical_size": "small",
788                "disk": false,
789                "billed_as": null,
790                "internal": false,
791                "reason": {"reason": {"Schedule": {}}},
792                "scheduling_policies": {
793                    "on_refresh": {
794                        "decision": {"Off": {}},
795                        "objects_needing_refresh": [],
796                        "rehydration_time_estimate": "00:00:00"
797                    }
798                }
799            }}"#,
800            r#"{
801                "cluster_id": "u1",
802                "cluster_name": "c",
803                "replica_id": "r1",
804                "replica_name": "n",
805                "logical_size": "small",
806                "disk": false,
807                "billed_as": null,
808                "internal": false,
809                "reason": "schedule",
810                "scheduling_policies": {
811                    "on_refresh": {
812                        "decision": "off",
813                        "objects_needing_refresh": [],
814                        "hydration_time_estimate": "00:00:00"
815                    }
816                }
817            }"#,
818        );
819    }
820
821    /// Double-wrap enum collapse: `{"reason":{"HydrationBurst":{}}}` →
822    /// `"hydration-burst"`. Confirms the kebab-case mapping is used (the
823    /// enum variant is `PascalCase` on the proto side).
824    #[mz_ore::test]
825    fn enum_double_wrap_reason_kebab() {
826        check(
827            r#"{"DropClusterReplicaV2": {
828                "cluster_id": "u1",
829                "cluster_name": "c",
830                "replica_id": "r1",
831                "replica_name": "n",
832                "reason": {"reason": {"HydrationBurst": {}}},
833                "scheduling_policies": null
834            }}"#,
835            r#"{
836                "cluster_id": "u1",
837                "cluster_name": "c",
838                "replica_id": "r1",
839                "replica_name": "n",
840                "reason": "hydration-burst"
841            }"#,
842        );
843    }
844
845    /// `AlterClusterReconfigurationV1.transition` uses a distinct kebab map
846    /// (`TimedOut` → `"timed-out"`, not shared with the reason map). Guards
847    /// against accidentally reusing `REASON_MAP` for transitions.
848    #[mz_ore::test]
849    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `decContextDefault` on OS `linux`
850    fn enum_double_wrap_transition_timed_out() {
851        check(
852            r#"{"AlterClusterReconfigurationV1": {
853                "cluster_id": "u1",
854                "cluster_name": "c",
855                "transition": {"transition": {"TimedOut": {}}},
856                "target_size": "small",
857                "target_replication_factor": 1,
858                "target_availability_zones": [],
859                "target_logging": {"log_logging": false, "interval": null},
860                "deadline": null
861            }}"#,
862            r#"{
863                "cluster_id": "u1",
864                "cluster_name": "c",
865                "transition": "timed-out",
866                "target_size": "small",
867                "target_replication_factor": 1,
868                "target_availability_zones": [],
869                "target_logging": {"log_logging": false, "interval": null},
870                "deadline": null
871            }"#,
872        );
873    }
874
875    /// `ClusterHydrationBurstV1.transition` uses a two-value map
876    /// (`Started`/`Finished`), separate from the reconfiguration lifecycle
877    /// map. Same field name (`transition`), different rule, different map —
878    /// dispatched by variant context. `finish_cause` is an *optional* enum:
879    /// its `Some` collapses like any other double-wrap enum, while `None`
880    /// serializes as JSON null on both sides and passes through.
881    #[mz_ore::test]
882    fn enum_double_wrap_hydration_burst_finished() {
883        check(
884            r#"{"ClusterHydrationBurstV1": {
885                "cluster_id": "u1",
886                "cluster_name": "c",
887                "transition": {"transition": {"Finished": {}}},
888                "finish_cause": {"cause": {"LingerElapsed": {}}},
889                "burst_size": "small"
890            }}"#,
891            r#"{
892                "cluster_id": "u1",
893                "cluster_name": "c",
894                "transition": "finished",
895                "finish_cause": "linger-elapsed",
896                "burst_size": "small"
897            }"#,
898        );
899        check(
900            r#"{"ClusterHydrationBurstV1": {
901                "cluster_id": "u1",
902                "cluster_name": "c",
903                "transition": {"transition": {"Started": {}}},
904                "finish_cause": null,
905                "burst_size": "small"
906            }}"#,
907            r#"{
908                "cluster_id": "u1",
909                "cluster_name": "c",
910                "transition": "started",
911                "finish_cause": null,
912                "burst_size": "small"
913            }"#,
914        );
915    }
916
917    /// `ResetAllV1` is the only variant whose `as_json` returns JSON null.
918    /// The proto side carries an `Empty` payload that serializes to `{}`,
919    /// so a naive strip-the-wrapper would emit `{}`. Special-cased.
920    #[mz_ore::test]
921    fn reset_all_v1_is_null() {
922        check(r#"{"ResetAllV1": {}}"#, r#"null"#);
923    }
924
925    /// An unlisted enum variant errors rather than being silently passed
926    /// through — matches the fail-fast contract on this
927    /// compliance-relevant table.
928    #[mz_ore::test]
929    fn enum_unknown_variant_errors() {
930        check_err(
931            r#"{"DropClusterReplicaV2": {
932                "cluster_id": "u1",
933                "cluster_name": "c",
934                "replica_id": "r1",
935                "replica_name": "n",
936                "reason": {"reason": {"NoSuchVariant": {}}},
937                "scheduling_policies": null
938            }}"#,
939            "unknown enum variant NoSuchVariant",
940        );
941    }
942
943    /// The proto enum payload must be `Empty` (i.e. `{}`); a variant that
944    /// grows a payload in the future would silently break the collapse, so
945    /// we fail fast instead.
946    #[mz_ore::test]
947    fn enum_non_empty_payload_errors() {
948        check_err(
949            r#"{"DropClusterReplicaV2": {
950                "cluster_id": "u1",
951                "cluster_name": "c",
952                "replica_id": "r1",
953                "replica_name": "n",
954                "reason": {"reason": {"Manual": {"unexpected": "x"}}},
955                "scheduling_policies": null
956            }}"#,
957            "non-empty payload",
958        );
959    }
960
961    /// Bad inputs: empty enum object, multiple keys, non-object.
962    #[mz_ore::test]
963    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `decContextDefault` on OS `linux`
964    fn error_cases() {
965        check_err(r#"{}"#, "empty details enum");
966        check_err(
967            r#"{"A": {"x": 1}, "B": {"y": 2}}"#,
968            "details enum had multiple keys",
969        );
970        check_err(r#"["IdNameV1", {"id": "u1"}]"#, "expected object");
971        check_err(r#"{"IdNameV1": "not an object"}"#, "expected inner object");
972    }
973}