Skip to content

fix(validator): treat an empty nested invariant result as a pass - #2228

Merged
smunini merged 4 commits into
mainfrom
fix/2171-nested-invariant-empty
Oct 27, 2026
Merged

smunini merged 4 commits into
mainfrom
fix/2171-nested-invariant-empty

Conversation

@mauripunzueta

Copy link
Copy Markdown
Contributor

Closes #2171.

Problem

FhirPathConstraintEvaluator wrapped constraints attached below the resource root as path.all(expr). FHIRPath all() treats a per-node empty result as false, but FHIR invariant semantics treat empty as a pass (and coerce already did that for root-attached constraints). As a result, R4/R4B ref-1 (which has no reference.exists() guard) fired on every display-only or identifier-only Reference, so those resources were rejected under HFS_VALIDATION_MODE=enforce.

Fix

This is option 2 from the issue: nested constraints are now evaluated in two phases, with no string splicing.

  1. Evaluate the relative focus path (contact[0], subject, …) against the resource to get the focused nodes.
  2. Evaluate the invariant AST once per node with evaluate(&invariant, ctx, Some(node)). This is the same call all() makes internally. Each result goes through the existing coerce.

The per-node results combine like this: if any node failed, the outcome is Failed. Otherwise, if any node was not evaluable, it is NotEvaluable. Otherwise it is Passed, which also covers the case where the path matches no nodes. Root and nested constraints now share one coercion rule. A nested invariant that returns a non-boolean still surfaces as a NotEvaluable warning; the where((e) = false).empty() rewrite would have dropped it silently. Both the focus paths and the invariants are cached as parsed ASTs. %context still resolves to the root, as before (doc note in lib.rs updated).

Tests

  • tests/fhirpath_constraint_tests.rs::nested_invariant_evaluating_to_empty_passes: ref-1 on Observation.subject for display-only, identifier-only, literal, and local-with-contained references (no issue), and local without contained (fires). Verified failing on main at the display-only case and passing with this change.
  • Unit tests in fhirpath_effects.rs: nested empty → pass, nested false → fail, nested non-boolean → NotEvaluable, absent focus → pass, multi-node name[0].given requires every node.
  • Existing real_pat1_invariant_fires_on_detail_less_contact and absent_nodes_pass_invariants pass unchanged.
  • cargo test -p helios-fhir-validator --features fhirpath passes locally. Clippy reports nothing for helios-fhir-validator.

Constraints attached below the resource root were spliced into
`path.all(expr)`. FHIRPath `all()` counts a per-node empty result as
false, so R4/R4B `ref-1` (which has no `reference.exists()` guard) failed
on every display-only or identifier-only Reference.

Evaluate nested constraints in two phases instead: evaluate the relative
focus path to get the nodes, then evaluate the invariant AST once per
node and run each result through the same `coerce` used for root
constraints. Any node failed -> Failed; else any not evaluable ->
NotEvaluable; else Passed (no nodes -> Passed). This keeps the
NotEvaluable warning for non-boolean nested results, which a pure
FHIRPath rewrite such as `where((e) = false).empty()` would drop.
@codecov

codecov Bot commented Oct 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.81633% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/fhir-validator/src/fhirpath_effects.rs 90.81% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

smunini and others added 3 commits October 24, 2026 12:14
FHIR/fhir-test-cases merged oldrobot's PRs #316-#320 on 2026-10-23 and
the "Test FHIRPath" gate (which runs the upstream corpus) went red on
every PR with 10 undeclared failures. Fix the engine rather than declare
them:

- Delimited identifiers: `none_of("`")` consumed the backslash before
  the escape parser ran, so Patient.`name` never resolved.
- unescape('json'): decode \uXXXX (pairing UTF-16 surrogates) and \/.
- Indexer: a negative index is out of range, so the result is empty
  instead of an error.
- skip({}) / take({}): an empty count returns empty.
- DateTime + / - calendar duration: accept hour- and minute-precision
  values with a timezone (@2015-02-04T10Z, @2015-02-04T10:00Z) and keep
  the input precision in the result, so `= @2015-02-04T11:00Z` compares.

Vendor the new cases into the local r4/r5 corpora. testLessThan28 then
exposed that extension(url) on a FHIR primitive only worked through a
hard-coded fabricated birthTime extension; read the primitive's own
extensions instead and drop the fabrication. The parse-all test now
requires invalid="syntax" expressions to be rejected.

(cherry picked from commit 7e2e6de)
@mauripunzueta
mauripunzueta marked this pull request as ready for review October 27, 2026 13:01
@claude

claude Bot commented Oct 27, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@smunini
smunini merged commit 5a4faf5 into main Oct 27, 2026
21 checks passed
@smunini
smunini deleted the fix/2171-nested-invariant-empty branch October 27, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

validator: nested FHIRPath invariant that evaluates to empty is reported as a failure (R4 ref-1 on a display-only Reference)

2 participants