Skip to main content

shipshape_core/contract/
schema.rs

1//! The ONE canonical serde model for `OSS-RELEASE.md` (ADR-0003 §1).
2//!
3//! These types are the single normalization model that `contract show`,
4//! `contract validate`, `audit`, the facts consumers, and release planning all
5//! use — no second parser anywhere. Their serialized form is the canonical
6//! JSON contract every `/shipshape-*` member reads (SCHEMA.md §4, preserved
7//! byte-for-shape by the migration rule). Public wire access goes through
8//! [`crate::protocol::contract`], which re-exports these types and owns the
9//! wire-version declaration so internals and wire can diverge under the
10//! migration rule (ADR-0001 §2).
11//!
12//! Hot file (ADR-0001): a change here ripples to every family member. Every
13//! enum's [`as_str`](Status::as_str) form is the wire string; a change to one is
14//! a `schema_version` bump, never silent.
15
16use serde::Serialize;
17
18/// The contract `schema_version` this build knows how to read.
19///
20/// A config declaring a higher version is refused rather than guessed
21/// (SCHEMA.md §2 floor 5) — skills upgrade independently of the repos they act
22/// on. Distinct from the wire-envelope [`crate::SCHEMA_VERSION`], which versions
23/// the JSON envelope, not the contract document. Mirrors the Python
24/// `KNOWN_SCHEMA_VERSION`.
25///
26/// **Bumped `1` → `2`** for the monorepo-distribution change: the single
27/// top-level [`Distribution`] key `distribution` (an object-or-`null`) became the
28/// collection [`Contract::distributions`] (`distributions`, always a JSON array),
29/// and every distribution gained an association key [`Distribution::package`].
30/// Renaming the canonical key and re-shaping the value is a **breaking** change,
31/// not a pure addition, so it bumps deliberately (never silently). This tool
32/// still *reads* a v1 document — a bare `distribution:` mapping deserializes as a
33/// one-element `distributions` list — but *emits* the v2 canonical shape.
34///
35/// A purely additive field (a new optional top-level key defaulting to
36/// absent/`null`) does NOT bump: an older reader preserves the unknown key under
37/// [`Contract::extra_fields`] and warns rather than failing. The migration rule
38/// bumps only on a **breaking** change — renaming/removing a field or re-meaning
39/// an existing one — never on a pure addition, which the forward-compat mechanism
40/// absorbs.
41pub const KNOWN_SCHEMA_VERSION: u32 = 2;
42
43/// The changelog fragment directory materialized when the config omits it.
44pub const DEFAULT_FRAGMENT_DIR: &str = "changelog/fragments";
45
46/// The cross-platform default [`Distribution::platforms`] set materialized when a
47/// distribution block omits `platforms`: macOS (`aarch64` + `x86_64`) and Linux
48/// (`aarch64` + `x86_64`). This is the KEYSTONE of the cross-platform install
49/// requirement — a distribution that OMITS `platforms` covers Linux **by
50/// default**, so a repo that never thinks about it still ships Linux binaries. (A
51/// repo that sets `platforms` explicitly owns its own coverage; the cross-platform
52/// `audit` — not this default — flags a Linux-less explicit set.) musl over gnu for
53/// Linux: for a pure-Rust CLI a musl target links statically and sidesteps the
54/// glibc-version cliff — though choosing a musl *target* does not by itself
55/// guarantee a static build, and a repo with C/native dependencies (`openssl-sys`,
56/// `libgit2`, …) may need to override to gnu. Windows is a deliberate omission (a
57/// bonus a repo opts into by listing it explicitly, never the default). The set
58/// always contains at least one Linux triple.
59pub const DEFAULT_CROSS_PLATFORM_TARGETS: [&str; 4] = [
60    "aarch64-apple-darwin",
61    "x86_64-apple-darwin",
62    "aarch64-unknown-linux-musl",
63    "x86_64-unknown-linux-musl",
64];
65
66/// Define a closed enum whose variants each map to a fixed wire string.
67///
68/// Generates the enum (with the standard derives), [`as_str`] (variant → wire),
69/// [`parse`] (wire → variant), the `VALID` slice of wire strings for error
70/// messages, and a `Serialize` impl that emits the wire string. `Deserialize`
71/// is intentionally not generated: the normalizer reads strings out of the
72/// parsed YAML and validates each with [`parse`] so it can collect *all* errors
73/// and substitute a default (mirroring the Python normalizer), rather than
74/// fail-fast on the first bad enum.
75macro_rules! wire_enum {
76    (
77        $(#[$emeta:meta])*
78        $name:ident { $($variant:ident => $wire:literal),+ $(,)? }
79    ) => {
80        $(#[$emeta])*
81        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82        pub enum $name {
83            $(
84                #[doc = concat!("Wire value `", $wire, "`.")]
85                $variant,
86            )+
87        }
88
89        impl $name {
90            /// The wire string for this variant (matches SCHEMA.md §4).
91            #[must_use]
92            pub fn as_str(self) -> &'static str {
93                match self { $(Self::$variant => $wire),+ }
94            }
95
96            /// Parse a wire string into the variant, or `None` if unrecognized.
97            #[must_use]
98            pub fn parse(s: &str) -> Option<Self> {
99                match s { $($wire => Some(Self::$variant),)+ _ => None }
100            }
101
102            /// Every valid wire string, for "must be one of …" messages.
103            pub const VALID: &'static [&'static str] = &[$($wire),+];
104        }
105
106        impl serde::Serialize for $name {
107            fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
108                ser.serialize_str(self.as_str())
109            }
110        }
111    };
112}
113
114wire_enum! {
115    /// Machine-readable approval gate (SCHEMA.md §1). `/shipshape-init` writes `draft`
116    /// and stops; a human flips it to `approved`. Mutating members refuse a
117    /// draft (they pass `--require-approved`).
118    Status { Draft => "draft", Approved => "approved" }
119}
120
121wire_enum! {
122    /// Master maturity dial that gates every member's output (SCHEMA.md §1).
123    /// Required — inference is `/shipshape-init`'s job, not the normalizer's.
124    Maturity { Spike => "spike", Mvp => "mvp", Production => "production" }
125}
126
127wire_enum! {
128    /// A packaging ecosystem. `homebrew` is a distribution *target*, never an
129    /// ecosystem. Listed in the canonical order used for stable de-dup/expansion.
130    Ecosystem {
131        Rust => "rust", Node => "node", Python => "python", Go => "go", Binary => "binary"
132    }
133}
134
135wire_enum! {
136    /// Base versioning scheme (SCHEMA.md §1). A `calver:<pattern>` config splits
137    /// into `Calver` + a separate [`Contract::versioning_pattern`]; the wire form
138    /// never carries the `calver:` prefix.
139    VersioningBase { Semver => "semver", Calver => "calver", Zerover => "zerover" }
140}
141
142wire_enum! {
143    /// How the changelog is produced (SCHEMA.md §1).
144    ChangelogMode { Curated => "curated", Automated => "automated", Fragment => "fragment" }
145}
146
147wire_enum! {
148    /// The changelog's structured input (SCHEMA.md §1).
149    ChangelogSource {
150        IssuectlTrailers => "issuectl-trailers",
151        ConventionalCommits => "conventional-commits",
152        Manual => "manual"
153    }
154}
155
156wire_enum! {
157    /// Release trigger model (SCHEMA.md §1). `auto` installs an on-merge
158    /// workflow; it never publishes from a chat turn.
159    ReleaseModel { Gated => "gated", Auto => "auto" }
160}
161
162wire_enum! {
163    /// Repository release layout (SCHEMA.md §1). `monorepo` drives per-package
164    /// versions/tags and flips the node adapter default to `changesets`.
165    ReleaseLayout { Single => "single", Monorepo => "monorepo" }
166}
167
168wire_enum! {
169    /// Contributor sign-off requirement, read by `/shipshape-contributing`.
170    ContributionProvenance { Dco => "dco", Cla => "cla", None => "none" }
171}
172
173wire_enum! {
174    /// Build-provenance level (SCHEMA.md §1). `slsa-l3` is production-only (floor).
175    ProvenanceLevel { None => "none", Keyless => "keyless", SlsaL3 => "slsa-l3" }
176}
177
178wire_enum! {
179    /// Which dependency-update bot `/shipshape-ci` emits.
180    DependencyBot { Dependabot => "dependabot", Renovate => "renovate", None => "none" }
181}
182
183wire_enum! {
184    /// A README health badge (SCHEMA.md §1). Every badge needs its producer
185    /// enabled (floor 4).
186    HealthBadge {
187        Ci => "ci", Registry => "registry", License => "license",
188        Coverage => "coverage", Scorecard => "scorecard", Discord => "discord"
189    }
190}
191
192wire_enum! {
193    /// Optional documentation-site generator (SCHEMA.md §1); production-tier.
194    DocsSite {
195        None => "none", Mkdocs => "mkdocs", Vitepress => "vitepress",
196        Docusaurus => "docusaurus", Sphinx => "sphinx", Mintlify => "mintlify"
197    }
198}
199
200wire_enum! {
201    /// A publish destination for a [`Target`] (SCHEMA.md §1).
202    Registry {
203        CratesIo => "crates.io", Npm => "npm", Pypi => "pypi", TestPypi => "testpypi",
204        GhReleases => "gh-releases", ProxyGolangOrg => "proxy.golang.org",
205        Homebrew => "homebrew"
206    }
207}
208
209wire_enum! {
210    /// The release tool pinned for a [`Target`] so it is not re-inferred each cut.
211    ///
212    /// `cargo-publish-ci` is the **CI-delegated** sibling of `cargo-publish`: the
213    /// crate reaches crates.io through a tag-triggered CI workflow (a repo-secret
214    /// `CARGO_REGISTRY_TOKEN` running `cargo publish` in Actions), never through a
215    /// `cargo publish` on the maintainer's host. A repo that forbids the local
216    /// publish declares it, and the engine's cut then gates + tags + **observes**
217    /// instead of publishing — the same delegation vocabulary the `cargo-dist`
218    /// gh-releases / homebrew targets already use
219    /// ([`is_ci_delegated`](crate::release::adapters::ReleaseAdapter::is_ci_delegated)).
220    ///
221    /// A **new enum value is additive**, so it does not bump
222    /// [`KNOWN_SCHEMA_VERSION`]: no existing field is renamed or re-meant, and a
223    /// contract that does not use it serializes byte-for-byte as before. An older
224    /// reader meeting the value reports it as an invalid adapter (the normalizer's
225    /// closed-enum error path) rather than mis-executing it — fail-closed, which is
226    /// the property that matters for a publish identity.
227    Adapter {
228        CargoPublish => "cargo-publish", CargoPublishCi => "cargo-publish-ci",
229        CargoDist => "cargo-dist",
230        ReleasePlease => "release-please", Changesets => "changesets",
231        GhActionPypiPublish => "gh-action-pypi-publish", Twine => "twine",
232        Goreleaser => "goreleaser", HomebrewTap => "homebrew-tap",
233        HomebrewCore => "homebrew-core", NpmPublish => "npm-publish", Manual => "manual"
234    }
235}
236
237wire_enum! {
238    /// The binary-distribution engine that produces multi-platform GitHub-Release
239    /// artifacts plus a generated installer set (distinct from a registry
240    /// [`Adapter`]). Owned by a tag-triggered `release.yml` the family must NOT
241    /// regenerate — hence first-class in the contract.
242    DistributionAdapter {
243        CargoDist => "cargo-dist", Goreleaser => "goreleaser", Manual => "manual"
244    }
245}
246
247wire_enum! {
248    /// An installer flavor a [`Distribution`] emits. `homebrew` requires a
249    /// [`Distribution::homebrew_tap`] (floor).
250    Installer {
251        Shell => "shell", Powershell => "powershell", Homebrew => "homebrew",
252        Msi => "msi", Npm => "npm"
253    }
254}
255
256impl Ecosystem {
257    /// The default registry for this ecosystem when `targets` is expanded
258    /// (SCHEMA.md §1 default-expansion table).
259    #[must_use]
260    pub fn default_registry(self) -> Registry {
261        match self {
262            Self::Rust => Registry::CratesIo,
263            Self::Node => Registry::Npm,
264            Self::Python => Registry::Pypi,
265            Self::Go => Registry::ProxyGolangOrg,
266            Self::Binary => Registry::GhReleases,
267        }
268    }
269
270    /// The default adapter for this ecosystem/layout when `targets` is expanded
271    /// (SCHEMA.md §1). Node's default is layout-sensitive: `single` →
272    /// `release-please`, `monorepo` → `changesets`.
273    #[must_use]
274    pub fn default_adapter(self, layout: ReleaseLayout) -> Adapter {
275        match self {
276            Self::Rust => Adapter::CargoPublish,
277            Self::Node => match layout {
278                ReleaseLayout::Monorepo => Adapter::Changesets,
279                ReleaseLayout::Single => Adapter::ReleasePlease,
280            },
281            Self::Python => Adapter::GhActionPypiPublish,
282            Self::Go => Adapter::Goreleaser,
283            Self::Binary => Adapter::Manual,
284        }
285    }
286}
287
288/// One concrete `ecosystem → package → registry` publish destination.
289///
290/// Always concrete in the canonical output: expanded from `ecosystems` when the
291/// source omitted `targets`. `package` may be `null` (the executor infers it
292/// from the manifest); every other field is present.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
294pub struct Target {
295    /// The ecosystem this target publishes for.
296    pub ecosystem: Ecosystem,
297    /// The package/crate name, or `null` when inferred from the manifest.
298    pub package: Option<String>,
299    /// The publish destination.
300    pub registry: Registry,
301    /// The release tool pinned for this target.
302    pub adapter: Adapter,
303}
304
305/// The binary-distribution block: multi-platform GitHub-Release binaries, a
306/// generated installer set, and an optional Homebrew tap — produced by a
307/// tag-triggered release workflow (cargo-dist / goreleaser).
308///
309/// SEPARATE from [`Target`] (registry publishes): a cargo-dist repo attaches
310/// per-platform binaries to its GitHub Release, ships a shell/Homebrew installer,
311/// **and** independently publishes its crate to crates.io — the crates.io publish
312/// is a [`Target`]; everything binary-distribution is this block. The two coexist,
313/// which is exactly the "registry publish alongside a cargo-dist release" the
314/// contract could not express before. First-class (not prose) so downstream
315/// members SEE the tap + installer and neither under-describe the release nor
316/// regenerate the existing `release.yml`.
317///
318/// One element of [`Contract::distributions`]: a registry-only repo has an empty
319/// list, a single-binary repo one entry, a **monorepo** one entry per
320/// independently-distributed binary (each with its own installers / tap /
321/// platforms), tagged by [`Self::package`].
322///
323/// Keeps `Eq` even after gaining [`Self::extra_fields`]: `serde_json::Value`
324/// (and `serde_json::Map`) implement `Eq` — JSON numbers exclude non-finite
325/// floats — so the added field does not weaken the derive (unlike the sibling
326/// [`Contract`], which is `PartialEq`-only for unrelated historical reasons).
327#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
328pub struct Distribution {
329    /// The package/target this distribution belongs to (matches a
330    /// [`Target::package`] or a manifest package name), or `null` for the sole /
331    /// unassociated distribution.
332    ///
333    /// The monorepo association key: a repo shipping multiple independently
334    /// distributed binaries gives each its own [`Distribution`] tagged with the
335    /// package it builds. `null` is the single-distribution back-compat case (a
336    /// bare `distribution:` mapping carries no package). The normalizer requires a
337    /// non-null, **unique** `package` on every entry once there are two or more
338    /// distributions — otherwise a monorepo's entries would be indistinguishable.
339    pub package: Option<String>,
340    /// The binary-distribution engine that owns the tag-triggered release
341    /// workflow.
342    pub adapter: DistributionAdapter,
343    /// Whether multi-platform binaries are attached to the GitHub Release.
344    pub gh_releases: bool,
345    /// Installer flavors this release produces (may be empty), canonically
346    /// ordered and de-duplicated.
347    pub installers: Vec<Installer>,
348    /// The Homebrew tap repo (`owner/repo`) the generated formula is pushed to,
349    /// or `null` when no tap is used. Required when `installers` includes
350    /// `homebrew` (floor).
351    pub homebrew_tap: Option<String>,
352    /// The platform target set — the target-triples this binary distribution builds
353    /// and ships, in Rust target-triple form (the vocabulary the `cargo-dist`
354    /// adapter consumes; a `goreleaser`/`manual` remodel into an adapter-neutral
355    /// shape is deliberately left to a follow-up). Always non-empty in the canonical
356    /// output: defaulted to the cross-platform [`DEFAULT_CROSS_PLATFORM_TARGETS`] set
357    /// (macOS + Linux) when the source OMITS it (an explicit empty list is rejected,
358    /// not defaulted), so a distribution that doesn't specify platforms still covers
359    /// Linux (the cross-platform install requirement). An explicit set is validated
360    /// per triple and de-duplicated, preserving the author's order. Validation is
361    /// STRUCTURAL, not semantic — a well-formed triple whose OS component stays
362    /// inspectable, so the cross-platform `audit` can flag a Linux-less explicit set;
363    /// the normalizer guarantees only that the field is present and every triple
364    /// well-formed, never that the set covers any particular OS or that the toolchain
365    /// will build it.
366    pub platforms: Vec<String>,
367    /// Preserved unknown keys inside the `distribution` block under a known
368    /// `schema_version` (forward-compat), so an older reader round-trips a newer
369    /// contract's distribution sub-keys rather than dropping them. Mirrors
370    /// [`Contract::extra_fields`] at the nested level; empty for a contract with
371    /// no unknown distribution keys.
372    ///
373    /// An EMPTY map is OMITTED from canonical JSON (`skip_serializing_if`), so a
374    /// distribution with no unknown keys carries no `extra_fields` key at all — the
375    /// "additive = absent-by-default" migration rule holds literally, and a
376    /// populated map serializes exactly as before. Kept symmetric with
377    /// [`Contract::extra_fields`] (both omit-when-empty).
378    #[serde(skip_serializing_if = "serde_json::Map::is_empty")]
379    pub extra_fields: serde_json::Map<String, serde_json::Value>,
380}
381
382/// The changelog block of the contract (SCHEMA.md §1). `fragment_dir` is always
383/// present, even for non-`fragment` modes.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
385pub struct Changelog {
386    /// How the changelog is produced.
387    pub mode: ChangelogMode,
388    /// The changelog's structured input.
389    pub source: ChangelogSource,
390    /// Where changelog fragments live (relative path inside the repo).
391    pub fragment_dir: String,
392}
393
394/// The release block of the contract (SCHEMA.md §1).
395#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
396pub struct Release {
397    /// Release trigger model.
398    pub model: ReleaseModel,
399    /// Repository release layout.
400    pub layout: ReleaseLayout,
401    /// An optional repo-provided command the engine runs in the clean checkout during
402    /// the engine-owned version-bump phase, **after** the version edits and **before**
403    /// the bump commit (`release-rust-workspace-multicrate` facet 3). Its purpose is to
404    /// regenerate version-embedding artifacts — the canonical case is a repo whose test
405    /// snapshots embed the version (insta `envelope_snapshots__version_*`), which go
406    /// stale on a bump and red CI unless regenerated against the new version. Keeping it
407    /// a declared command keeps the engine out of per-repo test-harness specifics: the
408    /// engine folds the hook's file changes into the bump commit and **fails closed** if
409    /// the hook exits non-zero. `null` (absent) = no hook, the default.
410    ///
411    /// **Security (trust boundary).** A declared hook is **arbitrary code the engine
412    /// runs during a release**, with whatever environment the cut carries (potentially
413    /// registry-publish credentials), before the publish barrier. It is equivalent in
414    /// trust to a `build.rs` / a test the release already runs, but it lives in the
415    /// contract, which may be reviewed less carefully than Rust source — so a malicious
416    /// `bump_hook` is a supply-chain surface. The engine surfaces the hook **verbatim**
417    /// as a plan-time warning so an approver sees exactly what will run, and the
418    /// executor's invocation contract (shell vs argv, working directory, timeout,
419    /// environment/secret policy, permitted file changes) is specified where execution
420    /// is wired — until then no hook is ever run (`release cut` refuses a bump plan).
421    ///
422    /// A **purely additive** optional field: it is omitted from the canonical JSON when
423    /// absent (`skip_serializing_if`), so a contract that declares no hook serializes
424    /// byte-for-byte as before and its `plan_id` is unchanged. By the migration rule an
425    /// additive optional field does not bump `schema_version`; and this codebase parses
426    /// the contract through a hand-written normalizer (no serde `Deserialize`), so there
427    /// is no missing-field deser hazard for older readers, which in any case lack
428    /// `--bump` and so never act on the hook.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub bump_hook: Option<String>,
431}
432
433/// The canonical, fully-defaulted, `targets`-expanded `OSS-RELEASE.md` contract.
434///
435/// This is the exact shape of SCHEMA.md §4 (the stable machine contract). Every
436/// field is present and defaulted; `versioning` is the base enum with the
437/// calver pattern split into [`Self::versioning_pattern`]; `extra_fields` holds
438/// preserved unknown frontmatter keys (forward-compat); `warnings` holds the
439/// non-fatal notes. Field order matches SCHEMA.md §4 for readable output; JSON
440/// consumers key-access, so order is not part of the contract.
441#[derive(Debug, Clone, PartialEq, Serialize)]
442pub struct Contract {
443    /// Contract schema version (bounded by [`KNOWN_SCHEMA_VERSION`]).
444    pub schema_version: u32,
445    /// Approval gate.
446    pub status: Status,
447    /// Maturity dial.
448    pub maturity: Maturity,
449    /// Packaging ecosystems, de-duplicated to canonical order.
450    pub ecosystems: Vec<Ecosystem>,
451    /// Concrete registry publish targets. Expanded from `ecosystems` when the
452    /// `targets` key is OMITTED (or written as a bare `targets:` / `targets: null`,
453    /// which read as absent); an explicit empty `targets: []` — the literal empty
454    /// sequence — is the author's authoritative "never publish anywhere" and is
455    /// honored as an empty set (not re-expanded), the machine-readable way to
456    /// declare a version-tracked but unpublished repo. An empty set is a valid,
457    /// honored state, not a misconfiguration. This re-meaning of the specific `[]`
458    /// value did NOT bump [`KNOWN_SCHEMA_VERSION`] deliberately: the serialized
459    /// shape is a JSON array either way (an empty array is already producible today
460    /// by a contract with no ecosystems), so every consumer that reads `targets`
461    /// already handles `[]` — no reader breaks.
462    pub targets: Vec<Target>,
463    /// The binary-distribution blocks (cargo-dist / goreleaser binaries +
464    /// installers + Homebrew tap). Empty for a registry-only repo, one entry for
465    /// a single-binary repo, one per independently-distributed binary for a
466    /// **monorepo** (each tagged by [`Distribution::package`]). Always a JSON
467    /// array in canonical output. Coexists with `targets` — a cargo-dist repo has
468    /// both. A bare `distribution:` mapping in the source deserializes as a
469    /// one-element list (v1 back-compat); a `distributions:` sequence carries many.
470    pub distributions: Vec<Distribution>,
471    /// Base versioning scheme.
472    pub versioning: VersioningBase,
473    /// The calver pattern string, or `null` for non-calver schemes.
474    pub versioning_pattern: Option<String>,
475    /// Changelog configuration.
476    pub changelog: Changelog,
477    /// Whether `/shipshape-release-cut` may derive the bump from commit types.
478    pub conventional_commits: bool,
479    /// Release model + layout.
480    pub release: Release,
481    /// Contributor sign-off requirement.
482    pub contribution_provenance: ContributionProvenance,
483    /// Build-provenance level.
484    pub provenance_level: ProvenanceLevel,
485    /// Dependency-update bot.
486    pub dependency_bot: DependencyBot,
487    /// README health badges.
488    pub health_badges: Vec<HealthBadge>,
489    /// SPDX license id/expression.
490    pub license: String,
491    /// Optional documentation-site generator.
492    pub docs_site: DocsSite,
493    /// Preserved unknown frontmatter keys under a known `schema_version`
494    /// (forward-compat); never dropped.
495    ///
496    /// An EMPTY map is OMITTED from canonical JSON (`skip_serializing_if`), so a
497    /// contract with no unknown keys carries no `extra_fields` key at all — the
498    /// "additive = absent-by-default" migration rule holds literally, and a
499    /// populated map serializes exactly as before. Kept symmetric with
500    /// [`Distribution::extra_fields`] (both omit-when-empty).
501    #[serde(skip_serializing_if = "serde_json::Map::is_empty")]
502    pub extra_fields: serde_json::Map<String, serde_json::Value>,
503    /// Non-fatal notes (aspirational draft producers, the unknown-field report).
504    pub warnings: Vec<String>,
505}