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`) — the SINGLE source
202/// of the reserved set (H3). An output-Table tool name that sanitizes to ANY of these
203/// would silently last-writer-wins over the meta tool at registration, so the offline
204/// compiler REJECTS it (a cell-precise compile failure) by checking against THIS
205/// const, not a hand-copied list. The served toolkit handlers' `NAME` constants
206/// (`ExplainHandler::NAME` etc.) are asserted EQUAL to these entries by a binding
207/// test in the toolkit, so the reserved set cannot drift from what is registered.
208///
209/// Lives in the runtime LEAF (not the toolkit) so the compiler reads it WITHOUT a
210/// compiler→toolkit dependency (which would breach the purity boundary / `make
211/// purity-check`); both the toolkit handlers and the compiler gate read the one const.
212pub const RESERVED_TOOL_NAMES: [&str; 4] =
213    ["explain", "get_manifest", "diff_version", "render_workbook"];
214
215/// Sanitize a raw output-Table name into an MCP tool name matching
216/// `^[a-zA-Z0-9_-]{1,64}$` (T-100-10). This is the SINGLE shared sanitizer — the
217/// served toolkit's registration AND the offline compiler's post-sanitize
218/// collision lint both call it, so "what we register" and "what we collision-check"
219/// cannot drift. The LOCKED five-rule semantics:
220///
221/// 1. **Lowercase** every ASCII letter (`Calculate_Tax` → `calculate_tax`).
222/// 2. **Collapse** each maximal RUN of illegal characters (anything not
223///    `[a-z0-9_-]` after lowercasing) to a SINGLE `_` (`"a  b"`/`"a@@b"` →
224///    `"a_b"`), never one `_` per illegal char.
225/// 3. **Trim** leading/trailing `_`/`-` (no governance-noise edges).
226/// 4. **Truncate** to 64 chars AFTER the above.
227/// 5. If the result is **empty** (the input was empty or all-illegal) return
228///    `Err` carrying the offending raw name — fail-closed.
229///
230/// # Errors
231/// Returns `Err(raw.to_string())` when the input has no character mappable to the
232/// charset (empty or all-illegal).
233pub fn sanitize_tool_name(raw: &str) -> Result<String, String> {
234    let mut out = String::with_capacity(raw.len());
235    let mut pending_underscore = false;
236    for ch in raw.chars() {
237        let lc = ch.to_ascii_lowercase();
238        if lc.is_ascii_alphanumeric() || lc == '_' || lc == '-' {
239            if pending_underscore && !out.is_empty() {
240                out.push('_');
241            }
242            pending_underscore = false;
243            out.push(lc);
244        } else {
245            pending_underscore = true;
246        }
247    }
248    let trimmed: String = out
249        .trim_matches(|c| c == '_' || c == '-')
250        .chars()
251        .take(64)
252        .collect();
253    let trimmed = trimmed.trim_matches(|c| c == '_' || c == '-').to_string();
254    if trimmed.is_empty() {
255        return Err(raw.to_string());
256    }
257    Ok(trimmed)
258}
259
260/// Whether a [`CellRole`] is a STRICT constant — a BA-only governed value that
261/// must be REJECTED if a caller tries to supply it as a `calculate` input
262/// (Codex HIGH #3). The rule keys on [`Role::Constant`] + `tier == None`, NOT on
263/// every untiered cell: an untiered [`Role::Input`] is a Variable candidate
264/// (mapped at ratification), never strict-rejected.
265pub fn is_strict_constant(role: &CellRole) -> bool {
266    matches!(role.role, Role::Constant) && role.tier.is_none()
267}
268
269/// Whether a [`CellRole`] is COMPUTED — derived by the bundle IR
270/// ([`Role::Output`] or [`Role::Formula`]) and therefore never caller-seedable
271/// (WR-02: seeding a computed cell would let a caller pin a served output under
272/// a valid provenance stamp). The SINGLE predicate shared by the served tools'
273/// override reject gate and their allowed-override list, so "what we reject"
274/// and "what we advertise as overridable" cannot drift.
275pub fn is_computed(role: &CellRole) -> bool {
276    matches!(role.role, Role::Output | Role::Formula)
277}
278
279/// Find the manifest [`CellRole`] whose fully-qualified `cell` key equals
280/// `cell_key` — the SINGLE exact-cell-key lookup shared by the served tools'
281/// schema builder, input validator, and explain trace, so the matching
282/// semantics cannot drift between them. (Lookups by `name`-or-`cell` are a
283/// DIFFERENT, looser predicate and stay separate.)
284pub fn role_for_cell<'a>(manifest: &'a Manifest, cell_key: &str) -> Option<&'a CellRole> {
285    manifest.cells.iter().find(|c| c.cell == cell_key)
286}
287
288/// One entry in the [`Manifest::changelog`] (ART-02): a version stamp recording a
289/// workbook-hash transition + a human note.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
291pub struct ChangelogEntry {
292    /// The manifest/workflow version this entry records.
293    pub version: String,
294    /// The source workbook content hash at this version.
295    pub workbook_hash: String,
296    /// A human-readable note describing the change.
297    pub note: String,
298}
299
300/// One declared capability call (ART-02 — DECLARE-ONLY seam). Phase 11 keeps
301/// capability cells OUT of scope (PROJECT.md); this only DECLARES the contract a
302/// future capability cell would honour.
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
304pub struct CapabilityDecl {
305    /// The cell key (`sheet!addr`) that would host the capability.
306    pub cell: String,
307    /// The capability kind (e.g. `"rust"`, `"remote"`, `"mcp-tool"`).
308    pub kind: String,
309    /// The declared contract the capability must honour (free-form for now).
310    pub declared_contract: String,
311}
312
313/// The declared loop block (the `Rooms` per-room iteration). Populated ONLY from a
314/// CONFIRMED `Rooms` named range (Plan 05's round-trip path; D-10).
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
316pub struct LoopDecl {
317    /// The loop name (e.g. `"Rooms"`).
318    pub loop_name: String,
319    /// The A1 range the loop iterates over.
320    pub loop_range: String,
321    /// The header row reference.
322    pub header_row: String,
323    /// The output column references.
324    pub output_cols: Vec<String>,
325    /// The 1-based first iteration row.
326    pub start_row: u32,
327    /// The 1-based last iteration row.
328    pub end_row: u32,
329}
330
331/// One row of the BA-owned governed-data table (Phase 10 Plan 02, D-03): a
332/// constant the BA has authorised, identified by a stable `key`, carrying a TYPED
333/// [`CellValue`] (NOT a bare `f64`) + effective-date + approval provenance.
334///
335/// Derive note: drops `Eq` because [`CellValue`] carries an `f64`.
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
337pub struct GovernedDatum {
338    /// The stable key identifying the constant (e.g. a `const_*` named range or a
339    /// fully-qualified `sheet!addr` cell key).
340    pub key: String,
341    /// The TYPED governed value (money/text/bool/empty — NOT a bare `f64`).
342    pub value: CellValue,
343    /// The date this governed value became effective (ISO-8601 string).
344    pub effective_date: Option<String>,
345    /// Who approved this governed value, when recorded (D-03).
346    pub approved_by: Option<String>,
347    /// Free-form provenance (e.g. a BA-doc citation) for the audit trail.
348    pub provenance: Option<String>,
349}
350
351/// One declared output/cell annotation (D-18): a neutral, additive note binding
352/// a human-readable `meaning` to a `target` (a cell key or output name).
353///
354/// Annotations are PURELY descriptive metadata the served tools may surface; they
355/// carry no integrity or routing semantics. The field is additive and
356/// `#[serde(default)]` on [`Manifest`], so older manifests without an
357/// `annotations` key deserialize unchanged (the [`CellRole::allowed_values`]
358/// additive-serde precedent).
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
360pub struct AnnotationDecl {
361    /// The annotation name (a stable label).
362    pub name: String,
363    /// The annotation target — a cell key (`sheet!addr`) or an output name.
364    pub target: String,
365    /// The human-readable meaning this annotation conveys.
366    pub meaning: String,
367}
368
369/// The logical manifest — the source of truth for cell roles + metadata that
370/// REPLACES colour as canonical (DIA-03). Synthesis builds a CANDIDATE
371/// (`ratified = false`); BA ratification (Plan 05) makes it conformant.
372///
373/// Derive note: `Eq` is relaxed to `PartialEq`-only because the
374/// [`Manifest::governed_data`] table carries a [`CellValue`] (`f64`-bearing).
375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
376pub struct Manifest {
377    /// The manifest schema version.
378    pub schema_version: u32,
379    /// The workflow name this manifest describes.
380    pub workflow: String,
381    /// The source workbook content hash (when stamped; round-trip is Plan 05).
382    pub workbook_hash: Option<String>,
383    /// `false` for a synthesized CANDIDATE (D-06); `true` only after BA
384    /// ratification (Plan 05). Roles are canonical only when ratified.
385    pub ratified: bool,
386    /// Who ratified the manifest (when ratified).
387    pub ratified_by: Option<String>,
388    /// When the manifest was ratified (ISO-8601 string; when ratified).
389    pub ratified_at: Option<String>,
390    /// The per-cell roles table.
391    pub cells: Vec<CellRole>,
392    /// The declared loop block — `None` until a confirmed `Rooms` named range is
393    /// read (D-10; synthesis only hints).
394    pub loop_block: Option<LoopDecl>,
395    /// The BA-owned governed-data table (Phase 10 Plan 02, D-03): the SOLE route
396    /// by which the reconciliation classifier may change a constant. Default
397    /// empty; each entry carries a typed [`CellValue`] value + provenance.
398    #[serde(default)]
399    pub governed_data: Vec<GovernedDatum>,
400    /// The manifest changelog (ART-02): version/workbook-hash/note entries.
401    #[serde(default)]
402    pub changelog: Vec<ChangelogEntry>,
403    /// Declared capability calls (ART-02 — DECLARE-ONLY seam).
404    #[serde(default)]
405    pub capability_calls: Vec<CapabilityDecl>,
406    /// Additive output/cell annotations (D-18): purely descriptive metadata the
407    /// served tools may surface. `#[serde(default)]` so old manifests without the
408    /// key deserialize to an empty Vec; `skip_serializing_if` keeps existing
409    /// `manifest.json` snapshots byte-stable when empty (the `allowed_values`
410    /// additive-serde precedent).
411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
412    pub annotations: Vec<AnnotationDecl>,
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn role_has_exactly_the_four_variants() {
421        let all = [Role::Input, Role::Constant, Role::Output, Role::Formula];
422        for r in all {
423            match r {
424                Role::Input | Role::Constant | Role::Output | Role::Formula => {},
425            }
426        }
427        assert_eq!(all.len(), 4, "Role must have exactly four variants");
428    }
429
430    #[test]
431    fn from_name_prefix_maps_the_three_role_prefixes() {
432        assert_eq!(Role::from_name_prefix("in_total_area"), Some(Role::Input));
433        assert_eq!(Role::from_name_prefix("const_margin"), Some(Role::Constant));
434        assert_eq!(Role::from_name_prefix("out_first_fix"), Some(Role::Output));
435        assert_eq!(Role::from_name_prefix("Rooms"), None);
436        assert_eq!(Role::from_name_prefix("unprefixed"), None);
437    }
438
439    #[test]
440    fn manifest_round_trips_through_serde_json() {
441        let manifest = Manifest {
442            schema_version: 1,
443            workflow: "ufh-quote".to_string(),
444            workbook_hash: Some("abc123".to_string()),
445            ratified: false,
446            ratified_by: None,
447            ratified_at: None,
448            cells: vec![
449                CellRole {
450                    cell: "1_Inputs!E6".to_string(),
451                    role: Role::Input,
452                    name: Some("in_total_area".to_string()),
453                    unit: Some("m2".to_string()),
454                    meaning: Some("Total floor area".to_string()),
455                    dtype: Dtype::Number,
456                    colour_evidence: Some("FF0000FF".to_string()),
457                    source: "colour+guide".to_string(),
458                    notes: None,
459                    tier: None,
460                    allowed_values: None,
461                },
462                CellRole {
463                    cell: "2_Constants!B2".to_string(),
464                    role: Role::Constant,
465                    name: None,
466                    unit: None,
467                    meaning: None,
468                    dtype: Dtype::Number,
469                    colour_evidence: Some("FFFFFF00".to_string()),
470                    source: "yellow-assumption".to_string(),
471                    notes: Some("BA assumption".to_string()),
472                    tier: None,
473                    allowed_values: None,
474                },
475            ],
476            loop_block: None,
477            governed_data: vec![
478                GovernedDatum {
479                    key: "const_coil_divisor".to_string(),
480                    value: CellValue::Number(100.0),
481                    effective_date: Some("2026-06-06".to_string()),
482                    approved_by: Some("BA".to_string()),
483                    provenance: Some("design §11.1".to_string()),
484                },
485                GovernedDatum {
486                    key: "const_pipe_family".to_string(),
487                    value: CellValue::Text("16mm".to_string()),
488                    effective_date: None,
489                    approved_by: None,
490                    provenance: None,
491                },
492            ],
493            changelog: vec![],
494            capability_calls: vec![],
495            annotations: vec![],
496        };
497
498        let json = serde_json::to_string(&manifest).expect("serialize Manifest");
499        let back: Manifest = serde_json::from_str(&json).expect("deserialize Manifest");
500        assert_eq!(manifest, back, "Manifest must serde round-trip to equality");
501    }
502
503    #[test]
504    fn governed_data_table_round_trips_a_non_numeric_typed_value() {
505        let manifest = Manifest {
506            schema_version: 1,
507            workflow: "ufh-quote".to_string(),
508            workbook_hash: None,
509            ratified: true,
510            ratified_by: Some("BA".to_string()),
511            ratified_at: Some("2026-06-06".to_string()),
512            cells: vec![],
513            loop_block: None,
514            governed_data: vec![GovernedDatum {
515                key: "const_install_enabled".to_string(),
516                value: CellValue::Bool(true),
517                effective_date: Some("2026-06-06".to_string()),
518                approved_by: Some("BA".to_string()),
519                provenance: Some("BA-doc §4".to_string()),
520            }],
521            changelog: vec![],
522            capability_calls: vec![],
523            annotations: vec![],
524        };
525        let json = serde_json::to_string(&manifest).expect("serialize Manifest");
526        let back: Manifest = serde_json::from_str(&json).expect("deserialize Manifest");
527        assert_eq!(manifest, back);
528        assert_eq!(back.governed_data[0].value, CellValue::Bool(true));
529    }
530
531    #[test]
532    fn governed_data_defaults_to_empty_when_absent_from_json() {
533        let json = r#"{
534            "schema_version": 1,
535            "workflow": "ufh-quote",
536            "workbook_hash": null,
537            "ratified": false,
538            "ratified_by": null,
539            "ratified_at": null,
540            "cells": [],
541            "loop_block": null
542        }"#;
543        let m: Manifest = serde_json::from_str(json).expect("deserialize without governed_data");
544        assert!(m.governed_data.is_empty());
545    }
546
547    #[test]
548    fn yellow_assumption_is_a_constant_with_source() {
549        let cell = CellRole {
550            cell: "2_Constants!B2".to_string(),
551            role: Role::Constant,
552            name: None,
553            unit: None,
554            meaning: None,
555            dtype: Dtype::Number,
556            colour_evidence: Some("FFFFFF00".to_string()),
557            source: "yellow-assumption".to_string(),
558            notes: None,
559            tier: None,
560            allowed_values: None,
561        };
562        assert_eq!(cell.role, Role::Constant);
563        assert_eq!(cell.source, "yellow-assumption");
564    }
565
566    #[test]
567    fn schema_for_manifest_produces_a_schema_without_panic() {
568        let schema = schemars::schema_for!(Manifest);
569        let json = serde_json::to_value(&schema).expect("schema serializes");
570        assert_eq!(json["title"], "Manifest");
571    }
572
573    fn role_with_tier(role: Role, tier: Option<InputTier>) -> CellRole {
574        CellRole {
575            cell: "1_Inputs!E6".to_string(),
576            role,
577            name: None,
578            unit: None,
579            meaning: None,
580            dtype: Dtype::Number,
581            colour_evidence: None,
582            source: "test".to_string(),
583            notes: None,
584            tier,
585            allowed_values: None,
586        }
587    }
588
589    #[test]
590    fn tier_defaults_to_none_when_absent() {
591        let json = r#"{
592            "cell": "1_Inputs!E6",
593            "role": "input",
594            "name": null,
595            "unit": null,
596            "meaning": null,
597            "dtype": "number",
598            "colour_evidence": null,
599            "source": "test",
600            "notes": null
601        }"#;
602        let r: CellRole = serde_json::from_str(json).expect("deserialize without tier");
603        assert_eq!(r.tier, None, "absent tier must default to None");
604    }
605
606    #[test]
607    fn variable_tier_round_trips() {
608        let r = role_with_tier(
609            Role::Input,
610            Some(InputTier::Variable {
611                default: CellValue::Number(0.37),
612            }),
613        );
614        let json = serde_json::to_string(&r).expect("serialize CellRole with Variable tier");
615        let back: CellRole = serde_json::from_str(&json).expect("deserialize");
616        assert_eq!(r, back, "Variable-tier CellRole must serde round-trip");
617    }
618
619    #[test]
620    fn bounded_variable_carries_unenforced_range() {
621        let r = role_with_tier(
622            Role::Input,
623            Some(InputTier::BoundedVariable {
624                default: CellValue::Number(0.2),
625                min: CellValue::Number(0.1),
626                max: CellValue::Number(0.3),
627            }),
628        );
629        let json = serde_json::to_string(&r).expect("serialize BoundedVariable tier");
630        let back: CellRole = serde_json::from_str(&json).expect("deserialize");
631        assert_eq!(
632            r, back,
633            "BoundedVariable carries min/max through round-trip"
634        );
635        match back.tier {
636            Some(InputTier::BoundedVariable { min, max, .. }) => {
637                assert_eq!(min, CellValue::Number(0.1));
638                assert_eq!(max, CellValue::Number(0.3));
639            },
640            other => panic!("expected BoundedVariable, got {other:?}"),
641        }
642    }
643
644    #[test]
645    fn allowed_values_defaults_to_none_when_absent() {
646        // A manifest JSON serialized BEFORE the allowed_values field existed
647        // must still deserialize (serde default → None).
648        let json = r#"{
649            "cell": "1_Inputs!C6",
650            "role": "input",
651            "name": null,
652            "unit": null,
653            "meaning": null,
654            "dtype": "text",
655            "colour_evidence": null,
656            "source": "test",
657            "notes": null
658        }"#;
659        let r: CellRole = serde_json::from_str(json).expect("deserialize without allowed_values");
660        assert_eq!(
661            r.allowed_values, None,
662            "absent allowed_values must default to None"
663        );
664    }
665
666    #[test]
667    fn allowed_values_round_trips_when_some() {
668        let mut r = role_with_tier(Role::Input, None);
669        r.allowed_values = Some(vec!["heat_pump".to_string(), "boiler".to_string()]);
670        let json = serde_json::to_string(&r).expect("serialize CellRole with allowed_values");
671        let back: CellRole = serde_json::from_str(&json).expect("deserialize");
672        assert_eq!(
673            r, back,
674            "Some(allowed_values) CellRole must serde round-trip to equality"
675        );
676        assert_eq!(
677            back.allowed_values,
678            Some(vec!["heat_pump".to_string(), "boiler".to_string()]),
679            "workbook order is preserved through the round-trip"
680        );
681    }
682
683    #[test]
684    fn allowed_values_is_skipped_from_json_when_none() {
685        // skip_serializing_if keeps existing manifest.json snapshots byte-stable:
686        // a None allowed_values must NOT appear as a key at all.
687        let r = role_with_tier(Role::Input, None);
688        let v = serde_json::to_value(&r).expect("serialize CellRole");
689        assert!(
690            v.get("allowed_values").is_none(),
691            "None allowed_values must be skipped from serialization, got {v}"
692        );
693    }
694
695    #[test]
696    fn changelog_and_capability_calls_default_empty() {
697        let json = r#"{
698            "schema_version": 1,
699            "workflow": "ufh-quote",
700            "workbook_hash": null,
701            "ratified": false,
702            "ratified_by": null,
703            "ratified_at": null,
704            "cells": [],
705            "loop_block": null
706        }"#;
707        let m: Manifest =
708            serde_json::from_str(json).expect("deserialize without changelog/capability_calls");
709        assert!(m.changelog.is_empty(), "absent changelog defaults empty");
710        assert!(
711            m.capability_calls.is_empty(),
712            "absent capability_calls defaults empty"
713        );
714    }
715
716    #[test]
717    fn annotations_default_to_empty_when_absent_from_json() {
718        // A manifest JSON serialized BEFORE the annotations field existed must
719        // still deserialize (serde default → empty Vec). D-18 additive contract.
720        let json = r#"{
721            "schema_version": 1,
722            "workflow": "tax-calc",
723            "workbook_hash": null,
724            "ratified": false,
725            "ratified_by": null,
726            "ratified_at": null,
727            "cells": [],
728            "loop_block": null
729        }"#;
730        let m: Manifest = serde_json::from_str(json).expect("deserialize without annotations");
731        assert!(
732            m.annotations.is_empty(),
733            "absent annotations must default to an empty Vec"
734        );
735    }
736
737    #[test]
738    fn annotations_round_trip_to_equality_when_present() {
739        let mut m: Manifest = serde_json::from_str(
740            r#"{
741                "schema_version": 1,
742                "workflow": "tax-calc",
743                "workbook_hash": null,
744                "ratified": false,
745                "ratified_by": null,
746                "ratified_at": null,
747                "cells": [],
748                "loop_block": null
749            }"#,
750        )
751        .expect("base manifest");
752        m.annotations = vec![
753            AnnotationDecl {
754                name: "headline".to_string(),
755                target: "out_total".to_string(),
756                meaning: "The total payable amount".to_string(),
757            },
758            AnnotationDecl {
759                name: "rate".to_string(),
760                target: "1_Inputs!E6".to_string(),
761                meaning: "The applied tax rate".to_string(),
762            },
763        ];
764        let json = serde_json::to_string(&m).expect("serialize Manifest with annotations");
765        let back: Manifest = serde_json::from_str(&json).expect("deserialize");
766        assert_eq!(m, back, "annotations must serde round-trip to equality");
767    }
768
769    #[test]
770    fn empty_annotations_are_skipped_from_serialization() {
771        // skip_serializing_if keeps existing manifest.json snapshots byte-stable:
772        // an empty annotations Vec must NOT appear as a key at all.
773        let m: Manifest = serde_json::from_str(
774            r#"{
775                "schema_version": 1,
776                "workflow": "tax-calc",
777                "workbook_hash": null,
778                "ratified": false,
779                "ratified_by": null,
780                "ratified_at": null,
781                "cells": [],
782                "loop_block": null
783            }"#,
784        )
785        .expect("base manifest");
786        let v = serde_json::to_value(&m).expect("serialize Manifest");
787        assert!(
788            v.get("annotations").is_none(),
789            "empty annotations must be skipped from serialization, got {v}"
790        );
791    }
792
793    #[test]
794    fn role_ontology_still_has_exactly_four() {
795        let all = [Role::Input, Role::Constant, Role::Output, Role::Formula];
796        for r in all {
797            match r {
798                Role::Input | Role::Constant | Role::Output | Role::Formula => {},
799            }
800        }
801        assert_eq!(all.len(), 4, "Role must still have exactly four variants");
802    }
803
804    #[test]
805    fn untiered_input_role_documented_not_strict() {
806        let untiered_input = role_with_tier(Role::Input, None);
807        let untiered_const = role_with_tier(Role::Constant, None);
808        assert!(
809            !is_strict_constant(&untiered_input),
810            "an untiered Role::Input must NOT be treated as a strict constant"
811        );
812        assert!(
813            is_strict_constant(&untiered_const),
814            "an untiered Role::Constant IS a strict constant (fails closed)"
815        );
816        let tiered_const = role_with_tier(
817            Role::Constant,
818            Some(InputTier::Variable {
819                default: CellValue::Number(1.0),
820            }),
821        );
822        assert!(
823            !is_strict_constant(&tiered_const),
824            "a Constant with an explicit tier is no longer strict"
825        );
826    }
827
828    // ---- F3: governance-prefix stripping on the served json_key ------------
829
830    fn named_role(role: Role, name: &str) -> CellRole {
831        let mut r = role_with_tier(role, None);
832        r.name = Some(name.to_string());
833        r
834    }
835
836    #[test]
837    fn json_key_strips_leading_in_prefix_from_name() {
838        let r = named_role(Role::Input, "in_gross_income");
839        assert_eq!(
840            json_key_for_role(&r),
841            "gross_income",
842            "the served input key must drop the in_ governance prefix"
843        );
844    }
845
846    #[test]
847    fn json_key_strips_leading_out_prefix_from_name() {
848        let r = named_role(Role::Output, "out_tax_owed");
849        assert_eq!(json_key_for_role(&r), "tax_owed");
850    }
851
852    #[test]
853    fn json_key_does_not_mutate_role_name() {
854        let r = named_role(Role::Input, "in_gross_income");
855        let _ = json_key_for_role(&r);
856        assert_eq!(
857            r.name.as_deref(),
858            Some("in_gross_income"),
859            "role.name must stay prefixed for governance/named-range matching"
860        );
861    }
862
863    #[test]
864    fn json_key_strips_only_a_single_prefix() {
865        // Only the FIRST governance prefix is removed.
866        let r = named_role(Role::Input, "in_in_x");
867        assert_eq!(json_key_for_role(&r), "in_x");
868    }
869
870    #[test]
871    fn json_key_leaves_unprefixed_name_untouched() {
872        let r = named_role(Role::Input, "loan_amount");
873        assert_eq!(json_key_for_role(&r), "loan_amount");
874        // A substring-but-not-prefix match must NOT be stripped.
875        let r2 = named_role(Role::Input, "margin_in_pct");
876        assert_eq!(json_key_for_role(&r2), "margin_in_pct");
877    }
878
879    #[test]
880    fn json_key_does_not_strip_prefix_only_name() {
881        // A name that is EXACTLY the prefix must not degenerate to "".
882        let r = named_role(Role::Input, "in_");
883        assert_eq!(json_key_for_role(&r), "in_");
884        let r2 = named_role(Role::Output, "out_");
885        assert_eq!(json_key_for_role(&r2), "out_");
886    }
887
888    #[test]
889    fn json_key_strip_does_not_apply_to_meaning_or_cell_fallback() {
890        // name absent → meaning verbatim (NOT stripped even if it looks prefixed).
891        let mut r = role_with_tier(Role::Input, None);
892        r.name = None;
893        r.meaning = Some("in_some_label".to_string());
894        assert_eq!(json_key_for_role(&r), "in_some_label");
895        // name + meaning absent → cell key verbatim.
896        let mut r2 = role_with_tier(Role::Output, None);
897        r2.name = None;
898        r2.meaning = None;
899        assert_eq!(json_key_for_role(&r2), "1_Inputs!E6");
900    }
901
902    #[test]
903    fn prop_strip_removes_at_most_one_prefix_and_is_loss_free() {
904        // PROPERTY (deterministic corpus): a SINGLE strip removes AT MOST one
905        // governance prefix (by design — the locked decision is "strip a single
906        // leading in_/out_"), never touches a non-prefixed name, and never yields
907        // an empty key from a non-empty input.
908        let corpus = [
909            "in_gross_income",
910            "out_tax_owed",
911            "in_in_x",
912            "loan_amount",
913            "margin_in_pct",
914            "in_",
915            "out_",
916            "x",
917            "in_a",
918            "outflow", // starts with "out" but not the "out_" prefix
919            "inflow",  // starts with "in" but not the "in_" prefix
920        ];
921        for raw in corpus {
922            let once = strip_governance_prefix(raw);
923            assert!(
924                !once.is_empty(),
925                "non-empty name {raw:?} must not strip to empty"
926            );
927            // A single strip removes 0 or 1 prefix: the result is either the input
928            // verbatim, or exactly the input with one in_/out_ prefix removed.
929            let removed_one = raw
930                .strip_prefix("in_")
931                .or_else(|| raw.strip_prefix("out_"))
932                .map_or(false, |rest| !rest.is_empty() && once == rest);
933            assert!(
934                once == raw || removed_one,
935                "strip removes at most one prefix for {raw:?} (got {once:?})"
936            );
937            if !raw.starts_with("in_") && !raw.starts_with("out_") {
938                assert_eq!(once, raw, "non-prefixed {raw:?} must be returned verbatim");
939            }
940        }
941    }
942
943    #[test]
944    fn prop_strip_is_idempotent_on_served_keys() {
945        // PROPERTY: on the keys callers actually see (single-prefixed or clean),
946        // stripping IS idempotent — re-stripping a served key is a no-op. This is
947        // the invariant the served-key path relies on (json_key_for_role applies
948        // the strip exactly once per role).
949        for served in [
950            "gross_income",
951            "tax_owed",
952            "loan_amount",
953            "x",
954            "in_", // prefix-only is preserved, so re-strip is a no-op
955        ] {
956            assert_eq!(
957                strip_governance_prefix(served),
958                served,
959                "an already-served key {served:?} must be a strip no-op"
960            );
961        }
962    }
963}