Skip to main content

active_core/
validate.rs

1//! Schema validation after evaluation (C-031, spec/validation.md).
2//!
3//! Walks the fully evaluated value tree (post-profile, post-patch)
4//! against the schema tree and reports every violation. Validation never
5//! echoes evaluated values into messages: kinds and schema-authored
6//! literals only, so secrets cannot leak. Strict mode never mutates the
7//! tree; resilient mode applies schema-driven repairs after the walk —
8//! default fills (W005), key renames (H005), string coercions (H007) —
9//! and revalidates each repaired subtree like a written one.
10
11use std::collections::HashMap;
12
13use crate::diag::{thresholds, Diagnostic, Repair, RepairKind, Severity, Span};
14use crate::eval::{
15    eval_binary, eval_unary, measure_value, value_kind_name, Accountant, BudgetCx, BudgetKind,
16    Engine, EvalError,
17};
18use crate::heal::{best_key_match, coerce_string_to, normalized_levenshtein, sentinel_meaning};
19use crate::ir::{AnnotationKind, Module, Node, NodeId, SchemaField, Value};
20use crate::parser::PathSeg;
21use crate::types::ActiveType;
22
23impl Engine {
24    /// Validate the evaluated result against the document schema.
25    /// Runs only when the document declares one; otherwise a no-op.
26    /// Strict mode collects every violation and fails; resilient mode
27    /// appends them to `diagnostics` and succeeds with the tree as-is.
28    pub(crate) fn validate_schema(
29        &self,
30        module: &Module,
31        values: &HashMap<NodeId, Value>,
32        result: &mut Value,
33        diagnostics: &mut Vec<Diagnostic>,
34        cx: BudgetCx<'_>,
35    ) -> Result<(), EvalError> {
36        let Some(schema_id) = module.schema else {
37            return Ok(());
38        };
39        let mut validator = Validator {
40            module,
41            values,
42            diagnostics: Vec::new(),
43            regexes: HashMap::new(),
44            schema_bugs: std::collections::HashSet::new(),
45            resilient: self.resilient,
46            fills: Vec::new(),
47            renames: Vec::new(),
48            coercions: Vec::new(),
49            deadline: cx.deadline,
50            wall_limit: cx.wall_limit,
51            ticks: 0,
52            account: cx.account,
53        };
54        validator.validate_node(schema_id, module.root, result, &mut Vec::new())?;
55        if self.resilient {
56            // Fixpoint: renames expose nested typos, coercions, and
57            // missing keys; fills insert values that need validating;
58            // coercions replace values that need re-checking. Each
59            // repair strictly reduces outstanding work, so the queues
60            // drain (wall-clock ticks inside revalidation bound
61            // pathological shapes first). The counter is a backstop:
62            // leftovers degrade to explicit diagnostics below, never
63            // silence.
64            for _ in 0..1024 {
65                if validator.renames.is_empty()
66                    && validator.coercions.is_empty()
67                    && validator.fills.is_empty()
68                {
69                    break;
70                }
71                validator.apply_renames(result)?;
72                validator.apply_coercions(result)?;
73                validator.apply_fills(result)?;
74            }
75            for req in std::mem::take(&mut validator.renames) {
76                validator.rename_fallback(&req);
77            }
78            for req in std::mem::take(&mut validator.coercions) {
79                validator.coercion_fallback(&req);
80            }
81            for fill in std::mem::take(&mut validator.fills) {
82                let path = render_path(&fill.segments);
83                validator.push(
84                    "T004",
85                    format!(
86                        "schema violation at '{}': missing required key (schema default could not be applied)",
87                        display_path(&path)
88                    ),
89                    &path,
90                    (fill.span, fill.file),
91                );
92            }
93        }
94        if !validator.diagnostics.is_empty() && !self.resilient {
95            // An expired deadline takes precedence over collected
96            // violations: the budget error must not be masked by
97            // validation output produced past the limit.
98            if let Some(end) = cx.deadline {
99                if std::time::Instant::now() >= end {
100                    return Err(EvalError::budget_exceeded(
101                        BudgetKind::WallClock,
102                        cx.wall_limit,
103                        module,
104                        None,
105                    ));
106                }
107            }
108            return Err(EvalError::ValidationFailed {
109                diagnostics: validator.diagnostics,
110            });
111        }
112        diagnostics.extend(validator.diagnostics);
113        Ok(())
114    }
115
116    /// Enforce `@constraint` annotations after schema validation
117    /// (spec/annotations.md §3, C-034). Each predicate is checked
118    /// against the final tree value at its path — `let` bindings fall
119    /// back to their evaluated node value (they never export) — in
120    /// path order with node-id tie-breaks (HashMap iteration is
121    /// random, so ordering is made explicit for determinism). The
122    /// predicate itself is re-evaluated against post-transform values
123    /// (spec §3): graph results predate patches, profiles, and fills.
124    /// Strict fails with everything collected; resilient collects
125    /// alongside the tree.
126    pub(crate) fn validate_annotations(
127        &self,
128        module: &Module,
129        values: &HashMap<NodeId, Value>,
130        result: &Value,
131        diagnostics: &mut Vec<Diagnostic>,
132        deadline: Option<std::time::Instant>,
133        wall_limit: u64,
134    ) -> Result<(), EvalError> {
135        let mut items: Vec<(NodeId, &crate::ir::Annotation)> = module
136            .annotations
137            .iter()
138            .flat_map(|(id, anns)| anns.iter().map(|a| (*id, a)))
139            .collect();
140        items.sort_by(|a, b| a.1.path.cmp(&b.1.path).then_with(|| a.0 .0.cmp(&b.0 .0)));
141        // `let` bindings never export: only their node values can feed
142        // constraints. Key constraints with no final-tree value are
143        // skipped (removed keys must not fail on stale values).
144        let bindings: std::collections::HashSet<NodeId> =
145            module.bindings.values().copied().collect();
146        // Post-transform overlay: base values overwritten by walking
147        // the final tree alongside the arena (Table by key, Array by
148        // index while lengths line up). Predicates evaluate against
149        // this, never against pre-patch graph results.
150        let mut overlay = values.clone();
151        if let Some(root) = module.root {
152            overlay_values(module, root, result, &mut overlay);
153        }
154        let mut local: Vec<Diagnostic> = Vec::new();
155        for (node_id, ann) in items {
156            // Wall-clock sampling for long annotation lists.
157            if let Some(end) = deadline {
158                if std::time::Instant::now() >= end {
159                    return Err(EvalError::budget_exceeded(
160                        BudgetKind::WallClock,
161                        wall_limit,
162                        module,
163                        Some(node_id),
164                    ));
165                }
166            }
167            let AnnotationKind::Constraint { source, expr } = &ann.kind else {
168                continue;
169            };
170            let value = lookup_result_path(result, &ann.path).or_else(|| {
171                bindings
172                    .contains(&node_id)
173                    .then(|| values.get(&node_id))
174                    .flatten()
175            });
176            let Some(value) = value else {
177                continue;
178            };
179            let span = module
180                .node_spans
181                .get(&node_id)
182                .cloned()
183                .or(Some(ann.span.clone()));
184            let file = module.node_files.get(&node_id).cloned();
185            let shown_source = render_source(module, source, *expr);
186            match predicate_value(module, &overlay, values, *expr) {
187                Some(Value::Bool(true)) => {}
188                Some(Value::Bool(false)) => local.push(Diagnostic {
189                    severity: Severity::Error,
190                    code: "T006".into(),
191                    message: format!(
192                        "constraint violation at '{}': {shown_source} does not hold (actual: {})",
193                        display_path(&ann.path),
194                        render_constrained(value),
195                    ),
196                    path: Some(ann.path.clone()),
197                    span,
198                    repair: None,
199                    confidence: 1.0,
200                    file,
201                }),
202                Some(other) => local.push(Diagnostic {
203                    severity: Severity::Error,
204                    code: "T006".into(),
205                    message: format!(
206                        "constraint violation at '{}': {shown_source} must evaluate to a boolean, got {}",
207                        display_path(&ann.path),
208                        value_kind_name(&other),
209                    ),
210                    path: Some(ann.path.clone()),
211                    span,
212                    repair: None,
213                    confidence: 1.0,
214                    file,
215                }),
216                // Defensive: exotic predicate roots fall back to the
217                // graph result; a missing one cannot happen on
218                // cycle-checked graphs.
219                None => {}
220            }
221        }
222        if !local.is_empty() && !self.resilient {
223            if let Some(end) = deadline {
224                if std::time::Instant::now() >= end {
225                    return Err(EvalError::budget_exceeded(
226                        BudgetKind::WallClock,
227                        wall_limit,
228                        module,
229                        None,
230                    ));
231                }
232            }
233            return Err(EvalError::ValidationFailed { diagnostics: local });
234        }
235        diagnostics.extend(local);
236        Ok(())
237    }
238}
239
240/// One resilient default insertion: the structured path of the missing
241/// key (table keys and array indices — array elements can hold
242/// defaulted fields too), the field that declares it, and the
243/// evaluated default expression plus its span for W005.
244struct FillRequest {
245    segments: Vec<PathSeg>,
246    field_id: NodeId,
247    default_id: NodeId,
248    span: Option<Span>,
249    file: Option<String>,
250}
251
252/// One resilient key rename: an unexpected closed-table key whose
253/// similarity to a schema field clears the auto-apply band, whose
254/// value type-checks against that field, and whose target is absent.
255/// Applied after the walk (spec/validation.md §10); the renamed
256/// subtree is then validated like a written one.
257struct RenameRequest {
258    segments: Vec<PathSeg>,
259    from: String,
260    to: String,
261    field_id: NodeId,
262    value_id: Option<NodeId>,
263    span: Option<Span>,
264    file: Option<String>,
265    confidence: f64,
266}
267
268/// One resilient string coercion: a T002 mismatch where the string
269/// parses as the target type. Applied after the walk; the coerced
270/// value is then constraint-checked like a written one.
271struct CoerceRequest {
272    segments: Vec<PathSeg>,
273    field_id: NodeId,
274    value_id: Option<NodeId>,
275    coerced: Value,
276    span: Option<Span>,
277    file: Option<String>,
278}
279
280struct Validator<'a> {
281    module: &'a Module,
282    values: &'a HashMap<NodeId, Value>,
283    diagnostics: Vec<Diagnostic>,
284    /// Compiled patterns by pattern-argument node: `None` means invalid
285    /// (reported once at insert). Avoids recompiling per element and
286    /// repeating schema-bug diagnostics across array elements.
287    regexes: HashMap<NodeId, Option<regex::Regex>>,
288    /// Schema-bug diagnostics already reported, keyed by (field node,
289    /// argument): incomparable bounds report once per field even when
290    /// array elements re-trigger them (spec/validation.md §4).
291    schema_bugs: std::collections::HashSet<(NodeId, String)>,
292    resilient: bool,
293    /// Resilient default insertions collected during the walk, applied
294    /// after it (spec/validation.md §9).
295    fills: Vec<FillRequest>,
296    /// Resilient key renames and string coercions collected during
297    /// the walk (and during rename revalidation), applied after it
298    /// (spec/validation.md §10). Strict mode records none: it only
299    /// attaches suggestions to the violations it reports.
300    renames: Vec<RenameRequest>,
301    coercions: Vec<CoerceRequest>,
302    /// Evaluation deadline for the wall-clock backstop (spec/budgets.md
303    /// §2); sampled every 64 validated nodes. `wall_limit` is the
304    /// configured milliseconds, carried for the error message.
305    deadline: Option<std::time::Instant>,
306    wall_limit: u64,
307    ticks: u64,
308    /// Cumulative materialization account: each default fill charges
309    /// its measured size before insertion (many filled defaults of one
310    /// large value trip like many aliases do).
311    account: &'a mut Accountant,
312}
313
314impl<'a> Validator<'a> {
315    /// Validate one value against one schema node. `value_id` is the
316    /// arena node for the value when the result tree still lines up
317    /// with it (always true except under profile merges and patches);
318    /// it feeds span attribution only, never semantics. `segments` is
319    /// the structured path (table keys and array indices) used for
320    /// messages, diagnostic paths, and default fill locations.
321    fn validate_node(
322        &mut self,
323        schema_id: NodeId,
324        value_id: Option<NodeId>,
325        value: &Value,
326        segments: &mut Vec<PathSeg>,
327    ) -> Result<(), EvalError> {
328        self.tick(schema_id)?;
329        match self.module.get(schema_id).cloned() {
330            Some(Node::SchemaField { field }) => {
331                self.validate_field(schema_id, &field, value_id, value, segments);
332            }
333            Some(Node::SchemaTable { fields, closed }) => {
334                self.validate_table(schema_id, &fields, closed, value_id, value, segments)?;
335            }
336            Some(Node::SchemaArray { element }) => {
337                self.validate_array(schema_id, element, value_id, value, segments)?;
338            }
339            // A non-schema node is not a contract: nothing to check.
340            _ => {}
341        }
342        Ok(())
343    }
344
345    /// Wall-clock sampling hook (spec/budgets.md §2): every 64th
346    /// validated node compares against the evaluation deadline.
347    fn tick(&mut self, node: NodeId) -> Result<(), EvalError> {
348        self.ticks += 1;
349        if self.ticks % 64 != 0 {
350            return Ok(());
351        }
352        if let Some(end) = self.deadline {
353            if std::time::Instant::now() >= end {
354                return Err(EvalError::budget_exceeded(
355                    BudgetKind::WallClock,
356                    self.wall_limit,
357                    self.module,
358                    Some(node),
359                ));
360            }
361        }
362        Ok(())
363    }
364
365    fn validate_table(
366        &mut self,
367        schema_id: NodeId,
368        fields: &indexmap::IndexMap<String, NodeId>,
369        closed: bool,
370        value_id: Option<NodeId>,
371        value: &Value,
372        segments: &mut Vec<PathSeg>,
373    ) -> Result<(), EvalError> {
374        let Value::Table(map) = value else {
375            let path = render_path(segments);
376            self.push(
377                "T002",
378                format!(
379                    "schema violation at '{}': expected Table, got {}",
380                    display_path(&path),
381                    value_kind_name(value)
382                ),
383                &path,
384                self.spans(value_id, schema_id),
385            );
386            return Ok(());
387        };
388        // The arena child ids behind this value table, for spans. Any
389        // key the arena does not know (patched or merged in) validates
390        // normally with a schema-side span.
391        let arena_children = match value_id.and_then(|id| self.module.get(id)) {
392            Some(Node::Table { fields }) => Some(fields.clone()),
393            _ => None,
394        };
395        for (name, field_id) in fields {
396            segments.push(PathSeg::Key(name.clone()));
397            match map.get(name) {
398                None => self.check_missing(*field_id, segments),
399                Some(v) => {
400                    let child_id = arena_children
401                        .as_ref()
402                        .and_then(|children| children.get(name).copied());
403                    self.validate_node(*field_id, child_id, v, segments)?;
404                }
405            }
406            segments.pop();
407        }
408        // Extra value keys are ignored in open tables; closed tables
409        // reject them (spec/validation.md §10), with phase-2 key
410        // repair (C-016): the typo is matched against the schema
411        // field names, and under resilient mode a confident,
412        // type-checking, collision-free match renames the key instead
413        // of failing. Iteration follows value order, so diagnostics
414        // stay deterministic.
415        if closed {
416            let legal: Vec<String> = fields.keys().cloned().collect();
417            for (key, value) in map {
418                if fields.contains_key(key) {
419                    continue;
420                }
421                segments.push(PathSeg::Key(key.clone()));
422                let key_path = render_path(segments);
423                let key_id = arena_children
424                    .as_ref()
425                    .and_then(|children| children.get(key).copied());
426                let spans = self.spans(key_id, schema_id);
427                // Suggestions clear the medium band in both modes
428                // (informational only); renames additionally require
429                // the high band (C-016 item threshold), checked
430                // explicitly below.
431                let suggestion = best_key_match(key, &legal, thresholds::MEDIUM);
432                // Resilient auto-rename guards (C-016 confidence
433                // policy): the match must clear the high band, the
434                // target must be absent (never clobber a written key),
435                // and the value must already type-check against the
436                // suggested field — applied renames are revalidated
437                // for constraints afterwards. Shared borrows end here;
438                // the push below needs `&mut self`.
439                let candidate: Option<(NodeId, bool)> = match &suggestion {
440                    Some((suggested, score))
441                        if *score >= thresholds::HIGH && map.get(suggested).is_none() =>
442                    {
443                        match fields.get(suggested) {
444                            Some(field_id) => match self.module.get(*field_id) {
445                                // Every schema-node kind participates:
446                                // fields check their declared type,
447                                // tables/arrays check the value shape
448                                // (constraints re-run post-rename).
449                                Some(Node::SchemaField { field }) => {
450                                    Some((*field_id, type_accepts(&field.typ, value)))
451                                }
452                                Some(Node::SchemaTable { .. }) => {
453                                    Some((*field_id, matches!(value, Value::Table(_))))
454                                }
455                                Some(Node::SchemaArray { .. }) => {
456                                    Some((*field_id, matches!(value, Value::Array(_))))
457                                }
458                                _ => None,
459                            },
460                            None => None,
461                        }
462                    }
463                    _ => None,
464                };
465                let mut renamed = false;
466                if self.resilient {
467                    if let (Some((field_id, true)), Some((suggested, score))) =
468                        (candidate, &suggestion)
469                    {
470                        self.renames.push(RenameRequest {
471                            segments: segments.clone(),
472                            from: key.clone(),
473                            to: suggested.clone(),
474                            field_id,
475                            value_id: key_id,
476                            span: spans.0.clone(),
477                            file: spans.1.clone(),
478                            confidence: *score,
479                        });
480                        renamed = true;
481                    }
482                }
483                if !renamed {
484                    let mut message = format!(
485                        "schema violation at '{}': unexpected key '{key}' in closed table",
486                        display_path(&key_path)
487                    );
488                    let mut repair = None;
489                    // Suggest-only confidence is the match score, never
490                    // 1.0: tooling auto-applies on confidence, and a
491                    // 0.70 suggestion must not look certain.
492                    let mut confidence = 1.0;
493                    if let Some((suggested, score)) = &suggestion {
494                        message.push_str(&format!("; did you mean '{suggested}'?"));
495                        // Canonical safe signal (heal.rs): near-identical
496                        // edit distance, not the composite score.
497                        repair = Some(Repair {
498                            original: key.clone(),
499                            suggested: suggested.clone(),
500                            kind: RepairKind::KeyTypo,
501                            safe: normalized_levenshtein(key, suggested) >= thresholds::AUTO_APPLY,
502                        });
503                        confidence = *score;
504                    }
505                    self.push_diag(
506                        "T005",
507                        message,
508                        &key_path,
509                        spans,
510                        DiagMeta {
511                            severity: Severity::Error,
512                            repair,
513                            confidence,
514                        },
515                    );
516                }
517                segments.pop();
518            }
519        }
520        Ok(())
521    }
522
523    fn validate_array(
524        &mut self,
525        schema_id: NodeId,
526        element: NodeId,
527        value_id: Option<NodeId>,
528        value: &Value,
529        segments: &mut Vec<PathSeg>,
530    ) -> Result<(), EvalError> {
531        let Value::Array(elements) = value else {
532            let path = render_path(segments);
533            self.push(
534                "T002",
535                format!(
536                    "schema violation at '{}': expected Array, got {}",
537                    display_path(&path),
538                    value_kind_name(value)
539                ),
540                &path,
541                self.spans(value_id, schema_id),
542            );
543            return Ok(());
544        };
545        let arena_elements: Option<Vec<NodeId>> = match value_id.and_then(|id| self.module.get(id))
546        {
547            // Positional mapping is only valid while the lengths line
548            // up: a patch that adds or removes elements breaks the
549            // correspondence, so the whole array falls back to schema
550            // spans (spec/validation.md §8).
551            Some(Node::Array { elements: arena }) if arena.len() == elements.len() => {
552                Some(arena.clone())
553            }
554            _ => None,
555        };
556        for (i, v) in elements.iter().enumerate() {
557            // Positional resolution against the arena array: nested
558            // arrays and tables attribute to their own level while the
559            // lengths line up; patched length changes fall back to
560            // schema spans (spec/validation.md §8).
561            let elem_id = arena_elements.as_ref().and_then(|els| els.get(i).copied());
562            segments.push(PathSeg::Index(i));
563            self.validate_node(element, elem_id, v, segments)?;
564            segments.pop();
565        }
566        Ok(())
567    }
568
569    fn validate_field(
570        &mut self,
571        schema_id: NodeId,
572        field: &SchemaField,
573        value_id: Option<NodeId>,
574        value: &Value,
575        segments: &mut [PathSeg],
576    ) {
577        let path = render_path(segments);
578        if !type_accepts(&field.typ, value) {
579            // Phase-3 wiring (C-016): a string that parses as the
580            // target type is coercible. Strict reports the mismatch
581            // with the coercion noted; resilient applies it and
582            // constraint-checks the coerced value like a written one.
583            // Sentinel words are recognized but never mapped: no
584            // schema syntax designates their values, so guessing
585            // would invent data (spec/validation.md §10).
586            if let Value::String(text) = value {
587                if let Some(coerced) = coerce_string_to(text, &field.typ) {
588                    let spans = self.spans(value_id, schema_id);
589                    if self.resilient {
590                        self.coercions.push(CoerceRequest {
591                            segments: segments.to_vec(),
592                            field_id: schema_id,
593                            value_id,
594                            coerced,
595                            span: spans.0,
596                            file: spans.1,
597                        });
598                    } else {
599                        self.push(
600                            "T002",
601                            format!(
602                                "schema violation at '{}': expected {}, got {} (coercible under --resilient)",
603                                display_path(&path),
604                                field.typ.name(),
605                                value_kind_name(value)
606                            ),
607                            &path,
608                            spans,
609                        );
610                    }
611                    return;
612                }
613                if let Some(intent) = sentinel_meaning(text) {
614                    // Echoing the word is safe: `sentinel_meaning`
615                    // matched, so the lowercase form is one of a
616                    // dozen fixed dictionary words — never user data.
617                    self.push(
618                        "T002",
619                        format!(
620                            "schema violation at '{}': expected {}, got {} (sentinel '{}' signals {} but the schema designates no value — set an explicit value or a schema default)",
621                            display_path(&path),
622                            field.typ.name(),
623                            value_kind_name(value),
624                            text.to_lowercase(),
625                            intent,
626                        ),
627                        &path,
628                        self.spans(value_id, schema_id),
629                    );
630                    return;
631                }
632            }
633            self.push(
634                "T002",
635                format!(
636                    "schema violation at '{}': expected {}, got {}",
637                    display_path(&path),
638                    field.typ.name(),
639                    value_kind_name(value)
640                ),
641                &path,
642                self.spans(value_id, schema_id),
643            );
644            return;
645        }
646        self.check_constraints(schema_id, field, value, &path, value_id);
647    }
648    /// A schema field with no value (spec/validation.md §5 and §9).
649    /// A declared default fills under resilient mode (recorded for
650    /// later insertion) but is T004 under strict mode (explicitness) —
651    /// the default takes precedence over the Null-absence rule so
652    /// `Null(default = …)` behaves like every other defaulted field.
653    /// Anything else is T004, except Null-typed absence (quiet).
654    fn check_missing(&mut self, field_id: NodeId, segments: &[PathSeg]) {
655        let (default_arg, is_null, field_span) = match self.module.get(field_id) {
656            Some(Node::SchemaField { field }) => (
657                field.default,
658                matches!(field.typ, ActiveType::Null),
659                field.span.clone(),
660            ),
661            _ => (None, false, None),
662        };
663        let path = render_path(segments);
664        let (span, file) =
665            match field_span.or_else(|| self.module.node_spans.get(&field_id).cloned()) {
666                Some(s) => (Some(s), self.module.node_files.get(&field_id).cloned()),
667                None => (None, None),
668            };
669        if let Some(default_id) = default_arg {
670            if self.resilient {
671                self.fills.push(FillRequest {
672                    segments: segments.to_vec(),
673                    field_id,
674                    default_id,
675                    span,
676                    file,
677                });
678                return;
679            }
680            self.push(
681                "T004",
682                format!(
683                    "schema violation at '{}': missing required key (strict mode requires explicit values despite a schema default)",
684                    display_path(&path)
685                ),
686                &path,
687                (span, file),
688            );
689            return;
690        }
691        if is_null {
692            return;
693        }
694        self.push(
695            "T004",
696            format!(
697                "schema violation at '{}': missing required key (no default)",
698                display_path(&path)
699            ),
700            &path,
701            (span, file),
702        );
703    }
704
705    /// Apply resilient default insertions collected during the walk
706    /// (spec/validation.md §9): insert the evaluated default, record
707    /// W005, then validate the filled value like a written one so a
708    /// mistyped default surfaces as T002/T003 instead of corrupting
709    /// the tree silently. Requests run in schema (walk) order. The
710    /// filled value attributes to the default expression's span, which
711    /// is more actionable than the schema field span.
712    fn apply_fills(&mut self, result: &mut Value) -> Result<(), EvalError> {
713        let fills = std::mem::take(&mut self.fills);
714        for mut fill in fills {
715            // A rename may have healed this path after the walk queued
716            // the fill (or a renamed subtree may already carry the
717            // key): present keys are never overwritten by defaults.
718            if value_at(result, &fill.segments).is_some() {
719                continue;
720            }
721            // Both skips are defensive (the walk verified the parents
722            // and evaluation fills the node map), but a missing key
723            // must never go silent: report T004 when the default
724            // cannot be applied.
725            let Some(default_value) = self.values.get(&fill.default_id).cloned() else {
726                self.push(
727                    "T004",
728                    format!(
729                        "schema violation at '{}': missing required key (schema default could not be applied)",
730                        display_path(&render_path(&fill.segments))
731                    ),
732                    &render_path(&fill.segments),
733                    (fill.span.clone(), fill.file.clone()),
734                );
735                continue;
736            };
737            // Charge the clone before inserting: many fills of one
738            // large default must trip like many aliases do.
739            let fill_size = measure_value(&default_value);
740            self.account.charge(self.module, None, fill_size)?;
741            if !insert_at(result, &fill.segments, default_value.clone()) {
742                self.push(
743                    "T004",
744                    format!(
745                        "schema violation at '{}': missing required key (schema default could not be applied)",
746                        display_path(&render_path(&fill.segments))
747                    ),
748                    &render_path(&fill.segments),
749                    (fill.span.clone(), fill.file.clone()),
750                );
751                continue;
752            };
753            let path = render_path(&fill.segments);
754            self.diagnostics.push(Diagnostic {
755                severity: Severity::Warning,
756                code: "W005".into(),
757                message: format!(
758                    "filled missing key '{}' from schema default",
759                    display_path(&path)
760                ),
761                path: Some(path),
762                span: fill.span,
763                repair: None,
764                confidence: 1.0,
765                file: fill.file,
766            });
767            self.validate_node(
768                fill.field_id,
769                Some(fill.default_id),
770                &default_value,
771                &mut fill.segments,
772            )?;
773        }
774        Ok(())
775    }
776
777    /// Apply resilient key renames collected during the walk
778    /// (spec/validation.md §10): move each typo'd value onto its
779    /// schema field, record H005, and revalidate the renamed subtree
780    /// like a written one so constraints still bite. Revalidation can
781    /// discover nested typos (a renamed table's own unexpected keys),
782    /// which queue further renames; every pass eliminates one
783    /// unexpected key and only schema-known targets are introduced,
784    /// so the queue drains. The pass counter is a backstop against
785    /// pathological shapes, not expected to fire: on overflow,
786    /// pending renames degrade to suggest-only T005s so nothing goes
787    /// silent.
788    fn apply_renames(&mut self, result: &mut Value) -> Result<(), EvalError> {
789        let mut passes = 0u32;
790        while !self.renames.is_empty() {
791            passes += 1;
792            if passes > 1024 {
793                let pending = std::mem::take(&mut self.renames);
794                for req in pending {
795                    let RenameRequest {
796                        segments,
797                        from,
798                        to,
799                        span,
800                        file,
801                        confidence,
802                        ..
803                    } = req;
804                    let path = render_path(&segments);
805                    let safe = normalized_levenshtein(&from, &to) >= thresholds::AUTO_APPLY;
806                    self.push_diag(
807                        "T005",
808                        format!(
809                            "schema violation at '{}': unexpected key '{}' in closed table; did you mean '{}'?",
810                            display_path(&path),
811                            from,
812                            to
813                        ),
814                        &path,
815                        (span, file),
816                        DiagMeta {
817                            severity: Severity::Error,
818                            repair: Some(Repair {
819                                original: from,
820                                suggested: to,
821                                kind: RepairKind::KeyTypo,
822                                safe,
823                            }),
824                            confidence,
825                        },
826                    );
827                }
828                break;
829            }
830            let pending = std::mem::take(&mut self.renames);
831            for req in pending {
832                let Some((last, parent_segs)) = req.segments.split_last() else {
833                    continue;
834                };
835                if !matches!(last, PathSeg::Key(key) if key == &req.from) {
836                    continue;
837                }
838                let Some(table) = value_at_mut(result, parent_segs) else {
839                    self.rename_fallback(&req);
840                    continue;
841                };
842                let Value::Table(map) = table else {
843                    self.rename_fallback(&req);
844                    continue;
845                };
846                // Recheck at apply time: a sibling rename may have
847                // taken the target since the walk (two typos, one
848                // field) — first wins, the other degrades.
849                if map.contains_key(&req.to) {
850                    self.rename_fallback(&req);
851                    continue;
852                }
853                let Some(moved) = map.shift_remove(&req.from) else {
854                    self.rename_fallback(&req);
855                    continue;
856                };
857                map.insert(req.to.clone(), moved);
858                let mut fixed: Vec<PathSeg> = parent_segs
859                    .iter()
860                    .cloned()
861                    .chain(std::iter::once(PathSeg::Key(req.to.clone())))
862                    .collect();
863                let fixed_path = render_path(&fixed);
864                // The rename satisfies the absence the walk already
865                // reported: retract the stale missing-key T004 for
866                // this exact path, and cancel any queued default fill
867                // for it — the healed value wins over the default
868                // (C1: fills applied afterwards must not clobber
869                // renames). Other paths are untouched.
870                self.diagnostics.retain(|d| {
871                    !(d.code == "T004" && d.path.as_deref() == Some(fixed_path.as_str()))
872                });
873                self.fills
874                    .retain(|f| render_path(&f.segments) != fixed_path);
875                self.push_diag(
876                    "H005",
877                    format!(
878                        "renamed key '{}' to '{}' (confidence {:.2})",
879                        req.from, req.to, req.confidence
880                    ),
881                    &fixed_path,
882                    (req.span.clone(), req.file.clone()),
883                    DiagMeta {
884                        severity: Severity::Warning,
885                        repair: Some(Repair {
886                            original: req.from.clone(),
887                            suggested: req.to.clone(),
888                            kind: RepairKind::KeyTypo,
889                            safe: normalized_levenshtein(&req.from, &req.to)
890                                >= thresholds::AUTO_APPLY,
891                        }),
892                        confidence: req.confidence,
893                    },
894                );
895                if let Some(current) = value_at(result, &fixed).cloned() {
896                    self.validate_node(req.field_id, req.value_id, &current, &mut fixed)?;
897                }
898            }
899        }
900        Ok(())
901    }
902
903    /// Suggest-only T005 for a rename that cannot apply (target taken
904    /// or subtree vanished): the violation must still be reported,
905    /// carrying the original match confidence.
906    fn rename_fallback(&mut self, req: &RenameRequest) {
907        let path = render_path(&req.segments);
908        self.push_diag(
909            "T005",
910            format!(
911                "schema violation at '{}': unexpected key '{}' in closed table; did you mean '{}'?",
912                display_path(&path),
913                req.from,
914                req.to
915            ),
916            &path,
917            (req.span.clone(), req.file.clone()),
918            DiagMeta {
919                severity: Severity::Error,
920                repair: Some(Repair {
921                    original: req.from.clone(),
922                    suggested: req.to.clone(),
923                    kind: RepairKind::KeyTypo,
924                    safe: normalized_levenshtein(&req.from, &req.to) >= thresholds::AUTO_APPLY,
925                }),
926                confidence: req.confidence,
927            },
928        );
929    }
930
931    /// Suggest-only T002 for a coercion that cannot apply (the
932    /// subtree vanished): the violation must still be reported.
933    fn coercion_fallback(&mut self, req: &CoerceRequest) {
934        let path = render_path(&req.segments);
935        let expected = match self.module.get(req.field_id) {
936            Some(Node::SchemaField { field }) => field.typ.name().to_string(),
937            _ => "schema type".to_string(),
938        };
939        self.push(
940            "T002",
941            format!(
942                "schema violation at '{}': expected {}, got string (coercion could not be applied)",
943                display_path(&path),
944                expected
945            ),
946            &path,
947            (req.span.clone(), req.file.clone()),
948        );
949    }
950
951    /// Apply resilient string coercions collected during the walk:
952    /// swap in the parsed value, record H007 (value-free, per the
953    /// no-echo policy), and revalidate constraints on the coerced
954    /// value like a written one. Coerced values are scalars, so no
955    /// materialization charge is needed (unlike default fills).
956    fn apply_coercions(&mut self, result: &mut Value) -> Result<(), EvalError> {
957        let pending = std::mem::take(&mut self.coercions);
958        for req in pending {
959            if !insert_at(result, &req.segments, req.coerced.clone()) {
960                // Defensive (the walk verified the path): re-report
961                // as a plain T002 so nothing goes silent.
962                self.coercion_fallback(&req);
963                continue;
964            }
965            let path = render_path(&req.segments);
966            self.push_diag(
967                "H007",
968                format!(
969                    "resilient: coerced value at '{}' from string to {}",
970                    display_path(&path),
971                    value_kind_name(&req.coerced)
972                ),
973                &path,
974                (req.span.clone(), req.file.clone()),
975                DiagMeta {
976                    severity: Severity::Warning,
977                    repair: None,
978                    confidence: 0.90,
979                },
980            );
981            if let Some(current) = value_at(result, &req.segments).cloned() {
982                let mut segs = req.segments.clone();
983                self.validate_node(req.field_id, req.value_id, &current, &mut segs)?;
984            }
985        }
986        Ok(())
987    }
988
989    fn check_constraints(
990        &mut self,
991        schema_id: NodeId,
992        field: &SchemaField,
993        value: &Value,
994        path: &str,
995        value_id: Option<NodeId>,
996    ) {
997        let spans = self.spans(value_id, schema_id);
998        match (&field.typ, value) {
999            (ActiveType::Int, Value::Int(n)) => {
1000                self.check_int_bounds(field, schema_id, *n, path, spans, true);
1001            }
1002            (ActiveType::Float, Value::Float(f)) => {
1003                self.check_float_bounds(field, schema_id, *f, path, spans);
1004            }
1005            (ActiveType::Float, Value::Int(n)) => {
1006                self.check_float_bounds(field, schema_id, *n as f64, path, spans);
1007            }
1008            (ActiveType::Duration, Value::DurationMs(ms)) => {
1009                self.check_duration_bounds(field, schema_id, *ms, path, spans);
1010            }
1011            // Bytes normalize to integers at evaluation (known
1012            // limitation: plain integers are indistinguishable from
1013            // byte counts, spec/validation.md §3). Unlike Int fields,
1014            // Bytes bounds are integers only (spec §4).
1015            (ActiveType::Bytes, Value::Int(n)) => {
1016                self.check_int_bounds(field, schema_id, *n, path, spans, false);
1017            }
1018            (ActiveType::String, Value::String(s)) => {
1019                self.check_string_bounds(field, schema_id, s, path, spans);
1020            }
1021            (ActiveType::Enum(members), Value::String(s)) if !members.iter().any(|m| m == s) => {
1022                let quoted: Vec<String> = members.iter().map(|m| format!("\"{m}\"")).collect();
1023                self.push(
1024                    "T003",
1025                    format!(
1026                        "schema violation at '{}': not a member of Enum({})",
1027                        display_path(path),
1028                        quoted.join(", ")
1029                    ),
1030                    path,
1031                    spans,
1032                );
1033            }
1034            _ => {}
1035        }
1036    }
1037
1038    fn check_int_bounds(
1039        &mut self,
1040        field: &SchemaField,
1041        field_id: NodeId,
1042        n: i64,
1043        path: &str,
1044        spans: (Option<Span>, Option<String>),
1045        allow_float_bounds: bool,
1046    ) {
1047        for (arg, name) in [(field.min, "min"), (field.max, "max")] {
1048            let Some(arg_id) = arg else { continue };
1049            let Some(bound) = self.values.get(&arg_id) else {
1050                continue;
1051            };
1052            let (numerically_less, numerically_greater) = match bound {
1053                Value::Int(b) => (n < *b, n > *b),
1054                Value::Float(b) if allow_float_bounds => ((n as f64) < *b, (n as f64) > *b),
1055                _ => {
1056                    // A broken bound reports once per field even when
1057                    // array elements re-trigger it (spec §4).
1058                    if self.bug_once(field_id, name) {
1059                        self.violation(
1060                            path,
1061                            &format!("incomparable {name} bound for {} fields", field.typ.name()),
1062                            spans.clone(),
1063                        );
1064                    }
1065                    continue;
1066                }
1067            };
1068            if (name == "min" && numerically_less) || (name == "max" && numerically_greater) {
1069                self.violation(
1070                    path,
1071                    if name == "min" {
1072                        "below minimum"
1073                    } else {
1074                        "above maximum"
1075                    },
1076                    spans.clone(),
1077                );
1078            }
1079        }
1080    }
1081
1082    /// Schema-bug diagnostics report once per (field, argument): the walk
1083    /// may reach the same broken constraint through many array elements.
1084    /// Returns true on first occurrence (caller reports), false after.
1085    fn bug_once(&mut self, field_id: NodeId, arg: &str) -> bool {
1086        self.schema_bugs.insert((field_id, arg.to_string()))
1087    }
1088
1089    fn check_float_bounds(
1090        &mut self,
1091        field: &SchemaField,
1092        field_id: NodeId,
1093        f: f64,
1094        path: &str,
1095        spans: (Option<Span>, Option<String>),
1096    ) {
1097        for (arg, name) in [(field.min, "min"), (field.max, "max")] {
1098            let Some(arg_id) = arg else { continue };
1099            let Some(bound) = self.values.get(&arg_id) else {
1100                continue;
1101            };
1102            let bound = match bound {
1103                Value::Int(n) => *n as f64,
1104                Value::Float(x) => *x,
1105                _ => {
1106                    if self.bug_once(field_id, name) {
1107                        self.violation(
1108                            path,
1109                            &format!("incomparable {name} bound for Float fields"),
1110                            spans.clone(),
1111                        );
1112                    }
1113                    continue;
1114                }
1115            };
1116            if (name == "min" && f < bound) || (name == "max" && f > bound) {
1117                self.violation(
1118                    path,
1119                    if name == "min" {
1120                        "below minimum"
1121                    } else {
1122                        "above maximum"
1123                    },
1124                    spans.clone(),
1125                );
1126            }
1127        }
1128    }
1129
1130    fn check_duration_bounds(
1131        &mut self,
1132        field: &SchemaField,
1133        field_id: NodeId,
1134        ms: u64,
1135        path: &str,
1136        spans: (Option<Span>, Option<String>),
1137    ) {
1138        for (arg, name) in [(field.min, "min"), (field.max, "max")] {
1139            let Some(arg_id) = arg else { continue };
1140            let Some(bound) = self.values.get(&arg_id) else {
1141                continue;
1142            };
1143            let bound = match bound {
1144                Value::DurationMs(b) => *b,
1145                // Plain integers read as milliseconds.
1146                Value::Int(b) if *b >= 0 => *b as u64,
1147                _ => {
1148                    if self.bug_once(field_id, name) {
1149                        self.violation(
1150                            path,
1151                            &format!("incomparable {name} bound for Duration fields"),
1152                            spans.clone(),
1153                        );
1154                    }
1155                    continue;
1156                }
1157            };
1158            if (name == "min" && ms < bound) || (name == "max" && ms > bound) {
1159                self.violation(
1160                    path,
1161                    if name == "min" {
1162                        "below minimum"
1163                    } else {
1164                        "above maximum"
1165                    },
1166                    spans.clone(),
1167                );
1168            }
1169        }
1170    }
1171
1172    fn check_string_bounds(
1173        &mut self,
1174        field: &SchemaField,
1175        field_id: NodeId,
1176        s: &str,
1177        path: &str,
1178        spans: (Option<Span>, Option<String>),
1179    ) {
1180        let len = s.chars().count();
1181        for (arg, name, detail) in [
1182            (field.min_len, "min_len", "too short"),
1183            (field.max_len, "max_len", "too long"),
1184        ] {
1185            let Some(arg_id) = arg else { continue };
1186            let Some(bound) = self.values.get(&arg_id) else {
1187                continue;
1188            };
1189            match bound {
1190                Value::Int(n) => {
1191                    // A negative limit is a nonsense schema, not a
1192                    // violation: clamp it so every string passes.
1193                    let limit = (*n).max(0) as usize;
1194                    if (name == "min_len" && len < limit) || (name == "max_len" && len > limit) {
1195                        self.violation(path, detail, spans.clone());
1196                    }
1197                }
1198                _ => {
1199                    if self.bug_once(field_id, name) {
1200                        self.violation(
1201                            path,
1202                            &format!("incomparable {name} bound for String fields"),
1203                            spans.clone(),
1204                        );
1205                    }
1206                }
1207            }
1208        }
1209        if let Some(pattern_id) = field.pattern {
1210            let owned_pattern: Option<String> = match self.values.get(&pattern_id) {
1211                Some(Value::String(pattern)) => Some(pattern.clone()),
1212                Some(_) => {
1213                    if self.bug_once(field_id, "pattern") {
1214                        self.violation(
1215                            path,
1216                            "incomparable pattern bound for String fields",
1217                            spans.clone(),
1218                        );
1219                    }
1220                    None
1221                }
1222                None => None,
1223            };
1224            if let Some(pattern) = owned_pattern {
1225                // Compile once per pattern node: array elements share
1226                // the result, and an invalid pattern reports once.
1227                let cached = self.regexes.get(&pattern_id).cloned();
1228                let compiled = match cached {
1229                    Some(result) => result,
1230                    None => {
1231                        let result = regex::Regex::new(&pattern).ok();
1232                        if result.is_none() && self.bug_once(field_id, "pattern") {
1233                            self.violation(path, "invalid pattern in schema", spans.clone());
1234                        }
1235                        self.regexes.insert(pattern_id, result.clone());
1236                        result
1237                    }
1238                };
1239                if let Some(re) = compiled {
1240                    if !re.is_match(s) {
1241                        self.violation(path, "does not match pattern", spans.clone());
1242                    }
1243                }
1244            }
1245        }
1246    }
1247
1248    /// Value span when the result tree still lines up with the arena
1249    /// (the common case), else the schema span, else none — each with
1250    /// its file (spec/validation.md §8).
1251    fn spans(&self, value_id: Option<NodeId>, schema_id: NodeId) -> (Option<Span>, Option<String>) {
1252        if let Some(id) = value_id {
1253            if let Some(span) = self.module.node_spans.get(&id).cloned() {
1254                return (Some(span), self.module.node_files.get(&id).cloned());
1255            }
1256        }
1257        (
1258            self.module.node_spans.get(&schema_id).cloned(),
1259            self.module.node_files.get(&schema_id).cloned(),
1260        )
1261    }
1262
1263    fn violation(&mut self, path: &str, detail: &str, spans: (Option<Span>, Option<String>)) {
1264        self.push(
1265            "T003",
1266            format!("schema violation at '{}': {detail}", display_path(path)),
1267            path,
1268            spans,
1269        );
1270    }
1271
1272    fn push(
1273        &mut self,
1274        code: &str,
1275        message: String,
1276        path: &str,
1277        spans: (Option<Span>, Option<String>),
1278    ) {
1279        self.push_diag(
1280            code,
1281            message,
1282            path,
1283            spans,
1284            DiagMeta {
1285                severity: Severity::Error,
1286                repair: None,
1287                confidence: 1.0,
1288            },
1289        );
1290    }
1291
1292    /// Full-shape diagnostic push: healing codes (H005/H007) and
1293    /// T005 suggestions carry repairs with confidence scores and
1294    /// non-error severities; plain violations stay errors without
1295    /// repairs. The severity/repair/confidence triple travels as one
1296    /// struct to keep the arity lint-clean.
1297    fn push_diag(
1298        &mut self,
1299        code: &str,
1300        message: String,
1301        path: &str,
1302        spans: (Option<Span>, Option<String>),
1303        meta: DiagMeta,
1304    ) {
1305        let (span, file) = spans;
1306        self.diagnostics.push(Diagnostic {
1307            severity: meta.severity,
1308            code: code.into(),
1309            message,
1310            path: Some(path.to_string()),
1311            span,
1312            repair: meta.repair,
1313            confidence: meta.confidence,
1314            file,
1315        });
1316    }
1317}
1318
1319/// Severity + repair + confidence for repair-carrying diagnostics.
1320struct DiagMeta {
1321    severity: Severity,
1322    repair: Option<Repair>,
1323    confidence: f64,
1324}
1325
1326/// Root-level violations would render as `at ''`; name the root instead.
1327fn display_path(path: &str) -> &str {
1328    if path.is_empty() {
1329        "<root>"
1330    } else {
1331        path
1332    }
1333}
1334
1335/// Look up a dotted annotation path in the final tree. Keys are
1336/// identifiers by construction, so splitting is exact. Returns None
1337/// for removed keys, `let` bindings, and profile-scoped paths (the
1338/// caller falls back to the annotated node's value).
1339fn lookup_result_path<'v>(result: &'v Value, path: &str) -> Option<&'v Value> {
1340    let mut cursor = result;
1341    for segment in path.split('.') {
1342        match cursor {
1343            Value::Table(map) => cursor = map.get(segment)?,
1344            _ => return None,
1345        }
1346    }
1347    Some(cursor)
1348}
1349
1350/// Overwrite `overlay` with final-tree values by walking the arena
1351/// alongside the result (Table by key, Array by index while lengths
1352/// line up). Keys absent from the final tree (patched away) lose
1353/// their whole subtree so predicates cannot read stale descendants;
1354/// length-changed arrays lose theirs for the same reason (positions
1355/// no longer correspond). `let` bindings keep their evaluated values
1356/// (they never transform).
1357fn overlay_values(
1358    module: &Module,
1359    node_id: NodeId,
1360    value: &Value,
1361    overlay: &mut HashMap<NodeId, Value>,
1362) {
1363    overlay.insert(node_id, value.clone());
1364    match (module.get(node_id), value) {
1365        (Some(Node::Table { fields }), Value::Table(map)) => {
1366            for (key, child_id) in fields {
1367                match map.get(key) {
1368                    Some(child_value) => overlay_values(module, *child_id, child_value, overlay),
1369                    None => {
1370                        remove_subtree(module, *child_id, overlay);
1371                    }
1372                }
1373            }
1374        }
1375        (Some(Node::Array { elements }), Value::Array(values))
1376            if elements.len() == values.len() =>
1377        {
1378            for (child_id, child_value) in elements.iter().zip(values.iter()) {
1379                overlay_values(module, *child_id, child_value, overlay);
1380            }
1381        }
1382        (Some(Node::Array { .. }), Value::Array(_)) => {
1383            remove_subtree(module, node_id, overlay);
1384            overlay.insert(node_id, value.clone());
1385        }
1386        _ => {}
1387    }
1388}
1389
1390/// Remove a node and every composite descendant from the overlay, so
1391/// no predicate can observe pre-transform values through a removed
1392/// (or reshaped) subtree. Expression nodes hold no independent state
1393/// beyond their children, so Table/Array descent suffices.
1394fn remove_subtree(module: &Module, node_id: NodeId, overlay: &mut HashMap<NodeId, Value>) {
1395    overlay.remove(&node_id);
1396    match module.get(node_id) {
1397        Some(Node::Table { fields }) => {
1398            for child_id in fields.values() {
1399                remove_subtree(module, *child_id, overlay);
1400            }
1401        }
1402        Some(Node::Array { elements }) => {
1403            for child_id in elements {
1404                remove_subtree(module, *child_id, overlay);
1405            }
1406        }
1407        _ => {}
1408    }
1409}
1410
1411/// Evaluate a constraint predicate against the post-transform overlay
1412/// (spec/annotations.md §3). Literals evaluate directly; references
1413/// read the overlay (final values); operators reuse the main
1414/// evaluator's pure functions. Anything exotic (interpolation,
1415/// tables, secrets-as-values) falls back to its graph result via the
1416/// overlay entry, which the walk above already finalized.
1417fn predicate_value(
1418    module: &Module,
1419    overlay: &HashMap<NodeId, Value>,
1420    base: &HashMap<NodeId, Value>,
1421    expr: NodeId,
1422) -> Option<Value> {
1423    match module.get(expr) {
1424        Some(Node::Literal(v)) => Some(v.clone()),
1425        Some(Node::Ref(target)) => overlay.get(target).cloned(),
1426        Some(Node::Ident(name)) => module
1427            .bindings
1428            .get(name)
1429            .and_then(|target| overlay.get(target))
1430            .cloned(),
1431        Some(Node::Binary { op, lhs, rhs }) => {
1432            let l = predicate_value(module, overlay, base, *lhs)?;
1433            let r = predicate_value(module, overlay, base, *rhs)?;
1434            eval_binary(*op, &l, &r).ok()
1435        }
1436        Some(Node::Unary { op, expr }) => {
1437            let v = predicate_value(module, overlay, base, *expr)?;
1438            eval_unary(*op, &v).ok()
1439        }
1440        // Composite or opaque nodes: their overlay entry already holds
1441        // the finalized value (walked above for reachable nodes).
1442        _ => overlay
1443            .get(&expr)
1444            .cloned()
1445            .or_else(|| base.get(&expr).cloned()),
1446    }
1447}
1448
1449/// Render a constraint source for T006: secret-bearing predicates
1450/// render generic so no locator or literal can leak; every source
1451/// truncates to 120 characters total. A predicate counts as
1452/// secret-bearing when a `Secret` node is reachable — following
1453/// references and `let` bindings, so indirection cannot smuggle
1454/// secret material into a message either.
1455fn render_source(module: &Module, source: &str, expr: NodeId) -> String {
1456    if subtree_has_secret(module, expr) {
1457        return "constraint".to_string();
1458    }
1459    truncate_120(source)
1460}
1461
1462/// True when a `Secret` node is reachable from `expr`, following
1463/// references and `let` bindings as well as composite children
1464/// (cycle-free DAG walk with a visited set for determinism).
1465fn subtree_has_secret(module: &Module, expr: NodeId) -> bool {
1466    let mut stack = vec![expr];
1467    let mut seen = std::collections::HashSet::new();
1468    while let Some(id) = stack.pop() {
1469        if !seen.insert(id) {
1470            continue;
1471        }
1472        match module.get(id) {
1473            None => {}
1474            Some(Node::Secret { .. }) => return true,
1475            Some(Node::Ref(target)) => stack.push(*target),
1476            Some(Node::Ident(name)) => {
1477                if let Some(target) = module.bindings.get(name) {
1478                    stack.push(*target);
1479                }
1480            }
1481            Some(Node::Binary { lhs, rhs, .. }) => stack.extend([*lhs, *rhs]),
1482            Some(Node::Unary { expr, .. }) => stack.push(*expr),
1483            Some(Node::Array { elements }) => stack.extend(elements.iter().copied()),
1484            Some(Node::Table { fields }) => stack.extend(fields.values().copied()),
1485            Some(Node::Interpolate { parts }) => {
1486                for part in parts {
1487                    if let crate::ir::InterpPart::Expr(e) = part {
1488                        stack.push(*e);
1489                    }
1490                }
1491            }
1492            Some(Node::Semantic { inner, .. }) => stack.push(*inner),
1493            Some(Node::Env {
1494                default: Some(def), ..
1495            }) => stack.push(*def),
1496            _ => {}
1497        }
1498    }
1499    false
1500}
1501
1502/// Render an actual value for T006 (spec/annotations.md §6): secrets
1503/// as `secret`, strings debug-quoted, scalars plain, containers by
1504/// kind — truncated to 120 characters. Secret material can never
1505/// reach a message through here.
1506fn render_constrained(value: &Value) -> String {
1507    let text = match value {
1508        Value::Secret(_) => "secret".to_string(),
1509        Value::String(s) => format!("{s:?}"),
1510        Value::Int(i) => i.to_string(),
1511        Value::Float(f) => f.to_string(),
1512        Value::Bool(b) => b.to_string(),
1513        Value::Null => "null".to_string(),
1514        Value::DurationMs(ms) => format!("{ms}ms"),
1515        Value::Quantity(n, unit) => unit.format_amount(*n),
1516        Value::Array(_) => "array".to_string(),
1517        Value::Table(_) => "table".to_string(),
1518    };
1519    truncate_120(&text)
1520}
1521
1522/// Truncate rendered diagnostic text to 120 characters total
1523/// (119 content characters plus the ellipsis marker).
1524fn truncate_120(text: &str) -> String {
1525    const LIMIT: usize = 120;
1526    if text.chars().count() > LIMIT {
1527        format!("{}…", text.chars().take(LIMIT - 1).collect::<String>())
1528    } else {
1529        text.to_string()
1530    }
1531}
1532
1533/// Render structured walk segments in patch-path format (`a.b[0].c`),
1534/// the same shape everywhere: messages, diagnostic paths, and fills.
1535fn render_path(segments: &[PathSeg]) -> String {
1536    let mut out = String::new();
1537    for segment in segments {
1538        match segment {
1539            PathSeg::Key(key) => {
1540                if !out.is_empty() {
1541                    out.push('.');
1542                }
1543                out.push_str(key);
1544            }
1545            PathSeg::Index(i) => {
1546                out.push_str(&format!("[{i}]"));
1547            }
1548        }
1549    }
1550    out
1551}
1552
1553/// Read a value at a structured path (the immutable twin of
1554/// `insert_at` below): used to revalidate renamed and coerced
1555/// subtrees after resilient repairs move them.
1556fn value_at<'v>(result: &'v Value, segments: &[PathSeg]) -> Option<&'v Value> {
1557    let mut cursor = result;
1558    for segment in segments {
1559        match (segment, cursor) {
1560            (PathSeg::Key(key), Value::Table(map)) => cursor = map.get(key)?,
1561            (PathSeg::Index(i), Value::Array(elements)) => cursor = elements.get(*i)?,
1562            _ => return None,
1563        }
1564    }
1565    Some(cursor)
1566}
1567
1568/// Mutable twin of `value_at`: navigates to a parent container so
1569/// resilient renames can move keys within it.
1570fn value_at_mut<'v>(result: &'v mut Value, segments: &[PathSeg]) -> Option<&'v mut Value> {
1571    let mut cursor = result;
1572    for segment in segments {
1573        match (segment, cursor) {
1574            (PathSeg::Key(key), Value::Table(map)) => cursor = map.get_mut(key)?,
1575            (PathSeg::Index(i), Value::Array(elements)) => cursor = elements.get_mut(*i)?,
1576            _ => return None,
1577        }
1578    }
1579    Some(cursor)
1580}
1581
1582/// Insert a filled default at a structured path, descending tables by
1583/// key and arrays by index. Only existing containers are traversed:
1584/// the walk verified every parent, so a miss is defensive (patches
1585/// share no guarantees). The final segment is always a table key —
1586/// array slots are positional, never "missing" — anything else fails.
1587fn insert_at(result: &mut Value, segments: &[PathSeg], value: Value) -> bool {
1588    let Some((first, rest)) = segments.split_first() else {
1589        return false;
1590    };
1591    if rest.is_empty() {
1592        if let (PathSeg::Key(key), Value::Table(map)) = (first, result) {
1593            map.insert(key.clone(), value);
1594            return true;
1595        }
1596        return false;
1597    }
1598    match (first, result) {
1599        (PathSeg::Key(key), Value::Table(map)) => match map.get_mut(key) {
1600            Some(next) => insert_at(next, rest, value),
1601            None => false,
1602        },
1603        (PathSeg::Index(i), Value::Array(elements)) => match elements.get_mut(*i) {
1604            Some(next) => insert_at(next, rest, value),
1605            None => false,
1606        },
1607        _ => false,
1608    }
1609}
1610
1611/// The type side of T002 acceptance (spec/validation.md §3). `Int`
1612/// widens to `Float`; every other pair is exact. Bytes arrive as
1613/// integers (evaluation erases byte-ness); non-string Enums fail here
1614/// while wrong-member strings fail as T003.
1615fn type_accepts(typ: &ActiveType, value: &Value) -> bool {
1616    use ActiveType::*;
1617    use Value::Table as Tab;
1618    matches!(
1619        (typ, value),
1620        (Null, Value::Null)
1621            | (Bool, Value::Bool(_))
1622            | (Int, Value::Int(_))
1623            | (Float, Value::Float(_))
1624            | (Float, Value::Int(_))
1625            | (String, Value::String(_))
1626            | (Duration, Value::DurationMs(_))
1627            | (Bytes, Value::Int(_))
1628            | (Secret, Value::Secret(_))
1629            | (Any, _)
1630            | (Table { .. }, Tab(_))
1631            | (Enum(_), Value::String(_))
1632    )
1633}