Skip to main content

pmcp_workbook_runtime/
manifest_model.rs

1//! The logical `Manifest` model — the source of truth that REPLACES "colour as
2//! canonical" (DIA-03). RELOCATED into `workbook-runtime` (Phase 11, Plan 05) so
3//! the served binary can deserialize the manifest projection WITHOUT linking the
4//! offline compiler. `workbook-compiler` re-exports these types (its
5//! `manifest::model` surface is unchanged) and keeps manifest SYNTHESIS /
6//! ratification / projections on its umya-linked side.
7//!
8//! Why `umya`-free: this is the type the parser / DAG compiler / artifact
9//! emitters / served binary consume. No `umya` type appears in any public
10//! signature here.
11//!
12//! # The four-variant `Role` set (Codex MEDIUM reconciliation)
13//!
14//! The colour palette emits an `assumption` EVIDENCE label for yellow fills, but
15//! the logical model keeps the `Role` set to exactly `Input | Constant | Output
16//! | Formula`. A yellow "assumption" is folded into [`Role::Constant`] with
17//! `source = "yellow-assumption"`.
18//!
19//! # The BA-owned governed-data table (Phase 10 Plan 02, D-03)
20//!
21//! [`Manifest::governed_data`] is the BA-owned constant table — the SOLE route
22//! by which a constant may change to close a reconciliation gap. Each
23//! [`GovernedDatum`] carries a TYPED [`CellValue`] (NOT a bare `f64`), plus
24//! effective-date + approval provenance.
25//!
26//! Derive note: because [`CellValue`] carries an `f64` (in `Number`) it is
27//! `PartialEq` but NOT `Eq`. [`GovernedDatum`] therefore drops `Eq`, and
28//! [`Manifest`] relaxes its derive to `PartialEq`-only.
29
30use serde::{Deserialize, Serialize};
31
32use crate::sheet_ir::value::CellValue;
33
34/// The role a cell plays in the workbook's computation, resolved from the
35/// MANIFEST (not from colour directly — colour only proposes; D-02). Exactly four
36/// variants: a yellow "assumption" is NOT a distinct role — it is a
37/// [`Role::Constant`] carrying `source = "yellow-assumption"`.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
39#[serde(rename_all = "lowercase")]
40pub enum Role {
41    /// A per-quote overridable input (blue input font in the lighthouse).
42    Input,
43    /// A governed constant (green fill in the lighthouse). A yellow "assumption"
44    /// is also a `Constant`, distinguished by `source = "yellow-assumption"` on
45    /// its [`CellRole`] — NEVER a separate role (Codex MEDIUM reconciliation).
46    Constant,
47    /// A declared output of the workflow (`out_*` named-range convention).
48    Output,
49    /// A derived/formula cell (default font + a formula `<f>`).
50    Formula,
51}
52
53impl Role {
54    /// Map a named-range NAME prefix to the role it implies (the redundant
55    /// "naming convention" evidence channel used by the D-04 overlap check):
56    /// `in_` → [`Role::Input`], `const_` → [`Role::Constant`], `out_` →
57    /// [`Role::Output`]. Returns `None` for any other prefix (e.g. `Rooms`).
58    pub fn from_name_prefix(name: &str) -> Option<Role> {
59        if name.starts_with("in_") {
60            Some(Role::Input)
61        } else if name.starts_with("const_") {
62            Some(Role::Constant)
63        } else if name.starts_with("out_") {
64            Some(Role::Output)
65        } else {
66            None
67        }
68    }
69}
70
71/// The declared data type of a cell's value.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
73#[serde(rename_all = "lowercase")]
74pub enum Dtype {
75    /// A numeric value.
76    Number,
77    /// A text value.
78    Text,
79    /// A boolean value.
80    Bool,
81}
82
83/// One row of the manifest's roles table: a cell's resolved role + the metadata
84/// (name/unit/meaning/dtype) the downstream phases consume, plus the colour
85/// EVIDENCE (lint-only) and the `source` provenance.
86///
87/// Derive note: `Eq` is relaxed to `PartialEq`-only because the additive
88/// [`CellRole::tier`] carries an [`InputTier`] whose default is a [`CellValue`]
89/// (`f64`-bearing).
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
91pub struct CellRole {
92    /// The fully-qualified cell key `sheet!addr` (e.g. `"1_Inputs!E6"`).
93    pub cell: String,
94    /// The cell's resolved role (manifest-canonical; D-03).
95    pub role: Role,
96    /// The named-range NAME (`in_*`/`const_*`/`out_*`), when one is assigned.
97    pub name: Option<String>,
98    /// The unit text (e.g. `"m2"`, `"GBP"`), when known.
99    pub unit: Option<String>,
100    /// The human-readable meaning (from the block header), when known.
101    pub meaning: Option<String>,
102    /// The declared data type.
103    pub dtype: Dtype,
104    /// The colour ARGB evidence that PROPOSED this role (lint-only).
105    pub colour_evidence: Option<String>,
106    /// The provenance of the role (e.g. `"colour+guide"`, `"yellow-assumption"`).
107    pub source: String,
108    /// Free-form notes.
109    pub notes: Option<String>,
110    /// The input TIER of this cell (D-07/D-08), additive + `#[serde(default)]` so
111    /// older manifests (no `tier` key) deserialize with `tier == None`.
112    ///
113    /// LOAD-BEARING contract (Codex HIGH #3 — tier migration):
114    /// `None` means STRICT **only for [`Role::Constant`]** — an untiered constant
115    /// is BA-only and is rejected as a `calculate` input (enforced via
116    /// [`is_strict_constant`]). An untiered [`Role::Input`] is **NOT** a
117    /// strict-rejected input: ratification maps an untiered `Role::Input` to
118    /// [`InputTier::Variable`].
119    #[serde(default)]
120    pub tier: Option<InputTier>,
121    /// The FROZEN closed-enum domain for this input (D-03/D-07): the EXACT
122    /// accepted tokens, in workbook order, trimmed + deduplicated, NEVER sorted.
123    /// `Some(tokens)` means the served tool schema bakes a closed JSON-Schema
124    /// `enum` for this input; `None` means the input stays DYNAMIC
125    /// (allowed-values-in-error + `value-schema://` resource path).
126    ///
127    /// Additive + `#[serde(default)]` (the [`CellRole::tier`] precedent) so older
128    /// manifests (no `allowed_values` key) deserialize with `None`;
129    /// `skip_serializing_if` keeps existing `manifest.json` snapshots byte-stable
130    /// when `None`.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub allowed_values: Option<Vec<String>>,
133}
134
135/// The input tier of a [`CellRole`] (D-07/D-08, RESEARCH OQ-2): whether (and how)
136/// a user may override the cell at quote time.
137///
138/// A [`Variable`](InputTier::Variable) carries a typed [`CellValue`] default. A
139/// [`BoundedVariable`](InputTier::BoundedVariable) additionally carries
140/// `min`/`max` which are CARRIED but UNENFORCED in Phase 11 (D-08).
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
142#[serde(rename_all = "snake_case", tag = "kind")]
143pub enum InputTier {
144    /// A freely user-overridable input with a typed default.
145    Variable {
146        /// The default value applied when the caller omits the input.
147        default: CellValue,
148    },
149    /// A user-overridable input with a declared `[min, max]` range. The range is
150    /// CARRIED here but NOT enforced in Phase 11 (D-08).
151    BoundedVariable {
152        /// The default value applied when the caller omits the input.
153        default: CellValue,
154        /// The lower bound (carried, unenforced in Phase 11).
155        min: CellValue,
156        /// The upper bound (carried, unenforced in Phase 11).
157        max: CellValue,
158    },
159}
160
161/// The LLM-facing JSON key for a role-bearing cell: the manifest `name` when present,
162/// else the human-readable `meaning`, else the fully-qualified cell key itself.
163///
164/// This is the SINGLE source of the name/meaning/cell precedence used to map a
165/// [`CellRole`] to the LLM-facing key — shared by the `cell_map` emitter and the
166/// served tools' input/output schema builders so the precedence cannot drift.
167///
168/// When the key comes from `role.name`, a SINGLE leading `in_`/`out_` GOVERNANCE
169/// prefix is STRIPPED from the served key (`in_gross_income` → `gross_income`): the
170/// prefix is a workbook-authoring convention (the named-range marker that
171/// `name_named_inputs`/`promote_named_outputs` match on) and must never leak into
172/// the caller-facing tool surface. The strip applies ONLY to the `name` branch —
173/// the `meaning` and `cell` fallbacks are returned verbatim. `role.name` itself is
174/// NOT mutated, so governance/named-range matching still sees the prefixed name.
175pub fn json_key_for_role(role: &CellRole) -> String {
176    if let Some(name) = role.name.as_deref() {
177        return strip_governance_prefix(name).to_string();
178    }
179    role.meaning.clone().unwrap_or_else(|| role.cell.clone())
180}
181
182/// Strip a SINGLE leading `in_`/`out_` governance prefix from a served `json_key`.
183///
184/// Only the FIRST matching prefix is removed (`in_in_x` → `in_x`), and only from a
185/// `role.name`-sourced key (the caller guards that). A name that is EXACTLY the
186/// prefix (`in_`) or carries no prefix is returned unchanged, so the strip is
187/// idempotent on already-clean keys and never yields an empty string from a
188/// non-empty prefixed-only name.
189fn strip_governance_prefix(name: &str) -> &str {
190    for prefix in ["in_", "out_"] {
191        if let Some(rest) = name.strip_prefix(prefix) {
192            if !rest.is_empty() {
193                return rest;
194            }
195        }
196    }
197    name
198}
199
200/// The reserved META-tool names the served workbook binary ALWAYS registers
201/// (`explain`, `get_manifest`, `diff_version`, `render_workbook`, `verify_accuracy`)
202/// — the SINGLE source of the reserved set (H3). An output-Table tool name that
203/// sanitizes to ANY of these would silently last-writer-wins over the meta tool at
204/// registration, so the offline compiler REJECTS it (a cell-precise compile failure)
205/// by checking against THIS const, not a hand-copied list. The served toolkit
206/// handlers' `NAME` constants (`ExplainHandler::NAME` etc.) are asserted EQUAL to
207/// these entries by a binding test in the toolkit, so the reserved set cannot drift
208/// from what is registered.
209///
210/// Lives in the runtime LEAF (not the toolkit) so the compiler reads it WITHOUT a
211/// compiler→toolkit dependency (which would breach the purity boundary / `make
212/// purity-check`); both the toolkit handlers and the compiler gate read the one const.
213///
214/// NOTE (Phase 100 Plan 01, NON-RELEASABLE intermediate): `verify_accuracy` is added
215/// here ahead of its `VerifyAccuracyHandler` (which lands in Plan 04). Until Plan 04
216/// registers that handler, the served binary advertises five meta tools by count/docs
217/// but the sixth handler does not yet exist — do NOT ship the repo between this plan
218/// and Plan 04 completion.
219pub const RESERVED_TOOL_NAMES: [&str; 5] = [
220    "explain",
221    "get_manifest",
222    "diff_version",
223    "render_workbook",
224    "verify_accuracy",
225];
226
227/// Sanitize a raw output-Table name into an MCP tool name matching
228/// `^[a-zA-Z0-9_-]{1,64}$` (T-100-10). This is the SINGLE shared sanitizer — the
229/// served toolkit's registration AND the offline compiler's post-sanitize
230/// collision lint both call it, so "what we register" and "what we collision-check"
231/// cannot drift. The LOCKED five-rule semantics:
232///
233/// 1. **Lowercase** every ASCII letter (`Calculate_Tax` → `calculate_tax`).
234/// 2. **Collapse** each maximal RUN of illegal characters (anything not
235///    `[a-z0-9_-]` after lowercasing) to a SINGLE `_` (`"a  b"`/`"a@@b"` →
236///    `"a_b"`), never one `_` per illegal char.
237/// 3. **Trim** leading/trailing `_`/`-` (no governance-noise edges).
238/// 4. **Truncate** to 64 chars AFTER the above.
239/// 5. If the result is **empty** (the input was empty or all-illegal) return
240///    `Err` carrying the offending raw name — fail-closed.
241///
242/// # Errors
243/// Returns `Err(raw.to_string())` when the input has no character mappable to the
244/// charset (empty or all-illegal).
245pub fn sanitize_tool_name(raw: &str) -> Result<String, String> {
246    let mut out = String::with_capacity(raw.len());
247    let mut pending_underscore = false;
248    for ch in raw.chars() {
249        let lc = ch.to_ascii_lowercase();
250        if lc.is_ascii_alphanumeric() || lc == '_' || lc == '-' {
251            if pending_underscore && !out.is_empty() {
252                out.push('_');
253            }
254            pending_underscore = false;
255            out.push(lc);
256        } else {
257            pending_underscore = true;
258        }
259    }
260    let trimmed: String = out
261        .trim_matches(|c| c == '_' || c == '-')
262        .chars()
263        .take(64)
264        .collect();
265    let trimmed = trimmed.trim_matches(|c| c == '_' || c == '-').to_string();
266    if trimmed.is_empty() {
267        return Err(raw.to_string());
268    }
269    Ok(trimmed)
270}
271
272/// Whether a [`CellRole`] is a STRICT constant — a BA-only governed value that
273/// must be REJECTED if a caller tries to supply it as a `calculate` input
274/// (Codex HIGH #3). The rule keys on [`Role::Constant`] + `tier == None`, NOT on
275/// every untiered cell: an untiered [`Role::Input`] is a Variable candidate
276/// (mapped at ratification), never strict-rejected.
277pub fn is_strict_constant(role: &CellRole) -> bool {
278    matches!(role.role, Role::Constant) && role.tier.is_none()
279}
280
281/// Whether a [`CellRole`] is COMPUTED — derived by the bundle IR
282/// ([`Role::Output`] or [`Role::Formula`]) and therefore never caller-seedable
283/// (WR-02: seeding a computed cell would let a caller pin a served output under
284/// a valid provenance stamp). The SINGLE predicate shared by the served tools'
285/// override reject gate and their allowed-override list, so "what we reject"
286/// and "what we advertise as overridable" cannot drift.
287pub fn is_computed(role: &CellRole) -> bool {
288    matches!(role.role, Role::Output | Role::Formula)
289}
290
291/// Find the manifest [`CellRole`] whose fully-qualified `cell` key equals
292/// `cell_key` — the SINGLE exact-cell-key lookup shared by the served tools'
293/// schema builder, input validator, and explain trace, so the matching
294/// semantics cannot drift between them. (Lookups by `name`-or-`cell` are a
295/// DIFFERENT, looser predicate and stay separate.)
296pub fn role_for_cell<'a>(manifest: &'a Manifest, cell_key: &str) -> Option<&'a CellRole> {
297    manifest.cells.iter().find(|c| c.cell == cell_key)
298}
299
300/// One entry in the [`Manifest::changelog`] (ART-02): a version stamp recording a
301/// workbook-hash transition + a human note.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
303pub struct ChangelogEntry {
304    /// The manifest/workflow version this entry records.
305    pub version: String,
306    /// The source workbook content hash at this version.
307    pub workbook_hash: String,
308    /// A human-readable note describing the change.
309    pub note: String,
310}
311
312/// One declared capability call (ART-02 — DECLARE-ONLY seam). Phase 11 keeps
313/// capability cells OUT of scope (PROJECT.md); this only DECLARES the contract a
314/// future capability cell would honour.
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
316pub struct CapabilityDecl {
317    /// The cell key (`sheet!addr`) that would host the capability.
318    pub cell: String,
319    /// The capability kind (e.g. `"rust"`, `"remote"`, `"mcp-tool"`).
320    pub kind: String,
321    /// The declared contract the capability must honour (free-form for now).
322    pub declared_contract: String,
323}
324
325/// The declared loop block (the `Rooms` per-room iteration). Populated ONLY from a
326/// CONFIRMED `Rooms` named range (Plan 05's round-trip path; D-10).
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
328pub struct LoopDecl {
329    /// The loop name (e.g. `"Rooms"`).
330    pub loop_name: String,
331    /// The A1 range the loop iterates over.
332    pub loop_range: String,
333    /// The header row reference.
334    pub header_row: String,
335    /// The output column references.
336    pub output_cols: Vec<String>,
337    /// The 1-based first iteration row.
338    pub start_row: u32,
339    /// The 1-based last iteration row.
340    pub end_row: u32,
341}
342
343/// One row of the BA-owned governed-data table (Phase 10 Plan 02, D-03): a
344/// constant the BA has authorised, identified by a stable `key`, carrying a TYPED
345/// [`CellValue`] (NOT a bare `f64`) + effective-date + approval provenance.
346///
347/// Derive note: drops `Eq` because [`CellValue`] carries an `f64`.
348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
349pub struct GovernedDatum {
350    /// The stable key identifying the constant (e.g. a `const_*` named range or a
351    /// fully-qualified `sheet!addr` cell key).
352    pub key: String,
353    /// The TYPED governed value (money/text/bool/empty — NOT a bare `f64`).
354    pub value: CellValue,
355    /// The date this governed value became effective (ISO-8601 string).
356    pub effective_date: Option<String>,
357    /// Who approved this governed value, when recorded (D-03).
358    pub approved_by: Option<String>,
359    /// Free-form provenance (e.g. a BA-doc citation) for the audit trail.
360    pub provenance: Option<String>,
361}
362
363/// One declared output/cell annotation (D-18): a neutral, additive note binding
364/// a human-readable `meaning` to a `target` (a cell key or output name).
365///
366/// Annotations are PURELY descriptive metadata the served tools may surface; they
367/// carry no integrity or routing semantics. The field is additive and
368/// `#[serde(default)]` on [`Manifest`], so older manifests without an
369/// `annotations` key deserialize unchanged (the [`CellRole::allowed_values`]
370/// additive-serde precedent).
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
372pub struct AnnotationDecl {
373    /// The annotation name (a stable label).
374    pub name: String,
375    /// The annotation target — a cell key (`sheet!addr`) or an output name.
376    pub target: String,
377    /// The human-readable meaning this annotation conveys.
378    pub meaning: String,
379}
380
381/// The logical manifest — the source of truth for cell roles + metadata that
382/// REPLACES colour as canonical (DIA-03). Synthesis builds a CANDIDATE
383/// (`ratified = false`); BA ratification (Plan 05) makes it conformant.
384///
385/// Derive note: `Eq` is relaxed to `PartialEq`-only because the
386/// [`Manifest::governed_data`] table carries a [`CellValue`] (`f64`-bearing).
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
388pub struct Manifest {
389    /// The manifest schema version.
390    pub schema_version: u32,
391    /// The workflow name this manifest describes.
392    pub workflow: String,
393    /// The source workbook content hash (when stamped; round-trip is Plan 05).
394    pub workbook_hash: Option<String>,
395    /// `false` for a synthesized CANDIDATE (D-06); `true` only after BA
396    /// ratification (Plan 05). Roles are canonical only when ratified.
397    pub ratified: bool,
398    /// Who ratified the manifest (when ratified).
399    pub ratified_by: Option<String>,
400    /// When the manifest was ratified (ISO-8601 string; when ratified).
401    pub ratified_at: Option<String>,
402    /// The per-cell roles table.
403    pub cells: Vec<CellRole>,
404    /// The declared loop block — `None` until a confirmed `Rooms` named range is
405    /// read (D-10; synthesis only hints).
406    pub loop_block: Option<LoopDecl>,
407    /// The BA-owned governed-data table (Phase 10 Plan 02, D-03): the SOLE route
408    /// by which the reconciliation classifier may change a constant. Default
409    /// empty; each entry carries a typed [`CellValue`] value + provenance.
410    #[serde(default)]
411    pub governed_data: Vec<GovernedDatum>,
412    /// The manifest changelog (ART-02): version/workbook-hash/note entries.
413    #[serde(default)]
414    pub changelog: Vec<ChangelogEntry>,
415    /// Declared capability calls (ART-02 — DECLARE-ONLY seam).
416    #[serde(default)]
417    pub capability_calls: Vec<CapabilityDecl>,
418    /// Additive output/cell annotations (D-18): purely descriptive metadata the
419    /// served tools may surface. `#[serde(default)]` so old manifests without the
420    /// key deserialize to an empty Vec; `skip_serializing_if` keeps existing
421    /// `manifest.json` snapshots byte-stable when empty (the `allowed_values`
422    /// additive-serde precedent).
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub annotations: Vec<AnnotationDecl>,
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn role_has_exactly_the_four_variants() {
433        let all = [Role::Input, Role::Constant, Role::Output, Role::Formula];
434        for r in all {
435            match r {
436                Role::Input | Role::Constant | Role::Output | Role::Formula => {},
437            }
438        }
439        assert_eq!(all.len(), 4, "Role must have exactly four variants");
440    }
441
442    #[test]
443    fn from_name_prefix_maps_the_three_role_prefixes() {
444        assert_eq!(Role::from_name_prefix("in_total_area"), Some(Role::Input));
445        assert_eq!(Role::from_name_prefix("const_margin"), Some(Role::Constant));
446        assert_eq!(Role::from_name_prefix("out_first_fix"), Some(Role::Output));
447        assert_eq!(Role::from_name_prefix("Rooms"), None);
448        assert_eq!(Role::from_name_prefix("unprefixed"), None);
449    }
450
451    #[test]
452    fn manifest_round_trips_through_serde_json() {
453        let manifest = Manifest {
454            schema_version: 1,
455            workflow: "ufh-quote".to_string(),
456            workbook_hash: Some("abc123".to_string()),
457            ratified: false,
458            ratified_by: None,
459            ratified_at: None,
460            cells: vec![
461                CellRole {
462                    cell: "1_Inputs!E6".to_string(),
463                    role: Role::Input,
464                    name: Some("in_total_area".to_string()),
465                    unit: Some("m2".to_string()),
466                    meaning: Some("Total floor area".to_string()),
467                    dtype: Dtype::Number,
468                    colour_evidence: Some("FF0000FF".to_string()),
469                    source: "colour+guide".to_string(),
470                    notes: None,
471                    tier: None,
472                    allowed_values: None,
473                },
474                CellRole {
475                    cell: "2_Constants!B2".to_string(),
476                    role: Role::Constant,
477                    name: None,
478                    unit: None,
479                    meaning: None,
480                    dtype: Dtype::Number,
481                    colour_evidence: Some("FFFFFF00".to_string()),
482                    source: "yellow-assumption".to_string(),
483                    notes: Some("BA assumption".to_string()),
484                    tier: None,
485                    allowed_values: None,
486                },
487            ],
488            loop_block: None,
489            governed_data: vec![
490                GovernedDatum {
491                    key: "const_coil_divisor".to_string(),
492                    value: CellValue::Number(100.0),
493                    effective_date: Some("2026-06-06".to_string()),
494                    approved_by: Some("BA".to_string()),
495                    provenance: Some("design §11.1".to_string()),
496                },
497                GovernedDatum {
498                    key: "const_pipe_family".to_string(),
499                    value: CellValue::Text("16mm".to_string()),
500                    effective_date: None,
501                    approved_by: None,
502                    provenance: None,
503                },
504            ],
505            changelog: vec![],
506            capability_calls: vec![],
507            annotations: vec![],
508        };
509
510        let json = serde_json::to_string(&manifest).expect("serialize Manifest");
511        let back: Manifest = serde_json::from_str(&json).expect("deserialize Manifest");
512        assert_eq!(manifest, back, "Manifest must serde round-trip to equality");
513    }
514
515    #[test]
516    fn governed_data_table_round_trips_a_non_numeric_typed_value() {
517        let manifest = Manifest {
518            schema_version: 1,
519            workflow: "ufh-quote".to_string(),
520            workbook_hash: None,
521            ratified: true,
522            ratified_by: Some("BA".to_string()),
523            ratified_at: Some("2026-06-06".to_string()),
524            cells: vec![],
525            loop_block: None,
526            governed_data: vec![GovernedDatum {
527                key: "const_install_enabled".to_string(),
528                value: CellValue::Bool(true),
529                effective_date: Some("2026-06-06".to_string()),
530                approved_by: Some("BA".to_string()),
531                provenance: Some("BA-doc §4".to_string()),
532            }],
533            changelog: vec![],
534            capability_calls: vec![],
535            annotations: vec![],
536        };
537        let json = serde_json::to_string(&manifest).expect("serialize Manifest");
538        let back: Manifest = serde_json::from_str(&json).expect("deserialize Manifest");
539        assert_eq!(manifest, back);
540        assert_eq!(back.governed_data[0].value, CellValue::Bool(true));
541    }
542
543    #[test]
544    fn governed_data_defaults_to_empty_when_absent_from_json() {
545        let json = r#"{
546            "schema_version": 1,
547            "workflow": "ufh-quote",
548            "workbook_hash": null,
549            "ratified": false,
550            "ratified_by": null,
551            "ratified_at": null,
552            "cells": [],
553            "loop_block": null
554        }"#;
555        let m: Manifest = serde_json::from_str(json).expect("deserialize without governed_data");
556        assert!(m.governed_data.is_empty());
557    }
558
559    #[test]
560    fn yellow_assumption_is_a_constant_with_source() {
561        let cell = CellRole {
562            cell: "2_Constants!B2".to_string(),
563            role: Role::Constant,
564            name: None,
565            unit: None,
566            meaning: None,
567            dtype: Dtype::Number,
568            colour_evidence: Some("FFFFFF00".to_string()),
569            source: "yellow-assumption".to_string(),
570            notes: None,
571            tier: None,
572            allowed_values: None,
573        };
574        assert_eq!(cell.role, Role::Constant);
575        assert_eq!(cell.source, "yellow-assumption");
576    }
577
578    #[test]
579    fn schema_for_manifest_produces_a_schema_without_panic() {
580        let schema = schemars::schema_for!(Manifest);
581        let json = serde_json::to_value(&schema).expect("schema serializes");
582        assert_eq!(json["title"], "Manifest");
583    }
584
585    fn role_with_tier(role: Role, tier: Option<InputTier>) -> CellRole {
586        CellRole {
587            cell: "1_Inputs!E6".to_string(),
588            role,
589            name: None,
590            unit: None,
591            meaning: None,
592            dtype: Dtype::Number,
593            colour_evidence: None,
594            source: "test".to_string(),
595            notes: None,
596            tier,
597            allowed_values: None,
598        }
599    }
600
601    #[test]
602    fn tier_defaults_to_none_when_absent() {
603        let json = r#"{
604            "cell": "1_Inputs!E6",
605            "role": "input",
606            "name": null,
607            "unit": null,
608            "meaning": null,
609            "dtype": "number",
610            "colour_evidence": null,
611            "source": "test",
612            "notes": null
613        }"#;
614        let r: CellRole = serde_json::from_str(json).expect("deserialize without tier");
615        assert_eq!(r.tier, None, "absent tier must default to None");
616    }
617
618    #[test]
619    fn variable_tier_round_trips() {
620        let r = role_with_tier(
621            Role::Input,
622            Some(InputTier::Variable {
623                default: CellValue::Number(0.37),
624            }),
625        );
626        let json = serde_json::to_string(&r).expect("serialize CellRole with Variable tier");
627        let back: CellRole = serde_json::from_str(&json).expect("deserialize");
628        assert_eq!(r, back, "Variable-tier CellRole must serde round-trip");
629    }
630
631    #[test]
632    fn bounded_variable_carries_unenforced_range() {
633        let r = role_with_tier(
634            Role::Input,
635            Some(InputTier::BoundedVariable {
636                default: CellValue::Number(0.2),
637                min: CellValue::Number(0.1),
638                max: CellValue::Number(0.3),
639            }),
640        );
641        let json = serde_json::to_string(&r).expect("serialize BoundedVariable tier");
642        let back: CellRole = serde_json::from_str(&json).expect("deserialize");
643        assert_eq!(
644            r, back,
645            "BoundedVariable carries min/max through round-trip"
646        );
647        match back.tier {
648            Some(InputTier::BoundedVariable { min, max, .. }) => {
649                assert_eq!(min, CellValue::Number(0.1));
650                assert_eq!(max, CellValue::Number(0.3));
651            },
652            other => panic!("expected BoundedVariable, got {other:?}"),
653        }
654    }
655
656    #[test]
657    fn allowed_values_defaults_to_none_when_absent() {
658        // A manifest JSON serialized BEFORE the allowed_values field existed
659        // must still deserialize (serde default → None).
660        let json = r#"{
661            "cell": "1_Inputs!C6",
662            "role": "input",
663            "name": null,
664            "unit": null,
665            "meaning": null,
666            "dtype": "text",
667            "colour_evidence": null,
668            "source": "test",
669            "notes": null
670        }"#;
671        let r: CellRole = serde_json::from_str(json).expect("deserialize without allowed_values");
672        assert_eq!(
673            r.allowed_values, None,
674            "absent allowed_values must default to None"
675        );
676    }
677
678    #[test]
679    fn allowed_values_round_trips_when_some() {
680        let mut r = role_with_tier(Role::Input, None);
681        r.allowed_values = Some(vec!["heat_pump".to_string(), "boiler".to_string()]);
682        let json = serde_json::to_string(&r).expect("serialize CellRole with allowed_values");
683        let back: CellRole = serde_json::from_str(&json).expect("deserialize");
684        assert_eq!(
685            r, back,
686            "Some(allowed_values) CellRole must serde round-trip to equality"
687        );
688        assert_eq!(
689            back.allowed_values,
690            Some(vec!["heat_pump".to_string(), "boiler".to_string()]),
691            "workbook order is preserved through the round-trip"
692        );
693    }
694
695    #[test]
696    fn allowed_values_is_skipped_from_json_when_none() {
697        // skip_serializing_if keeps existing manifest.json snapshots byte-stable:
698        // a None allowed_values must NOT appear as a key at all.
699        let r = role_with_tier(Role::Input, None);
700        let v = serde_json::to_value(&r).expect("serialize CellRole");
701        assert!(
702            v.get("allowed_values").is_none(),
703            "None allowed_values must be skipped from serialization, got {v}"
704        );
705    }
706
707    #[test]
708    fn changelog_and_capability_calls_default_empty() {
709        let json = r#"{
710            "schema_version": 1,
711            "workflow": "ufh-quote",
712            "workbook_hash": null,
713            "ratified": false,
714            "ratified_by": null,
715            "ratified_at": null,
716            "cells": [],
717            "loop_block": null
718        }"#;
719        let m: Manifest =
720            serde_json::from_str(json).expect("deserialize without changelog/capability_calls");
721        assert!(m.changelog.is_empty(), "absent changelog defaults empty");
722        assert!(
723            m.capability_calls.is_empty(),
724            "absent capability_calls defaults empty"
725        );
726    }
727
728    #[test]
729    fn annotations_default_to_empty_when_absent_from_json() {
730        // A manifest JSON serialized BEFORE the annotations field existed must
731        // still deserialize (serde default → empty Vec). D-18 additive contract.
732        let json = r#"{
733            "schema_version": 1,
734            "workflow": "tax-calc",
735            "workbook_hash": null,
736            "ratified": false,
737            "ratified_by": null,
738            "ratified_at": null,
739            "cells": [],
740            "loop_block": null
741        }"#;
742        let m: Manifest = serde_json::from_str(json).expect("deserialize without annotations");
743        assert!(
744            m.annotations.is_empty(),
745            "absent annotations must default to an empty Vec"
746        );
747    }
748
749    #[test]
750    fn annotations_round_trip_to_equality_when_present() {
751        let mut m: Manifest = serde_json::from_str(
752            r#"{
753                "schema_version": 1,
754                "workflow": "tax-calc",
755                "workbook_hash": null,
756                "ratified": false,
757                "ratified_by": null,
758                "ratified_at": null,
759                "cells": [],
760                "loop_block": null
761            }"#,
762        )
763        .expect("base manifest");
764        m.annotations = vec![
765            AnnotationDecl {
766                name: "headline".to_string(),
767                target: "out_total".to_string(),
768                meaning: "The total payable amount".to_string(),
769            },
770            AnnotationDecl {
771                name: "rate".to_string(),
772                target: "1_Inputs!E6".to_string(),
773                meaning: "The applied tax rate".to_string(),
774            },
775        ];
776        let json = serde_json::to_string(&m).expect("serialize Manifest with annotations");
777        let back: Manifest = serde_json::from_str(&json).expect("deserialize");
778        assert_eq!(m, back, "annotations must serde round-trip to equality");
779    }
780
781    #[test]
782    fn empty_annotations_are_skipped_from_serialization() {
783        // skip_serializing_if keeps existing manifest.json snapshots byte-stable:
784        // an empty annotations Vec must NOT appear as a key at all.
785        let m: Manifest = serde_json::from_str(
786            r#"{
787                "schema_version": 1,
788                "workflow": "tax-calc",
789                "workbook_hash": null,
790                "ratified": false,
791                "ratified_by": null,
792                "ratified_at": null,
793                "cells": [],
794                "loop_block": null
795            }"#,
796        )
797        .expect("base manifest");
798        let v = serde_json::to_value(&m).expect("serialize Manifest");
799        assert!(
800            v.get("annotations").is_none(),
801            "empty annotations must be skipped from serialization, got {v}"
802        );
803    }
804
805    #[test]
806    fn role_ontology_still_has_exactly_four() {
807        let all = [Role::Input, Role::Constant, Role::Output, Role::Formula];
808        for r in all {
809            match r {
810                Role::Input | Role::Constant | Role::Output | Role::Formula => {},
811            }
812        }
813        assert_eq!(all.len(), 4, "Role must still have exactly four variants");
814    }
815
816    #[test]
817    fn untiered_input_role_documented_not_strict() {
818        let untiered_input = role_with_tier(Role::Input, None);
819        let untiered_const = role_with_tier(Role::Constant, None);
820        assert!(
821            !is_strict_constant(&untiered_input),
822            "an untiered Role::Input must NOT be treated as a strict constant"
823        );
824        assert!(
825            is_strict_constant(&untiered_const),
826            "an untiered Role::Constant IS a strict constant (fails closed)"
827        );
828        let tiered_const = role_with_tier(
829            Role::Constant,
830            Some(InputTier::Variable {
831                default: CellValue::Number(1.0),
832            }),
833        );
834        assert!(
835            !is_strict_constant(&tiered_const),
836            "a Constant with an explicit tier is no longer strict"
837        );
838    }
839
840    // ---- F3: governance-prefix stripping on the served json_key ------------
841
842    fn named_role(role: Role, name: &str) -> CellRole {
843        let mut r = role_with_tier(role, None);
844        r.name = Some(name.to_string());
845        r
846    }
847
848    #[test]
849    fn json_key_strips_leading_in_prefix_from_name() {
850        let r = named_role(Role::Input, "in_gross_income");
851        assert_eq!(
852            json_key_for_role(&r),
853            "gross_income",
854            "the served input key must drop the in_ governance prefix"
855        );
856    }
857
858    #[test]
859    fn json_key_strips_leading_out_prefix_from_name() {
860        let r = named_role(Role::Output, "out_tax_owed");
861        assert_eq!(json_key_for_role(&r), "tax_owed");
862    }
863
864    #[test]
865    fn json_key_does_not_mutate_role_name() {
866        let r = named_role(Role::Input, "in_gross_income");
867        let _ = json_key_for_role(&r);
868        assert_eq!(
869            r.name.as_deref(),
870            Some("in_gross_income"),
871            "role.name must stay prefixed for governance/named-range matching"
872        );
873    }
874
875    #[test]
876    fn json_key_strips_only_a_single_prefix() {
877        // Only the FIRST governance prefix is removed.
878        let r = named_role(Role::Input, "in_in_x");
879        assert_eq!(json_key_for_role(&r), "in_x");
880    }
881
882    #[test]
883    fn json_key_leaves_unprefixed_name_untouched() {
884        let r = named_role(Role::Input, "loan_amount");
885        assert_eq!(json_key_for_role(&r), "loan_amount");
886        // A substring-but-not-prefix match must NOT be stripped.
887        let r2 = named_role(Role::Input, "margin_in_pct");
888        assert_eq!(json_key_for_role(&r2), "margin_in_pct");
889    }
890
891    #[test]
892    fn json_key_does_not_strip_prefix_only_name() {
893        // A name that is EXACTLY the prefix must not degenerate to "".
894        let r = named_role(Role::Input, "in_");
895        assert_eq!(json_key_for_role(&r), "in_");
896        let r2 = named_role(Role::Output, "out_");
897        assert_eq!(json_key_for_role(&r2), "out_");
898    }
899
900    #[test]
901    fn json_key_strip_does_not_apply_to_meaning_or_cell_fallback() {
902        // name absent → meaning verbatim (NOT stripped even if it looks prefixed).
903        let mut r = role_with_tier(Role::Input, None);
904        r.name = None;
905        r.meaning = Some("in_some_label".to_string());
906        assert_eq!(json_key_for_role(&r), "in_some_label");
907        // name + meaning absent → cell key verbatim.
908        let mut r2 = role_with_tier(Role::Output, None);
909        r2.name = None;
910        r2.meaning = None;
911        assert_eq!(json_key_for_role(&r2), "1_Inputs!E6");
912    }
913
914    #[test]
915    fn prop_strip_removes_at_most_one_prefix_and_is_loss_free() {
916        // PROPERTY (deterministic corpus): a SINGLE strip removes AT MOST one
917        // governance prefix (by design — the locked decision is "strip a single
918        // leading in_/out_"), never touches a non-prefixed name, and never yields
919        // an empty key from a non-empty input.
920        let corpus = [
921            "in_gross_income",
922            "out_tax_owed",
923            "in_in_x",
924            "loan_amount",
925            "margin_in_pct",
926            "in_",
927            "out_",
928            "x",
929            "in_a",
930            "outflow", // starts with "out" but not the "out_" prefix
931            "inflow",  // starts with "in" but not the "in_" prefix
932        ];
933        for raw in corpus {
934            let once = strip_governance_prefix(raw);
935            assert!(
936                !once.is_empty(),
937                "non-empty name {raw:?} must not strip to empty"
938            );
939            // A single strip removes 0 or 1 prefix: the result is either the input
940            // verbatim, or exactly the input with one in_/out_ prefix removed.
941            let removed_one = raw
942                .strip_prefix("in_")
943                .or_else(|| raw.strip_prefix("out_"))
944                .map_or(false, |rest| !rest.is_empty() && once == rest);
945            assert!(
946                once == raw || removed_one,
947                "strip removes at most one prefix for {raw:?} (got {once:?})"
948            );
949            if !raw.starts_with("in_") && !raw.starts_with("out_") {
950                assert_eq!(once, raw, "non-prefixed {raw:?} must be returned verbatim");
951            }
952        }
953    }
954
955    #[test]
956    fn prop_strip_is_idempotent_on_served_keys() {
957        // PROPERTY: on the keys callers actually see (single-prefixed or clean),
958        // stripping IS idempotent — re-stripping a served key is a no-op. This is
959        // the invariant the served-key path relies on (json_key_for_role applies
960        // the strip exactly once per role).
961        for served in [
962            "gross_income",
963            "tax_owed",
964            "loan_amount",
965            "x",
966            "in_", // prefix-only is preserved, so re-strip is a no-op
967        ] {
968            assert_eq!(
969                strip_governance_prefix(served),
970                served,
971                "an already-served key {served:?} must be a strip no-op"
972            );
973        }
974    }
975}