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}