Skip to content

Fix faulty invariant FHIRPath expressions (+ regression fixtures)#4122

Open
jmandel wants to merge 1 commit into
masterfrom
invariant-expression-fixes
Open

Fix faulty invariant FHIRPath expressions (+ regression fixtures)#4122
jmandel wants to merge 1 commit into
masterfrom
invariant-expression-fixes

Conversation

@jmandel

@jmandel jmandel commented May 28, 2026

Copy link
Copy Markdown
Contributor

Fix faulty invariant FHIRPath expressions (+ regression fixtures)

What this does

Corrects invariant (constraint) FHIRPath expressions whose evaluation does not match the
rule's stated human text, and adds a new invariant-test fixture for each so the behavior is
locked in.

Key evaluation fact this relies on: the validator passes a constraint only when its FHIRPath
converts to boolean true; false, an empty result {}, and a thrown runtime error all
count as fail. Several of the fixes (and several non-fixes, see below) turn on this.

How each fix was verified

Every change was checked against the real validator (org.hl7.fhir.core 6.9.8, R6) and/or a
convertToBoolean wrapper that reproduces the validator's pass/fail mapping. Each new fixture's
instance was evaluated under both the old and the new expression and the verdict flips in
the intended direction (a .fail fixture that the old expression wrongly passed; a .pass
fixture the old expression wrongly failed). Abbreviations below: FP = false-positive (rejects
valid data), FN = false-negative (accepts invalid data).

Fixes included (≈29 invariants, 15 files) + new fixtures

Single-value operators (=/!=/in) on repeating elements

=/!= on different-length collections return false; in throws on a multi-item operand.

key dir sev issue fixture
bdl-14 Bundle error FN: a PATCH in a multi-entry history bundle isn't caught → entry.request.all(method != 'PATCH') bdl-14.f2.fail.json
bdl-16 Bundle error FP: any OperationOutcome with ≥2 issues is rejected → issues.issue.all(severity = …) bdl-16.p2.pass.json
csd-3 CodeSystem warning FN: a concept with >1 property never warns → property.where(code='parent' or code='child').exists() csd-3.f3.fail.json
csd-6 CodeSystem error FP: a concept that is the alternate of two others fails reciprocity → …where(code='alternateCode' and value=%sc).exists() csd-6.p2.pass.json
pld-3 PlanDefinition warning FP: an action with 2+ goalIds throws → …goalId.all($this in %context.goal.id) pld-3.p1.pass.json

count()/exists() aggregated across all repeats instead of per item

key dir issue fixture
isl-7/isl-8/isl-11 error FN: odd per-region/instance counts summing even pass → instance.imageRegion2D.all(coordinate.count() mod 2 = 0) (and mod 3, waveform) isl-7.f2, isl-8.f2, isl-11.f2 .fail.json
isl-10 error FP: two instances each using one selection mechanism are rejected → wrap in instance.all(...) isl-10.p1.pass.json

xor used for "mutually exclusive"

key dir issue fixture
mrp-3/mrp-4 error FP: a population with neither count nor countQuantity is rejected → (count.exists() and countQuantity.exists()).not() mrp-3.p1, mrp-4.p1 .pass.json

Unanchored matches() (partial match)

key dir issue fixture
que-16 / qrs-3 error FN: a linkId like "double space" passes → anchor with ^…$ que-16.f1, qrs-3.f1 .fail.json
exp-2 error FN: an Expression.name like "9-bad name" passes → anchor exp-2.f1.fail.json
eld-12 error FN: startsWith('https') (no colon) accepts 'httpsfoo'startsWith('https:') eld-12.f1.fail.json

Tree-walk that skips a level

key dir issue fixture
csd-1 error FN: code-uniqueness misses concepts one level deep → concept.code.combine(concept.combine(concept.descendants()).concept.code).isDistinct() csd-1.f2.fail.json
qrs-2 error FN: duplicate-linkId children of a top-level item aren't checked (repeat() excludes the seed) → also check the item's own children qrs-2.f1.fail.json

Other

key dir issue fixture
eld-21 warning FN: expression.exists() is true for an extension-only primitive → expression.hasValue() eld-21.f1.fail.json
cnt-3 error FP: a whole number written 5.0 is rejected (toString() keeps the .) → value = value.round() cnt-3.p1.pass.json
bdl-7 error FP: delimiter-less fullUrl & versionId key collides → insert '|' bdl-7.p1.pass.json
drt-1 error FN+FP: implication inverted (code→value) and a code-only Duration is rejected → (value.exists() implies code.exists()) and (system.exists() implies system = %ucum) drt-1.f1.fail + drt-1.p1.pass.json
pld-6 / adf-2 warning FP: clause references a non-existent artifact element; both resource+resourceReference (allowed) is rejected → … implies (resource.exists() or resourceReference.exists()) pld-6.p1, adf-2.p1 .pass.json
que-5 / que-5b error FP: the type list says 'uri' but the item-type code is 'url' → use 'url' que-5.p1, que-5b.p1 .pass.json
ist-4 error FP: antecedent gates on series.exists() instead of series.instance.exists() → a summary-only study is rejected ist-4.p1.pass.json

Text-only (the FHIRPath was already correct — only the human text was wrong; no fixture)

  • bdl-3b (Bundle): text said "request or response" but the spec/expression require both → text fixed to "and".
  • docRef-2 (DocumentReference): text said "context is not present" but the expression (matching its sibling docRef-1 and its own pass fixture) means "context is not an encounter" → text fixed.

Element-rename only (no fixture possible)

  • inv-2 (Event pattern): referenced the obsolete reasonCode/reasonReference (R6 merged these into reason) so it could never fire → updated to reason.exists().not(). It's defined only on the abstract Event pattern and isn't applied to concrete resources, so there is no instance that can exercise it.

Left out, and why

Earlier-suspected, but verified CORRECT (no change) — ~16 invariants

An earlier reasoning pass (before validating) wrongly assumed an empty FHIRPath result meant
"pass." The validator treats empty as fail, so the following are actually correct and were
not touched:

  • "Max is a number or *" family: eld-3, md-1, opd-9, smp-3 (a non-numeric max
    toInteger() empty → fails, which is right).
  • "Empty-antecedent" family: que-10, que-13, que-17, sdf-1, vsd-9, opd-2, opd-3,
    sdf-21, sdf-30 (the empty result lands on the violation, so it fails correctly; valid edge
    cases produce a real true).
  • obd-0 (the Java engine supports .value on a primitive), eld-11 (special-cased in Java),
    apd-1 (fires correctly for resolvable formOf; only the universal resolve() limitation
    remains).

Confirmed issues deferred (need work this branch can't safely do)

  • dgr-1 (DiagnosticReport): only walks top-level section.entry, missing nested sub-section
    entries. The traversal fix (repeat(section)) is verified in isolation, but the full invariant
    depends on resolve()/%resource/hasMember membership, which can't be evaluated outside full
    validation — so I couldn't construct a fixture whose end-to-end verdict provably flips. Reverted
    here pending validation-context confirmation.
  • cmp-3 (Composition): the rule is anchored on Composition.section, so it can't fail when
    reached and never reaches its real violation (attester + no text + no section). The fix is to
    re-anchor the constraint to the resource root (a structural move, not just an expression
    edit) — left for a deliberate change.
  • tst-1 / tst-2 / tst-3 (TestScript): 3-way chained xor is true for an odd count, so
    "all three present" passes; tst-3's or-of-empties only catches all-three. These are authored
    in a binary .xlsx spreadsheet that isn't editable in this workflow; they need fixing in the
    TestScript spreadsheet source.

Notes

  • A few invariants (isl-7/8/10/11, pld-6, adf-2, cnt-3, drt-1, eld-12, eld-21) are
    newer than, or are datatype invariants not surfaced by, the validator's loaded core package, so
    they were verified via the validator's FHIRPath engine + the convertToBoolean rule rather
    than a full package validation. Datatype-invariant fixtures are hosted on a carrier resource
    (cnt-3/drt-1 under Parameters, exp-2 under PlanDefinition, eld-* under
    StructureDefinition), following the existing convention (e.g. qty-4 under Observation).
  • csd-1's gap is also masked at full validation by a separate built-in duplicate-code check, but
    the invariant's own FHIRPath is corrected regardless.

🤖 Generated with Claude Code

Corrects invariant expressions whose FHIRPath does not match the rule's stated
intent under the validator's evaluation semantics (a constraint passes only if
its expression converts to boolean true; false, empty {}, and runtime errors
all fail). Each behavioral fix ships a new invariant-test fixture that flips
verdict between the old and new expression.

Fixes, by class:
- Single-value operators (=, !=, in) on repeating elements: bdl-14, bdl-16,
  csd-3, csd-6, pld-3
- count()/exists() aggregated across all repeats instead of per item:
  isl-7, isl-8, isl-10, isl-11
- xor used for "mutually exclusive" (wrongly forbids "neither"): mrp-3, mrp-4
- Unanchored matches() (partial match): exp-2, que-16, qrs-3; eld-12 (missing ':')
- Tree-walk that skips a level: csd-1, qrs-2
- .exists() where a value is required (extension-only primitive): eld-21
- Representation pitfalls: cnt-3 (5.0 toString), bdl-7 (delimiterless key)
- Inverted/over-strict logic: drt-1
- Clause referencing a non-existent element: pld-6, adf-2
- Obsolete element names (reasonCode/reasonReference -> reason): inv-2
- Wrong literal token ('uri' -> 'url'): que-5, que-5b
- Antecedent guards the wrong element (series vs series.instance): ist-4
- Human text corrected, expression already correct: bdl-3b, docRef-2

Deferred (identified, not included): dgr-1 (needs resolve()-context
verification), cmp-3 (needs structural re-anchor from .section to the
resource), tst-1/tst-2/tst-3 (3-way chained xor; authored in a binary
spreadsheet, not editable here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant