Skip to main content

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