Skip to main content

pmcp_workbook_runtime/
changelog.rs

1//! The shared version-changelog data model (Phase 13, Plan 01 — D-13/D-15).
2//!
3//! This model is the CONTRACT that crosses two trust boundaries:
4//!
5//! 1. **offline-compiler → served-binary (via bundle):** the promote step RECORDS
6//!    a prev→current [`VersionChangelog`] into the bundle (D-15); the served
7//!    `diff_version` MCP tool DESERIALIZES it and serves it.
8//! 2. **`workbook-runtime` ↔ `workbook-compiler` (crate link boundary):** because
9//!    the served binary links ONLY `workbook-runtime` (umya-free), every TYPE the
10//!    `diff_version` tool reads from a bundle MUST be defined HERE — never in
11//!    `workbook-compiler`. `just purity-check` (cargo-tree) enforces this.
12//!
13//! In particular [`ChangeClass`] lives HERE (review item 7): `OutputDelta`'s
14//! `change_class` is the closed serde enum, NOT a stringly-typed label. An unknown
15//! wire tag fails deserialization at the served boundary, so a forged class cannot
16//! masquerade as a known routing class (T-13-19). This enum carries NO derivation
17//! logic — Plan 03's `classify`/`policy` operate on the SAME enum but the
18//! classification rules stay compiler-side.
19//!
20//! Owned-only fields; no foreign types cross the public boundary (the project's
21//! owned-types-at-boundary idiom). All records derive
22//! `Serialize + Deserialize + schemars::JsonSchema` (so `diff_version` can
23//! advertise an output schema), following the exact derive style of
24//! [`crate::manifest_model::ChangelogEntry`].
25
26use serde::{Deserialize, Serialize};
27
28/// The auto-derived class of a single output change (D-08). Six closed variants,
29/// each mapping onto a manifest-model dimension; the offline classifier
30/// (Plan 03's `classify`/`policy`) AND the served `diff_version` tool share this
31/// ONE definition (review item 7).
32///
33/// Wire tags are kebab-case so they match the D-08 class labels
34/// (`output-schema`, `governed-data`, …). The closed set is LOAD-BEARING: an
35/// unknown tag fails deserialization at the served boundary (T-13-19), so a forged
36/// class label can never masquerade as a known routing class.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
38#[serde(rename_all = "kebab-case")]
39pub enum ChangeClass {
40    /// A named output region's declared `meaning`/`unit`/`source` (provenance)
41    /// changed.
42    OutputSchema,
43    /// A `GovernedDatum.value` changed (`manifest.governed_data`).
44    GovernedData,
45    /// The compiled IR (formula AST) for a region changed.
46    FormulaLogic,
47    /// A `Role::Input` cell was added / removed / retyped.
48    InputSchema,
49    /// `manifest.capability_calls` (`CapabilityDecl`) changed.
50    CapabilityContract,
51    /// A yellow assumption (`Role::Constant` with `source == "yellow-assumption"`)
52    /// changed.
53    Assumption,
54}
55
56/// The severity of an output change (D-14). Exactly TWO variants:
57///
58/// - [`Drift`](Severity::Drift): a pure value change with identical schema (handled
59///   by the corpus / `--accept` numeric gate).
60/// - [`Redefinition`](Severity::Redefinition): a change to a named output's declared
61///   `meaning`/`unit`/`source` or the IDENTITY of what it computes (BA review;
62///   flagged; never silently re-baselined).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
64#[serde(rename_all = "kebab-case")]
65pub enum Severity {
66    /// A pure value change with identical schema.
67    Drift,
68    /// A schema/identity change (BA review, never silently re-baselined).
69    Redefinition,
70}
71
72/// The declared metadata of a named output region at a single version: the
73/// `meaning`/`unit`/`provenance` triple the redefinition predicate (D-14) compares.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
75pub struct OutputMeta {
76    /// The human-readable meaning of the output region.
77    pub meaning: Option<String>,
78    /// The unit text (e.g. `"GBP"`, `"m2"`), when known.
79    pub unit: Option<String>,
80    /// The provenance/source of the output region (e.g. `"colour+guide"`).
81    pub provenance: Option<String>,
82}
83
84/// One per-changed-output record (D-13). Aligns with the cargo-pmcp schema-diff
85/// compare-and-summarize shape; serde-clean so the served `diff_version` tool can
86/// deserialize it. `change_class` is the [`ChangeClass`] ENUM (NOT a `String`).
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
88pub struct OutputDelta {
89    /// The named output region (`sheet!addr` or named-range name).
90    pub region: String,
91    /// The auto-derived change class (from the shared [`ChangeClass`] enum).
92    pub change_class: ChangeClass,
93    /// The region metadata at the previous version.
94    pub old: OutputMeta,
95    /// The region metadata at the current version.
96    pub new: OutputMeta,
97    /// Whether the change is a drift or a redefinition.
98    pub severity: Severity,
99}
100
101/// The top-level recorded changelog (D-15): the prev→current transition the promote
102/// step records into the bundle and the served `diff_version` tool serves. No
103/// multiple bundle versions are kept loaded at runtime — this single recorded
104/// changelog is the served artifact.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
106pub struct VersionChangelog {
107    /// The version this changelog transitions FROM.
108    pub from_version: String,
109    /// The version this changelog transitions TO.
110    pub to_version: String,
111    /// The per-changed-output records.
112    pub deltas: Vec<OutputDelta>,
113    /// A human-readable summary of the transition (D-13).
114    pub summary: String,
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn sample_changelog() -> VersionChangelog {
122        VersionChangelog {
123            from_version: "1.0.0".to_string(),
124            to_version: "1.1.0".to_string(),
125            deltas: vec![
126                OutputDelta {
127                    region: "7_Quote!C11".to_string(),
128                    change_class: ChangeClass::GovernedData,
129                    old: OutputMeta {
130                        meaning: Some("supply total".to_string()),
131                        unit: Some("GBP".to_string()),
132                        provenance: Some("colour+guide".to_string()),
133                    },
134                    new: OutputMeta {
135                        meaning: Some("supply total".to_string()),
136                        unit: Some("GBP".to_string()),
137                        provenance: Some("colour+guide".to_string()),
138                    },
139                    severity: Severity::Drift,
140                },
141                OutputDelta {
142                    region: "7_Quote!C12".to_string(),
143                    change_class: ChangeClass::OutputSchema,
144                    old: OutputMeta {
145                        meaning: Some("install total".to_string()),
146                        unit: Some("GBP".to_string()),
147                        provenance: None,
148                    },
149                    new: OutputMeta {
150                        meaning: Some("install total (inc VAT)".to_string()),
151                        unit: Some("GBP".to_string()),
152                        provenance: Some("colour+guide".to_string()),
153                    },
154                    severity: Severity::Redefinition,
155                },
156            ],
157            summary: "1 drift, 1 redefinition".to_string(),
158        }
159    }
160
161    /// Locks the serde JSON round-trip (serialize → deserialize → equality),
162    /// mirroring the `ir_round_trip`-style shape locking.
163    #[test]
164    fn version_changelog_round_trips() {
165        let original = sample_changelog();
166        let json = serde_json::to_string_pretty(&original).expect("serialize");
167        let restored: VersionChangelog = serde_json::from_str(&json).expect("deserialize");
168        assert_eq!(original, restored);
169    }
170
171    /// `OutputDelta` deserializes from a hand-written JSON fixture — proving the
172    /// served `diff_version` tool can read a recorded changelog member, and that
173    /// `change_class` is the kebab-case enum tag (NOT a free-form string).
174    #[test]
175    fn output_delta_deserializes_from_fixture() {
176        let fixture = r#"{
177            "region": "7_Quote!C11",
178            "change_class": "governed-data",
179            "old": { "meaning": "supply total", "unit": "GBP", "provenance": "colour+guide" },
180            "new": { "meaning": "supply total", "unit": "GBP", "provenance": "colour+guide" },
181            "severity": "drift"
182        }"#;
183        let delta: OutputDelta = serde_json::from_str(fixture).expect("deserialize fixture");
184        assert_eq!(delta.region, "7_Quote!C11");
185        assert_eq!(delta.change_class, ChangeClass::GovernedData);
186        assert_eq!(delta.severity, Severity::Drift);
187    }
188
189    /// `Severity` serializes to EXACTLY two kebab-case wire tags: `drift` and
190    /// `redefinition`. A closed two-variant set is the D-14 contract.
191    #[test]
192    fn severity_has_exactly_two_variants() {
193        assert_eq!(
194            serde_json::to_string(&Severity::Drift).expect("serialize drift"),
195            "\"drift\""
196        );
197        assert_eq!(
198            serde_json::to_string(&Severity::Redefinition).expect("serialize redefinition"),
199            "\"redefinition\""
200        );
201        // Round-trip both variants so the closed set is locked end-to-end.
202        for variant in [Severity::Drift, Severity::Redefinition] {
203            let json = serde_json::to_string(&variant).expect("serialize");
204            let restored: Severity = serde_json::from_str(&json).expect("deserialize");
205            assert_eq!(variant, restored);
206        }
207    }
208
209    /// `ChangeClass` serializes to EXACTLY six stable kebab-case wire tags
210    /// (review item 7) and round-trips identically. The closed set is what makes a
211    /// forged class label fail deserialization at the served boundary (T-13-19).
212    #[test]
213    fn change_class_has_exactly_six_variants() {
214        let expected = [
215            (ChangeClass::OutputSchema, "\"output-schema\""),
216            (ChangeClass::GovernedData, "\"governed-data\""),
217            (ChangeClass::FormulaLogic, "\"formula-logic\""),
218            (ChangeClass::InputSchema, "\"input-schema\""),
219            (ChangeClass::CapabilityContract, "\"capability-contract\""),
220            (ChangeClass::Assumption, "\"assumption\""),
221        ];
222        assert_eq!(expected.len(), 6);
223        for (variant, tag) in expected {
224            assert_eq!(
225                serde_json::to_string(&variant).expect("serialize"),
226                tag,
227                "wire tag for {variant:?}"
228            );
229            let restored: ChangeClass =
230                serde_json::from_str(tag).expect("deserialize from stable tag");
231            assert_eq!(variant, restored);
232        }
233    }
234
235    /// An unknown `change_class` wire tag MUST fail deserialization (T-13-19): a
236    /// forged class label cannot masquerade as a known routing class.
237    #[test]
238    fn unknown_change_class_tag_is_rejected() {
239        let forged = "\"super-admin-bypass\"";
240        let result: Result<ChangeClass, _> = serde_json::from_str(forged);
241        assert!(result.is_err(), "unknown ChangeClass tag must be rejected");
242    }
243}