Skip to content

Validate the instance

DHIS2 lets you put almost anything in a code and almost anything in a name. Most of it never bites - until the metadata leaves DHIS2 for a system that reads codes as identifiers and names as page headings, at which point a < in an option-set code stops a publish dead and a code with a space in it changes shape. This page runs the check that reads every code and name your instance holds, grades each finding by what it costs the publish, and writes the list your metadata owner can act on. It needs no project directory and it writes nothing to DHIS2.

Who this is for: the operator gating a build, and the implementer sizing up an instance's codes before generating anything.

Before you start: a d2w with a profile for your instance. A project is optional - see scope for what having one changes.

You will be able to:

  • run the FHIR-safety check and read its verdict off the terminal
  • say what an error, a warning, and an info each cost your build
  • read a grade against the posture your project generates under
  • use the code-source dial to preview a code migration
  • hand the written report to whoever owns the metadata

Run it

$ d2w fhir validate
running 5 step(s)
[1/5] connecting: http://localhost:8080
[2/5] selection: 2 data sets, 6 programs, 5 stages, 70 data elements, 13 option
sets, 5 categories, 1,332 organisation units
[3/5] instance sweep: 40 collections, 1,634 objects
[4/5] option sets: 13 read
[5/5] findings: 41 finding(s)
wrote /home/you/demo-ig/reports/fhir-validate-report.md
wrote /home/you/demo-ig/reports/fhir-validate-report.csv
wrote /home/you/demo-ig/reports/fhir-validate-report.pdf
                                 fhir validate
┌───────────────────┬──────────────────────────────────────────────────────────┐
│profile            │ local_basic (fhir.toml)                                  │
│resource types     │ 40                                                       │
│objects swept      │ 1634                                                     │
│option sets        │ 13                                                       │
│options            │ 52                                                       │
│attributes         │ 4                                                        │
│errors             │ 4                                                        │
│warnings           │ 4                                                        │
│infos              │ 33                                                       │
│selection findings │ 4 errors, 4 warnings, 20 infos                           │
│code coverage      │ 1/1433 (selection objects whose code can serve as an     │
│                   │ identity stem)                                           │
│[generate]         │ id                                                       │
│concept_code_source│                                                          │
│[generate.naming]  │ id                                                       │
│source             │                                                          │
│hostile names      │ refuse - every name is published exactly as DHIS2 states  │
│                   │ it                                                       │
└───────────────────┴──────────────────────────────────────────────────────────┘

The two code rows are labelled by the `fhir.toml` key each one is, because they
decide different things and the table is where a reader works out which of them
a `code-stem-refusal` is about. `[generate] concept_code_source` decides the
concept codes a CodeSystem publishes; `[generate.naming] source` decides the
identity stems every artifact is named by, which is what the `code coverage`
row above counts.
               findings by category (7)
┏━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┓
┃Severity ┃ Scope     ┃ Category              ┃ Count┃
┡━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━┩
│error    │ selection │ template-hostile-name │ 4    │
│warning  │ selection │ template-hostile-name │ 4    │
│info     │ selection │ spaced-code           │ 20   │
│info     │ instance  │ invalid-code          │ 1    │
│info     │ instance  │ missing-code          │ 1    │
│info     │ instance  │ template-hostile-name │ 11   │
│info     │ instance  │ unmapped-tracked-en...│ 1    │
└─────────┴───────────┴───────────────────────┴──────┘
                                  findings (4)
[one row per error - the object, its code, and the character it carries]
error: 4 error(s) found; exiting 1 (--no-fail to suppress)

That run is a 2.43.1 instance carrying the Sierra Leone demo database plus a few added programs, and it is the instance every transcript in this series was taken against. Its project states hostile_names = "refuse", which is why four names are errors; the scaffold writes "substitute", under which the same four are informational - see Grade under your hostile-names posture. The three wrote lines are echoed before the tables so a redirected log still names the files. The PDF writer can add a line of its own naming a glyph the bundled font has no drawing for - Font MPDFAA+NotoSans is missing the following glyphs - which is a note about the font, not a finding about your instance.

The default output is a status view, not the finding firehose: the summary table, a rollup with one row per (severity, scope, category), every error individually - because an error is what gates the build and you need the object without opening a file - and one closing line. instance rows render dimmed so the build path carries the visual weight. A national instance raises hundreds of warnings; reading them one row at a time is what the written report is for. --details puts every finding on the terminal too.

Read severity as build impact

Every finding is graded by what it does to your build, not by abstract hygiene:

Severity Meaning
error Your build will abort. An in-scope <, in a code on an identifier surface or in a name the guide publishes byte-true - both land in HTML the publisher writes unescaped and then strict-parses. The only findings that gate exit 1. Whether a name is published byte-true is hostile_names.
warning An in-scope degradation the build survives - a code falling back to the UID, a > or & malforming a page.
info Instance hygiene: the same defects on objects the build never reads - a code-migration watchlist, not build noise.

The summary's code coverage line counts how many in-scope objects carry a code usable as an identity stem - the R4 id bar, stricter than the R4 code datatype. It is the number to watch grow before switching [generate.naming] source from "id" toward "code". It counts the seven surfaces whose objects take an identity from their code - the six naming surfaces plus dataElements, whose codes become dictionary concept codes. The scope carries more surfaces than that, because a name is graded on every kind of object the selection publishes, but a fraction over a category option or a tracked entity attribute would answer a question nobody asks: those read their identity from the object that publishes them.

Read the scope column

Before anything is graded, the run resolves the configured selection into an emission scope - the same selection semantics generate uses, so validate and generate can never disagree about what is on the build path. Every finding carries the verdict as its scope: selection for objects the configured IG emits, instance for the rest.

Outside a project every selection table is empty, which selects everything of its kind - so the whole instance grades as being on the build path. Inside a project, findings grade against that project's own selection.

A package is graded against what it publishes

An organisation-unit registry package - [ig] kind = "package", publishes = "organisation-units" - holds no data set, no program and no form. d2w fhir generate refuses those targets in such a project by name, and validate scopes to the same thing: it resolves the organisation units and nothing else, costs one id-only read instead of five, and grades every form-side finding as the instance hygiene it is for a project that publishes no form.

The summary says so, and names the checks that do not apply:

$ d2w fhir validate
                              fhir validate
┌──────────────────┬────────────────────────────────────────────────┐
│profile           │ play43 (fhir.toml)                             │
│...               │                                                │
│scope             │ organisation units alone - [ig] publishes =    │
│                  │ "organisation-units", so this project holds no  │
│                  │ data set, program or form for the other checks  │
│                  │ to grade                                       │
└──────────────────┴────────────────────────────────────────────────┘
note: not applicable: data sets, programs, program stages, data elements,
tracked entity types, tracked entity attributes, option sets, categories,
category options - this project publishes none of them, so what the instance
holds of each is hygiene rather than this build's problem

The written Markdown and PDF reports carry the same two lines, so a reader of the file is never left to work out why a package's report is quiet. The facade's /facade/metadata-health reads the scoping off the project it serves, so it answers the same way.

Grade under your hostile-names posture

A severity says what an object costs this project, and half of that is hostile_names - the [generate] key that decides what a generate run does with a DHIS2 name the published guide cannot carry as it stands. Validate reads the same key, and the summary's hostile names row says which reading produced the counts above it.

Posture What a name carrying < is graded
"substitute" info. The guide publishes the name rewritten, the build survives, and the finding states both spellings: published as 'Vitamin A given to under 5y' - the name is rewritten for publication (hostile_names = "substitute"); DHIS2 keeps 'Vitamin A given to < 5y'.
"refuse" error. The guide publishes the name byte-true, so the build aborts and the remedy is to change the name in DHIS2.
unset error. An unanswered run refuses, so the grade is the refusing one.

Only what the rewrite settles moves. A name carrying > or & stays a warning under either posture, because the rewrite reads < and nothing else, so the page is malformed either way; when one name carries both, the finding states the published spelling and keeps the warning the leftover character earns. Under "substitute" a spaced-code finding names the hyphenated code the guide publishes - published as 'Pre-eclampsia' - each space becomes a hyphen for publication - instead of describing the quoted FSH form, which that posture never emits.

template-hostile-code follows the posture too. Under "refuse" and unset, an in-scope code carrying < is an error, and d2w fhir generate refuses the run. Under "substitute" the code is rewritten before any emitter reads it - the comparison reworded as a name's is, every space hyphenated, the DHIS2 code stated beside it as the dhis2-code property - so the build never meets the < and the finding is informational, naming the code the guide publishes. Changing the code in DHIS2 is still how it gets published byte-true.

code-substitution-collision is raised only under "substitute". It names a DHIS2 code whose rewrite lands on a code another object already publishes - Pre eclampsia beside a literal Pre-eclampsia, or <5 beside under 5 - with the other object in the message. It is an error when both are in scope, because d2w fhir generate refuses that run rather than publish one code for two objects; change one of the two codes in DHIS2.

--hostile-names substitute and --hostile-names refuse read the instance under the other posture without touching fhir.toml, which answers "what would this project cost if we changed our mind?":

d2w fhir validate --hostile-names refuse

The flag beats the config; the config beats unset. Exit 1 follows the graded severities, so a "substitute" project carrying nothing but rewritten names exits 0 - which is what lets make refresh run validate mid-chain and keep going.

Know what the five passes cover

Pass What it covers
Instance-wide sweep Every object's code in every collection /api/metadata returns, checked against the R4 code datatype; per-type duplicates; organisation units with no code at all.
Deep option-set pass Exactly what code-mode generation would do with each option set's options, over the same projections the emitter consumes.
Code-stem pass Exactly what a code-sourced [generate.naming] source does with each in-scope object of the naming surfaces (option sets, categories, organisation units, data sets, programs, program stages).
Deep attribute pass Every DHIS2 attribute the instance left uncoded - info, a coverage signal about how legible the D2AttributeValue extension is to a consumer without the instance.
Subject-typing pass Every tracked entity type the instance holds that [generate.tracked_entity_types] does not name.

The subject-typing checklist

A tracked entity type this project's mapping table never mentions is published as a Patient. That default is what keeps a person-tracking project's config empty, and it is applied silently - so on an instance that also tracks households, fridges, vehicles, water points, and lab samples, the way to find out which types are people is to read the list.

This pass writes the list out, one finding per untyped type, under the category unmapped-tracked-entity-type:

warning  selection  unmapped-tracked-entity-type  trackedEntityTypes  oWMH7vxiPpZ  Fridge
  tracked entity type is absent from [generate.tracked_entity_types], so its registrations
  are published as Patient; write '"oWMH7vxiPpZ" = "<resource>"' to publish it as something
  else

The severity is build impact like every other finding here. A type this build publishes a form for is a warning - something in this guide is about to call it a person. A type outside the selection is info: a fact about the instance, which this build never reads. Neither is ever an error, because "this really is a person" is a perfectly good reason to leave the line unwritten, and validate cannot tell which types those are. Working down the list once turns the silence into a decision.

The mapping itself is Custom subject types; what a served register does with several types is The register.

Every pass also checks the object's name for one thing that has nothing to do with codes: template-hostile-name, raised on any name carrying <, >, or &, which the IG publisher's template injects into HTML unescaped and then strict-parses. A name containing < is an error wherever the guide publishes it byte-true: the publisher cannot read back the page it just wrote, and the build fails. Under hostile_names = "substitute" the guide publishes that name rewritten and the finding is informational - Grade under your hostile-names posture. > and & cost a malformed page the build survives, so they are warnings under either posture.

The check reads the object's NAME and FORM_NAME translations beside its name, because those are the two translated properties the generate gate rewrites: a translated NAME becomes the published _title, _name or concept designation, and a translated FORM_NAME becomes a question's _text, so both land on page positions the publisher strict-parses. An object whose own name is clean and whose en_GB name carries a < is otherwise a build the publisher dies on and a report that never mentioned it. The finding names the object under its DHIS2 name - that is what you search the instance for - and the message names the locale and the property.

A second name check reads the other thing a real instance carries and a page cannot show: control-character-name, raised on any name holding a C0 control character (U+0000 through U+001F). SUSHI carries one byte-true from the FSH it compiles into the resource JSON, so the character reaches the published guide whatever the toolchain does with it. Tab, newline, and carriage return are the whole of what XML 1.0 admits below U+0020 - and the R4 string value regex [ \r\n\t\S]+ names the same three - so those reach the guide and cost only what they render as: the pages collapse them to a space, the FSH Title: the guide writes has already flattened them, and one object ends up stating its name two ways. Those are warnings. Every other C0 control character has no XML form at all, not even a numeric character reference, and the IG publisher writes an XML rendering of every resource beside the JSON one - so the guide has no readable rendering of that name and the build ends. Those are errors. The message names the character in words - a tab character, a null character, the control character \x01 - and the reports print it as an escape rather than letting an invisible byte reach the page as nothing.

Under hostile_names = "substitute" every control character is rewritten away: each one reads as the space it stood in and the whitespace around it collapses, which is what the FSH path already does to a name, so the resource element and the page furniture state one spelling instead of two. The finding is then informational and names both spellings. control-character-name is a category of its own rather than a template-hostile-name because it is a different defect on different evidence - no HTML template is what breaks - and because < alone is what the generate refusal gates on. A control-character-name error does not refuse a generate run.

A third check reads the invisible characters a value picks up when it is pasted from somewhere else: invisible-character, raised on a name, form name, option name, or code holding a stray Unicode format character - a zero-width space, a zero-width joiner, a byte-order mark, a direction mark. No screen shows one, so the value looks right everywhere, while search, sorting and matching read it as a different string from the one people see.

"Stray" is deliberate. Lao, Thai, Khmer and Myanmar are written without spaces between words and mark each word break with a zero-width space, and their input methods also leave one at the start of the text or beside an ordinary space. All of that is the script at work, so the check reports a format character only when everything it touches is visible ASCII: B. Hin\u200bTang, a code OU\u200b2, a byte-order mark ahead of Clinic.

The grade follows what the value is for. A code is a warning in scope: it is published in identifiers and, under a code-sourced naming, in resource ids and URLs, where nobody can see or type what it holds. A name or form name is info: the build survives it and the guide publishes it as DHIS2 holds it. The reports print the character as its escape (\u200b), and the fix is to retype the value in DHIS2 - editing around a character nobody can see does not remove it.

Phone numbers and emails are not read by validate. d2w fhir generate drops an invisible format character from an organisation unit's phone number or email as it publishes the Organization - the IG publisher renders a phone contact as a tel: link and fails QA on one - and raises a note naming each organisation unit whose DHIS2 value carried one.

template-hostile-name's sibling template-hostile-code grades the same three characters the same way, on the collections whose codes become identifier values (optionSets, categories, organisationUnits, dataSets, programs, programStages). The publisher writes identifier values into a table cell unescaped and fails in its last pass - so on a large IG one hostile code or name costs the entire build before it says so. The same finding here takes seconds. Generation does not escape its way around either finding: names and identifier values are what a consumer matches on, and an IG that disagrees with its instance is worse than a malformed page. The fix is in DHIS2, which is what the finding is for.

The name grade and the generate refusal are the same statement

Under hostile_names = "refuse" - and unset, which refuses - a template-hostile-name error on the build path and a d2w fhir generate refusal name the same objects, and they do so in both directions:

  • every name graded error in the selection scope here refuses a generate run, and
  • every name a generate run refuses is graded error in the selection scope here.

That holds over every kind of object a selection can publish: option sets and their options, categories and their category options, organisation units, data sets, event programs, tracker programs and their stages, tracked entity types, and the questions all of those ask - data elements and tracked entity attributes. Whichever command you run first, the other one has nothing new to tell you about names. Under "substitute" the parity holds the other way: neither command stops, because the guide publishes wording the build survives. Codes are the asymmetry that remains, and deliberately: a data element's code becomes a concept property the publisher escapes rather than an identifier value it does not, so template-hostile-code is raised only on the six collections above and generate gates codes only there.

Run both anyway, because they answer different questions. Validate answers "what does this instance cost this guide?" and grades every object it finds. Generate answers "may I write this guide?" and stops at the first object it cannot write - so on an instance with several offenders, only validate lists them all.

Preview a code migration with --code-source

The option-pass findings are gated on the effective code source - the --code-source flag when given, otherwise concept_code_source from fhir.toml. In id mode (the default) invalid-code, missing-code, and duplicate-code are downgraded to info and their message says so, because generation is not reading those codes yet. Run with code to see what switching would cost:

d2w fhir validate --code-source code

The code-stem pass works the same dial for naming: under source = "code-or-id" a missing, unusable, or colliding code is a code-stem-fallback warning (that object's ids fall back to the UID); under source = "code" the same object is a code-stem-refusal error - d2w fhir generate refuses the run through the same defect predicate, so a validate error is also a generate refusal.

The stem is read off the code the posture publishes, not the one DHIS2 holds. A run screens its names and codes before it plans an identity, so under hostile_names = "substitute" the option set coded Development activities stems from Development-activities - a perfectly good stem, and the file the run writes is CodeSystem-d2-os-Development-activities-cs.json. Grading the DHIS2 spelling would count offenders the run does not have and assert a fall-back the generator did not make. Where the rewrite leaves the code unusable anyway, the finding names both spellings.

spaced-code is the info-grade neighbour: a code with spaces is FHIR-valid but emits in the quoted #"..." FSH form - unless hostile_names = "substitute", under which the finding names the hyphenated code the guide publishes instead. It reads every surface the generate gate screens a code on, not options alone: optionSets, categories, categoryOptions, organisationUnits, dataSets, programs, programStages, trackedEntityTypes, dataElements and trackedEntityAttributes, so the report the refusal calls "the full report" names every code the run would rewrite. A code the R4 datatype refuses outright is reported as the invalid code it is and not a second time here.

Hand over the report

--output-dir names a directory, created if needed; the files inside are always fhir-validate-report.md / .csv / .pdf. The default is reports/ under the project root, or under the working directory when there is no project. --format takes a comma list of md, csv, pdf; all three are written by default, and each written path is echoed.

File Shape
.md Findings grouped under one section per resource type, a Scope column on every row.
.csv One row per finding: severity,scope,category,resource_type,uid,name,code,message. For spreadsheets and for diffing two runs.
.pdf Cover page with summary counts, clickable table of contents, one bookmarked section per resource type with severity-tinted rows. Noto Sans with Noto Sans Lao and DejaVu Sans fallbacks, so a Lao-script name and a code carrying a symbol such as the square root sign both render as themselves.

The human-facing renderings print a code with its control characters escaped (BLUE\nBLUE reads on one line) and wrap a code with leading or trailing spaces in double quotes (" M "), so an invisible character is visible; the CSV and JSON carry the raw code. The scaffolded .gitignore covers reports/ - the reports are regenerable snapshots of instance state; pin one deliberately (git add -f) when handing it over.

Wire it into CI

Exit 1 when there are errors - --fail is the default - which makes the command a CI gate. --no-fail exits 0 regardless, and drops the red

error: N error(s) found; exiting 1 (--no-fail to suppress)

line with it. d2w --json fhir validate emits the full findings list as JSON on stdout in place of the tables; progress narration stays on stderr (one plain [k/N] label: summary line per step when stderr is redirected, which is the form a CI log wants; --no-progress silences it). The exit-1 gate still applies, so CI reads the findings off stdout and the job still fails on errors.

Next: Generate the IG source