10 — Invariant coverage
FHIR states many rules as ElementDefinition.constraint invariants —
FHIRPath expressions such as "an extension must have a value or children, not
both". This spec records which of them the crate enforces, and enumerates the
rest so that unenforced rules are visible rather than silently dropped.
Requirements#
- R10.1 Every invariant the crate does not enforce MUST be listed here. Silence is not permitted: a reader must be able to tell what validation does and does not cover.
- R10.2 An invariant is enforced only if it can be checked structurally,
without a FHIRPath evaluator. Enforcement lives in
invariant_stmtsinfhir-derive-macros, so it applies to every release at once.
Scale#
| Release | Distinct invariant keys | Constraint occurrences | ele-1 occurrences |
|---|---|---|---|
| R6 | 360 | 9,782 | 8,705 |
| R5 | 314 | 10,992 | 8,363 |
| R4 | 240 | 8,971 | 6,710 |
| R3 | 187 | 1,039 | 383 |
| R2 | 147 | 155 | 1 |
Every figure in this spec is produced by bin/invariant-counts;
run it and paste when the vendored definitions change.
Both counts are taken over the snapshot element lists of the
StructureDefinitions in a release's profiles-resources and profiles-types
definitions. The qualifiers matter, and were previously unrecorded: counting
differential as well adds a few hundred (R5 goes to 11,374), and including
profiles-others changes the key column too (R5 goes to 321).
The occurrence column is not a measure of how much a release asks for, and the
right-hand column is why. It is dominated by ele-1 — "all FHIR elements must
have a @value or children" — which the later releases restate on every element
of every snapshot and the earlier ones do not. R2 states 147 distinct rules in
155 occurrences, because it records each one essentially once; R5 states 314 in
10,992, because 8,363 of those are one universal constraint repeated. R2 is
thus not two orders of magnitude simpler than R5, though the middle column says
so.
Compare the keys column when reading this as coverage.
Enforced#
| Key | R6 | R5 | R4 | R3 | R2 | Rule |
|---|---|---|---|---|---|---|
ele-1 |
8705 | 8363 | 6710 | 383 | 1 | All FHIR elements must have a @value or children |
ext-1 |
22 | 1637 | 1295 | 1 | 0 | An extension must have either extensions or value[x], not both |
dom-2 |
124 | 122 | 145 | 116 | 1 | A contained resource SHALL NOT itself contain nested resources |
dom-4 |
124 | 122 | 145 | 116 | 1 | A contained resource SHALL NOT have a meta.versionId or meta.lastUpdated |
qty-3 |
7 | 7 | 7 | 7 | 7 | If a unit code is present, the system SHALL also be present |
inv-1 |
1 | 3 | 3 | 4 | 2 | A parameter must have one and only one of (value, resource, part) |
att-1 |
1 | 1 | 1 | 1 | 1 | If an Attachment has data, it SHALL have a contentType |
drq-1 |
1 | 1 | 1 | 0 | 0 | Either a path or a searchParam must be provided, but not both |
These are the structurally checkable invariants: each is a presence, absence, or
exclusive-choice test that needs no FHIRPath evaluator. Between them they cover
8,363 of R5's 10,992 constraint occurrences. ele-1 is almost all of that;
ext-1 covers 1,637 R5 elements; the two dom-* rules apply to every domain
resource's contained list; qty-3 applies to Quantity and each of its six
specializations.
R10.5
ele-1—hasValue() or (children().count() > id.count())— MUST be enforced, and does not require a FHIRPath evaluator in this representation. This spec previously said it did, which was wrong: the expression names onlychildren()andid, and against a statically typed model "children other thanid" is just "fields other thanid". The primitives carry their value in a newtype and always satisfy the first clause, so only the complex types can be empty.R10.6
ele-1MUST NOT be applied to a resource root. In FHIRResourcedescends fromBase, not fromElement, and the definitions agree: of the 71 root elements carryingele-1in R5, every one is a datatype. An emptyPatientis unhelpful but is not anele-1violation. A resource is identified by itsimplicitRulesfield, which comes from theResourcebase and appears in no datatype or backbone element.R10.7 An element whose only child is its
idMUST failele-1. The expression is a strict inequality againstid.count(), soiddoes not count towards the children that satisfy it.R10.3 A check MUST be gated on the fields it reads actually existing with the shape it expects, not on the struct's name alone. The releases disagree about both: DSTU2 models
Ageas an empty struct beside anAgeQuantityholding the fields, and R3 typesDataRequirement.codeFilter.pathas1..1where R4 has0..1. A check written against one release's shape and keyed only by name does not merely fail to apply elsewhere — it fails to compile that release. Where the shape is absent the rule is skipped, which in thedrq-1case agrees with the specification, R3 not stating that constraint at all.R10.4 A check MUST NOT be inferred from field names alone either.
Codingcarriescodeandsystemexactly asQuantitydoes, and must not be subject toqty-3.
These numbers count restatement, not enforcement. All these rules are
checked identically in every release, because enforcement lives in
invariant_stmts in fhir-derive-macros and applies to all of them at once
(R10.2). What varies is how often a release's published snapshots repeat the
rule. R2 states dom-2 and dom-4 once each, on DomainResource, and does not
state ext-1 at all; R3 states ext-1 exactly once, on Extension itself;
R4 and R5 restate it on every extensible element, which is where the four-figure
counts come from; R6 returns to stating it only on the datatypes. A low number
in this table therefore says something about the specification's editorial
style, and nothing about this crate's coverage.
Not yet enforced (most frequent)#
| Key | R6 | R5 | R4 | R3 | R2 | Rule |
|---|---|---|---|---|---|---|
dom-3 |
124 | 122 | 145 | 116 | 1 | A contained resource SHALL be referred to from elsewhere in the resource |
dom-5 |
124 | 122 | 145 | 0 | 0 | A contained resource SHALL NOT have a security label |
dom-6 |
124 | 122 | 145 | 0 | 0 | A resource should have narrative for robust management |
dom-1 |
0 | 0 | 0 | 116 | 1 | A contained resource SHALL NOT contain narrative (dropped after R3) |
cnl-1 |
34 | 33 | 0 | 0 | 0 | URL should not contain a pipe or # — they make canonical processing hard |
cnl-0 |
32 | 32 | 0 | 0 | 0 | Name should be usable as a machine-processing identifier |
org-3 |
2 | 2 | 1 | 1 | 1 | An organization's telecom can never be of use home |
age-1 |
1 | 1 | 1 | 1 | 1 | An Age SHALL have a code if it has a value, expressing time |
cnt-3 |
1 | 1 | 1 | 1 | 0 | A Count SHALL have code 1 if it has a value |
What is left here is the tail, not the bulk: ele-1 accounted for the large
majority of all constraint occurrences and is now enforced (R10.5). The keys
below appear once or twice each. Of the 360 distinct keys in R6, 314 in R5, 240
in R4, 187 in R3 and 147 in R2, the enforced seven cover 8,363 of R5's 10,992
occurrences and the remainder are long and flat. Regenerate these tables with
bin/invariant-counts when coverage changes.
These need a FHIRPath evaluator in a way ele-1 did not: dom-3 has to
resolve references across the resource, csd-1 walks descendants(), and
several others project a collection and test isDistinct(). Traversal is the
dividing line, not FHIRPath syntax as such.
The releases do not state the same rules: cnl-0 and cnl-1 arrived in R5 and
carry into R6, and dom-1 was dropped after R3. A column per release is shown
rather than one because a zero can mean either "not stated in this release" or
"not restated per element" — and the two are indistinguishable from the count
alone, which is why the sentence above the previous table says what it says.
Future work#
- A FHIRPath evaluator, which would unlock the tail: the rules that traverse —
resolve(),descendants(),isDistinct()over a projection.ele-1is no longer among them (R10.5). - Enforcing the arithmetic invariants (
app-5: start ≤ end, and similar) that are structurally checkable but element-specific, most likely as generated per-type checks driven by themetatable. - The regex rules
cnl-0andcnl-1, which are structural but are stated on particular elements (url,name) rather than on a type, so they need the element table rather than the struct's shape to place them.
Acceptance criteria#
- Every invariant in the Enforced table has a test in the release crates'
validatemodule:invariant_ele_1,invariant_ext_1,invariant_dom_2,invariant_att_1,invariant_qty_3,invariant_drq_1,invariant_inv_1.invariant_ele_1asserts all three of its edges: an empty element fails, an element whose only child isidfails (R10.7), and an empty resource root does not (R10.6). - A test asserts that
qty-3does not fire on aCoding, which has the same two fields (R10.4). - Every release crate's unit tests run in CI, so a check that compiles against one release's shape and not another's fails the build (R10.3).
- Unrecognized constraints are enumerated here rather than dropped.
- The counts above match the shipped specification JSON, and are produced by
bin/invariant-counts.