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}