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}