Check an instance with doctor¶
Somebody hands you a DHIS2 URL and a login. Before you spend a day on it, you want to know whether this instance's metadata actually survives the trip - whether its codes are usable, whether its forms can be turned into something another system reads, whether a filled-in form comes back and imports. This command answers that in a single run, against that instance, in a throwaway directory it deletes afterwards. It never writes data to your instance.
Who this is for: anyone about to point this toolchain at a DHIS2 instance they have not used before - a new country profile, a fresh deployment, an upgrade they want to re-clear.
Before you start: a DHIS2 profile that resolves (Profiles). Nothing else. Doctor scaffolds its own project, in its own throwaway directory, and cleans up after itself.
You will be able to:
- get a one-command verdict on whether this instance works with the toolchain
- read the phase table and tell a broken instance from a noisy one
- explain what the oracle phase proves, and in which direction
- find out whether the guide you already published still describes the instance
d2w -p laos fhir doctor # the whole chain, one verdict
d2w -p laos fhir doctor --live # and let the instance judge the output
cd my-guide && d2w fhir doctor # and check that guide for drift as well
When to run it¶
Run doctor the first time you meet an instance, and again whenever the instance changes under you.
- A handover. Someone gives you a URL and a token. Doctor tells you in one
run whether
generate,serve,capture, andforwardall work against it - before you invest a day in a project directory. - An upgrade. The instance moved from 2.41 to 2.42. Doctor's connect phase states the version and which plugin tree bound to it, and the rest of the run says whether anything downstream noticed.
- A metadata change. A country renamed half its facilities. Run
--liveand the oracle says whether what the toolchain would serve still derives from what the instance now holds. Run it from your project directory and the drift phase says the same thing about the guide you already published. - A bug report. "Forwarding fails on our instance." A doctor run is the smallest complete reproduction, and its markdown report is the attachment.
Doctor is not a build. It never publishes anything, it never writes to the instance - the forward phase runs in validate-only mode - and its workspace is deleted when the run ends unless you name one.
The verdict¶
A run ends on one line:
verdict: BROKEN: 6 pass, 3 warn, 1 fail, 0 skipped, 0 blocked; forward failed - this
instance breaks the toolchain as configured
Exit code 1 follows a fail and nothing else. Warnings and skips exit 0, because a warning is something to read and a skip is something this machine could not offer.
| Outcome | What it means |
|---|---|
pass |
The phase ran and found nothing worth acting on. |
warn |
The phase ran and found something that degrades the result without breaking it. |
fail |
The phase ran and found something broken. The run exits 1. |
skipped |
The phase did not run, because this machine or this invocation does not offer what it needs. The reason is stated. |
blocked |
The phase did not run, because an earlier phase did not produce its input. The reason names it. |
A failure does not stop the run. Only a phase that structurally depends on a missing input is blocked - no store means no capture - so one broken thing does not hide the six working ones behind it.
The ten phases¶
connect opens a client against the profile, detects the instance's
version from /api/system/info, and states which of the v41 / v42 /
v43 plugin trees bound to it. Bad credentials or an unreachable host fail
here as one line, and everything after is blocked: there is nothing to
check against.
scaffold writes a throwaway project. By default it picks a small
representative probe - the first data set by name, the first
WITHOUT_REGISTRATION program, the first WITH_REGISTRATION program - and
then chooses the slice of the organisation-unit hierarchy those forms are
actually assigned to, because DHIS2 refuses a response naming a unit a form
is not assigned to and a probe that ignored that would be grading its own
arithmetic. A form assigned nowhere inside that slice is dropped from the
selection with the reason stated. --all-targets scaffolds empty selection
tables instead, which means every data set, every program, and every level.
generate runs the full pipeline against the instance - the same
d2w fhir generate a project runs - and keeps every note it raised. Notes
are warnings: an unmatched selection entry, a question no synthetic answer
fits, a reference that leaves the selection.
compile runs a real FSH compiler over the emitted source: sushi on
PATH, or the fhir-ig docker image the scaffold's make setup builds.
Doctor never builds that image - pulling a JVM and a node toolchain is not
a decision a conformance check gets to make on your machine - so a machine
with neither is reported skipped with that reason. A compile is evidence,
not a gate doctor may demand of every machine.
validate is d2w fhir validate folded in: the instance's codes graded
for FHIR-safety, with the scope-aware severity rollup. An error on the
configured build path fails the phase; hygiene elsewhere in the instance is
a warning.
serve builds the served store in process - no port is bound, no subprocess starts. When the compile ran, the store is the compiled guide. When it did not, the live builders produce the same documents and doctor writes them where a compiler would have, so the phases after it read one tree either way. The phase then counts resources per type and says whether the read-set a capture client needs is present.
capture asks the served endpoint to fill in each published form
($generate) and posts each answer straight back at it
(POST /QuestionnaireResponse), holding it to the invariant the operation
exists for: what this server generates, this server accepts, 201. A
form the server cannot generate against is a warning; an endpoint refusing
its own output is a failure. Registration forms are captured before their
stages, because a stage response answers against the enrollment a spooled
registration minted.
forward drains that corpus at the real instance in validate-only mode - nothing is written, nothing moves. Rejections roll up by cause, so two hundred responses breaking one rule read as one row. A clean instance shows 0 rejected. Responses a dry run cannot check - a stage event whose enrollment this very run would have created - are counted separately and are not held against the instance.
oracle is the phase doctor exists for, and it runs on --live.
drift is the one phase whose subject is not the throwaway project. Run
from a directory a fhir.toml sits in or above, it reads the guide already
published there and names everything the instance now holds inside that
project's own selection that the guide does not. Run from anywhere else it
is skipped, with that as the reason.
The oracle, explained¶
Every other phase asks: does the toolchain run? The oracle asks a harder question: does what the toolchain would serve still say what the instance says?
It works one family at a time - organisation units, option sets, data sets, programs. For each, it takes the resources the store holds, reads the DHIS2 UID each one names, and asks the instance for those objects back. Two things can go wrong, and both are findings:
- A resource names an object the instance does not hold. Something was deleted, or renamed out from under the guide.
- A resource disagrees with the object it derives from. The instance
says the facility is called "Bo District" and the served
Organizationsays "Bo".
For the second check it takes a sample - five per family by default,
--samples N to change it - drawn with a fixed seed, so two runs against
one unchanged instance judge the same objects and a mismatch reproduces by
re-running the command.
The instance is the authority, always. The DHIS2 object is the fact and
the served resource is the claim about it; a mismatch is never the
instance's mistake. That direction is the whole point. Anyone can write a
test that says the toolchain agrees with itself. The oracle says whether it
agrees with the country's own data, today, and it names the field path -
name, title, identifier[1].value - where it stopped agreeing.
Drift, explained¶
A guide is a photograph. d2w fhir generate reads the instance once, the
compiler turns that reading into artifacts, and from then on the artifacts
say what the instance said on the day they were written. DHIS2 keeps moving.
A chiefdom is split, an option is added to a set, a question is dropped from
a stage - and nothing in the published guide knows.
The drift phase is that comparison. It reads the artifacts on disk - the
same trees d2w fhir serve and d2w fhir check-artifacts read - and asks
the instance for everything they claim, in five classes:
| Class | Published side | Instance side |
|---|---|---|
| Organisation units | the Location of every unit the registry carries |
the hierarchy under root, down to max_level |
| Options | the concepts of every published option-set CodeSystem |
that option set's options today |
| Tracked entity attributes | the questions of a registration form | the program's or the type's attributes |
| Data elements | the questions of a data set, event, or stage form | that object's data elements |
| Program stages | the stage forms a tracker program publishes | that program's stages |
Each class reports in both directions and on renames alike: something the
instance gained, something it lost, and something whose name changed under a
UID that did not. A rename matters because the published name is what a
reader of the guide sees - D2TEA_CS carries every attribute's name, a
CodeSystem concept carries every option's - so a guide calling an object
what the instance no longer calls it is wrong the way documentation is wrong.
Scope is the whole discipline. Drift is measured inside
[generate.organisation_units] and the selection tables, and nowhere else. An
organisation unit outside the registry root is not drift - the project never
asked for it - so a one-district guide against a national instance reports
one district's worth of movement, not a thousand facilities it never claimed.
Drift is a warning, never a failure. A guide describing the instance as it stood last month still serves, still captures, and still forwards. It is out of date, not broken, so drift never exits 1. The remedy is the same sentence for every finding, which is why it is stated once on the phase rather than once per row:
Tracked entity types are deliberately not here. d2w fhir validate
already names every type the project never typed, under
unmapped-tracked-entity-type, and the drift phase points at that checklist
rather than repeating it.
Reading a real run¶
A whole --live run against a 2.43.1 instance carrying the Sierra Leone demo
database, launched from a project directory so the drift phase has a
published guide to read. It opens with what it connected to:
fhir doctor
┌──────────────┬───────────────────────────────────────────────────────────────────────┐
│profile │ local_basic (--profile/DHIS2_PROFILE) │
│instance │ http://localhost:8080 │
│DHIS2 version │ 2.43.1 (plugin tree v43) │
│workspace │ /home/you/doctor-ws │
└──────────────┴───────────────────────────────────────────────────────────────────────┘
phases (10)
┏━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃Phase ┃ Outcome ┃ Seconds ┃ What it found ┃
┡━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│connect │ pass │ 0.9 │ http://localhost:8080 is DHIS2 2.43.1, bound to the v43│
│ │ │ │ tree │
│scaffold │ pass │ 0.1 │ 13 file(s) into /home/you/doctor-ws; Child Health │
│ │ │ │ (BfMAe6Itzgt) as the first data set by name, Antenatal │
│ │ │ │ care visit (lxAQ7Zs9VYR) as the first event program by │
│ │ │ │ name, ANC follow-up (PrAncCare01) as the first tracker │
│ │ │ │ program by name; organisation units under at6UHUQatSo │
│generate │ warn │ 0.5 │ 322 file(s) across 7 target(s), 4 note(s) │
│compile │ pass │ 152.7 │ docker fhir-ig sushi compiled 84 resource(s) │
│validate │ warn │ 0.6 │ 1,634 object(s) swept; 0 selection error(s), 5 │
│ │ │ │ selection warning(s), 0 error(s) and 5 warning(s) │
│ │ │ │ instance-wide │
│serve │ pass │ 0.1 │ 355 resource(s) from the compiled guide: │
│ │ │ │ CapabilityStatement 1, CodeSystem 25, ConceptMap 19, │
│ │ │ │ ImplementationGuide 1, List 3, Location 108, │
│ │ │ │ NamingSystem 26, OperationDefinition 1, Organization │
│ │ │ │ 108, Questionnaire 5, QuestionnaireResponse 5, │
│ │ │ │ StructureDefinition 27, StructureMap 1, ValueSet 25 │
│capture │ pass │ 0.0 │ 5 form(s), 5 generated, 5 accepted as 201 │
│forward │ fail │ 0.2 │ 5 spooled, 5 translated, 0 refused, 5 posted (validate │
│ │ │ │ only), 3 accepted, 1 rejected, 1 unverifiable │
│oracle │ pass │ 0.0 │ organisation units: 108 resource(s) over 107 DHIS2 │
│ │ │ │ object(s), 107 resolved, 5 deep-compared; option sets: │
│ │ │ │ 13 resource(s) over 13 DHIS2 object(s), 13 resolved, 5 │
│ │ │ │ deep-compared; data sets: 1 resource(s) over 1 DHIS2 │
│ │ │ │ object(s), 1 resolved, 1 deep-compared; programs: 2 │
│ │ │ │ resource(s) over 2 DHIS2 object(s), 2 resolved, 2 │
│ │ │ │ deep-compared │
│drift │ warn │ 0.3 │ 2 object(s) moved since the guide was published: 16 │
│ │ │ │ organisation unit(s), 12 option set(s), and 6 form(s) │
│ │ │ │ read against the hierarchy under O6uvpzGd5pu down to │
│ │ │ │ level 3. run `d2w fhir generate`, then `make sushi`, to│
│ │ │ │ publish the instance as it now stands. tracked entity │
│ │ │ │ types are graded by `d2w fhir validate` under │
│ │ │ │ `unmapped-tracked-entity-type`, not here │
└─────────┴─────────┴─────────┴────────────────────────────────────────────────────────┘
Read the phase table first and the findings second. The table says which leg of the chain broke; the findings table under it says what to do about it:
findings (7)
┏━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃Phase ┃ Severity ┃ Subject ┃ Where ┃ What ┃
┡━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│generate │ warning │ form-structure │ - │ data set 'Child Health' (BfMAe6Itzgt) │
│ │ │ │ │ greys out 8 disaggregated cells, which │
│ │ │ │ │ are not published; a response answering│
│ │ │ │ │ one would not be of the form: ... │
│generate │ warning │ selection-gap │ - │ 1 organisation units have a parent │
│ │ │ │ │ outside the selection; partOf omitted: │
│ │ │ │ │ Western Area (at6UHUQatSo) │
│forward │ error │ E1300 │ - │ 1 response(s): Generated by ProgramRule│
│ │ │ │ │ (`...`) - `...`. │
│drift │ warning │ organisation │ added │ the instance holds it in the registry │
│ │ │ unit Kpaka │ │ scope this project publishes; the guide│
│ │ │ (OuNewChief1) │ │ publishes nothing for it │
│drift │ warning │ option │ added │ the instance holds it in the published │
│ │ │ Rotavirus │ │ option set Vaccine type (OsVaccType1); │
│ │ │ (OptVacRot01) │ │ the guide publishes nothing for it │
└─────────┴──────────┴────────────────┴───────┴────────────────────────────────────────┘
Here the chain is sound end to end and the one failure is a DHIS2 program
rule on the instance refusing a synthetic value - a fact about that
instance's configuration, which is exactly what a handover needs to surface
before anyone builds on it. The selection-gap warning is the probe's own
doing: it scoped the run to one subtree, so that subtree's own root has a
parent nobody selected. One finding is one row: several generate targets read
the same source notes, and a note three of them raised is still one fact about
the instance.
The two drift rows are the other half of the story the oracle tells. The
oracle says the served output still derives from the instance; drift says the
guide on disk no longer covers all of it - somebody added a chiefdom and a
vaccine option since the last generate. Neither is a failure, and the exit
code reflects that.
Two things to expect on the timings. compile is effectively the whole run -
it dwarfs everything else put together, because it is a real FSH compile in
Docker. And a --live oracle costs almost nothing on top, because
it re-reads a seeded sample rather than the instance.
Options¶
| Flag | What it does |
|---|---|
--workspace <dir> |
Run in a named directory and keep it. The report is written into it. |
--keep |
Keep the temporary workspace instead of removing it. |
--all-targets |
Scaffold empty selection tables: every data set, every program, every level. |
--live |
Run the oracle phase. |
--samples N |
How many resources per family the oracle deep-compares (default 5). |
| (none) | Drift has no flag. It runs whenever the working directory sits in a project, and is skipped with a reason when it does not. |
--no-progress |
Do not narrate each phase as it completes. |
The instance and the JSON output both come from root flags, exactly as they
do for every other d2w command - there is no doctor-local --profile and
no doctor-local --json:
d2w -p laos fhir doctor
DHIS2_PROFILE=laos d2w fhir doctor
d2w --json -p laos fhir doctor # the typed report on stdout, narration on stderr
One instance per run, named the same way everywhere.
The narration is one [k/10] line per phase as it completes, which is the
form a redirected log wants:
running 10 step(s)
[1/10] connect: pass - http://localhost:8080 is DHIS2 2.43.1, bound to the v43 tree
[2/10] scaffold: pass - 13 file(s) into /home/you/doctor-ws; ...
[3/10] generate: warn - 322 file(s) across 7 target(s), 4 note(s)
[4/10] compile: pass - docker fhir-ig sushi compiled 84 resource(s)
[5/10] validate: warn - 1,634 object(s) swept; 0 selection error(s), 5 selection warning(s),
0 error(s) and 5 warning(s) instance-wide
[6/10] serve: pass - 355 resource(s) from the compiled guide: ...
[7/10] capture: pass - 5 form(s), 5 generated, 5 accepted as 201
[8/10] forward: fail - 5 spooled, 5 translated, 0 refused, 5 posted (validate only),
3 accepted, 1 rejected, 1 unverifiable
[9/10] oracle: pass - organisation units: 108 resource(s) over 107 DHIS2 object(s), ...
[10/10] drift: warn - 2 object(s) moved since the guide was published: 16 organisation
unit(s), 12 option set(s), and 6 form(s) read against the hierarchy under O6uvpzGd5pu
down to level 3. run `d2w fhir generate`, then `make sushi`, ...
BROKEN: 5 pass, 3 warn, 1 fail, 0 skipped, 0 blocked
The written report¶
Every run writes reports/fhir-doctor-report.md - into the workspace when
you named one, into the working directory otherwise. It carries the phase
table, every finding with its field path, and the header a handover needs:
which profile, which instance, which DHIS2 version, and when.
That file is the artifact. Attach it to the handover.
What doctor is not¶
- It is not a build. It never runs the IG publisher and it publishes nothing.
- It is not a write. The forward phase runs validate-only; no data reaches the instance.
- It is not exhaustive. The default probe is three forms and one
subtree, chosen to be representative rather than complete. Use
--all-targetswhen you want the whole instance, and expect it to take correspondingly longer. - Its drift phase is not a diff of your project. It reads the artifacts a build publishes from, so a project generated but never compiled has no forms to compare and the phase says so.
- It has no MCP tool. A run writes a project tree, shells out to a compiler, and posts a corpus through a server it started - a write-heavy orchestration with no read-only shape a tool could honestly advertise.
Where to go next¶
- Set up a project - once doctor says the instance works, this is the real project.
- Validate an instance - the codes phase on its own, with the full md / csv / pdf reports.
- Forward captured responses - what the forward phase is a dry run of.
- Regeneration and hand-authoring - what a regenerate keeps and what it rewrites, once drift says to run one.
- Troubleshooting - the failure modes doctor names, one by one.
Next: Set up an IG project - the project you build once doctor says the instance can carry one.