Features¶
The catalog of what the three packages of this pack do: dhis2w-fhir (d2w fhir: scaffold a
project, generate an Implementation Guide from DHIS2 metadata, validate an instance, forward
captured data), dhis2w-fhir-serve (the read-and-capture facade and its UI) and
dhis2w-fhir-engine (FHIRPath, CQL and quality measures over FHIR-shaped data). The guide series
teaches them in order; this page lists every capability in one place.
FHIR IG Toolchain¶
Packages: dhis2w-fhir, dhis2w-fhir-serve | Install: uv tool install "dhis2w-cli[fhir,serve]"
Turn a DHIS2 instance into a published FHIR R4 Implementation Guide, serve that
guide as a read-and-capture facade, and drain what was captured back into DHIS2.
The version-neutral fhir plugin is the dhis2w-fhir package, which d2w
mounts through the dhis2w.plugins.v1 entry point when the dhis2w-cli[fhir]
extra installs it; the facade is dhis2w-fhir-serve, pulled in by the
dhis2w-cli[serve] extra so an install that only generates stays FastAPI-free.
Four verbs over one project directory - init, generate, validate,
serve - plus forward to close the loop and doctor to drive the whole
chain in one command.
Scaffold a project¶
d2w fhir init scaffolds a dockerized SUSHI project.
--template <name>scaffolds the project pre-populated from a guide already generated against a real DHIS2 instance, so it compiles and serves without reaching one;--list-templatesnames what the install carries. Three templates ride the wheel (aggregate-minimal,event-program,patient-summary); the rest ofexamples/igs/scaffolds from a checkout. The template's own[generate]keys land inside the[generate]table the scaffold renders and every table below them is appended, so a template statingconcept_code_sourceproduces one[generate]table rather than a file that is not TOML. An unknown name is refused naming every template the listing names.- A bundled payload is the tree today's generator writes. All three are cut
from the DHIS2 demo database every
play.im.dhis2.orgserver and the seeded local stack carry, so each UID aselection.tomlnames exists on an instance anyone can reach. Regenerating one needs an instance; catching a payload nobody regenerated does not -test_fhir_template_payloads.pyemits one Location from a fixture organisation unit and holds every bundled Location to the shape it produces, profile, identifier slices, translated name and level extension alike.scaffold/projects/README.mdis the regeneration procedure. - An example is a template only when it declares itself one. Each guide of
examples/igs/carries atemplate.toml:scaffolds = truewith thesummarythe listing prints, orscaffolds = falsewith therefusal--templateprints instead.refused-namesis the second kind - the exhibit for a selectiond2w fhir generaterefuses on the first hostile name, which leaves the foundation target on disk and nothing after it, so there is no generated tree to lay down and nothingmake sushican compile - so it is out of the listing, and--template refused-namesstates what it demonstrates rather than scaffolding a project it would then tell you to compile. initrefuses what the IG publisher cannot build. A--titleor--namecarrying<or>is refused naming the flag and the character, because the publisher strict-parses the pages it writes them into and aborts its last pass hours in. A--nameoutside the FHIR computer-friendly shape (^[A-Z][A-Za-z0-9_]{0,254}$) is refused with the rule stated, because SUSHI rewrites such a name without saying so; the name derived from--idconforms. Each--data-set/--event-program/--tracker-programUID is checked for the DHIS2 shape (eleven characters, a letter then ten letters or digits), and a--registry-pathis refused when no directory stands at it, resolved from the new project's own root as the scaffoldedfhir.tomlreads it.-
--forcereports a rewrite as a rewrite. A file that already stood there is reportedoverwrittenand counted apart fromcreated, and the run closes by saying how many files it replaced and that their contents -fhir.tomland every hand-written line included - are gone. There is no confirmation prompt: the CLI stays non-interactive and the honesty is in the report. -
A
uvproject.pyproject.tomldeclaresdhis2w-cli+dhis2w-fhir+dhis2w-fhir-serve, which resolve from PyPI as one release - each package's dependency floors hold thed2wbinary, the plugin behindd2w fhir, and the server behindd2w fhir serveto the same version. The committeduv.lockpins the exact one every make target drives throughuv run d2w; a.python-versionof3.13pins the interpreter beside it. A commented[tool.uv.sources]block in the same file is the opt-in for tracking the repository'smainbranch instead. - The rest of the tree.
fhir.toml,sushi-config.yaml, the Makefile, the Dockerfile, and a.gitignorecovering.venv/and the generatedig/input/resources/but never the lock norig/input/fsh/.load/and.serve/are gitignored too. - Seeding the selection.
--data-set/--event-program/--tracker-programseed the questionnaire targets, with the option sets those targets reference unioned into the terminology selection. --statusseeds[ig] status;--publisher-urlopts into apublisher.urlinsushi-config.yaml;--profileseeds the top-levelprofilekey so the scaffolded project reads an instance without a flag (offline - the name is written as given, never resolved againstprofiles.toml);--org-unit-rootseeds the organisation unit subtree the project publishes (checked for UID shape, and under--with-registrywritten into both projects, which have to mean the same organisation units);--org-unit-max-levelseeds the organisation-unit depth cap;--geometryseeds[generate.organisation_units] geometry(full,positionornone), landing in the registry package under--with-registryand refused for a guide naming a registry package;--with-registryscaffolds the guide and the registry package it depends on, asregistry/andguide/under one directory plus a Makefile driving both in the order that resolves and a README describing the pair - the registry's id is the guide's with.registryappended, its canonical the guide's with/registry, androotandmax_levelreach both because the two selections have to mean the same units;--publishes,--templateand every--registry-*are refused beside it, each naming what to drop. That Makefile runs its own targets one at a time, somake -j2 buildand an inherited-jbuild the registry to completion before the guide starts, while each project's own make still parallelises; the cost is that a-jno longer shortensmake generate, whose two reads of the instance are independent, sogenerate-registryandgenerate-guideare each a target to run in its own shell.make updatethere moves both projects to the current release - each one's ownmake update, then a refresh of the pair through the guide's moved toolchain.make refreshthere is each project's refresh done once for the pair: both cleaned, the shared image rebuilt once rather than twice, both generated, both validated non-fatally - each grades the instance by what it publishes - then the registry built before the guide.d2w fhir init --refreshon that directory refreshes both projects and the two files no project'sfhir.tomldescribes: the Makefile is the scaffold's own and is rewritten whole, and the README takes the line rule every other file takes, so a deployment note written into it is reported and kept while the guide's title on its cover and the registry canonical it names are identity lines the refresh writes;--publishes organisation-unitsscaffolds a package rather than a guide (kind = "package"pluspublishes = "organisation-units"under[ig], the organisation-unit registry alone, a Home / Registry / Artifacts menu,path-resourcelimited toregistry/) - the two keys are apart so a second sort of package is a newpublishesvalue, not a new project kind;--registry-id/--registry-canonical/--registry-version/--registry-pathname the registry package a guide depends on, seeding the[generate.organisation_units.registry]table, thedependencies:entry ofsushi-config.yaml(uri<canonical>/ImplementationGuide/<id>) and the Makefile'sREGISTRY_ID/REGISTRY_VERSION/REGISTRY_TGZknobs - a half-named registry and a registry package naming one are refused;--sushi-timeoutsets the[FSH] timeoutofig/fsh.ini, the ceiling the IG publisher gives its internal SUSHI run - an IG whose FSH overruns it fails the build with exit 143.--refreshbrings an existing project's scaffold-managed files up to date, recovering the IG identity from the project's ownfhir.toml,ig/fsh.ini, andig/sushi-config.yaml. TheMakefile, theDockerfile,.python-version,ig/ig.iniandig/fsh.iniare the scaffold's own files, rewritten whole from the current render whenever it differs, and reportedrewritten (scaffold-owned)- their own verdict, becauserefreshedis the rewrite that keeps every line on disk. An edit to one of those five does not survive: the flag's help names them, the scaffolded Makefile's own header says so, and the report's note says where a value of your own belongs instead - every knob the Makefile has is a?=default set on the command line (make build JAVA_HEAP=8g) or in the environment, which is outside the file. The Makefile is owned by that name at a project root and at the root of a directory scaffolded with--with-registry. Every other file is rewritten only when the current render reproduces every line already on disk in order, so a refresh adds what the scaffold gained (a newpath-resourceglob, a new.gitignoreentry, a new menu entry) and never drops a line the user wrote. Each file is reported as created / rewritten (scaffold-owned) / refreshed / refreshed, with your additions / unchanged / with your additions / diverged (kept), in the summary table and in the file list below it in the same words - the last two both keep the file byte-identical, anddivergednames no author, because a line the user wrote and a scaffold line that has since changed read the same to a line-preserving refresh.refreshed, with your additionsis both halves at once: an identity line landed on a file that also carries lines the render does not produce, so a title change and an appended section read as two facts rather than one.fhir.tomlis never written;--forceis rejected; any flag the refresh would ignore is refused. The accepted consequence is that a scaffold line deliberately deleted is restored, since a deletion leaves the file a subsequence of the render.- The refresh writes the scaffold lines
fhir.tomldeclares. Five files carry the guide's identity in every project - six where--with-registryscaffolded a pair - and one refresh lands an[ig]edit in all of them:ig/sushi-config.yaml(id,canonical,name,title, the description built from the title,status, the publisher name, and the sixspecial-urllines the[generate] identifier_system_basestem addresses), the[ig]table offhir.example.toml, the first-line heading ofig/input/pagecontent/index.md, theig =line ofig/ig.ini, the[project] nameofpyproject.toml, and on a pair the heading of the rootREADME.mdplus the line naming the registry canonical. Each is reported refreshed, andig/ig.inirewritten, since the toolchain owns that one. Every other line of those files is the project's and survives byte-identical -releaseLabel,version, the publisher home page,copyrightYear, the parameters, the menu, the path-resource globs, the project's own prose and headings, an option it uncommented in the example. Until the refresh runs,d2w fhir generateraises ascaffold-driftnote naming the keysfhir.tomlandig/sushi-config.yamlstate differently.
Build and publish¶
- The scaffolded Makefile.
make refreshchains clean, upgrade, generate, a non-fatal validate, and build, keeping the caches.make updatere-runs the scaffold refresh.make serve/make serve-liveread the[serve]table and serve the capture UI at/;make forward/make forward-importdrive the drain.make checkis the artifact scan below, andmake buildruns it first. make buildbuilds on the container's own disk. Docker on macOS reaches a mounted host directory over a network-style filesystem, and the publisher's output phase writes tens of thousands of small files one at a time - 341 seconds through the mount against 21 on the container's disk, measured on one guide. The target streams the project in, builds there, and streamsoutput/,fsh-generated/andinput-cache/back;temp/andtemplate/stay behind, since nothing reads them and they are the bulk of what a build writes.make build-bindruns the publisher straight over the mount for anyone who wants to watchoutput/fill as it is written.d2w fhir check-artifactsrefuses a doomed build in seconds. The generate-time gate stands at the emit site, and a build never visits it:make buildpublishes whateverig/fsh-generated/andig/input/hold, so artifacts written before the gate existed, artifacts from an older toolchain pin, and hand-authored FSH all reach the publisher without passing it. The command applies the same refusal to those files -ig/fsh-generated/**/*.json,ig/input/resources/**/*.json, andig/input/fsh/**/*.fsh- readingname,title,display,text, andidentifier[].valuethrough the sharedbuild_aborting_name/build_aborting_codepredicates rather than a second copy of the rule. Each finding names the file, the resource, the element, the value, and the one line that answers it. It opens no connection and reads no profile, exits 1 on a build-aborting finding, and takes--no-fail,--json, and an optional project directory.ig/input/pagecontent/**/*.mdis out of that scan on purpose: markdown carries HTML by design. One line of it answers a different question - a page carrying the generated header is how the selection check knows the run it is reading finished. An existing project takes the gate up with oned2w fhir init --refresh.- The findings table reads at 80 columns, which is what a CI log gets and
make buildruns this command into one. The file path is cut from the front, so what survives is the end that names the file; the resource and the element are dropped in that order when the terminal is too narrow for them, both being in--json. The remedy is not a column at all: six sentences stand behind every finding the scan raises, so they are printed under the table once each and whole - which is also the only place the[generate.*]table names inside one survive a narrow screen. - The scan reads the guide's own identity too, in
fhir.toml's[ig]table and inig/sushi-config.yaml(title,name,publisher,description). No DHIS2 selection supplies those, and no compiled resource carries them until SUSHI has run, so a project that has only generated has nowhere else on disk for a title carrying a<to be found - and the publisher dies on it in the same last pass. One identity stated in both files is one finding, named atfhir.toml, which is where a refresh writes the other from. - Each finding carries its origin, and the remedy follows from it. A value
from DHIS2 asks for a rename there or a narrower selection followed by
d2w fhir generate; a value from[ig]asks for that key infhir.tomland ad2w fhir init --refresh; a hand-authored source asks for an edit, because nothing regenerates one. The origin is modelled onArtifactFinding, so the line a reader is given never guesses at where the string came from. - A form nobody can submit is a warning rather than a refusal. A published
Questionnaire whose organisation-unit assignment List names no unit the project
publishes is reported once per form, with
max_levelnamed as what usually narrows the registry. The build is valid and would publish, so the command still exits 0; what it costs is the form, and the scan says so instead of letting a guide full of unusable forms read as clean. JAVA_HEAPis the publisher's JVM heap ceiling, derived from the memory docker reports less 6 GB - the JVM's own memory and Jekyll's - capped at8gbecause the JVM grows into a larger ceiling without needing it, floored at4g, falling back to8gwhen docker cannot be asked, and stated on every build. Each build ends by printing two peaks: the memory in use (the heap, the JVM and Jekyll, sampled inside the container) and the container'smemory.peak, which also counts reclaimable file cache. The daemon is asked once per build, on first use, sohelp,cleanandgeneratenever wake it and every line of a build quotes one answer. A value set on the command line or in the environment is taken as it stands and asks docker nothing; an empty one derives exactly as an unset one does. It is a ceiling, not a reservation.- Exit 137 is diagnosed, not asserted. The container reads its own cgroup's
oom_killcount as it exits and prints it beside the peak it reached, sobuildandbuild-bindreport a kernel out-of-memory kill in full - the ceiling, the memory docker reports, the peak, the containers running when it was sampled, and the ways out, numbered so the stop-the-containers step appears only when there are containers to stop - and name the opposite failure, anOutOfMemoryError, so the two are not confused. A 137 the cgroup did not count as an out-of-memory kill - adocker stop, adocker kill, a timeout around the build - gets a shorter message saying exactly that. make registry-installin a guide that depends on a registry package streams the package'spackage.tgz(REGISTRY_TGZ, defaulting to<registry.path>/ig/output/package.tgz) into the shared package-cache volume under<id>#<version>/package/;sushi,buildandbuild-binddepend on it.make registry-presentruns ahead of all of them and of the volume they share: an archive that is not there is twotestcalls and a refusal, so a compile that cannot finish creates no docker volume on its way to failing. The refusal names the registry's ownmake build, and says thatd2w fhir serve --liveandd2w fhir forwardread the two projects as they stand and need no build of either. The registry project builds in its own container, so neither build carries the other's resources; oneJAVA_HEAPon the root Makefile reaches both through make's own variable propagation, and each project derives its own ceiling when none is set.- Registry scale.
d2w fhir generate org-unitswarns at generate time once the registry passes 2,000 instances, because the IG publisher validates and renders every resource and the registry therefore sets the wall clock ofmake build. The warning names the[generate.organisation_units]max_level/rootdials. - A code or name carrying
<refuses the run.d2w fhir generaterefuses a run whose emitted code or byte-true name carries<, through the same predicatesfhir validategrades with, naming the resource type, UID, name, and code - because the IG publisher writes identifier values and titles into pages it strict-parses after writing, and aborts its final pass on the malformed page after every resource has been rendered. The whole run is refused rather than the object skipped, since skipping leaves every Questionnaire binding it pointing at a ValueSet nobody wrote. - The name gate covers every kind of object a selection publishes: 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 data elements and tracked entity attributes those forms
ask as questions - a question's name is its data-dictionary concept display and
its label wherever DHIS2 writes no form name, and its form name is that label
wherever DHIS2 writes one; the gate reads both spellings, byte-true DHIS2 data
either way, and the refusal says which of the two to change. Every target that
writes a form's name reads the same gate, so
generate pagesandgenerate examplesrefuse exactly whatgenerate questionnairesrefuses. The parity withfhir validateruns both ways: a validate error on the build path is a generate refusal, and a generate refusal is a validate error on the build path. Only codes stay asymmetric, and deliberately - a question object's code becomes a concept property the publisher escapes rather than an identifier value it does not. - Or the guide publishes the name in wording the build survives. Refusing is
one answer and often the wrong one: DHIS2 names carry
<legitimately, and an age band called5 to < 15 years, Femaleis not a defect to be renamed in a production instance.--substitute-hostile-namespublishes it as5 to under 15 years, Female(<=and</=read asat most,>=and>/=asat least);--refuse-hostile-nameskeeps today's refusal;[generate] hostile_names = "refuse" | "substitute"is the project's standing answer; and with none of them a run holding a terminal prints the count, up to tenbefore -> aftersamples, and asks, while a run with no terminal names the two flags and rewrites nothing rather than hanging a script on a prompt. A flag beats the dial, the dial beats the question. DHIS2 is never written to and no UID is rewritten, so the ConceptMaps still take a published concept back to its DHIS2 object. The rewrite lands where DHIS2 metadata enters the emission inputs, before a single identity, stem, or decomposition is planned off a name, so every target inherits one spelling - which is also how it covers the class no refusal reaches: a category option combo name, which becomes a cell's label and a data dictionary concept display (one national selection generated cleanly and handed the publisher 738 of them). Every rewrite is aname-substitutionnote, one per distinct DHIS2 name. - A substitute build says so on its pages. Under
substituteevery generated site page opens with a note that the guide publishes some names and codes in a different spelling from the DHIS2 instance, and where the instance's spelling is stated, so a substitute build never reads like a clean one. - The substitute posture rewrites every code carrying a
<or a>. A code becomes an identifier value the publisher writes into a table cell unescaped and then strict-parses, so a<in it aborts the build's last pass; a>is reworded too, so a code and the name beside it read alike. Undersubstituteits comparison is reworded the way a name's is and then every space is hyphenated (ENTO - IRS < 6 Monthspublishes asENTO---IRS-under-6-Months,>10 secasover-10-sec), with the DHIS2 code stated as thedhis2-codeproperty exactly as for a spaced code. Underrefusethe run is refused, naming the object. - The substitute posture hyphenates every code carrying a space. An R4
codeadmits single internal spaces, soPre eclampsiais legal FHIR and nothing refuses it - and the publisher's anchor slug strips the whitespace, so it and a literalPreeclampsiarender one anchor id (IG_PUBLISHER_ISSUES.md 107), while every URL, CQL quotation, and terminology server below the guide handles the space at its own discretion. Undersubstituteeach space becomes a hyphen, everywhere the guide publishes a DHIS2 code: the option-set, category, and category-option-combo vocabularies underconcept_code_source = "code", the.../id/*-codeidentifier CodeSystems the ConceptMaps target, the ConceptMap rows themselves, and the identifier values that name the same namespaces - so the published vocabulary is self-consistent. A published code depends on its own DHIS2 code alone, so a wider selection or a cleaned neighbour never changes it, and two codes that would land on one published code (Pre eclampsiabeside a literalPre-eclampsia) refuse the run, naming both;d2w fhir validatereports the pair as acode-substitution-collisionerror. Every rewritten concept states the DHIS2 code byte-true as adhis2-codeproperty, which is what the capture path reads: a QuestionnaireResponse answering with the published code lowers to the DHIS2 code on the import payload. Underrefuseand unset, every code reaches the guide byte-true. Every rewrite is acode-substitutionnote, one per distinct DHIS2 code. Names holding a comparison are rewritten as wording -<reads "under",>reads "over", both the character and the HTML entity spelling a DHIS2 instance can store - with the same rewrite applied to NAME translations, and the instance's byte-true spelling stated as adhis2-nameproperty beside the published display; every such rewrite is aname-substitutionnote. - A rewritten option set states both DHIS2 spellings as defined extensions. A
concept hangs its instance spelling on a concept property, and an option set's
own CodeSystem and ValueSet have no concept to hang one on - so each carries the
DHIS2 code under
D2OriginalCodeand the DHIS2 name underD2OriginalName, twostringextensions thefoundationtarget defines and contexts onCodeSystemandValueSet. A resource the run published byte-true carries neither.
Generate the IG source¶
d2w fhir generate runs all seven targets in one go, off a single pass over
the instance - 8 requests where the solo targets total 25 - reported as one
summary row per target. Notes go to reports/fhir-generate-notes.md with one
counted hint on the terminal, counting the kinds that only restate a validate
finding apart; --details prints them inline.
-
Every target says what it covers and what that cost in files. The row carries a
Subjectcolumn -1,332 organisation units,13 option sets,14 questionnaires- besideFiles written,Files unchanged, andFiles deleted, and the step line leads with the same subject ([7/8] organisation units: 1,332 organisation units, 2,667 files written, 0 files unchanged). The two numbers differ wherever one covered object ships as several resources: an organisation unit becomes both anOrganizationand aLocation. The foundation target covers no instance object, so it reports files alone, and the closingfull pipeline:line is a file count. -
A run that rewrites FSH removes the compile it no longer matches. Any target that writes or sweeps a file under
ig/input/fsh/removesig/fsh-generated/- SUSHI's output and nobody else's, whichd2w fhir check-artifacts,d2w fhir serve,d2w fhir forward, andmake buildall read - so the tree on disk is never new sources beside an old compile. Onecompile-removednote per run names the directory andmake sushi; the target that writes only predefined JSON leaves the compile alone, an unchanged regenerate leaves it alone, andig/temp/(the publisher's scratch) andig/output/(a published site) are never touched. -
A compile that stops on an error leaves nothing to serve. SUSHI keeps whatever it had written when it stopped, and that half reads as a finished guide to everything downstream, so
make sushiremovesig/fsh-generated/when SUSHI exits non-zero, says so, and hands SUSHI's own status back to make. The presence of that directory therefore means one thing: a compile that finished.d2w fhir servethen meets a project with no compiled guide and refuses by namingd2w fhir generateandmake sushi, rather than publishing a partial one.
Targets and selection¶
- Four target tables select what is published:
[generate.data_sets],[generate.event_programs](WITHOUT_REGISTRATION),[generate.tracker_programs](WITH_REGISTRATION), and[generate.tracked_entity_forms]. [generate.tracked_entity_forms]names the tracked entity types that publish a person-only registration form underfsh/tracked-entity-types/(D2TET_<type>, form kindtracked-entity): the attributes the type itself collects, every item at the entity level, no enrollment, no organisation-unit assignment, answered againstD2TrackedEntityResponseand forwarded as a bare/api/trackertrackedEntitiesentry.- Absent or empty means all of that kind for the first three tables, and the types the selected tracker programs register for the fourth; a non-empty list filters.
enabled = falseon any of the four tables publishes no form of that kind and costs no request for one;include_idsis kept and ignored, so the selection returns when the table is switched back on. A guide about programs alone switches[generate.data_sets]off rather than listing a UID that matches nothing.- The whole-instance sweep routes every program by its live
programTypeand collects the types neither table maps into one aggregate note. A program listed under the table its type does not belong to is a loud failure by name, pointing at the table that does select it.
Foundation: identifiers and extensions¶
The foundation target emits the DHIS2 identifier aliases and the
NamingSystems declaring them, plus these extensions:
- The identifier stem is a label, not an address. Every system is
[generate] identifier_system_baseplus a path,http://dhis2.org/fhirby default, and nothing is published at that domain: the guide's own NamingSystems are what a consumer resolves. A project sets a stem under a domain it controls before its first real publish, and the sixspecial-urllines ofig/sushi-config.yamlfollow it on refresh. D2Period- contexted on QuestionnaireResponse and MeasureReport, with its period-type CodeSystem/ValueSet over all 23 DHIS2 period types, matched by aparse_periodISO parser and itsrecent_periodsinverse.D2PeriodType- contexted on Questionnaire and bound to that same ValueSet: the reporting frequency an aggregate form's responses have to report under, so a client resolves the ISO period format off the form rather than off an example.D2DateLabels- contexted on Questionnaire, onevalueStringslice per date the instance labels (enrollmentDateandincidentDateoff a tracker program,eventDateoff a program stage or an event program's stage), each slice present only where DHIS2 states a label and each carrying its own translations.D2Repeatable- contexted on Questionnaire, valued boolean: whether one enrollment may capture a tracker program stage more than once, declared either way on every stage form.D2Description- contexted onQuestionnaire.item, valued string: the DHIS2 free text about the data element, tracked entity attribute, or section a question or group is asked from.D2OriginalNameandD2OriginalCode- contexted on CodeSystem and ValueSet, valued string: the name and the code the DHIS2 instance holds for the object a resource publishes, stated wherever the run published either rewritten underhostile_names = "substitute".D2FormType- contexted on Questionnaire and QuestionnaireResponse alike.D2ProgramRule- a repeating complex extension carrying, per rule the form does not itself express, the DHIS2 rule UID (valueId), its name and free text with their translations, the expression the server evaluates character for character, what the rule does, coded from theD2ProgramRuleActionCodeSystem/ValueSet pair over everyprogramRuleActionTypev41, v42, and v43 declare, and - on anASSIGNrule - oneassignssub-extension per question the rule computes the answer to, valued with that question'slinkId.D2OrganisationUnitAssignment- contexted on Questionnaire, valuedReference(List): the organisation units a form may be captured against.D2AttributeOptionCombos(Questionnaire,canonical(ValueSet)) paired withD2AttributeOptionCombo(QuestionnaireResponse,Coding) - the key a submission is filed under: the third key of a DHIS2 data value set,(orgUnit, period, attributeOptionCombo), and theattributeOptionComboof the event or enrollment a program response creates. A data set declares its own category combo's option combos; every form of a program declares the program's, because DHIS2 answersE1055to an event of a program whose category combo is not the default one and which was filed under the default.D2OrganisationUnitLevel- contexted on Location, valuedCodingand bound extensibly to the organisation-unit level ValueSet: the hierarchy level a published place sits at, stated on the Location because that is the hierarchy-bearing half of the pair, while the Organization already carries the same coding asOrganization.type. Both read one coding, whose display is the instance's own name for that depth and whose code islevel-<n>.D2AttributeValue- contexted on Organization, Location, CodeSystem, ValueSet, and Questionnaire.D2TrackedEntityAttributeValue- contexted on Patient.
Program rules¶
- Two tiers are expressed in core FHIR and carry no extension of their own.
A
SHOWERRORrefusing a number outside a range becomes the coreminValue/maxValueon the question it tests, narrowing whatever the DHIS2 value type already stated. AHIDEFIELDon one other question's answer becomes coreitem.enableWhenwith the operator negated, plus - where DHIS2 would show the question before anything is answered - anexistsarm underenableBehavior = #any. - A hide on a coded answer names the concept code the bound set publishes,
which follows
concept_code_source- the option UID underid, the option code undercode, hyphenated where the substitute posture rewrote it. A literal no option of the set carries is published whole onD2ProgramRuleinstead, with a note naming the rule, the question, and the literal. - A deliberately conservative grammar reads the conditions: one comparison
between one
#{variable}and one literal, optionallyd2:hasValue-guarded on that same variable, with the variable resolved throughprogramRuleVariablesto a question the same form asks. - Every rule it cannot read whole is published whole instead on
D2ProgramRule, never half-translated. - A rule whose action is
ASSIGNis published with the questions it computes, and every emitter leaves those questions empty. The rule'sassignssub-extensions name each question DHIS2 calculates the answer to, so a client can join the rule to the item on its own form; the question itself carries no marking, because the fact belongs to the rule. The examples target andd2w fhir generate load-setanswer none of them, andd2w fhir generateraises a note per form naming the rules and the questions, then closes the run with a line naming how many published forms ask such a question and how many questions across them, because a form whose corpus answers nine of thirteen questions on purpose is otherwise a progress counter a reader cannot read. DHIS2 refuses a payload whose answer is neither empty nor byte-equal to the value it calculated (E1307), and a calculated value can be one no answer expresses at all, so no answer is the only answer that always lands.d2w fhir check-artifactsfiles a warning-level finding for a published example that answers one, in both formats an example lives in - the compiled JSON, and the FSH source it was compiled from, which is the only place a hand-authored example sits and the only half of the tree a project holds beforemake buildruns SUSHI. The forms are read in both formats for the same reason: before a compile, the questions a rule computes are on the FSH Questionnaire's ownassignsslice, which is where the scan reads them. The capture UI renders such a question read-only, naming the rule, out of the progress count and out of the submission.
Organisation-unit assignment¶
- Published as one R4
Listof Locations per form underig/input/resources/assignments/, and only when the DHIS2 assignment is a proper subset of the published registry: assigned everywhere publishes nothing, and absence means the whole registry. - Tracker stages share their program's
List, because DHIS2 hangs the assignment on the program - oneListacross the registration form and every stage. Listrather thanGroupbecause R4 admits no Location as aGroup.member.entity.- A run that published forms nobody may report says so on its own line. When
an assignment intersects the published registry at nothing,
d2w fhir generatecloses with a warning naming how many published forms carry an empty assignment and the[generate.organisation_units] max_levelin force, then names both selections in one sentence: widen the organisation-unit selection, or narrow the form selection.d2w fhir check-artifactsanswers its own finding with that identical sentence. Forms are the unit counted - a tracker program's stages each publish a Questionnaire and share oneList- so the run, the facade's 422, andd2w fhir check-artifactsall state one number, and the line names the data sets and programs the assignment hangs on. A national instance raises several hundred terminology notes per run, which is why this is a line of its own rather than one of them. - A run that published forms no organisation unit may file a capture for says
so on its own line too. DHIS2 scopes a category option to organisation units
on top of the assignment and refuses a capture keyed to a combo not usable at
the organisation unit it was filed from (
E8025), so a form whose whole combo vocabulary is scoped away from every organisation unit that may report it is a form nobody can submit. The run already wrote the restrictionLists that prove it, sod2w fhir generatecloses with a warning naming the forms and themax_levelin force, and names both dials in one sentence: widen the organisation-unit selection until one of the restricted organisation units is published, or narrow the form selection.d2w fhir check-artifactsanswers its own warning-level finding with that identical sentence, read off the publishedLists with no connection. - A run that published forms DHIS2 has closed every combo of says so on its
own line too. The date axis's answer to the one above: DHIS2 scopes a
category option to a calendar window and refuses a capture the window does not
cover entirely (
E8032), so a form whose every declared combo closed before the period it reports now - the newest completed period of its own period type, and every later one - is a form nobody can submit.d2w fhir generatecloses with a warning naming the forms, and its remedy says plainly where the fix is: a category option'sstartDate/endDateis DHIS2 metadata and no fhir.toml setting widens it, so the options are reopened in DHIS2 or the form selection is narrowed.d2w fhir check-artifactsanswers its own warning-level finding with that identical sentence, read off the windows the vocabulary publishes and the period type the Questionnaire declares, with no connection. - A selection entry that matched nothing says so on its own line too. A
[generate.*] include_idsUID the instance answers nothing for costs the guide a whole form, its examples and its page, sod2w fhir generatecloses with a warning per family naming every unmatched UID, and a family none of whose entries matched says outright that the run publishes nothing of that kind.d2w fhir check-artifactsreports the same entry as a warning-level finding againstfhir.toml, read off the published tree with no connection. Aninclude_idsthat is absent or an empty list selects everything, which is whyenabled = falseis the switch that publishes none. The scan grades a selection only against a tree a run finished writing: a refused run writes the foundation target and stops, and reading a selection off that half-written tree would say a UID live on the instance - the very object the run refused over - is not on it. The pages are the evidence the run reached the end - they narrate what every other target wrote, so a run writes them last, and one page carrying the generated header is a tree every entry may be read off. Without one the scan says the run did not complete and grades nothing. d2w fhir servegrades the subject, the tracker organisation-unit extension, and every ORGANISATION_UNIT answer against it, on the same lenient/strict dial coded answers take, and$generatedraws its Location from it. The finding names the code that kind's import really answers: the two halves of DHIS2 grade one fact under two names,E8022 Data set ... not usable with org unit(s)on an aggregate import andE1029on a tracker or event one.- A form publishing no assignment is graded against the served registry.
"Assigned everywhere" is every organisation unit this server publishes, not
every string shaped like a reference, so a unit the registry does not hold
takes the same dial - a warning on the receipt, a refusal under
--strict-codes- naming theE1011orE1049DHIS2 answers a capture filed at a unit it does not hold. The guide's own worked exemplar is refused whatever the dial says: it illustrates the organisation-unit profile and stands for no place on any instance. A project publishing no registry at all states no set to check against, and the reference's shape is all that is graded.
Attribute option combos¶
- One CodeSystem/ValueSet pair per distinct non-default attribute category
combo under
ig/input/resources/attribute-option-combos/, on theAOCnaming token - deliberately not the data dictionary'sCOC, which codes a question's disaggregation cells rather than the combo the whole response is filed under. One pair is shared by every data set on that combo. - A ConceptMap per pair takes each concept back to
<base>/id/category-option-comboand its-codesibling. - One
Coding-valued concept property per category the combo splits over, declaredcategory-<stem>with the category's name as its description and valued into that category's own publishedd2-cat-<stem>-csCodeSystem, in the category combo's own order, on the same concept-code assignment the category pair builds its concepts from. Carried on theD2COC_CSdisaggregation vocabulary as well as on everyD2AOCpair, so a reader holding "Fixed, <1y" can dig into the Fixed and the <1y it was met from. - One
Listper organisation-unit-restricted category option, named from every combo concept met from it by a repeatingdhis2-organisation-unitsconcept property valuedList/<id>. DHIS2 scopes a category option to organisation units and refuses a value keyed to a combo not usable at the unit it was filed from (E8025), so the vocabulary publishes the scope: the List holds the units of this project's registry that sit at or under one of the restricted units, which settles DHIS2's descendant rule once at generate time. An option every published unit already sits under narrows nothing and publishes nothing; an option none of them sits under publishes an empty List, which is the vocabulary saying the combo is usable nowhere here. The read is scoped to the category options of the non-default attribute combos the selection rides and carries the published units'pathon the registry read the run already makes, so it costs one extra request and no hierarchy walk. - A validity window on every combo concept whose category options state one,
as a
dhis2-valid-from/dhis2-valid-topair ofdateTimeconcept properties. DHIS2 scopes a category option by calendar window as well as by organisation unit and refuses a data value set whose period the window does not cover withE8032 Untimely data entry, so the vocabulary publishes the window the way it publishes the restriction - on the concept itself, a window being two dates rather than thousands of organisation units. What a concept carries is the narrowest window of the options it is met from, the latest start and the earliest end, which is DHIS2's ownCategoryOptionCombodate range; an option stating neither date publishes nothing, and absence means always open. The dates ride the samecategoryOptionsread the organisation-unit restriction does, so the date axis costs no request of its own. - A category outside
[generate.categories]drops its axis with a selection-gap note rather than coding into a CodeSystem nobody wrote. - Nothing at all for a default-combo data set, because absence means the
default combo. Publishing the combo is what un-skips a non-default data set
in
generate load-setand lets a third party construct a complete aggregate capture from the guide alone.
DHIS2 attribute values¶
D2AttributeValueis a complex extension ofattributeId(1..1),attributeCode(0..1, absent because DHIS2 leaves most attributes uncoded) andvalue(1..1, a string whatever the attribute's declared valueType), carrying a DHIS2attributeValuesentry onto every generated resource that can hold one.- The attribute code is joined from a
/api/attributesread resolved unpaged once per generate run, because DHIS2 pages that endpoint 50 at a time. - The same read carries
unique: a value of an attribute DHIS2 declares unique names its object rather than annotating it, so those values leave the extension and join the resource'sidentifierlist, after the UID and code slices so the order stays byte-stable, under{base}/attribute/{attributeUid}- keyed on the UID because a DHIS2 attribute code may hold spaces and a system URI may not. - The per-attribute namespaces are declared by convention rather than as
NamingSystems, since the foundation layer is built from
fhir.tomlalone and cannot know which attributes an instance has. D2TrackedEntityAttributeValuecarries the same three sub-extensions for the other DHIS2 key-value family - a tracked entity attribute being a different object from a metadata attribute, and one extension claiming both would publish a definition false of half its instances. Same unique-becomes-an-identifier rule under{base}/tracked-entity-attribute/{attributeUid}, with the flag read off theuniquepropertyD2TEA_CSalready publishes. This is whatd2w fhir serve --liveprojects a person onto.
The capture contract¶
- Five response profiles on QuestionnaireResponse, one per form kind:
D2AggregateResponse,D2EventResponse,D2TrackerRegistrationResponse,D2TrackerEventResponse, andD2TrackedEntityResponse. Each pins the extensions,questionnaire,subject, and - per kind - the mandatoryD2Periodorauthoreda captured response has to carry, withD2FormType.valueCodefixed to the kind's own code. - The aggregate profile and the three program profiles additionally slice
D2AttributeOptionCombo0..1 and state in prose that a response answering a form which declares aD2AttributeOptionCombosvocabulary has to carry it - requiredness is a fact about the form, not the kind, so it cannot be a cardinality. The person-only profile slices none: a tracked entity type belongs to no program and is filed under no combo. - The registration profile additionally slices
D2SubjectExists0..1: the boolean stating that the person the response is subject to is already held by the instance, so the response enrols them rather than creating them.d2w fhir forwardimports that as a top-levelenrollmentsarray naming that tracked entity under plainCREATErather than atrackedEntitieswrapper whoseCREATE_AND_UPDATEwould rewrite the person's owning organisation unit (DHIS2_ISSUES.md 73), carries the program's own attributes on the enrollment because DHIS2 answersE1018when they ride nothing, and refuses the whole response withentity-level-answer-on-existing-subjectwhere it answers a question of the person's own record - an enrollment-only import has nowhere to put it, and a silently dropped answer is a captured value that reaches no instance. - Subject typing. The aggregate and event profiles restrict
subject only Reference(D2Location). The three tracked-entity profiles (registration, tracker-event, and the person-only one) restrict it toReference(Patient)plus every other resource type[generate.tracked_entity_types]names, so the published union is as tight as the project is. - The subject is a logical reference: no
referenceelement,subject.identifier1..1 with its system fixed to{base}/id/tracked-entity, because the guide publishes no Patient instances and the tracked entity resolves against DHIS2. - Two extensions instead on the tracker-event profile:
D2TrackerEnrollment1..1 carrying the enrollment UID as a valueIdentifier under{base}/id/tracker-enrollment, andD2OrganisationUnit1..1 carrying the capture unit as a valueReference to its published Location. - The tracker registration profile keys the same way and adds the two dates
a DHIS2 enrollment holds:
D2EnrolledAt1..1 andD2IncidentAt0..1, the second only because a program states whether it collects an incident date at all and the registration Questionnaire publishes that statement on theD2CollectsIncidentDateextension, so both stores read one declared fact. - A registration response mints both identities it names, as client-generated DHIS2 UIDs, since it is the document that creates them - which is what lets a client enrol a person and capture the enrollment's first stage events in one breath. The registration form ships identifier-keyed, deferring roadmap decision 5.2; no Patient, EpisodeOfCare, or CarePlan resource is published.
D2CaptureServerCapabilityStatement declares onecreateper QuestionnaireResponse against all five profiles a server captures.CAPTURED_FORM_KINDSis the single tuple serve's capture index, the conversion gate, thesupportedProfiledeclarations,/metadata, and the load set all key off, so the statement can never claim an interaction the facade does not perform. It also declares read and search over the Questionnaire, CodeSystem, ValueSet, Location, and Organization resources a client resolves a form from.D2GenerateOperationOperationDefinition stands behind$generate:kind #operation,code #generate,resource #Questionnaire,instance = truewithsystem/typefalse,affectsState = falseso a GET is legal, one optionalintegerseedinput, one optionalstringsubjectinput naming an organisation unit asLocation/<id>, and aQuestionnaireResponsereturn, plus acommentstating outright that it is not SDC's$populate. It is deliberately absent from thekind #requirementscapture statement, because a server that only receives captures is still conformant.
The conversion contract¶
Published as two FHIR-native artifacts:
D2DataValueSet- the/api/dataValueSetsenvelope as akind = logicalStructureDefinition: the three keys DHIS2 stores a data value under required, the attribute option combo and the completeness date optional, one repeating data value carrying its data element, its category option combo, and the string every DHIS2 value is on the wire.D2AggregateResponseToDataValueSet- the StructureMap from an aggregate QuestionnaireResponse onto it in two groups: the envelope, then a recursive walk of the item tree splitting<dataElement>.<categoryOptionCombo>out of each answered link id. Authored as anInstance:of StructureMap because SUSHI compiles no FHIR Mapping Language.- The rules whose meaning exceeds what a transform states carry that on
their own
documentation: the data set is the form's identifier rather than the response's, the organisation unit needs the Location registry resolved, the attribute option combo is a ConceptMap translation under code-mode naming, and the wire value is the whole serialisation table. - One value rule per answered
value[x]type, each naming its type onsource.typeand reading its own variable, because the publisher's validator types an expression over the whole choice as no primitive. - Gated in CI by
test_fhir_conversion_contract.py, which reads the SUSHI-compiled model and holds every data value set the Python forwarder produces against its cardinalities and types - the model judging the implementation, never the reverse, and never executing the map.
Terminology¶
- Option sets become CodeSystem/ValueSet pairs carrying both DHIS2
identifiers and the set's DHIS2 attribute values on both halves, shipped as
pre-built R4 JSON under
ig/input/resources/terminology/, oneCodeSystem-<id>.jsonand oneValueSet-<id>.jsonper set. They are serialised from thedhis2w_fhir.r4models and loaded by SUSHI as predefined resources intosushi-local#LOCALrather than compiled from FSH, each carrying the FSH-stylenamea questionnaire'sCanonical(...)binding fishes them by. - Concept codes are made unique in DHIS2 sort order; an option with no unique code left to take is skipped with its own note rather than emitted twice.
- One ConceptMap per set beside the pair under
ig/input/resources/concept-maps/(D2OS_<stem>_CM, id sharing the pair's identity stem,sourceCanonicalthe set's own ValueSet), whose two groups take every emitted concept code back to the DHIS2 option UID under{base}/id/optionand to the DHIS2 option code under{base}/id/option-code,equivalence #equalon every row because both name the same DHIS2 option. Built from the same concept assignment the concepts are, so a mapping can only name a concept the pair carries. The code group is emitted only where an option has a DHIS2 code that is a valid FHIRcode, and there is no map at all for a set with no concepts. - DHIS2 categories become the same CodeSystem/ValueSet pair built by the
same concept assignment: one axis of a disaggregation and its category
options as the concepts, in the category's own
categoryOptionsorder, named by theCATtoken (D2CAT_Sex_CS/_VS), carrying the category's DHIS2 attribute values on both halves. Shipped as pre-built R4 JSON underig/input/resources/categories/- its own directory, because a JSON sync deletes every unproduced file in its target and two targets sharing one would delete each other's documents - and declared by a thirdpath-resourceglob in the scaffoldedsushi-config.yaml. - One ConceptMap per category beside the option-set maps in
ig/input/resources/concept-maps/(D2CAT_<stem>_CM, id sharing the category's identity stem, the category UID under{base}/id/categoryas its business identifier,sourceCanonicalthe category's own ValueSet), whose two groups take every emitted concept code back to the DHIS2 category-option UID under{base}/id/category-optionand to the DHIS2 category-option code under{base}/id/category-option-code. Both namespaces are declared as NamingSystems and aliased ($DHIS2-CO,$DHIS2-CO-CODE) by thefoundationtarget. D2Sex_CMis the first map this project publishes onto a vocabulary that is not DHIS2's own: oneequalrow per line of[ips.identity.administrative_gender], sourced from the nominated attribute's own value namespace ({base}/tracked-entity-attribute/{uid}- the very system the register publishes that attribute's values under) and targetinghttp://hl7.org/fhir/administrative-gender. Written by thequestionnairestarget beside the attribute vocabulary it reads out of, rows sorted by the DHIS2 value so an unchangedfhir.tomlregenerates the same bytes, and no file at all where nosexis nominated. The register reads the file rather than the map, because the nominations the map depends on are published nowhere; the map is what makes the translation auditable from the guide alone.D2Section_CMis the second such map: oneequalrow per DHIS2 object[ips.sections]nominates as feeding a section of an International Patient Summary, in two groups - the stages under{base}/id/program-stageand the dose data elements under{base}/id/data-element, both onto LOINC, both onto11369-6for the one section a mapping reaches. The sources are the DHIS2 identifier namespaces rather than the generated concept codes, because a concept code moves with[generate.naming] sourceand a UID namespace never does. Written by thequestionnairestarget asconcept-maps/ConceptMap-d2-section-cm.json, rows sorted by UID so an unchangedfhir.tomlregenerates the same bytes, a namespace with no nomination getting no group rather than an empty one, and no file at all where no section is mapped. The server assembles a summary from the file and publishes the map for audit - the postureD2Sex_CMalready has.- The one directory the map families share states ownership by file-name
prefix (
sync_json_artifacts(owned_prefix=...)), so each family sweeps only the ids its own naming token produces (ConceptMap-d2-os-,ConceptMap-d2-cat-,ConceptMap-d2-aoc-,ConceptMap-d2-sex,ConceptMap-d2-section) and onepath-resourceglob covers them all. - Every identifier namespace a map targets is published as a CodeSystem too,
at the same URL, with
content: completeand every identifier the guide's own maps name enumerated in it -{base}/id/optionand{base}/id/option-codebeside the option-set pairs, the two category-option namespaces beside the category pairs, the two option-combo namespaces beside the attribute-combo pairs. A NamingSystem states what a namespace is and lists nothing, so a ConceptMap row validated against one sent the IG publisher to a terminology server and came back UNKNOWN_CODESYSTEM: 4,779 requests on one district-scale guide, against 5 once the namespaces are enumerated, with the publisher's narrative phase down from 438 seconds to 1.5. The namespaces are read off the built maps rather than assumed, so a family that grows a mapping group cannot leave its new target system un-enumerated, and the scaffoldedsushi-config.yamldeclares all six underspecial-urlbecause they sit outside the IG's own canonical. Each code is stated once per CodeSystem, whichever maps named it: two option sets legitimately share aPreeclampsiaoption code, and the publisher anchors a concept row by its code, so the same code twice is one anchor id on two rows - which its own QA pass reports as a duplicate anchor on the rendered page. [generate.categories]include_idsselects, where absent or empty meaning every category except DHIS2's built-indefaultplaceholder. That placeholder exchanges no information, soinclude_default = falseskips it unless the flag opts it back in or aninclude_idsentry names its UID outright; detection is the reserved rename-protected name matched case-sensitively, since/api/categoriescarries noisDefaultflag.d2w fhir validateresolves its categories scope through the same rule.- There is deliberately no
category_optionnaming token, since category options are concepts inside their category's CodeSystem exactly as options are inside an option set's, and theCOname stays reserved for a future standalone artifact.
Questionnaires¶
Data sets, event programs, tracker program stages, and a tracker program's own
registration form become Questionnaire instances.
- Structure. Sections as
#groupitems, data elements typed from theirvalueType, option-set-bound questions answered from the set's ValueSet, non-default category combos as per-option-combo child groups rendered#gtable. - The combo is resolved at one point,
service._effective_category_combo: the data set element's owncategoryCombowhere DHIS2 states one and the data element's where it does not - the disaggregation a data set really holds its cells over, and what every downstream reader of a cell follows. - Each cell asks the element's own question - same item type, same
answerValueSet, samerepeats, same bounds. required = truecomes from a data set'scompulsoryDataElementOperandsat the grain DHIS2 states them: an operand naming a data element alone marks the whole question and every disaggregated cell of it; an operand also naming a category option combo marks only that cell.- Standard
minValue/maxValueextensions on the value types that are a constraint (INTEGER_POSITIVE,INTEGER_ZERO_OR_POSITIVE,INTEGER_NEGATIVE,PERCENTAGE,UNIT_INTERVAL), typedvalueIntegerorvalueDecimalby the item type and shared by a disaggregated element's children. - The source data set's, event program's, or program stage's own DHIS2
attribute values ride as
D2AttributeValueextensions, plus data-element, tracked-entity-attribute, and category-option-combo support terminology. - Four synced directories:
data-sets/,event-programs/,tracker-programs/(nested one subdirectory per program,<program stem>/<stage stem>.fshplus the program's ownregistration.fsh, with the FSH sweep walking subdirectories and pruning one it emptied), anddata-dictionary/for the three shared support pairs. - A form that would emit one
linkIdtwice - a DHIS2 section UID reused as a data element UID inside one form, which R4'sque-2forbids and which would leave a response naming two questions at once - is skipped whole with an aggregate note naming the form and the clashing id, its peers emitted as usual. - A tracker program is one Questionnaire per program stage: the stage's
identity stem as the id,
{prefix}PS_<stage stem>as the name,$DHIS2-PS/$DHIS2-PS-CODEas its identifiers, the program's own subject type onsubjectType, a title carrying both names ("Child Programme - Birth"), and a third$DHIS2-PROGRAMidentifier slice holding the program UID. - Plus one registration form for the program itself,
tracker-programs/<program stem>/registration.fsh, whose questions are the program'sprogramTrackedEntityAttributesin DHIS2 sort order - typed through the very same value-type table, an option-set-bound attribute answered from that set's published ValueSet,mandatorybecomingrequired = true. Its identity is the program's own ({prefix}PR_<program stem>,$DHIS2-PROGRAM/$DHIS2-PROGRAM-CODE) plus a$DHIS2-TETslice naming the tracked entity type it enrols an entity as. Questionnaire?identifier={base}/id/program|<programUid>therefore selects a program's whole capture surface, registration and stages together, on any FHIR server.
Subject types¶
[generate.tracked_entity_types]is a UID to FHIR resource type map (Patient,Person,Practitioner,RelatedPerson,Group,Device,Location,Organization,Specimen; anything else refuses the config) that lets a project publish its herds asGroupand its water points asLocation. It defaults toPatient, so a person-tracking project configures nothing and every artifact stays byte-identical.- The tracked entity type decides the
subjectTypeof the registration form and of every stage form of the program alike, and feeds the example responses'subject.type,$generate's minted subject, and the capture server's subject-type check - read off the compiledsubjectType, warned by default and refused under--strict-codes- from the same resolution. - The guide publishes the map rather than keeping it in
fhir.toml: aD2TET_CS/_VSpair over every tracked entity type the run's person-only forms register (concept code the DHIS2 UID, display the name the instance holds,dhis2-codewhere the instance states one, NAME translations as designations under[generate] locales) and aD2TET_CMConceptMap beside it indata-dictionary/tracked-entity-types.fshstating oneequalrow per type ontohttp://hl7.org/fhir/resource-types- every row, not only the exceptions, so a consumer resolves a type over$translatewithout holding the project's config. d2w fhir validatenames every type the table does not. One finding per tracked entity type the instance holds that[generate.tracked_entity_types]never mentions, categoryunmapped-tracked-entity-type, carrying the UID, the name the instance holds, and the config line that would type it - a warning on a type this build publishes a form for, info on one outside the selection. A fifty-type instance gets a fifty-row checklist instead of the silence thePatientdefault otherwise applies in.[generate.tracked_entity_types]stays exceptions-only and UID-keyed. A run leaving two or more registered types unmapped raises a generate note naming each by instance name and UID, which doctor's generate phase reports as a warning.
Who a person is¶
[ips.identity]nominates, by UID, which tracked entity attribute holds a person'sname(orgiven_nameandfamily_nameapart),birth_date,sex,phone, and - under[ips.identity.address]- each address part (line,city,district,state,postal_code,country). DHIS2 has no field that means any of them, so the nomination is the instance's own statement or there is nothing to publish. Absent - the default - the register answers exactly what it answered before the table existed, byte for byte, which is what the whole serve test suite asserts by passing unchanged.- A name is never split, only nominated.
namepublishes one attribute asname.text;given_nameandfamily_namepublish two asgivenandfamilyon the same name. Which half an attribute holds is what its key says - nothing reads it off an attribute's name or cuts one value in two. phonepublishes as onetelecomentry of systemphone, and[ips.identity.address]as oneaddress. An address part of DHIS2 typeORGANISATION_UNITreads as the unit's name as the guide publishes it - its own Location or its registry package's - and a unit the guide does not publish leaves its part out rather than filling it with an id.[ips.identity.administrative_gender]maps each value the sex attribute holds - the option's DHIS2 code where it is option-set bound - onto one of R4's fouradministrative-gendercodes, whose binding onPatient.genderis required. A fifth word is refused whenfhir.tomlloads; asexwith no map, and a map with nosex, are both refused as halves of a dial that would do nothing.- The value shape is checked against the guide, not guessed. At startup each
nomination is looked up in the published
D2TEA_CSvocabulary and itsvalue-typechecked against what the FHIR element takes -DATEforbirth_date, free text for the names andsex,PHONE_NUMBERor text forphone,ORGANISATION_UNITor text for an address part. A mismatch refuses the run, naming the key and the type it found. An attribute the guide publishes nothing about is logged and served, because the guide's silence means the attribute is outside the selection rather than wrong. - Absence is stated per person, in the IG's own mechanism. A nominated birth
date the instance holds no value for keeps the element and carries
data-absent-reasonunknownon its_birthDatesibling - the IPS guide's own worked example; a value this server cannot read as a date carrieserrorinstead, because "nobody recorded one" and "what was recorded is not a date" are different answers.nameandgenderstate no absence, being required on nothing the register serves, and a sex value outside the map publishes nogenderat all. - A nomination adds a reading and removes nothing. The attribute's own value
still rides the
D2TrackedEntityAttributeValueextension as the string DHIS2 sent, so a client sees both what the instance holds and what the project says it means. Nomination reaches only the resource types R4 gives those elements -Patient,Person,Practitioner,RelatedPerson- and never aSpecimenor aLocation. - One mapping surface, so a synced copy and a live read agree. The nominations
ride
TrackedEntityIndex, whichregistered_entity_foris the sole consumer of, sod2w fhir syncwrites the same bytes the register answers with.
What a summary carries about them¶
[ips] enabledis the dial the whole summary surface hangs off, and it is false by default. A patient summary is a clinical document about a person, so publishing one is a decision a deployment states rather than one it inherits. False,$summaryanswers anot-supportedOperationOutcome naming the key and the line that would turn it on; the register, the record, and everything else the facade serves are untouched either way.[ips.sections]nominates which recorded values belong in which section of a summary, one sub-table per section.immunizationsis the one section a mapping reaches, and any other key is refused by name - a project writing[ips.sections.problems]is told the key is not one rather than left with a table that quietly does nothing.[ips.sections.immunizations]takesprogram_stagesanddose_data_elements, both lists of DHIS2 UIDs and both required together. The stages are the ones whose events record doses; the data elements are the ones inside them that each record a dose of one vaccine, which is the shape a DHIS2 immunisation form has - so the data element is the vaccine and its value is the dose, andImmunization.vaccineCodecarries the data element's own DHIS2 coding. A value that is not a UID is refused, and so is either list without the other: a stage with no data element nominated records nothing a summary reads, and a data element with no stage names values on events nobody said were doses.- The IPS binds
vaccineCodepreferably rather than requiredly, which is what lets this section carry real doses out of a DHIS2 coding while an international vaccine vocabulary is still missing. - The guide publishes the mapping as
D2Section_CM, so a consumer audits which recorded values a served summary carries without ever holding the project'sfhir.toml.
The data dictionary¶
D2TEA_CS/_VSis the tracked-entity-attribute support pair the registration form's item codes point into:dhis2-code,form-name,value-type, auniqueboolean marking the attributes that are business identifiers, and searchability with its provenance.- Searchability carries its provenance: a
searchableroll-up true where any context this run publishes declares it, plus onesearchable-<contextUid>boolean per context that asked the attribute - because DHIS2 holds the flag on the join, and two programs asking one attribute disagree as readily as they agree. Each per-context property is declared once with the context named in words, and every flag is read off the veryprogramTrackedEntityAttributes/trackedEntityTypeAttributesjoin the forms already cost. - Input surfaces speak
formName, reference surfaces speakname. DHIS2 writes a data element'snamefor analytics and reference and itsformNameto clarify what a person is being asked, so aQuestionnaire.item.text- an aggregate question, an event or tracker stage question, a registration form's attribute, and the group heading of a disaggregated element - is the form name wherever DHIS2 states one and the name wherever it does not. The dictionary is the reference surface: aD2DE_CSorD2TEA_CSconcept keeps displaying the name and states the form name beside it as aform-namestring property, so one concept reaches both spellings. A disaggregated element's cells are labelled by their category option combo, which has no form name of its own. - Every dictionary property is declared only where a concept carries it,
and
dhis2-codeis written only where DHIS2 states a code rather than repeating the UID the concept code already is. D2EntityLevelrides each question, read offtrackedEntityType[trackedEntityTypeAttributes]on the same program fetch: true for an attribute the tracked entity type collects, false for one only the program asks. That is what makesd2w fhir forwardwrite the first ontotrackedEntities[].attributesand the second ontoenrollments[].attributes, the two levels DHIS2 imports a registration at. The level rides the item rather than aD2TEA_CSproperty because it is a fact about the attribute and the tracked entity type together, and two programs on different types can disagree; a question stating no level is written on the tracked entity.
Examples¶
Usage: #exampleQuestionnaireResponses declare themselvesInstanceOfthe matching response profile, so the publisher validates every example against the capture contract on every run, and answer those Questionnaires on the same link ids. One file per example underexamples/, sized and sourced by[generate.examples]per_target/source.source = "synthetic"(the default) generates values locally from a SHA-256 seed - stable across machines and runs, every option combo filled.- An example is captured at an organisation unit its own form is assigned
to, under an attribute option combo that organisation unit admits and that is
open for the period the example reports for. DHIS2 scopes a data set and a
program to the organisation units it is assigned to and refuses a capture
outside that scope (
E1029on an event,E1041on an enrollment); it scopes a category option to organisation units on top of that and refuses a capture keyed to a combo not usable at the organisation unit it was filed from (E8025); and it scopes the same option to a calendar window, refusing a capture whose combo does not cover the whole period it reports for (E8032). A published example is the shape a consumer copies, so the three are one choice rather than three: the organisation-unit selection's own root where every rule admits it, and otherwise the first organisation unit by UID that they do, so a rerun places it identically, and the combo is drawn from the concepts that organisation unit admits for the period the example carries - the newest completed period of the form's own period type. A form DHIS2 hangs no assignment on, and a form whose assignment names nothing published, fall back to that root; a form left with no published organisation unit at all is an aggregate note rather than a reference the publisher cannot resolve, and a form no organisation unit it admits may file any of its combos at, or whose every combo has closed for the period it reports, publishes no example, because there is no capture DHIS2 would take. - A form no organisation unit may file a capture for is said out loud.
d2w fhir generatecloses such a run with a warning naming the forms and the two dials that answer it, the way it does for an empty organisation-unit assignment, and files the same fact as a note.d2w fhir check-artifactsraises a warning-level finding per reference to the vocabulary, read back off the published restrictionLists alone, so a build machine with no DHIS2 connection asks the same question the run asked. - A published stage example answers an enrollment a registration example of
the same run creates. A tracker program publishes a registration form and
its stages together, and the two are drawn as one corpus: the
n-th registration of a program mints the tracked entity and enrollment pair, and every stage example of that program is assigned round-robin across those registrations. DHIS2 refuses an event naming an enrollment nothing creates withE1313, and the program mismatch that follows withE1079, so a corpus minting a pair per example would be a guide whose own examples the instance turns down. The load set and the IG examples run the same builder under the same rule. - An example answers the form it answers. Only the questions the form's own
enableWhenleaves enabled given the rest of the response are answered - the sweep runs to a fixed point, because dropping an answer can close the question that depended on it - and every numeric answer falls inside the question'sminValue/maxValue, its value type's and its program rules' both. A question whose bounds admit no value at all is left unanswered, and both outcomes are tallied into one aggregate note apiece. source = "instance"reads real data value sets and tracker events off the server: an event program byprogram, a tracker stage byprogramStageplus itsprogram, whosefieldsalso carryenrollmentandtrackedEntity. An event answering neither declares the base QuestionnaireResponse and is tallied in one aggregate note rather than dropped. Aggregate data walks back through the six newest completed periods of the data set's period type via therecent_periodsinverse of the ISO parser, grouping data values by(orgUnit, period, attributeOptionCombo).- Answers are typed from the DHIS2
valueType, with option codes resolved to codings carrying the very concept code the terminology target assigned that option, so a coding never names a concept the CodeSystem lacks. An answer selecting an option that received no concept code is left unanswered with its own note, and so is anORGANISATION_UNITanswer naming a unit outside the organisation-unit selection, which the IG publishes no Location for. - Numeric answers are admitted only in the plain lexical forms an R4
primitive can carry (
NaN,1e3,+1,01.5stay strings). Temporal answers are cleared against the calendar, the clock, and the R4 offset range before they are emitted. authoredand everyDATETIMEanswer get the offset R4 requires and DHIS2 omits: the offset the[generate] timezoneIANA zone stood at on that very timestamp, DST included, orZwhen the project names no zone (DHIS2_ISSUES.md #62).- A target holding no data is one aggregate note, never a failure.
Organisation unit registry¶
- Paired Organization/Location instances with
Organization/<stem>/Location/<stem>partOfhierarchy, losslessly embedded GeoJSON, and the unit's DHIS2 attribute values asD2AttributeValueextensions on both halves. On the Location the boundary extension is emitted first, the attribute values after it, and theD2OrganisationUnitLevelextension last, so a regenerate of an unchanged unit stays byte-identical. [generate.organisation_units] geometrydecides how much of that geometry each Location carries:full(the default - the position and the boundary),position(the point, or the polygon centroid, and no boundary extension) ornone(neither, withgeometryleft out of the DHIS2 read). The registry page and the scaffoldedindex.mddescribe what was chosen, and the capture server hands the setting to the organisation units page throughGET /facade/uiconfig, which draws points only, or no map at all, to match.- Shipped as pre-built R4 JSON under
ig/input/resources/registry/, oneOrganization-<stem>.jsonand oneLocation-<stem>.jsonper unit, serialised from thedhis2w_fhir.r4models and loaded by SUSHI as predefined resources intosushi-local#LOCALrather than compiled from FSH. D2Organization/D2Locationprofiles, the latter slicing the level extensionnamed level 1..1, so every published Location states the hierarchy level it sits at instead of leaving it to be counted offpartOfhops. The level CodeSystem ships beside them - one concept per depth the selection reaches, codedlevel-<n>and displayed under the name the instance gives that depth in/api/organisationUnitLevels(Level <n>where it names none), with that name's DHIS2 translations as concept designations under[generate] locales; the table is one unpaged read per generate run - as does a curatedregistry-examples.fsh(D2OrganizationExample/D2LocationExample,Usage: #example) taking its level and position from the selection's own root unit so the publisher validates both registry profiles against shapes the instance really holds - kept beside the profiles rather than underexamples/, whose sync deletes every file it did not produce. Its identity is synthetic: both profiles require the two DHIS2 identifier slices 1..1, and the pair statesd2-exampleon each, under the name "Example organisation unit". No DHIS2 UID can be that value, so an identifier search over a published guide answers with exactly one resource per organisation unit rather than two for whichever unit the example was drawn from.- These stay FSH under
ig/input/fsh/organization/, along with the optional whole-selection CodeSystem representation ([generate.organisation_units] terminology). - Published as a package of its own, opt-in. A guide naming a registry
package under
[generate.organisation_units.registry](id,canonical,version, optionalpath) writes no Organization, Location, registry profile, registry example or level terminology - both directories are emptied - reads the selection once asid,code,nameinstead of walking the hierarchy, references every unit by its absolute URL<canonical>/Location/<stem>in the examples, the assignment Lists and the capture page (the only form the IG publisher resolves across a dependency), typesD2OrganisationUnitand everyD2Responsessubject with the package's Location profile by canonical, leaves the level extension and the organisation-unit NamingSystems to the package, and raises oneregistry-dependencynote. The Registry page states the package. A guide whose canonical equals the registry's is refused. Afhir.tomlwithout the table is untouched by any of this. - The registry package itself is a project with
kind = "package"andpublishes = "organisation-units"under[ig]:d2w fhir generateruns its foundation slice (aliases, the organisation-unit NamingSystems, the attribute-value extension, the level extension), the organisation units and the pages (Registry plus the unit intros), reports the four form-side targets as not applying, and refusesoption-sets,categories,questionnaires,examplesandload-setby name. A registry package selecting a form or naming a registry is refused when itsfhir.tomlloads. - The translator reads both reference forms.
location_id_oftakes the id offLocation/<id>and off<canonical>/Location/<id>, so a response written against either guide resolves its organisation unit. - The facade reads both forms too. The capture UI's reporting-unit picker,
the
$generatedraw, the assignment grading on receipt, the shape check a submission's own subject and organisation-unit extension pass, and the unit a receipt names all read a reference through the same rule - so a form assigned by absolute registry reference is offered at the units it names, drafts inside them, and accepts the example response the generator wrote for it. An absolute reference is admitted under an authority this project publishes units at - the guide's canonical or its registry package's - and one under any other authority is refused by name, because the same id under somebody else's registry is a different organisation unit. - A form assigned nowhere drafts nothing. An assignment
Listnaming no organisation unit this project publishes admits nothing on receipt, so$generateanswers 422 naming that List rather than drafting a capture DHIS2 would refuse withE1029. - A draft names its organisation unit the way the guide's own documents do:
the relative
Location/<id>where the guide publishes its own registry, the absolute<registry canonical>/Location/<id>where a package publishes it. Both post, so what decides it is which spelling a client copying a draft should learn. - Generating, serving, forwarding and checking a depending guide all find
the units through one resolver (
dhis2w_fhir.registry_package): the checkout[generate.organisation_units.registry] pathnames, then apackage.tgzor extracted directory given as--registry-package, then aRegistryMissingErrornaming both remedies. A guide scaffolded with nopathnames itsREGISTRY_TGZto all four through the Makefile. A tarball is read in place withtarfile, never unpacked to disk, and only theLocationandOrganizationresources at the package's top level are taken out of it. The workedd2-examplepair underpackage/example/names no organisation unit and stays out, and the package's own profiles and ImplementationGuide stay where they are, so a facade answering for one guide never holds a second ImplementationGuide.d2w fhir servepreflights the registry inServeSettings.resolve, so the refusal lands before the starting banner - in both store modes. A--liverun over a depending guide serves the package's units too and walks no hierarchy on the instance: units of its own would publish a second identity for every place at this guide's base URL, so a client developed against--livewould resolve units the published guide never names. Both halves ofd2w fhir forwardread that one registry: the guide read off disk and the guide built off the instance for a project that has never run SUSHI resolve everyLocation/<id>through the package, and a drain that can reach neither source refuses before it opens a connection rather than translating against places this guide does not publish. The refusal is deliberate rather than a fall-back: with no Location loaded, reference resolution reads the id as the DHIS2 UID, which is right undernaming.source = "id"and silently wrong under"code". - A package serves no capture surface.
[ig] publishessays the project holds no form, sod2w fhir serveover one declares noQuestionnaireResponseand no$generatein its CapabilityStatement, says what it publishes on the starting banner and at the service base, and refuses a posted QuestionnaireResponse with the same sentence the capture UI shows: "This project is a package: it publishes organisation units for guides to depend on, and no form. Captures are made in a guide that depends on it, not here." It answersGET /Questionnairethe same 404 it answersGET /Specimen, because the CapabilityStatement is the route table and it declares neither. - The capture UI over a package is a package UI.
/facade/uiconfigcarriespublishes, soserve --uiover a package draws a rail without Forms, Responses, Evaluate, or Playground - the four pages whose reads that server answers 404 and whose submissions it answers 405 - and keeps the pages a package really has: the overview, the organisation-unit hierarchy and map, the terminology, and the server's own contract. The overview says what the project is instead of advising a generate that would produce no form, and the four addresses still answer: opening#/formsfrom a link kept elsewhere renders the same sentence the endpoint answers a client with, plus the way to the hierarchy, rather than a card about a read that failed. d2w fhir check-artifactsreports a dangling registry reference as a finding of the newregistrykind, comparing the<canonical>/Location/<id>references already on disk - in compiled JSON and in generated FSH - against the ids the package publishes. It stays offline and connectionless, which is what letsmake buildrun it; a registry it cannot read at all is one finding againstfhir.tomlrather than silence.d2w fhir generatecounts the references the package carries no place for as it writes them, reading theLocation-<id>.jsonfiles the checkout published against the stems this run resolved. The two[generate.naming] sourceliterals are not that fact:code-or-idfalls back to the id for every unit whose code cannot serve as a stem, so a registry stating it beside a guide statingidpublishes exactly the stems the guide references and nothing dangles - and a registry that code-stems some units and falls back on others produces a partial mismatch no comparison of two literals has a shape for. The note states how many of how many dangle and names the first few, or says nothing at all.
Site pages¶
d2w fhir generate pages writes a narrative documentation layer into
ig/input/pagecontent/.
- Six site pages: Forms (a data-set catalog, an event-program catalog, and a "Tracker programs" section grouping each program's stages under its own heading), Registry, Terminology, Identifiers, Periods, and Capture.
- The Capture page states what a third party sends to capture data: the
single-response-per-request rule; an aggregate, an event, and a tracker event
response worked step by step against the selected forms with a real period
and an organisation unit that form is assigned to - the very placement the
examples target files its own responses from, so the page teaches the capture
the examples beside it make rather than one DHIS2 refuses with
E1029, and a form the run published an example for is preferred as the worked one; the attribute option combination an aggregate response is filed under, as a step of its own on a form riding a category combination that is not the default one - quoted as a combination the worked organisation unit may file under for the worked period, because the instance refuses one it scopes away (E8025) or has closed (E8032) as surely as it refuses a response naming none (E8023); the logical Patient subject and both tracker extensions; where a client obtains the enrollment and tracked entity UIDs (d2w data tracker enrollment list, outside the guide's scope); the<dataElementId>/<dataElementId>.<categoryOptionComboId>linkId grammars; the required rules; the event status map; an answer-typing table derived from the same tables the examples answer from; the coded-answer rule; and the validate-before-you-send workflow. - A form this DHIS2 instance takes no capture for is never the worked one, and
where the selection holds no other, the page says so. A form whose every
attribute option combination the instance scopes away from the worked
organisation unit, or has closed for the worked period, is one no response
exists for - the examples target publishes none for it, and the page states
that outright where it has to work against it rather than quoting a
combination the instance answers
E8025orE8032to. - A walk-through only claims an assignment the run proved. The aggregate and
the tracker steps quote an organisation unit only where the run placed one of
its own examples there, which is what says the form's DHIS2 assignment holds
it. Where it placed none - a tracker program assigned to organisation units
below the published
max_level, say - the page writes the reference as the shape it is and states the fact: this guide publishes no organisation unit the form is assigned to, and DHIS2 refuses a capture from outside one withE8022on an aggregate response andE1029on a tracker one. <Type>-<id>-intro.mdintros that the IG publisher injects into the matching artifact pages - one per Questionnaire, and one per option set or organisation unit carrying a DHIS2 description.- Sync-managed by a markdown generated header, so the hand-authored
index.mdsurvives every regenerate, with every metadata-derived string escaped for the publisher's strict HTML parse and for markdown table cells. - Escaping is page furniture only: the FSH
Title:/Description:keywords and the generated markdown, never an element of a served resource, whosetitle,description,name,alias,display, andtextall carry the DHIS2 text byte for byte.
Translations¶
DHIS2 translations are carried through across the whole surface, filtered by
[generate] locales.
NAMEbecomes a CodeSystem concept designation on every vocabulary (options, category options, organisation units, and theD2DE_CS/D2TEA_CSdata dictionary) and an HL7 translation extension on every title and instance name (option-set and category CS/VS titles,Questionnaire.title,Organization.name,Location.name).- A
Questionnaire.itemtakes its_textfromFORM_NAMEwhere DHIS2 gives the object a form name, and fromNAMEwhere it does not. - A program stage's composed
<program> - <stage>title is translated only in the locales translating both halves. - Tags are normalised to BCP-47 (
pt_BRbecomespt-BR) and locale-sorted, so a regenerate of unchanged metadata is byte-identical. [generate] localesnarrows which languages travel; absent means every language the instance holds.
Artifact naming¶
[generate.naming]configures artifact naming, with underscore-delimited computational names (D2OS_Qdm5fPK5Ra9_CS,D2OU_Level_VS,D2DS_BfMAe6Itzgt,D2PS_A03MvHHogjR).sourcepicks the identity stem every artifact of an object derives from: the FHIR resource id, the canonical URL, the file name, and the FSH name all follow one resolved segment - across option sets (the CS/VS/ ConceptMap triple shares one stem), categories, organisation units (registry file names, ids,partOf,managingOrganization), questionnaires, examples, and pages."id"(the default) is the DHIS2 id verbatim, keeping its own case:d2-os-Qdm5fPK5Ra9-cs."code-or-id"is the object's code when it meets the R4idbar, fits the surface's stem budget with no truncation ever, and is unique among the selected peers - else the id, with one aggregate note per surface."code"is the code always: a selected object with a missing, unusable, or colliding code refuses the run before a file is written, with a one-liner naming the offenders.- Stems are assigned once over the whole selection and read by every target,
so a question's
answerValueSetand an example's coding name the artifacts that run writes whichever source is set - while the DHIS2 id and code always remain as identifier slices. The option-set plan is the one object the terminology target emits from, the questionnaires bind to, the examples code from and the pages link to, and it is read from a projection carrying the DHIS2 code, so a set is published and referenced under one stem. - A
"code"refusal states how many of the selection cannot serve, the rule they are held to - a stem becomes a FHIR resource id, so ASCII letters, digits, hyphen and dot, 1 to 64 characters, unique across the selection - and what"code-or-id"does with those very objects: the code wherever one can serve, the DHIS2 id on the rest, so that run completes.
Publication status¶
[ig] status(draft/active, settable at scaffold time withfhir init --status) drives thesushi-config.yamlstatus plus the publicationstatusand theexperimentalflag on every generated definitional resource.- NamingSystems take the status alone. The Organization/Location instances
are data: their
active/statuscarries the unit'sclosedDate.
Generate notes¶
- Every note a generate target raises is a
GenerateNotecarrying its kind -selection-mismatch,selection-closure,empty-selection,selection-gap,refused-form,form-structure,skipped-question,answer-fallback,instance-data-gap,build-cost,compile-removed,scaffold-drift,registry-dependency,code-fallback,code-collision,stem-fallback- beside its text and anechoes_validateverdict derived from it. - A bare run counts the three kinds that merely restate a
fhir validatefinding apart from what generation itself found (note: 3 distinct note(s) across 2 target(s) (+8 validate echoes); full list in ...), counting each note once across the targets that raised it - which is what the summary table'sDistinct notescolumn counts, while a target's own[k/N]step line counts its own share and names it (379 notes raised here), so the two numbers a run prints for one target are two named numbers, while the notes file still carries every one, echoes under a trailing per-targetRestatements of validate findingsheading. A note several targets share is counted and filed once, on the first target that raised it;--jsonkeeps the full per-target lists. - A solo target prints all of its notes inline, and
--jsoncarries the whole model.
Load sets¶
d2w fhir generate load-set writes a synthetic load set of QuestionnaireResponse
JSON under load/ (--per-target, default 25; --salt; --output-dir) for
posting at a running facade.
- Covers every form kind and places each response at a unit its target is really assigned to.
- A tracker program's corpus is internally consistent: its registration
responses mint the tracked entity and enrollment UIDs from the program UID
and the ordinal - the program UID being in the seed material is what keeps
one program's identities out of another's, an event answering the wrong
program's enrollment being
E1079- and its stage responses reuse those very pairs round-robin rather than inventing enrollments nothing creates, which a drain lands whole since it posts registrations before events. - A
uniquetracked entity attribute is answered from the minting response's own tracked-entity UID in whatever spelling its value type admits (textual types embed it,EMAIL/URL/PHONE_NUMBERin their own shape, the integer family as a nine-digit derivation on the admitted side of zero), because DHIS2 refuses a second registration claiming one business identifier withE1064and takes its enrollment and every event on it down too. A value type with no room for distinctness (BOOLEAN,LETTER, a date, an option-bound attribute) keeps the ordinary draw and is named in a note rather than faked out of range. - A corpus mints the identities it names so it imports once;
importStrategy=CREATErefuses a re-import onE1002/E1080before any value is read, which is what--saltanswers by moving every drawn value at once into a genuinely different corpus that the same salt still reproduces. - Deliberately not part of a full
generaterun, because a load set is test data rather than IG source.
Validate the instance¶
d2w fhir validate checks an instance's codes for FHIR-safety: an
instance-wide /api/metadata sweep applying the R4 code check, the
template-hostile-name check, and the control-character-name check to every
object in every collection it returns,
graded against the emission scope the run resolves from the same selection
semantics generate uses.
- Every finding carries a
scopeofselection(on the configured build path, where severity means build impact) orinstance(hygiene the build never reads, alwaysinfo). - The summary splits the totals into a
selection findingsrow and acode coveragefraction counting the in-scope objects whose code can serve as an identity stem (usable_code_stem, the R4idbar). The resolvedValidationScopecosts five id-only reads rather than a second sweep. - A package is graded against what it publishes. With
[ig] publishes = "organisation-units"the project holds no data set, program or form -generaterefuses those targets by name - so the run resolves the organisation units and nothing else, makes one id-only read instead of five, and every form-side finding is the instance hygiene it is for a project that publishes no form. The terminal summary, the Markdown report and the PDF cover each carry agraded againstline and name the surfaces that do not apply (data sets, programs, program stages, data elements, tracked entity types, tracked entity attributes, option sets, categories, category options)./facade/metadata-healthreads the same scoping off the project it serves. - Three deep passes for what the sweep structurally cannot see: an
option-set pass gated on
--code-source; a code-stem pass previewing a code-sourced[generate.naming]source over the six naming surfaces (code-stem-fallbackwarnings under code-or-id semantics,code-stem-refusalerrors undersource = "code"- the same defect predicate generate refuses through, so a validate error equals a generate refusal, with collisions graded per id namespace, data sets, event programs and tracker stages pooling into the Questionnaire namespace exactly as generate resolves them, and every stem read off the code the posture publishes rather than the one DHIS2 holds - a run screens its names before it plans an identity, so under"substitute"the option set codedDevelopment activitiesstems fromDevelopment-activitiesand is neither counted nor refused, which is what keeps the two commands stating one number and one fact per object; a finding on a code the rewrite left unusable names both spellings); and an attribute pass naming every attribute the instance left uncoded, whose values therefore ride a bare UID on all five resource types theD2AttributeValueextension is contexted on, counted asattribute_countin the report beside the option-set, option, resource-type, and object counts. template-hostile-namefires in either code mode on any name holding<,>, or&- the characters the IG publisher's template injects into HTML unescaped - and reads the object's NAME and FORM_NAME translations beside its name, because the generate gate rewrites those two translated properties exactly as it rewrites the name: a translated NAME becomes a published_title,_nameor designation and a translated FORM_NAME becomes a question's_text, so an object whose own name is clean and whoseen_GBname carries a<is a build the publisher dies on. The message names the locale and the property. Its siblingtemplate-hostile-codereads the code for the same three on the six collections whose codes become identifier values (optionSets,categories,organisationUnits,dataSets,programs,programStages).spaced-codereads every surface the generate gate screens a code on -optionSets,categories,categoryOptions,organisationUnits,dataSets,programs,programStages,trackedEntityTypes,dataElements,trackedEntityAttributes, and the options of the deep pass - 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.control-character-namefires in either code mode on any name or form name holding a C0 control character (U+0000 through U+001F), which SUSHI carries byte-true from the FSH into the compiled resource. Tab, newline, and carriage return are the whole of what XML 1.0 admits below U+0020 - and what the R4stringvalue regex names - so those are warnings: the published pages collapse them while the FSHTitle:has already flattened them, and one object states its name two ways. Every other C0 control 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 those are errors. The message names the character in words (a tab character, the control character\x01) anddisplay_codeprints it as an escape rather than letting an invisible byte reach the page as nothing. Under"substitute"the rewrite collapses every control character to the space it stood in, so the finding isinfoand names both spellings. It is a category of its own, not atemplate-hostile-name: no HTML template is what breaks, and acontrol-character-nameerror does not refuse a generate run.invisible-characterfires on a name, form name, option name, or code holding a stray Unicode format character (a zero-width space, zero-width joiner, byte-order mark, direction mark): one touching only visible ASCII. A zero-width space inside Lao, Thai, Khmer or Myanmar text is how those scripts mark word breaks and is not reported. A code is a warning in scope, since it reaches identifiers, resource ids and URLs; a name or form name isinfoand is published as DHIS2 holds it.display_codeprints the character as its\uXXXXescape. Organisation unit phone numbers and emails are cleaned byd2w fhir generateinstead (contact_value), which drops the format character on publish and raises a note naming each affected unit.- Both hostile-character checks are graded the same way: an error for an in-scope
<, a warning for an in-scope>/&, andinfofor either out of scope - because a name and an identifier value alike land in HTML the publisher writes unescaped and then strict-parses, so an aborted build is what a<costs on either surface and a malformed page is what the other two cost, and the build aborts only after every resource has been rendered. Both errors are also generate refusals, through the sharedbuild_aborting_code/build_aborting_namepredicates. - The run grades under the project's
[generate] hostile_namesposture, and the summary row, the Markdown report, and the PDF cover each state which posture produced the counts. Under"substitute"a name carrying<is rewritten for publication, so its finding isinfoand its message names both spellings (published as 'Vitamin A given to under 5y' ... DHIS2 keeps 'Vitamin A given to < 5y'), and aspaced-codefinding names the hyphenated code the guide publishes; under"refuse"and unset the grading is the refusing one.template-hostile-codefollows the posture the same way: an in-scope<code is an error under"refuse", andinfounder"substitute", which rewrites it before any emitter reads it.--hostile-names substitute|refusereads the instance under the other posture for a what-if run; the flag beats the config, the config beats unset, and exit 1 follows the graded severities. - The name grade and the generate refusal hold in both directions. Every
name graded a
selection-scopedtemplate-hostile-nameerror refuses ad2w fhir generaterun, and every name generate refuses is graded that error here. TheValidationScopetherefore carries one surface per kind of object a selection can publish - option sets, options, categories, category options, organisation units, data sets, programs, program stages, tracked entity types, data elements, and tracked entity attributes - rather than only the six whose codes become identity stems. Codes stay asymmetric on purpose: a data element's code is a concept property the publisher escapes, so neither command gates it. --detailsdegrades at 80 columns rather than folding. Every cell is cut to what the terminal carries before the table is built, so the table asks for no more room than the screen has: nothing folds down the page, nothing is squeezed to a blank stub, and each row is one line. The scope, the category, the resource type and the code are dropped in that order when the terminal is too narrow for them - thefindings by categoryrollup above the table counts the first two and the Markdown, CSV and PDF reports carry all four. What is left is what a reader acts on: how bad it is, which object, and why. 80 columns is what a non-TTY pipe gets. The Object cell is cut on its DHIS2 name and never on its UID - the UID is what a reader greps the report files for and types every remedy against, and an 11-character UID rendered one character to a line names nothing - and the sentence saying what a finding costs takes whatever the other columns leave, which makes it the cell a narrow terminal shortens.- The scope and both restrictions keep the error meaning "this build will
fail": a dashboard is never generated and a data element carries its code
through an escaped surface, so neither is a finding;
<is the only character seen to abort a build; and an unselected object cannot abort this project's build, so only errors gate exit 1. - Reports are written as Markdown, CSV, and PDF (clickable contents,
bookmarked sections, Lao-script and symbol font support, so the very code a
finding is about renders in the page reporting it) into
--output-dir, with exit 1 on errors and--fail/--no-failgating the exit code. The PDF library narrates its own render at debug level, so nothing but the command's owninfo:/note:/warning:lines reaches the terminal. - The terminal is a status view: the summary table with the selection split
and the code-coverage fraction, a rollup row per (severity, scope, category)
with the instance rows dimmed, every error individually because an error
names the object that gates the build, and one closing line splitting the
pass into selection warnings, selection infos, and instance findings before
pointing at the report file.
--detailsexpands every finding.
Serve the guide¶
d2w fhir serve is the second verb over the same project: a FastAPI facade
bound to loopback by default that loads the project once at startup.
- Two APIs in one process. The base URL is FHIR's - the reads and searches,
the capture POST, and the declared operations
$generate,$translate,$summary, and$evaluate- and/metadatais the whole of its contract. Everything the facade answers about itself is a separate API mounted at/facade, with its own OpenAPI document at/facade/openapi.jsonand an interactive page at/facade/docs:/facade/whoami,/facade/spool,/facade/uiconfig,/facade/metadata-health,/facade/evaluate, the two/facade/terminologyreads, and the tracked-entity enrollment listing and record./cds-servicesis neither: CDS Hooks fixes discovery at{base}/cds-servicesthe way FHIR fixes{base}/metadata, so it sits at the base URL beside FHIR. The document and its page are readable in everyauth_scope, for the reason/metadatais - a contract nobody may read is a contract nobody can meet. - The store is the compiled
ig/fsh-generated/resourcesmerged with the predefinedig/input/resources/{registry,terminology,categories}tree SUSHI never re-emits. With--liveit is the same read set built straight off a DHIS2 instance through one client opened during startup and held open for the life of the process: no read of the store touches DHIS2 again, but the connection stays becausePatientand the enrollment listing answer from the instance per request. A live build screens DHIS2's names and codes through the project's own[generate] hostile_names, at the same choke point a generate run screens at, so one project means one set of names whichever way it is served. Undersubstitutea form is served under the name the compiled guide publishes it under; underrefuseand unset a live serve is byte-true and aborts over no name, because serving is not generating. - What one mode publishes, both modes publish, and a test holds them to it.
The live store and
d2w fhir generatebuild their JSON artifacts from one instance read through one set of builders, so every file a compiled project commits underig/input/resourcesis a document the live store serves under the same id and the same bytes: the assignment Lists, the registry, the terminology and its ConceptMaps, the attribute-option-combo vocabularies, and the organisation-unit restriction Lists each combo concept names. Both stores are built off one mocked instance and compared artifact for artifact, so a keyword one call site stops passing is a failing test rather than a family of documents only one of them publishes - which is how a--livedraft came to name a combo DHIS2 refuses at the unit it was drawn for, withE8025. - A worked example is held, and published by nothing. A guide compiles an
exemplar beside its registry profiles - the
Usage: #exampleLocation and Organization that show what a published organisation unit looks like - and its ownImplementationGuideresource names them ondefinition.resource[](exampleBoolean,exampleCanonical). The store reads that and keeps them out of what it publishes: out of every searchset, out of every count, out of the units$generatedraws a place to report from, and out of what a capture may name. SoGET /Location?_count=0on a compiled guide answers the numberd2w fhir generatewrote, exactly as a--liverun does. Each example stays readable at its ownGET /{type}/{id}, because the guide's published pages link to it there. /metadatais the route table. A resource type the statement declares no interaction for answers 404 naming/metadata, rather than an empty searchset that would read as "this guide published none of those" - so a package publishing organisation units answersQuestionnairethe way it answersSpecimen. The declared set is the read types this project actually publishes:Questionnaire,CodeSystem,ValueSet,Location,Organization,List,ConceptMap,NamingSystem, and the guide's own conformance resources, plusQuestionnaireResponsewhere the project receives one.- Four CodeSystem/ValueSet pairs the foundation FSH declares are included -
form type, period type, organisation-unit levels, and the organisation-unit
code list
[generate.organisation_units] terminologyturns on - each built from the very Python vocabulary its FSH template renders and gated dict-equal against the SUSHI-compiled pair. - The profile is the root
d2w -p, resolved before the start banner. - Authentication is
[serve] auth, in four postures.none- the default - serves every caller.tokentakesAuthorization: Bearer <token>and compares it againstD2W_FHIR_SERVE_TOKENSwithhmac.compare_digest; the tokens come from the environment and never fromfhir.toml, and rotating them is replacing the variable and restarting.dhis2takes the caller's own DHIS2 credentials - HTTP Basic, or a personal access token asAuthorization: ApiToken <token>- and validates them with oneGET /api/meagainst the same instance the live run reads, in a fresh request carrying the caller's header and never the runtime's client, cached about a minute against a hash of that header. The validated username becomes the request identity, and under this posture every register read is answered under the caller's own DHIS2 authorization.jwttakesAuthorization: Bearer <token>from an external OpenID Connect issuer and verifies it locally against that issuer's JWKS; the value of[serve.jwt] username_claimbecomes the request identity.oauth2is the name reserved for an authorization server this facade would run itself and is deliberately not accepted: DHIS2 2.43.1's authorization server 500s for any client its API creates (DHIS2_ISSUES.md 96) - a deployment wanting bearer tokens today statesjwtand names the issuer it already has. - The
jwtposture is[serve.jwt], verified locally against the issuer's published keys.issuernames the OpenID Connect issuer identifier and is required for the posture;audienceis checked only when stated;username_claimdefaults topreferred_usernameand names the claim that identifies the caller;forward_bearerdecides whether a register read carries the caller's token to DHIS2. While the server starts it reads{issuer}/.well-known/openid-configurationand thejwks_uriit names, and every request after that is checked in memory with no round trip: the signature against the key the token'skidselects, over RS256/RS384/RS512 and ES256/ES384/ES512 and no symmetric algorithm (a shared-secret algorithm verified against a public key is the algorithm-confusion attack),iss,expwith a minute of clock leeway and no token accepted without one,nbfwhere stated,audwhere configured, and the username claim. The JWKS answer's ownCache-Control: max-ageis honoured with a five-minute floor and no ceiling; akidthis process does not hold forces one refetch, so a key rotation is not an outage, and that refetch is itself floored at a minute so a stream of inventedkids costs the issuer one read. Revocation is the stated trade: a token withdrawn before it expires stays valid here until it expires. - Under
jwt, the register is refused rather than read as the facade. DHIS2 resolves a foreign issuer's JWT only when the instance was configured to trust the same issuer (oidc.jwt.token.authentication.enabled), which this facade cannot read and will not guess, so[serve.jwt] forward_bearerstates it and is false by default. False answers every register read 501 with an OperationOutcome naming both halves that would make it answerable; true forwards the caller'sBearerheader over exactly the path thedhis2posture forwardsBasicover - same opaque header, same credential-free pool. There is no silent fallback to the facade's own profile, which under an administrator profile would be DHIS2's whole ownership and access-level model skipped with no break-the-glass audit entry. [serve] auth_scopesays how much the posture covers.write- the default - guardsPOST /QuestionnaireResponseand nothing else, which is the facade's whole state-changing surface:$generate,/facade/evaluate,$evaluate, and a CDS Hooks call are POSTs that write nothing.allguards every router but/metadata, which stays open in every posture so a client can read the posture it has to meet;/facade/openapi.jsonand/facade/docsstay open for the same reason, and the capture UI's own files stay open too.GET /facade/whoaminames the caller, and carries the check under every scope. A caller is named only where a posture is configured: underauth = "none"the address answers 404 saying this server authenticates nobody, so it names nobody, and that/facade/whoamianswers a caller only where[serve] authstates a posture - the address's own refusal rather than the read catch-all callingwhoamia resource type nobody asked for. It answers{posture, username, name}: the DHIS2 username underdhis2, the[serve.jwt] username_claimclaim underjwt, and no username at all undertoken, which names a deployment rather than a person. Wrong credentials meet the same 401 and the same OperationOutcome every other refusal carries. It is what gives a verdict on a credential without spending one, which under the defaultwritescope is otherwise only discoverable by making a submission.- The
dhis2posture's 401 challenges withxBasic, notBasic. A browser meetingWWW-Authenticate: Basicon a request a page made opens its own credential dialog and leaves the request pending, so the capture UI would hang on Submit instead of rendering the refusal. The scheme callers send is unchanged, and the header reads the same for every caller rather than shifting byAcceptor user agent. dhis2forwards the caller's credentials on every register read. The tracked entity read, the identifier search, the register listing and its counts, the enrollment listing, and the registered context of/facade/evaluateand$evaluateare sent to DHIS2 carrying the caller's ownAuthorizationheader, verbatim and unparsed, over a pooled connection the process holds open with no credential of its own - so DHIS2's five authorization gates (authority, sharing, the data-element bits, the three organisation-unit scopes, ownership with access levels) are enforced per caller by DHIS2 itself, and the facade computes no permission of its own. DHIS2's verdicts are answered as they stand: a tracked entity a caller may not see is the 404 DHIS2 gave, and a 401 or 403 is carried rather than turned into a 502. Nothing on the path is cached - the one cache isauth's identity cache, keyed by a hash of the header and holding a username. Each forwarded read carries one header of the facade's own,X-DHIS2W-Facade, naming the software and version; never the username, which the caller's header already carries. A register read presenting no credential is a 401 in either scope, since there is nobody to answer as; one presenting credentials is answered in either scope, checked on the spot through the same cache.- The facade's own profile still answers the work no caller asked for. The
startup store build, the instance address
/facade/uiconfighands the capture screens, andd2w fhir forward's drain read and write as the facade's (or the forwarding) profile in every posture, because none of them acts on behalf of a request - so least privilege still applies to that profile, and undernoneandtokenit is what answers every caller. rest.securitysays so underdhis2: the description states that reads of the register are answered under the caller's own DHIS2 authorization, and thewrite-scope sentence names the register as needing credentials rather than claiming every read is open.- Five startup refusals, in
ServeSettings.resolvebeside the sibling preflights, sod2w fhir serveand an embedder meet the same ones: binding an interface other than loopback while neither the run norfhir.tomlhas stated a posture (the message names the fhir.toml line to write), thetokenposture withD2W_FHIR_SERVE_TOKENSunset, thedhis2posture on a compiled run, which has no instance to check anybody against, thejwtposture with no[serve.jwt] issuer, and[serve.jwt] forward_beareron a compiled run, which has no instance to forward to. A sixth refusal is a round trip rather than a value, so it lands while the server starts: ajwtrun whose issuer this machine cannot reach raises the sameServeAuthConfigurationErrorfromopen_serve_runtimebefore a single request is taken. /metadatadeclaresrest.securityin every posture,noneincluded, so a client never infers an absence: the DHIS2 posture namesBasicby its code in R4'srestful-security-servicevalue set and the personal access token as text, the token posture states its scheme as text, thejwtposture namesOAuthby its code withJWT bearer tokenas text and carries the issuer in an extension on the element (never a key, never the audience, never the claim name) while its description states whether the register is forwarded or refused, and thenoneposture says in words that every caller is served.- The check is one FastAPI dependency, mounted over the routers
ServeRouters.guardednames. An embedding application reads that set and mounts its own dependency in its place, writing aRequestIdentityontorequest.stateif it wants captures attributed;register_routestakes the same seam as itsauthenticationargument. - Attribution. Under
dhis2, the validated username lands on the receipt assubmitted_by;d2w fhir spool --detailsshows it as a Captured by column when any receipt has one, and the forward report carries it through. It is facade-side provenance and says nothing about the identity DHIS2 stores: a drain posts as the forwarding profile, andstoredByis DHIS2's own stamp of that profile. - Configuration.
host,port,auth,auth_scope,strict_codes,capture,ui, andspool_dirfall back to the[serve]table offhir.toml, whichmake serve/make serve-liveread too, with flags beating the table beating the defaults and--strict-codes/--no-strict-codesreaching all three levels. The precedence isServeSettings.resolve, indhis2w-fhir-serve, so an embedded facade asks for the postured2w fhir servehas by name rather than reproducing it: it applies the flag-over-table precedence, resolves the DHIS2 profile into the address the screens link out to, and refuses a project with nothing compiled. captureandspool_dircarry no flag, because each says what the server is rather than what one run does.capture = falseis the viewer posture: it mounts a 405 that names the key in the create route's place and dropscreatefrom/metadata, while every read,$generate, and every receipt already spooled answers exactly as before.spool_dirmoves the receipt tree - relative to the project unless absolute - through the oneresolve_spool_rootthe forwarder reads the same key through, so the writer and the drainer cannot land on two directories.- Content negotiation. Every FHIR route answers
application/fhir+json; anAcceptthat rules JSON out is a 406 naming the one format served, while/facade/spool,/facade/uiconfig,/facade/metadata-health,/facade/evaluate,/facade/terminology/*, and/cds-servicesnegotiate nothing.POST /is a 405 saying the facade runs no batch and no transaction. _formatas R4 defines it._format=json,_format=application/json, and_format=application/fhir+json- in any casing - make JSON acceptable whatever theAcceptheader said, so any FHIR query is a link a browser can open. Any other value is a 406 naming the three spellings, even where the header would have admitted JSON. It narrows no search: a route that screens its search parameters passes over it.
Evaluating, terminology, and CDS Hooks¶
POST /facade/evaluateruns one FHIRPath expression, CQL library, or compiled ELM library over a resource this facade serves - one from the guide by type and id, one posted inline, or one tracked entity read from the DHIS2 instance a live run holds open. It answers typed results, one row per CQL define, and real diagnostics: a parse failure carries the line and column its parser stopped on, and a define that refuses carries its message on its own row. A bad expression is a 200 with the reason, never a 500. The engine reaches the named context and nothing else - no library path is passed and no file is opened.POST /$evaluateis the same evaluation answered as theParametersresource a FHIR client expects from an operation: one parameter per define named by the define,value[x]for a single primitive,resourcewhere a define answered a FHIR resource, onepartper value where it answered several, anOperationOutcomepart where a define refused, and anoutcomeparameter whose issue carries the line and column a parser stopped on. It is system-level -[base]/$evaluate, declared atrest.operationin/metadataunder a definition this project defines - because what it runs over is whichever resource the request names, so no resource type owns it. AParametersbody naminglanguage,source,expression, and acontextof the same three kinds is canonical; the plain/facade/evaluatebody is read at the same address for a caller who already has one. A define that matched nothing carries no parameter, because FHIR has no empty collection - which is what/facade/evaluate's own shape exists to keep.GET /facade/terminology/validate-codeandGET /facade/terminology/lookupanswer about the CodeSystems and ValueSets this project publishes: is this code in that set, and what is this code called. It is not a terminology server, and says so - a SNOMED CT or LOINC code is answered "this server publishes no code system under that url".GET /cds-servicesandPOST /cds-services/{id}are CDS Hooks, one service wide: it evaluates a CQL library the caller sends, or one this guide publishes as a Library, over the resources the hook prefetched, and answers a card per define that resolves to true or to a message.fhirServeris read and never followed.- The Evaluate screen in the capture UI is all of the first of those as a place to click: a language, a worked example already loaded, a context picker offering exactly what the endpoint offers, and a parse error shown against the line it names.
- The source boxes are CodeMirror 6 editors, not textareas: JSON is read by
its own grammar with brace matching, and FHIRPath and CQL by stream
tokenisers this project writes, so keywords, strings, comments, and date
literals are told apart on both grounds. The colours are CSS tokens declared
beside every other palette in
index.css, so a theme change or a switch of ground repaints the editor with nothing re-created. The editor is deferred behindReact.lazyinto its own chunk, so a client that only fills forms in never downloads it. The same read-only renderer paints the JSON results, the receipt page's Raw QuestionnaireResponse, and the Server page's Raw CapabilityStatement. - A reference panel sits beside the editor, on two tabs. Examples holds every runnable example on named shelves - 29 FHIRPath, 18 CQL, 8 ELM, each titled by what it answers rather than by the feature it uses, each loading into the editor on a click, and every one of them verified to run against the shipped context or a stored resource. The language tab states what THIS engine answers - the FHIRPath function and operator vocabulary, the CQL header, retrieves, query clauses and interval vocabulary, and the ELM library shape and expression nodes - drawn from the engine's own registries rather than from the published specifications, so nothing on it is a name the server would refuse. Each language's shelf of refusals is stated beside what it refuses: an unknown function, an unresolved value set, a library with no identifier.
Read and search¶
GET /metadataanswers akind #instanceCapabilityStatement instantiating the IG's ownD2CaptureServerand narrowed to the types this store actually holds.GET /{type}/{id}answers the resource byte-faithfully as the project published it.GET /{type}?_id&url&identifier&_countanswers a searchset Bundle whoseselflink echoes only the parameters that were applied, soidentifier={base}/id/program|<uid>selects one program's stages,_countcaps the entries rather than paging them and_count=0states the total alone, and an unrecognised parameter is ignored rather than refused.- The guide's own conformance resources are served, by default.
StructureDefinition,ImplementationGuide,OperationDefinition, and the requirementsCapabilityStatementthat/metadatainstantiates are read and searched on the same routes and the same grammar as every other read type, so a profile canonical named on a response, an extension url carried inside one, and the$generatedefinition/metadatanames all resolve against the server that served them -url={canonical}is the search, since a client holding a canonical holds no id. They come out of the compiled guide in either store mode: a--liverun hosts whatever SUSHI last compiled beside the project, and a run with nothing compiled beside it holds none and declares none. It is the default until the guide is published under a canonical of its own, and there is no dial for it.
The tracked entity register¶
Under --live only, GET /{resourceType}?identifier= is the output leg: one
read surface per FHIR resource the published D2TET_CM takes a registered
tracked entity type onto, so a project tracking people alone serves Patient
and one that also registers specimen batches serves Specimen beside it, over
exactly the types the map names. The artifact is the contract;
[generate.tracked_entity_types] is what produced it, and the server never
reads that table.
- A token under
{base}/id/tracked-entityreads that tracked entity directly - a UID is not an attribute, and a value that is not UID-shaped is never spent on a read DHIS2 answers 400 to. - A token under
{base}/tracked-entity-attribute/<uid>filtersGET /api/tracker/trackedEntities?trackedEntityType=<published TET>&filter=<uid>:eq:<value>&orgUnitMode=ACCESSIBLE.ACCESSIBLEalways, because a unique attribute gets no organisation-unit scope exemption on the tracker endpoint (DHIS2_ISSUES.md 74), so a capture-unit scope would miss exactly the people identifier search exists to find. - A bare value tries every key at once and folds the results deduplicated by tracked entity UID.
- A key whose value type cannot hold the value is left out, and a key DHIS2
refuses matched nobody. The keys are the instance's own - every attribute
DHIS2 declares unique or searchable - so a clinic keeping a zip code
searchable puts a NUMBER key in the same fan-out as the names, and
filter=<zip>:eq:Sebhatdraws a 400 rather than an empty page. The declared value type settles it before the request goes out, and the 400 the rest can still draw is that key matching nobody: the keys that could hold the value answer, and one of the instance's keys never stands between a person and their own record. - One FHIR resource type is one register serving the UNION of its tracked
entity types. Two DHIS2 types mapped to
Device- a cold-chain fridge and a delivery vehicle - are oneGET /Deviceanswering about both: no collision, no refusal, no last-writer-wins. The read, the search, the listing, and the_count=0count are all parameterized by the list of types the resource is served over,/metadatanames every type in that register's documentation, and each served resource still states its own type as ameta.tag. _tagasks that union about one of its types. R4's own token search overmeta.tag, which is the very element the type is stated in:_tag={base}/id/tracked-entity-type|<uid>, or_tag=<uid>for the code alone. Values widen the wayidentifiervalues do. It narrows the listing walk, the identifier search, and the count alike; it rides everynextandpreviouslink so a walk stays inside the type it started in; under[serve.search] backend = "projection"it narrows the store's own query rather than thinning its pages; and a tag naming a type that resource is not served over is an empty searchset rather than a refusal. Declared as asearchParamon every register entry of/metadata.d2-attributefilters the register by what a record holds, whereidentifiernames who it is.d2-attribute={trackedEntityAttributeUid}|{value}is one attribute and one value; the parameter repeated narrows to whoever holds every value named, and a comma is part of the value rather than a separator, because a DHIS2 attribute value may contain one. It answers equality and nothing else - no prefix, no substring, no range, no:missing- forgiving case alone, because DHIS2's owneqon a tracked entity attribute is case-insensitive (DHIS2_ISSUES.md 109) and one operator must not mean two things across the backends. It narrows the listing, the identifier search,_content, and the_count=0count alike, and rides everynextandpreviouslink. Underbackend = "dhis2"it becomes afilter=<uid>:eq:<value>on the tracker query, repeated once per filter and ANDed by the endpoint; under"projection"it is one indexed read of the attribute values the sync already wrote.- What each register filters on is declared per register, in two places.
/metadatanames the attributes in thed2-attributesearchParam's documentation - each with its name, its DHIS2 value type, and the canonical of the published ValueSet where DHIS2 binds an option set - and/facade/uiconfigcarries the same set as values undertracked_entities.registers[].filter_attributes[], so a screen draws a select over a coded attribute and a box over the rest. Each entry names the tracked entity types that declare it undertypes[], so a screen narrowed to one type of a register offers that type's own attributes rather than the union's - a register carrying a person and a focus area does not offer a focus area's reader a filter on first name. The set is what the published registration forms ask of that register's own tracked entity types, so a register of specimens filters on a sample's attributes and never on a person's; there is no config dial narrowing it, because it filters on values every response already carries. An attribute a register does not filter on is a 400 naming the ones it does, never an empty searchset. - Every search runs through a
NameSearchIndex, which answers with tracked entity identifiers and never with records; each match is then read back by UID under the credentials the request runs as, so DHIS2 authorizes every record this server hands out whatever found it.[serve.search] backendnames the index and has two values."dhis2"- the default - is the instance itself, onefilter=<uid>:eq:<value>query per key, which is the search a live run has always run."projection"is the synced copyd2w fhir syncfills: one indexed query however many keys and types are in scope, plus_contentfor a search across every value a person holds."index"arrives with the OpenSearch backend and is refused until then, namingserve.search.backend. The seam isProjectionStoreandNameSearchIndex, both exported, both documented in the materialized projection. - The identifier set is the attributes
D2TEA_CSpublishesunique- notsearchable, which a superuser is not held to. The tracked entity types come from the registration forms the store publishes, and an unmatched identifier or an unpublished system is an empty searchset rather than a 404. Every parameter the register cannot apply is refused with a 400 naming the ones it does answer -identifier,_tag,d2-attribute, and_contentunder the synced backend - rather than answered with the register dressed as a match set. - A search naming no parameter at all is the listing rather than an empty
search: the register paged, for a client with no identifier to type. It takes
_count(clamped to[serve.tracked_entities] page_size_limitrather than refused, defaulting topage_size, and_count=0answering how large the register is without building a page) andpage, an opaque token because one page can sit part-way through a DHIS2 cursor per tracked entity type at once, withself/next/previouslinks a client follows rather than constructs - nopreviouson the first page, nonexton the last, so the end is a missing link rather than an empty page. totalis the whole searchset counted. DHIS2 counts one tracked entity type at a time, so a listing over several asks each type for its count: one count-only request per type, spent on the first page of a walk and carried through the rest on the page token, and states the sum - absent only where the instance stated no count for one of those types, rather than guessed or walked.- The whole people surface is
[serve.tracked_entities]' to give.enabledfalse answers none of it even under--live;listingfalse keeps the identifier search and drops the browse;eventsfalse keeps identity and drops one entity's own record;page_size/page_size_limitsize a page and cap what may be asked for;tracked_entity_typesnarrows search and listing to named types (the laboratory instance that registers specimens beside patients); andsearch_attributesnames the search keys in place of the default set, which is every attribute DHIS2 declares unique or searchable - uniqueness names a subject, searchability is DHIS2's own statement that people are looked up by it, and keying on uniqueness alone would refuse the clinic finding a woman by her searchable first name. Several matches is a normal answer the listing already renders. Each refusal names the setting and the line to change. - The projection is identity, plus whatever
[ips.identity]nominated:idand anidentifierunder{base}/id/tracked-entity, oneidentifierper unique attribute value under{base}/tracked-entity-attribute/<uid>, the tracked entity type as ameta.tag, and every other attribute value - entity-level and enrollment-level alike, so a person found by a program attribute comes back holding it - on theD2TrackedEntityAttributeValuefoundation extension.name,gender,birthDate,telecom, andaddressare filled from the nominations and from nothing else, because DHIS2 states no mapping for them and a wrong one is worse than none. GET /{resourceType}/{uid}reads one tracked entity, which is what each Bundle entry'sfullUrlpoints at. The projection states nothing the target resource otherwise defines: the tracked entity uid, the values of the attributes DHIS2 declares unique, the type asmeta.tag, the rest as extensions, and the nominated demographics where the resource is one R4 gives them - a servedSpecimenstates noSpecimen.type, and no name, and no birth date, whatever anybody nominated about people.GET /facade/tracked-entities/{uid}/enrollmentsis the picker's feed: typed JSON rather than a FHIR resource, because EpisodeOfCare-versus-CarePlan is still an open decision. It lists enrollment uid, program uid and the name the guide publishes it under, status,active,enrolledAt, and the organisation unit uid and registry name - read entity-scoped and never by program (DHIS2_ISSUES.md 72: a program the person is not enrolled in answers 404 claiming the person does not exist), with a COMPLETED enrollment listed and marked rather than hidden (DHIS2_ISSUES.md 70: DHIS2 takes events into one without a word).GET /facade/tracked-entities/{uid}/eventsis the record: every event of that entity's enrollments, newest first, each served as theQuestionnaireResponseits program stage's own published form describes - the stage's canonical asquestionnaire, the entity assubjectunder the resource type the published map registers it as, the enrollment and the reporting unit as extensions, the event's instant asauthored, and one item per data value typed by the very form a submission is validated against, coded answers carried as the concepts the guide publishes. The shape is the capture contract's own, so what a client may post is what it reads back, and no clinical resource is invented for data DHIS2 states no mapping for. One read of the tracked entity per request, entity-scoped throughout (DHIS2_ISSUES.md 72 and 91:/api/tracker/eventsdemands a program on v43, and naming one an entity is not enrolled in answers 404), and ordered here because DHIS2 nests the events unordered._countandpagewalk it on the register's own dials,_count=0answers how long the record is, and any other parameter is refused rather than ignored. An event of a stage the guide publishes no form for counts in the total, carries no document, and is named in anoutcomeentry. One event is read atGET /facade/tracked-entities/{uid}/events/{eventUid}, which is what each entry'sfullUrlpoints at - neverQuestionnaireResponse/{id}, which answers the spool's receipts.[serve.tracked_entities] events = falserefuses the record and leaves identity served.GET /facade/data-sets/{uid}/responses?orgUnit=&period=is the aggregate half of the same question: what the DHIS2 instance holds for one data set, at one organisation unit, over the periods the request names, as oneQuestionnaireResponseper organisation unit, period, and attribute option combo the values are filed under - the data set's canonical asquestionnaire, the reporting unit assubject, the period and the combo as extensions, and one item per cell typed by the very form a submission is validated against. The shape is the capture contract's own, so an aggregate form captured through the guide reads back through the guide.orgUnitand at least oneperiodare required, because a read without them is every organisation unit for every period the data set collects;periodrepeats up to[serve.data_sets] period_limit,attributeOptionCombonarrows to one combo,_countandpagewalk the pages on the table's own dials,_count=0answers how many forms the selection holds, and any other parameter is refused rather than ignored. The selection is read whole -/api/dataValueSetsoffers no cursor and itslimittruncates silently - and ordered here on(orgUnit, period, attributeOptionCombo), so two reads of an unchanged period answer the same bytes. One reported form is read atGET /facade/data-sets/{uid}/responses/{responseId}, whose id is those three keys, so the item read needs no parameters.[serve.data_sets] responses = falserefuses the values and leaves the forms published; adata_setslist narrows which data sets are answered for, and one outside it is answered as one the guide publishes no form for.- A compiled run holds no client, so all of them answer a
not-supportedOperationOutcome naming--live, and/metadatadeclares no register resource at all - which is where the record's address is stated too, on the QuestionnaireResponse entry whose documents it answers with and on every register entry, so a client that found somebody learns where their record is.
$summary¶
- The IPS's own two addresses, so a client that speaks International Patient
Summary reaches this without learning a route this project invented.
GET /{type}/{uid}/$summarynames one person by their DHIS2 tracked entity UID;GET /{type}/$summary?identifier=resolves one through the register's own identifier search, on the same token grammarGET /Patient?identifier=answers. An identifier several people hold is refused rather than answered, because a summary is about one person and handing back the first match would be the server picking which one, and every parameter butidentifieris refused too. - Scoped to the people. The register serves nine resource types over whatever
tracked entity types a project maps onto them, and a summary of a cold chain
fridge is a document nobody has defined - so
$summaryis answered onPatient,Person,Practitioner, andRelatedPerson, and refused on the rest with a message naming the four it does answer on. - An IPS document Bundle is what comes back: a
Compositioncoded LOINC60591-5leading the Bundle, the subject exactly asGET /{type}/{uid}serves them, and oneImmunizationper recorded dose. The three sections the IPS requires - Problems11450-4, Allergies and Intolerances48765-2, Medication Summary10160-0- each carry anemptyReasonofunavailablerather than content nobody nominated, and Immunizations11369-6carries the doses[ips.sections.immunizations]mapped. Nothing is invented to fill a section: an unmapped stage does not become a free-text Observation. - Nothing new is read. The subject is the register's own projection and the
doses come off the record projection
GET /facade/tracked-entities/{uid}/eventsruns on, so a value cannot be typed one way in the record and another inside a summary. A summary with no mapped section reads no record at all, which is why[serve.tracked_entities] eventsis checked only where one is mapped. - Deterministic. Every id is derived from a DHIS2 identifier through
uuid5rather than minted per call, the sections are in a fixed order, and the doses keep the record's own order - so two assemblies of an unchanged record differ inBundle.timestampandComposition.dateand nowhere else. - The caveat is part of the document and rides beside it.
Composition.textstates what the document is and is not - which sections carry no mapping, and that this is a valid IPS Bundle claiming none of the Creator (IPS) actor's obligations - and the same sentence comes back asX-DHIS2W-Summary-Caveat, the two-place idiomX-DHIS2W-Projection-As-Ofuses. The all-empty case is served rather than refused, saying so; a mapped section with no dose for this person is a different fact and reads differently, and a mapped stage the guide publishes no form for is named in the section's own narrative rather than passed over. meta.profilenames no IPS StructureDefinition, because a live run publishes and resolves none: the IG is a vocabulary this document conforms to, not a dependency the generated country guide takes on.- Declared in
/metadataon each register entry whose subjects are people and on no other, namingsummaryand the IPS's ownOperationDefinition/summary, so a client reads where a summary lives rather than probing for it. The whole surface sits behind[ips] enabled, whose refusal names the key and the line that would turn it on.
$translate¶
- R4's type-level
GET /ConceptMap/$translate?system&code[&targetsystem]over the published ConceptMaps, answering aParametersresource carryingresultplus onematchper mapping (equivalence, the targetconceptas a Coding, thesourcemap), orresultfalse with amessage. - Declared in
/metadataon theConceptMapresource entry - the entry whose URL answers it - and only when the store holds a ConceptMap. Served in--livemode from the same builders. - The maps themselves are read and searched like every other type
(
GET /ConceptMap,GET /ConceptMap/{id}), so the mapping tables are browsable, not only translatable.
$generate¶
The custom instance-level GET|POST /Questionnaire/{id}/$generate answers one
served form with a profile-declared synthetic QuestionnaireResponse - and is
deliberately not SDC's $populate, which means fill-from-real-context.
- Built from the very
CaptureIndexthe capture path validates against: the samevalue[x]element, the sameminValue/maxValuebounds on both their numeric and theirvalueDatespellings (a drawn day is clamped into the calendar range the form pins rather than redrawn, so a range the generation window does not overlap still terminates), the sameenableWhen, and the samerepeats. enableWhenis evaluated over the whole draw to a fixed point, so a generated response never answers a question its own answers closed: draw everything in document order keeping the seed reproducible, then drop what the conditions turned out to hide, and repeat until the set stops shrinking.- Coded answers are drawn as real concepts of the served CodeSystem in the exact concept-code spelling; a question bound to terminology the project never published is left unanswered.
- Every value is drawn on the axis DHIS2 grades it on - the DHIS2 value
type rather than the FHIR item type - so the five types R4 asks as a
stringand DHIS2 still parses are spelled the way it parses them (a[longitude,latitude]COORDINATE, anEMAILaddress, aPHONE_NUMBER, a one-letterLETTER, aUSERNAME) instead of landing on the free-text wording DHIS2 refuses withE1302. The types holding a document or a reference to a DHIS2 object the guide publishes nothing for (FILE_RESOURCE,IMAGE,GEOJSON,REFERENCE,TRACKER_ASSOCIATE) are left unanswered. Both throughseeded_format_constrained_value, the one rule the guide's own example corpus draws from. - Wrapped in the context its form kind's response profile requires: a
D2Periodand aLocationsubject for aggregate, plus oneD2AttributeOptionCombodrawn out of the vocabulary the form declares where it declares one - whatever the form kind, which is what holds the 201 invariant for a data set or a program on a non-default category combo,--strict-codesincluded; anauthoredinstant for event; and for tracker-event anauthoredinstant plus the tracked-entity and enrollment pair a registration receipt in this project's spool minted. - That join is made server-side on the program the two forms share, with
forwarded receipts preferred over received ones and the newest of either,
rejected ones never - so a generated stage event names an enrollment DHIS2
can resolve rather than one it refuses with
E1079andE1313. It mints a shaped pair of its own only where the spool holds no registration of that program, which the contract admits either way because it checks the shape of those identifiers rather than their existence. - The invariant that generated output POSTed back to this server's own
/QuestionnaireResponseanswers 201 is held as a test per form kind, in both store modes, and under--strict-codes. - An optional
seed(query for GET, aParametersbody for POST, an R4integerso0..2147483647) makes a call byte-reproducible and rides back onQuestionnaireResponse.identifierunder{canonical}/id/generate-seed, so a seedless call is reproducible too and a corpus can be regenerated from the seeds off it. - An optional
subject(query for GET, aParametersbody for POST, aLocation/<id>reference or the bare UID) pins the organisation unit the draft reports from instead of leaving it to the draw, so a capture client refilling a form somebody has already chosen one on gets a draft drawn there - the attribute option combo beside it included - rather than a draft that replaces the choice. An organisation unit the form's published assignment does not admit is refused with the reason, because drafting a capture DHIS2 answersE1029would be worse than saying which organisation units admit it. - Two facts a compiled Questionnaire cannot carry take documented rules:
the data set's period type is read off a served example response answering
the same form and falls back to
Monthly(which is every--livestore, since a live build serves no examples), andTRUE_ONLYis indistinguishable fromBOOLEANso both generate either value. The incident date, by contrast, is a published fact rather than an inferred one, so a registration always generatesD2EnrolledAtand generatesD2IncidentAtexactly where the form'sD2CollectsIncidentDatesays true, compiled store and--livestore alike. - Declared in
/metadataon theQuestionnaireresource entry - the entry whose URL answers it - naming theD2GenerateOperationOperationDefinition the project's ownfoundationtarget publishes.
Capture¶
The one write is POST /QuestionnaireResponse, validated against the served IG
in phases that stop at the first level to find an error.
- Phase order: body and R4 shape (400), then the
D2FormTypekind and the invariants that kind's profile pins, the questionnaire canonical and its index, the ISO period, and finally every answer against that index (422). - A tracker registration's envelope is among those invariants: a subject
and an enrollment identifier that are DHIS2-UID-shaped, since a client mints
both and a facade holding no instance data can honestly check nothing else
about them; an enrolment date that parses; and an incident date graded only
on its primitive, because the compiled form publishes no
displayIncidentDate. Auniquetracked entity attribute is deliberately not checked for uniqueness, which is global instance state DHIS2 enforces at import. - The answer is an OperationOutcome naming each issue by FHIRPath
expression, or 201 with a
Locationheader and an OperationOutcome carrying the warnings the server had to record. - Coded answers are lenient by default: a code that names the right option
in the wrong spelling resolves through the option-UID and DHIS2-code tiers
and records a warning; a code the served terminology does not hold at all is
warned about and stored;
--strict-codesturns both into refusals; two options matching one code is an ambiguity refused under either setting. - The same dial grades the attribute option combo. A form declaring
D2AttributeOptionComboswhose response names noD2AttributeOptionCombo- or names a concept the served vocabulary does not hold - warns and refuses under--strict-codes, naming the DHIS2 error the write would earn:E8023on a data set,E1055orE1115on a program. The mirror case of a combo named against a form declaring none grades the same way, because it would be stored and silently not written. A coding from another system or with no code is refused under either setting. - And the same dial grades where that combo may be filed. A concept
carrying a
dhis2-organisation-unitsrestriction is usable only at the organisation units every List it names holds, which is DHIS2's own rule for a category option scoped to organisation units; a response filed outside it warns and refuses under--strict-codes, in the shape an organisation unit outside the form's assignment is told in and naming theE8025the write would earn. - And when. A concept carrying a
dhis2-valid-from/dhis2-valid-towindow is open only while that window covers the whole period a response reports for, both ends inclusive - DHIS2's own rule, read off 2.43 with validate-only posts: a combo closing on2016-10-01takes period201609and refuses201610. A response reporting outside it warns and refuses under--strict-codes, in the same shape and naming theE8032 Untimely data entrythe write would earn. An event or an enrollment reports for no period and carries a date of its own, which the instance grades against the same window on import.
Receipts and the spool¶
- An accepted response is stored as a receipt: the submission as it
arrived, stamped with the id it is served under, written atomically to
.serve/responses/received/<id>.json. So reading one back throughGET /QuestionnaireResponse/{id}or?questionnaire=says what was submitted and never what DHIS2 now holds, andlson that directory is the pending count the forwarding phase will drain. - The 201 says what the receipt holds, not only which receipt it is. The
information issue names the tuple
d2w fhir forwardgrades the submission by and DHIS2 keys the values it writes by - the form, the organisation unit reported from, the period where the form reports for one, and the attribute option combo where the form declares one - each as its published name beside its DHIS2 UID, with the logical id still leading. A client posting a batch can tell its receipts apart from the answers alone, and a clause is written only where the submission carries the fact: a tracker response reports for no period, and a form on the default category combo is keyed to no combo. The receipt page states the same four facts in its capture context block. - The spool is a directory rather than an index: reads re-read
received/,forwarded/,rejected/, andwithdrawn/on every request, becausefhir forwardrenames receipts between them from another process while the server is up, and a receipt keeps reading back after a drain rather than expiring the id its sender was handed.[serve] spool_diris where that tree lives. - The served lifecycle names the spool's fourth state: a receipt
d2w fhir withdrawretracted is read out ofwithdrawn/, counted byGET /facade/spool, and carries the record of the delete on its row - the event UID, the instant, and what the instance keeps - which is the one sidecar that is not an import report. The receipt still reads back atGET /QuestionnaireResponse/{id}, because retracting data from an instance does not unsay the submission. - A correction or a withdrawal is refused at capture where the project's
dial is off:
status = "amended"is read against[forward] correctionsandstatus = "entered-in-error"against[forward] withdrawals, both off unless a project says otherwise, and an unreceived one is answered 422 with an OperationOutcome naming the key and the value that would accept it. The check runs before the profile invariants, so a client that sent a correction is told the one thing that decided the request. With a dial on the submission is stored like any other receipt, status preserved - what a drain then does with the marker is the corrections design's later slices. - A translator-refused receipt says so in the listing: a committing drain
writes
<id>.refusal.jsonbeside a receipt it refused and left queued - the drain's instant, an attempt count, and the reasons - and/facade/spoolrows and the Responses page state it, so a receipt every drain refuses reads differently from one no drain has touched. The move that finally drains the receipt deletes the marker, and so doesd2w fhir requeue- a receipt entering the queue has been refused by no drain. - A capture is durable before it is acknowledged: the temporary file is
fsynced, renamed, and the directory entryfsynced too, so the 201 promises a receipt that survives power loss rather than one that reached the page cache. - A file that no longer reads as a receipt is moved to a fourth directory,
malformed/, with a<file>.reason.jsonbeside it naming what stopped it, so one unreadable byte costs one row rather than 500-ing the whole listing - a directory the process cannot read at all still does. Temporary files an interrupted write abandoned are swept at startup under an hour's mtime guard, so a concurrent in-flight write is never deleted. - Both spool reads run off the event loop and are paged behind the register
listing's own idiom:
_countfor the page size (50 by default, 500 at most) and an opaquepagecursor a client only ever gets from anextorpreviouslink, withtotalthe whole listing on every page of a walk and/facade/spool's per-state counts the whole spool rather than the page. So a facade holding ten thousand receipts answers a page of them and pays the per-row projection for that page alone. GET /facade/spoolis deliberately not FHIR, which is why it is on the facade's own API rather than at the base URL: it answers typed JSON carrying the receipt envelopes - the instant each submission was accepted, its form kind, its warnings, its lifecycle state, and the DHIS2 import report stored beside a rejection - none of which are QuestionnaireResponse elements.
Sync a copy of the register¶
d2w fhir sync fills a materialized projection: a durable copy of the mapped
scope of a DHIS2 instance, held as the FHIR resources this project's map
publishes, on one SQLite file under the project. [serve.search] backend =
"projection" is what searches it. Both are opt-in, and a facade that configures
neither behaves exactly as it always has - reading the instance per request needs
no operator, no second command, and no schedule, and that stays the product.
- The first run reads the whole mapped scope, bulk-paged, projecting each page
through the same
registered_entity_fora live register read answers with - so a synced answer and a live one are the same bytes from the same code, and the only difference between them is the instant they are true as of. Measured on the seeded 2.43.1 stack: 502 people in about five seconds over ten pages. - Every run after it reads what moved, on a
lastUpdatedcursor, and applies creates, updates, and tombstones.--rebuilddrops the copy and fills it from zero, which is routine rather than a recovery step: it is how a change to[serve.tracked_entities]or to the published map reaches what is already stored.--dry-runreads the instance exactly as a committing run does, counts what would change, and writes neither a row nor a cursor. includeDeleted=truerides every poll and is not a flag, because its absence is silent - a sync without it never learns that anybody left and does not error. A tombstone removes the row rather than archiving a last state, because DHIS2 answers 404 to a read of a deleted entity, so there is no final state to archive. DHIS2 2.42.6 refuses the type-scoped read that carries the flag (DHIS2_ISSUES.md #116); the poll then reads without it, the report saystombstones_visible: false, andd2w fhir syncprints a note.- The watermark is the instance's own clock and is per collection. Tracked
entities and enrollments carry one each - a person's
lastUpdateddoes not move when one of their enrollments does, and an enrollment carries programme-level attribute values the projected resource does carry, so the enrollment poll says whose copy went stale and each one is re-read through the single tracked entity path. Events are not polled: the projected resource carries no data value, so an event that moved is not a change to anything held. The enrollment poll is scoped by programme because/api/tracker/enrollmentsaccepts no other scope (DHIS2_ISSUES.md 102). - A watermark never runs ahead of its rows. The store writes a batch and its
cursor in one transaction, and a walk advances its watermark only once every row
it read is durable - so a walk that failed halfway advances nothing and the next
run re-reads it, which the idempotent write makes free. An incremental run polls
from the watermark less
[serve.projection] overlap_seconds, because a poll from exactly the watermark drops the rows written in the instant it was reading. SyncReportis typed and--jsonprints it whole: the mode, created / updated / removed per FHIR resource type, the pages read, where each collection's cursor stood before and after, and the instant every answer served from the projection now states. The counts are read out of the projection rather than assumed, so a row the overlap window re-read is honestly an update.- DHIS2 stays the record. No route writes the projection, no operator writes
it, and a row that disagrees with the instance is a defect of this command whose
fix is
--rebuildrather than an edit. Deleting the file is supported. A capture still travels spool,d2w fhir forward, DHIS2, then the next sync, so a captured value appears in a synced server one sync interval after DHIS2 accepted it - stated rather than hidden behind a write-through.
Serve from the synced copy¶
[serve.search] backend = "projection" moves the finding half of a register
search into the copy and leaves the disclosing half exactly where it is.
_contentis the search that arrives with it - R4's own parameter for a text search over a resource's whole content, matching a case-insensitive substring of any value a person holds. It is spelled_contentand notnameorfamilybecause this server does not know which of somebody's DHIS2 attribute values is their name and will not guess;"dhis2"refuses the parameter, because an exact-match filter cannot answer it./metadatadeclares it only where it is answered.- One indexed query replaces one tracker query per key per tracked entity
type. The identifier search matches exactly over the token index; the listing
pages the copy with the same
_countand opaquepagepair the live listing uses, so a client cannot tell the backends apart by the shape of a link. - Every projection-served answer states the instant it is as of - an
outcomeentry in the searchset, which is R4's own way for a server to say something about a search inside the search's answer, plus anX-DHIS2W-Projection-As-Ofheader beside it. A live answer states neither, because it is as of the moment the instance answered. - Who may see whom does not change. The copy says who is on the page; each
record is read from the instance under the credentials of whoever asked, so
DHIS2 applies its sharing, organisation-unit scopes, and ownership rules per
person per request. A person the copy holds and the instance will not disclose
to this caller is on nobody's page, and
GET /{resourceType}/{id}is a person-level read answered from the instance whatever the backend says. - A projection-served searchset states no
total. The copy counted its rows under the identity the sync ran as, and how many of them a given caller may see is the instance's to say one read at a time - so a count taken for somebody else is not offered. The Bundle keeps the same silence it already keeps when the instance states no count, and_count=0- which asks for that number alone - comes back with the cursor and no walk to follow. - What it still cannot do is cross scripts. Finding
ສົມສັກfromSomsackneeds transliteration applied when the index is built, which arrives with a search-engine backend; a one-character typo finds nothing here either. The measurement and what closes each gap are in the materialized projection.
A client for a running facade¶
FacadeClient in dhis2w-fhir is a typed async client for a running
d2w fhir serve facade: capability, read, read_response, search (over a
typed ResourceQuery), resolve by canonical, generate, submit_response
(answering a CaptureReceipt with the id, the location, and the warnings), and
evaluate. Credentials are BearerToken, UsernamePassword, or
PersonalAccessToken; a refusal raises FacadeError carrying the facade's
OperationOutcome.
Embed the facade¶
dhis2w-fhir-serve publishes what d2w fhir serve assembles, so an application
that already runs FastAPI serves the real FHIR surface rather than a second
implementation of it.
- The settings.
ServeSettings.resolve(project, ...)returns aServeInvocation: the frozen settings, the address the process binds, and the profile it resolved - the credentials stay on the invocation and never reach the settings the app is handed. - The runtime.
open_serve_runtime(settings)is an async context manager over everything one facade holds - project, store, spool, register surface, and the CapabilityStatement/metadataanswers with - as aServeRuntime, with the DHIS2 client a live run reads through open for as long as it is entered and closed when it is left. A caller already holding an authenticatedDhis2Clienthands it in and keeps owning it.attach_serve_runtime(app, runtime)writes the two names every handler reads. - The routers.
serve_routers(capture=..., serve_ui=...)answers aServeRouterswith the mount requirements as data: the FHIR routers that must carryrequire_json_is_acceptable, the three that answer plain JSON about the facade and must not, and the read catch-alls that mount after every fixed path.accept_head_wherever_get_is_servedis the HEAD parity a liveness probe needs.d2w fhir serveis the first caller of all of it. - The UI is not on this surface. The capture bundle and
/facade/uiconfigexist so the browser can work and are reached by running the server with[serve] ui;create_appis the only thing that mounts them, andUiBundleMissingErroris the one name of theirs the package publishes, becausecreate_appraises it.
Capture in the browser¶
d2w fhir serve --ui (or [serve] ui; the scaffolded make serve and
make serve-live pass it) adds a browser
capture UI at /, same-origin with the FHIR routes so it reads the very
endpoint it is served from with no URL to configure. It is a React 19 +
TypeScript + Tailwind v4 + shadcn/ui app under
packages/dhis2w-fhir-serve/frontend/, built into the Python package and
shipped inside the wheel.
- Every screen names the query behind it. A screen that reads a resource
carries a small API chip beside its heading, linking the FHIR query it is
a rendering of -
/metadataon Server,/Questionnaireon Forms,/Questionnaire/{id}on a form, the active tab's type on Terminology,/QuestionnaireResponse/{id}on a receipt,/Location/{id}on an organisation unit,/{resourceType}/{uid}on a tracked entity. The register's chip carries the live query, filters and all, so it opens what the table is the answer to. Each link carries_format=jsonand opens in a new tab, so it answers in the server's own format rather than meeting the 406 a browser'sAcceptwould earn. Evaluate carries none:$evaluateis a POST.
Signing in¶
- The shell reads the posture off
/metadatabefore it draws a page, because that is the one document open under every posture. Anoneposture renders nothing new; atokenposture asks for one field and adhis2posture for a DHIS2 username and password, in place of the page rather than over it - so the app never sends a request this server would answer 401 to, and a browser never gets the chance to open a credential dialog of its own over ours. - Submitting the panel asks
GET /facade/whoamiwith what was typed, and holds nothing until the server names the caller. A wrong password is refused at the prompt - "DHIS2 did not accept this username and password." under the DHIS2 posture, "This server did not accept this token." under the other two - with the fields still there to try again with. A server that could not be reached says so in its own sentence, because credentials that were never checked are not credentials that were rejected. Without the check the defaultwritescope leaves every read open, so the first thing that would refuse a wrong password is a submission somebody spent minutes filling in. - The name that is kept is the server's, never what was typed.
/facade/whoamianswers the DHIS2 instance's own spelling of the username underdhis2and the claim the server read out of the token underjwt; the token posture names nobody, and the header names nobody for it. - The credential is the whole
Authorizationvalue, held in the page and in no browser store: neithersessionStoragenorlocalStoragesees it, so a reload asks who this is again and a second tab signs in on its own. Under the DHIS2 posture it is a password anything can decode, and a page load is the longest it is held for; a deployment token and a JWT are held the same way. The one function in the app that reaches the network attaches it and records any 401 that comes back, so a credential that goes stale after signing in - a password changed, an account disabled - is refused at the next read, listing, or submission, dropped rather than signed with again, and the prompt returns with the same sentence. The header names whoever is signed in and offers Sign out, which forgets the credential and the name and asks again. - The Server page states the posture and its scope, off
/facade/uiconfig'sauth, beside therest.securitydescription the conformance document carries.
Overview¶
- Four spool counts as stat tiles, with
Received(the queuefhir forwarddrains) set at hero size and every tile linking into the Responses table with that lifecycle already selected via#/responses?lifecycle=. - A withdrawn receipt states what the instance keeps, in the withdrawal record's own words - "This DHIS2 instance keeps a hidden copy of the event; it no longer appears in reports" - beside the instant and the event UID, and never the bare word "deleted". The answers stay on the page.
- The rejected tile names the DHIS2 error code most of its receipts share, counted per receipt rather than per issue, because DHIS2 states a rule once and then names every object that broke it.
- The served forms beneath as quick-entry cards, and a server-identity strip carrying the guide, its version, the store mode, the resource-type count and the declared operations.
- Each of the three sections reads its own endpoint behind its own loading/error state, so one dead read cannot blank the others.
Forms¶
/formsshelves every served Questionnaire by the DHIS2 capture model itsD2FormTypestates: Data sets (periodic reports for an organisation unit), Event programs (single events, no person registered), Tracker programs as one group per program with the registration form leading its stages and the enrols-a-person / records-a-visit dependency stated per group, and People - thetracked-entitykind, registering a person in the instance without enrolling them in a program.- People is a shelf of its own because it is generated from a tracked entity type, names no program to group under and no period to report for, and is reportable at every published organisation unit since DHIS2 hangs no assignment on a type.
- A form declaring no kind gets its own stated section, since the facade
refuses to capture against it. The same
catalogueFormsfold inlib/catalogue.tsshelves the organisation-units rail. - Every row keeps its title, question count, and id.
The form view¶
/forms/{id} renders any served Questionnaire as fillable controls: the item
tree flattened into an ordered spec, one reducer over every answer, and a
control per R4 item type.
- A three-state Switch for
boolean- Yes, No, or not answered - which becomes a two-state tick for a question the served dictionary typesTRUE_ONLY, since DHIS2 stores"true"or nothing for one and an offered No would be discarded at import. - Numeric inputs bounded by the
minValue/maxValueextensions, each a text box a dozen characters wide with a numeric keypad and the digits set right - never<input type="number">, which drops what it cannot parse; nativedate/dateTime/timeinputs whose values are completed into R4 primitives on submit; Textarea fortext; a Select forchoicewhose options are expanded by reading the bound ValueSet and the CodeSystem it composes; and a cmdk-backed searchable combobox for areferencequestion - the item type the emitter writes for a DHIS2ORGANISATION_UNITdata element - writing
valueReferencethrough an answer slot of its own rather than through the text a keyboard writes. - Repeating questions with add and remove rows.
- A run of data elements cut by one set of category option combos renders as one table - the elements are the rows, the combos are the columns, the answer is the cell where they meet - which is the shape DHIS2's own data entry uses and the difference between a screen of 14 rows and one of 56 stacked questions. Every cell is a bordered box a count fits in, the rows are striped, and the whole run sits in a bordered box of its own. The categories the columns cut along are named once for the run, and so is anything every cell of it accepts; no uid is repeated inside a run, since fifty-six identifiers on a screen of fifty-six numbers is not where anybody looks for one. An element cut differently opens its own run, and an element whose cells are not numeric answers stays stacked.
- How wide the cut is decides the run's shape. Up to four combos it is the
table above. A combo cut over two categories -
Female, under 15y- is banded by whichever category has fewer options, for up to six bands and only where every band ends up with the same options of every remaining category; the banding is part of the table shape, so eight combos that band into two fours are still a table, one per band, each band headed by the value it stands for. Wider than four, the run is rows instead: each element in a box of its own with its name on the band across the top, one line per combo under it, wrapped into two column groups past about ten lines and three past two dozen, and no sideways scrolling at any width. Under a facet band the lines carry only the category the band did not name. From thirty lines the band itself offers a filter box and an Unfilled only tick, both of which hide lines from the screen and nothing else. - Reordering a category combo does not move the renderer. A DHIS2 admin who swaps the categories inside a combo renames every combo in it and changes the order DHIS2 expands the cells in. The shape is decided from the decomposition the served combo vocabulary publishes - which category, which option, by uid - never from where a comma falls in a name, so the band axis is chosen by option count with the category name breaking a tie, and the bands and rows are read back in each category's own option order. Both spellings of one cut are the same screen, cell for cell.
- Every row states what it currently adds up to. A muted Total column closes each row of a table, and the row form states the same figure on the element's own band (Total 226, over every line it has, filtered or not). Both recompute as the boxes are typed in, and neither is submitted: a data set's own totals are DHIS2's to compute. A row nobody has typed in has no total rather than a zero, a blank beside a figure counts as nothing, and a box holding something that is not a number leaves no figure at all. A run whose cells are not one element cut several ways - a section of plain numeric data elements reaches the same shape - is drawn without totals, because adding two different quantities is not a figure.
- One switch per run overrides that default - Show as rows on a table, Show as columns on a list of rows - remembered per run in this browser, so a form reopened is drawn the way it was left. A cut no arrangement makes a table of offers no switch, because there is nothing to switch to.
- The submission is the same in every shape. Every cell keeps its own linkId and its place in document order, so a capture filled in as rows, as a table, or under a filter is the one QuestionnaireResponse the stacked drawing produced.
- A run of scalar questions flows into as many columns as the screen has room
for, each control the width of the answer it takes - about sixty characters
for free text, its natural width for a date, a dozen for a count. A narrative
(
text, which is what DHIS2'sLONG_TEXTemits) keeps the full width on purpose, and so does a question that takes several answers or carries questions under it. A run renders as a table or as columns, never both. enableWhenevaluated with full R4 semantics: the six comparison operators plusexists,any/allbehaviour, a group's conditions cascading to everything beneath it, and a condition on an unanswered question holding only forexists=false. A disabled item is hidden, uncounted in the required sweep, and has its answer cleared rather than held out of sight - a stale answer under a question the form stopped asking is the value DHIS2's own program rules exist to prevent, and forwarded it becomes a real data value.- Bounds are honoured client-side on both the numeric and the
valueDatespellings ofminValue/maxValue: the hint states the range, and Submit refuses an answer outside it with the fact and nothing else (137 is above 100, the highest value this form accepts). A box holding text the question cannot record at all is refused the same way (5.5 is not a whole number, which is what this question records), so an answer that would convert to nothing is stated rather than dropped from the submission. - A repeating
D2ProgramRuledeclaration is read off the form and stated where the form describes itself - This DHIS2 instance enforces N more rules when the submission is imported - each rule's name, what it does to a submission (Warns, Refuses the submission, Works out an answer) and its DHIS2 description listed behind adetailsfold with its uid and machine condition kept mono inside it, since a program rule is an instance-side expression this server can name but never evaluate. A rule whose action isASSIGNalso names the questions it computes, which are the controls that take no answer further down the same page. - Every question is labelled with the DHIS2 uid it is known by, and a
Fill with test data button reads
$generateand pours its answers into the form to be edited rather than posting them blind. The seed rides beside the button as a box: a fill writes the drawn seed into it, the next fill asks for whatever it holds, and the seed rides the submission - so the receipt states which draw it came from and a reported bug can be drawn again. - The reporting period is chosen from recent periods of the data set's own
type - the current one and the twelve before it, eight quarters, or five
years - each labelled for a person (July 2026, Week 34, 2026 (starts Mon 17
Aug)) with the identifier DHIS2 keys by beside it, most recent first, opening
on the period the draft states. Other period reveals the identifier box
for any period at all, which is also what a type whose numbering carries an
offset (
BiWeekly,2026WedW30,2026April) gets instead of a list of periods DHIS2 might not have. - Only the Submit button submits a capture. Enter in a text box - the period identifier, a question, the person search - is swallowed, because HTML's implicit submission would post a form somebody was still filling in and a receipt is permanent.
- A coded question offering more than fifty options is searched, not scrolled: the same popover, cap, and N more match - narrow the search to reach them footer the organisation-unit picker uses, matching a concept's display and its code alike. A real terminology binding runs to five figures, and a select over one mounts every row before it opens.
- A submission keeps the
$generateskeleton's envelope - theD2Period, the tracked entity and enrollment - rather than deriving DHIS2 period arithmetic client-side, so what the page posts carries capture-valid context by construction. The facts written over that envelope are the person's rather than the server's: - A Reporting from organisation-unit picker beside the combo, whose choice
is kept for the browser tab (session-scoped, so a fresh tab starts fresh) and
adopted by the next form that admits it, with the mismatch stated when the
next form does not. It offers the published registry intersected with the
form's own
D2OrganisationUnitAssignmentList, so the control cannot produce the capture DHIS2 refuses withE1029; an empty intersection says so instead of offering the registry. Searchable by name, uid, or DHIS2 code through the same rule the Organisation units tree filters by, and browsable as the hierarchy itself through a Browse mode beside the search box (units the assignment does not name kept as disabled context, branches it admits nothing in pruned away, opened on the held unit's ancestors, walked with the arrow keys). Pre-selected from whatever unit$generatedrew, and rewritingsubjectfor an aggregate or event form and theD2OrganisationUnitextension for a tracker one. The same one read ofGET /Locationfeeds everyORGANISATION_UNITquestion in the form below it. - A chosen organisation unit stands across a refill, the rule the combo
beside it already followed. Fill with test data asks
$generatefor a draft drawn at the chosen organisation unit -?subject=Location/<id>- so the answers refill, the choice stays, and the attribute option combo that comes back is one this DHIS2 instance accepts there. With nobody having chosen, the draw stands as it always did. - An attribute option combo control for a data set or program on a
non-default category combo: the combo the whole submission is filed under,
expanded from
the
D2AttributeOptionCombosValueSet the form declares and rendered UNANSWERED however the draft was drawn.$generatefiles its skeleton under a combo so the skeleton is postable, and adopting that pick would make every unread submission claim a project a random draw chose - so the page disables Submit with a stated reason until somebody picks one, mirroring DHIS2's own refusal to render a form until the combo is chosen. Fill with test data still adopts the fresh draw, because that is the server proposing a whole submission. - The combos DHIS2 takes no capture under are listed and disabled, with the
reason on the row. The vocabulary states each combo's calendar window
(
dhis2-valid-from/dhis2-valid-to) and its organisation-unit restriction (dhis2-organisation-units, one publishedListper restricted category option) on the concepts themselves, so the picker grades every option against the period and the organisation unit currently on the form: Closed on 2016-10-01, Not open until 2017-01-01, Not capturable at Njandama MCHP. A choice made before the period or the unit moved under it is flagged under the control rather than silently kept. Options are marked rather than dropped, and a period type whose date arithmetic this app does not hold - the offset weeks, the financial years - grades nothing on the date axis, as does a restrictionListthis guide publishes nothing for. - A form this DHIS2 instance takes no capture for says so, in the server's own
words. Where
$generateanswers 422 - every attribute option combo restricted away from every organisation unit the form admits, or closed for the period it reports for - the page renders that refusal's reason in place of the No submission context yet block, disables the combo control and Fill with test data, and names the two selections infhir.tomlthat would change it. The alternative is what a reader met before: a full capture page inviting a submission the instance refuses, and a Fill button whose only trace was a 422 in the developer console. - A Person control on both registration kinds, naming who the submission is
about. New person by default - the minted identity, and the only option a
compiled run offers, which says so rather than offering a search it cannot
answer. Find in this DHIS2 instance is offered exactly when
/metadatadeclares the form's own register - itssubjectType, and where a form names none, the one register this run states it serves - with asearch-typeinteraction onidentifier, searchingGET /{RegisterType}?identifier=in its bare-value form once the typing stops and listing each match as what the projection carries: the value of a unique attribute leading, the other attribute values beside it, the tracked entity uid last, and never an invented name, since DHIS2 states no attribute that means one. - Choosing a person rewrites the subject to their real tracked-entity uid,
names that person through the served
D2TEA_CSdisplays on the picker cards rather than repeating their uid twice, writes theD2SubjectExistsmarker (pinned as one exported constant inlib/patients.tsand derived off the form's own canonical like every other extension url this UI writes), and makes everyD2EntityLevelquestion read-only and cleared with the reason stated. That is load-bearing rather than tidy, becausefhir forwardrefuses a submission that states its subject exists and carries an optional entity-level answer anyway, while a program-mandatory one rides the enrollment instead - DHIS2 answersE1018to a mandatory program attribute arriving on nothing, and an enrollment attribute writes the same store the person already carries the value in. - The person's existing enrollments are listed beneath from
GET /facade/tracked-entities/{uid}/enrollments: program name where the guide publishes one, the status in one human spelling withactivestated in words beside it, the enrolment date, the organisation unit - and a completed one carrying the warning that DHIS2 takes new events into it without complaint. - The stage form's Answering for picker gains the same instance source beside its spool receipts, offering the found person's enrollments in that stage's own program alone, since DHIS2 refuses an event filed against another program's.
- A tracker registration form gains an Enrollment block stating what the
submission will file: the enrollment date, the incident date where the
program declares
D2CollectsIncidentDate, and the client-minted enrollment UID. - The dates are drafted by the server and editable, because a visit typed
up on Thursday is not a visit that happened on Thursday, and an edit rides
the envelope in the exact slot
$generateput it. An event or stage form dates itself the same way through a Visit date control overauthored, and an aggregate form through a Reporting period control over theD2Periodisosub-extension - required and period-type-aware off the form's ownD2PeriodType, so it opens with the shape of its data set's period as the placeholder and the worked example beneath, and refuses an empty box and an identifier of the wrong shape before the round trip (Daily,Weekly,BiWeekly,Monthly,BiMonthly,Quarterly,SixMonthlyandYearlyare checked; the offset weeks and the financial years spell their offset into the identifier and are accepted as typed rather than half-checked, with the server naming both types in its refusal). It keeps the draftedtypeand drops the optional range sub-extension rather than claim a range no client-side period arithmetic resolved. - The form is the authority on what it asks, and this UI states what it
states. The enrollment, incident and visit date controls take their labels
from the form's own
D2DateLabelswhere the instance renamed them - the receipt page labels the same facts from the same function, so one programme's "Date first seen" reads that way on both surfaces. A stage form declaringD2Repeatablesays so where the form describes itself and on its row in the forms listing. An item'sD2Descriptionrenders as the question's help text under its label and as a section's under its heading. A table of disaggregated cells names the DHIS2 categories its columns cut along, joined from the served combo vocabulary's own property declarations in DHIS2's declared order - nothing in this UI sorts a decomposition or a combo expansion. - A question the form marks
readOnlyover an attribute the dictionary declaresgeneratedrenders disabled with what will arrive stated ("DHIS2 fills this in when the submission is imported, shapedANC-#######"), is never counted among the required questions the form is waiting on, and is left unanswered by$generate. One rule held on both sides of the wire: the capture index carriesreadOnly, the synthesizer declines to draw for it, and the validator admits its absence even where the form marks it required. - The form screens gate their Submit on the
captureflag/facade/uiconfigcarries. False, and a form still opens, fills, and reads, with This server does not accept submissions where the button was. Silence is read as receiving, so an unanswered settings read never withholds the one control the screens exist for. - A refusal is rendered issue by issue with the severity, code, and FHIRPath expression the capture validator names the offending question with.
Responses and receipts¶
/responseslists every stored receipt with the lifecycle state its file is in (received, forwarded, rejected), tinted by shared theme tokens, filterable by state or form, the state chips carrying the counts so the queue depth is on screen.- A Quarantined section above the table states what
/facade/spoolcounted asmalformedand names each file with the reason the facade could not read it as a receipt. It is not a fifth state and has no row in the table - a quarantined file has no form, no answers, and no id to open - so it is said where a reader meets it rather than left to the API. - A second source under the receipts: one tracked entity in the DHIS2
instance. A receipt is what this server stored; a record is what the
instance holds now, and a capture posted straight into DHIS2 leaves no receipt
here at all - so a picker under the table finds a tracked entity by an
identifier value and draws their events as the same unfoldable rows the
register's record page draws, one per DHIS2 event, unfolding into the answers
the instance holds. Register-aware: a run serving several registers offers the
register first, named by what the instance calls the types riding it, and one
serving a single register offers no chooser. There is no instance-wide feed
and there will not be one -
GET /facade/tracked-entities/{uid}/eventsis entity-scoped as a security boundary - so the section is a picker by construction. It is drawn only where/facade/uiconfigreportstracked_entities.events, which a compiled run and a run switching the record off both report false; the empty receipts table is worded to match, stating the spool rather than claiming the project holds nothing. /responses/{id}is a deep-linkable receipt page opened by clicking a row: the answers joined to the questions the served Questionnaire asks, in that form's order, each with its enclosing groups (what turns a disaggregated cell fromFixed, <1yintoImmunization / BCG doses given - Fixed, <1y), its link id, and its value rendered as what it is - a coding keeping both display and the code DHIS2 stores, a boolean as Yes or No, a repeating question showing every answer, an organisation-unit answer named off the servedLocationwhen the stored reference carries no display, which is what turns a bare reference into the place it names - read in both spellings, so an answer naming<registry canonical>/Location/<uid>resolves to its name and its hierarchy link exactly asLocation/<uid>does. The receipt's own capture-context organisation unit is resolved the same way.- A capture-context grid merges what the spool derived with what the stored
resource carries, so a fact reaching it from both sources is stated once -
which is how a registration receipt states its enrolled-at and incident
dates, the two the spool has no column for, beside the tracked entity and
enrollment it minted. It degrades to link ids and values with a stated reason
when the form has been recompiled away. The two dates read as calendar days
and not as instants, because that is what DHIS2 holds them as: the capture
profiles spell both as
dateTime, so a drafted one carries an hour and a minute no register ever recorded and rendering it would state a precision nobody has. An instant that really is one - when a receipt arrived, when an event happened - keeps its clock. - Beside it: the DHIS2 context the receipt carries, the
$generateseed it was drawn from, the capture warnings, and the import report's rollup of what DHIS2 said about a rejection - with anE1300row's program rule read back as the rule's own name, joined client-side from theD2ProgramRulelist on the served form (the uid taken off DHIS2's own Generated by ProgramRule (uid) sentence, never off the row'ssubject, which on anE1300is the data element the rule read; a rule the served form does not list stays unnamed rather than guessed at). - A collapsible raw view of the stored QuestionnaireResponse, reloaded on demand, on window focus, and on every in-app arrival at the listing, because the forwarder moves files under an open page. Nothing polls, so a window left open and unfocused shows what it last read until it regains focus.
Terminology browser¶
- A listing per type over all three terminology types, carrying each artifact's id, the DHIS2 identifiers it was generated from, and its concept or mapping count, with one filter narrowing all three at once. The filter lives in the address bar, so a narrowed listing is an address and the browser's Back button brings the search back; a row whose own codes matched opens the artifact already showing them.
/terminology/{resourceType}/{id}shows, for a CodeSystem, every concept with one column per declared property, headed by the property code as words with the declared description as the header's tooltip - the DHIS2 option code beside the concept code standing for it, the category a combo splits over beside the combo itself.- Any property valued as a
Codinginto a published CodeSystem renders as a link to that CodeSystem's own page with the concept filter preset to the coded concept. Generic to every coding-valued property, which is how a category option combo digs down into the category options it was met from. - The concept filter lives in the address bar so the one row is deep-linkable, filtered client-side and paged at 200 rows for the systems that run to thousands.
- A ValueSet expands through the CodeSystems it composes, because the
facade publishes no
$expand; a ConceptMap shows every mapping one table per group with its target code and equivalence. - A
$translatetester on the pages that have an answer to give - a CodeSystem some served ConceptMap names as a group source, and every ConceptMap - asking the running server about a typed or clicked concept code and rendering theParametersas match rows or the not-found message. A system no map translates from carries neither the tester nor the per-row button, because every press would be a refusal. A row of a map's group asks in that group's direction; a concept row asks every map. The button is pinned to the right edge of the table's own scroll box, and an ask brings the panel to the reader.
Organisation units¶
/organisation-units folds GET /Location into the reporting hierarchy.
- A lazily expanded tree over
partOf: children rendered only when a node is open, a filter that keeps the ancestors of every match so a matched facility is never shown detached, and a unit whose parent the project never published shown as a flagged root rather than dropped. The count in the panel header says what the filter matches while one is on - 12 of 1,332 organisation units match - because it sits above the tree and is read as a count of what is on screen. - Three resizable panes on wide viewports, in a GIS tool's shape: the tree, the map as the always-visible centre canvas, and a collapsible inspector rail that opens on selection. Narrower viewports fall back to two columns with the selection's sections behind tabs, Map the default.
- The rail opens with the selected unit's own identity: its level off the
D2OrganisationUnitLevelcoding rather than a count ofpartOfhops, its DHIS2 uid and organisation-unit-code identifiers, and its parent chain as clickable breadcrumbs. - Which forms may be captured there, shelved by DHIS2 kind as Data sets,
Programs (a tracker program's registration and stages grouped under the
program), and Tracked entity registration - the
tracked-entitykind, which registers whatever a project tracks and is a Focus area as often as a Person - with the assignment join in DHIS2's own vocabulary: the forms assigned to this organisation unit badged and the ones assigned everywhere listed plainly, because a form carrying noD2OrganisationUnitAssignmentis assigned everywhere and badging all of them at every unit would bury the one that is not. What a missing badge means is said once under the shelves, and every row keeps its link out beside its own title rather than at the far edge of the rail. - Captured here: the spool receipts naming the unit - lifecycle counts, the five most recent linked to their pages, a descendant rollup worded on what is above it (3 captures below this organisation unit where nothing names the unit itself, 3 more below where something does), and the stated scope, captures this server received rather than what DHIS2 holds.
- Children: the subtree as a mini tree that re-roots on selection.
- A MapLibre GL JS map renders every decoded boundary and point over raster basemap tiles.
[serve.basemaps]is an ordered list of named{z}/{x}/{y}layers, defaulting to one OpenStreetMap entry, withbasemaps = []for the self-contained boundary-only canvas and repeatable--basemap Name=url/--basemap noneoverriding per run. The UI reads it from a typedGET /facade/uiconfigendpoint carrying only what the browser may know.- A layers control in the corner stack lists each configured layer plus
None and swaps the raster source in place on a choice, so the camera, the
selection, and the popup survive a switch and the boundaries restyle for the
ground they land on. Tiles are muted per ground through
raster-brightness-max/-contrast/-saturation/-opacityso they read as ground rather than glare, with MapLibre's own attribution control fed the OpenStreetMap credit the tile policy requires - and no credit invented for a source the server cannot know the terms of. - The GeoJSON is decoded out of the base64
location-boundary-geojsonattachments, which are DHIS2's owngeometryfield and therefore hold a Polygon for a district and a Point for a facility. Both are drawn, withLocation.positionwinning the dedupe when a unit states its coordinates twice, leaving the unreadable count for payloads that are genuinely neither. - The selection is lit in amber: a two-hue encoding, the
--map-selectionpair against the identity-coloured subtree wash over a neutral context tier, with the colours read from the live CSS custom properties so the map is the same product in every theme and on both grounds, and a surface-coloured casing under each stroke so the ramp keeps its validated contrast over a busy basemap. - A zoom-aware click model: a left-click on a shape opens a popup naming the unit, its level, its parent, and what sits below, with an Open action selecting it - or eases a step in toward the pointer while the map is still too far out for the click to have meant one shape. A right-click drills straight to the selection whatever the zoom, the point outranking the boundaries under it where shapes stack.
- Corner controls for fullscreen, a globe projection toggle that hangs the sphere in a deterministic starfield, and a recenter button back to the selection's extent or the whole registry's. The map grows into whatever height the page has left with a floor rather than sitting in a fixed box, and the whole route is lazy-loaded so the ~930 kB renderer is fetched only when the browser is opened.
- The selected unit rides the query string
(
#/organisation-units?unit=<uid>) so a unit is a link that can be sent. A unit with no geometry of its own is framed by a stated priority - the union of its subtree's shapes, else the nearest located ancestor, else the whole registry - with a caption naming which. A registry with no coordinates at all hides the map panel behind one sentence, and tiles that fail to load leave the painted ground behind them. - One organisation unit published as two Locations - the registry instance
and the curated profile exemplar a generated IG ships beside it, both
claiming the same uid - is deduplicated by that identifier in favour of the
instance the hierarchy hangs off, so a root is never listed twice. The server
answers no exemplar in the first place, on the guide's own word about which of
its instances are examples; this is the screen's guard against a guide that
states nothing, and the count the page shows is what
GET /Locationanswered.
Tracked entities¶
On a live run that serves them, /tracked-entities is a page over the register
the instance holds, headed by the register's own name where one type is served:
an identifier search and a paged listing on one page, with a detail route at
/tracked-entities/{resourceType}/{uid}.
-
What is being searched for rides the query string (
?q=<value>), the way the selected organisation unit does - so a search is a link that can be sent, reloaded, and arrived at from elsewhere. The command palette is the first thing to arrive from elsewhere: it hands the value over rather than running a search of its own. Typing into the box replaces the entry rather than pushing one, so Back leaves the page instead of unwinding the keystrokes. -
Gated into the navigation by the
tracked_entitiesblockGET /facade/uiconfigcarries beside thecaptureflag -enabled,listing,events, andregisters- all of them effective rather than as written, so a compiled run reports false and no entry is drawn.eventsis what the Responses page's record picker is drawn from, so a run answering no record draws no picker. - A link to a register this run does not serve is answered where it was
opened. The address states one card carrying the server's own refusal, read
off
GET /{resource}- this facade serves a compiled implementation guide, so it holds no register to search; rund2w fhir serve --liveto search one, or thefhir.tomlkey a project turned the register off with, which it names - two different things to do about it. Neither the listing nor the record silently exchanges the address for the overview. - The words follow the tracked entity types, never the FHIR resource. A
guide maps whatever a project tracks onto whatever resource fits, and
Patientis the fallback for a subject FHIR has no resource for - so a register served asPatientroutinely carries a Focus area and a Malaria Entity beside the people. A register is worded as people only where every type riding it is a Person; narrowed to one type it speaks that type's own name (Open the Focus area identified by MGC694579, Showing 11 of 11 Focus area tracked entities this DHIS2 instance holds, never a pluralised DHIS2 name); anything else says tracked entity. The listing, the section, and the record read one rule, so the record of a Focus area cannot call it a person under a badge that says otherwise. registersis the publishedD2TET_CMread for a screen: one entry per served FHIR resource with the tracked entity types riding it under the instance's own names, so the navigation entry and the page heading alike read the instance's own name for the one type a run serves - Person, Fridge, Specimen batch, singular and unpluralised because the string is DHIS2's - and Tracked entities once more than one type rides, never the FHIR resource this project projects a person onto. A section is titled Specimen batch rather thanSpecimenon the same rule.- A register over several tracked entity types offers the choice between
them. A chip group above the table - All, then one per type under the
name
/facade/uiconfigstates for it - narrows the listing, the search, and the address at once: the reads carry_tag=<uid>, R4's own token search over themeta.taga served register resource states its type in, and the address carries?type=<uid>beside?q=, so a narrowed register is a sendable link. A register serving one type shows no chips. Choosing one starts the paging again at the server's first page, because a page token names a place inside a scope. - A register can be asked which of its entities hold an attribute value.
/facade/uiconfigstates the attributesd2-attributeanswers over per register, with the DHIS2 value type of each and the ValueSet a coded one draws from; the control beside the type chips picks one of them and takes its value - a choice over the published vocabulary where one is bound, a date or number or plain box otherwise - and sendsd2-attribute={uid}|{value}on the listing and the identifier search alike. A coded value goes on the wire as thedhis2-codethe published concept carries, which is what the instance holds, rather than as the concept code, which is an option uid. The pair rides the address as?attribute=<uid>|<value>beside?q=and?type=, so a filtered register is a sendable link, and the control says what the server documents: the match is exact, ignoring case, and part of a value is not a match. - Each row carries what the projection states and no name column, since
DHIS2 states no attribute that means one. Every attribute the people on the
page hold a value of gets a column of its own, named once in the header with
the value alone in the cell; the attributes the published
D2TEA_CSmarksdisplay-in-listlead - DHIS2's own answer to which values let a clerk recognise somebody - and inside each half the order is the dictionary's own, with an attribute it never published sorted after every one it did, by uid. That order is a property of the register rather than of the page, so the five columns are the same five on every page of a walk; the cap's remainder is stated under the table and shown in full on the record. - A column nothing on the page fills is not drawn. The identifier column goes when no row carries a value of a unique attribute, rather than standing full of dashes; the tracked entity type column is drawn only while several types are on screen, since one type on every row is the page's own title stated once per record.
- The detail keeps showing everything, heads itself with the tracked entity
uid only once, and drops the badge beneath when no unique value names the
record. A total is shown only where DHIS2 stated one. The record carries the
subject's identifiers, attribute values in the same proportional face the
listing sets them in, its enrollments with a completed one warned (DHIS2_ISSUES.md
70), and what it has been through:
GET /facade/tracked-entities/{uid}/eventsread as one row per DHIS2 event, named by the published title of the stage form it answered, with the date DHIS2 dates it - and the register's own words for an instance holding none. - A run that answers no record draws none.
[serve.tracked_entities] events = false, and a compiled run, leave the events out of the record and out of the quick view the listing opens - no heading and no refusal prose, the way the Responses page's own record section is absent on the same run - and nothing is read for them. The summary line then counts the enrollments alone, since "0 events" would be a count of a surface this server does not offer. - The summary line waits for the reads it counts. A read that has not landed is in flight rather than an answer of none, so the bar under a record says nothing until every half of it is known - never "0 enrollments - 0 events" over rows the next paint fills in - and a half the server refused is left out rather than counted as nought.
- An event unfolds in place into what it recorded. Every row of the record opens onto its own answers, closed to start with and opened where it stands - no second page and no second read. The answers keep the nesting the served document states, so a value stays inside the section its question was asked in; each question is named the way the served form asks it and keeps its link id in mono when no served form declares it; each answer is drawn as what it is, a coding standing as the display a person reads beside the code DHIS2 stores. An event the instance holds no answer on says so.
- A search this server refused leaves the page of everything standing. The box states the refusal; the listing under it is not taken away, because a reader whose search failed is left with a blank page and nothing to go back to but clearing what they typed.
- Only the matched entries of a searchset become rows. R4 lets a server
append entries beside its results, and the projection backend appends an
outcomeone; an entry statingsearch.modeof anything butmatchis never a row and never counted, while an entry stating no mode at all is a match by omission, which is what R4 says it is. - The searchset's own outcome becomes the page's as-of line. A projection-
served answer carries
X-DHIS2W-Projection-As-Ofbeside an OperationOutcome saying the same thing in prose, so the instant is taken from the header and said once, in the wall clock every other instant on every other page is read in - Answered from the synced copy of this DHIS2 instance, as of ... The outcome's own sentence is what answers for a copy nothing has filled yet, and a facade that asks DHIS2 itself states no line at all, because there is nothing to say about an answer read a moment ago. - The search box sends the parameter
/metadatadeclared for that register.identifieris what every live facade publishes and what the box has always sent. A run keeping a synced copy of the instance declares_contentbeside it, and the box then searches that instead - any part of any value a record holds, upper and lower case alike - with its own label and its own sentence about what it searches, so a blank result never means something the copy on screen did not describe. Neither wording calls anything a name: DHIS2 states no attribute that means one, which is why the server spells the parameter_contentrather thanname.
Playground¶
/playgroundsends one request to this server and shows exactly what comes back: a method (GETorPOST, the two the facade answers), a path relative to the service base, query parameters as rows, and a JSON body in the same CodeMirror editor the Evaluate screen writes expressions in. The address the three add up to is printed under the builder, so what Send would send is never a guess. Every request is same-origin, carriesAccept: application/fhir+json, and is signed with the credential this browser already holds - there is one network path in this UI and this page uses it.- The presets are the CapabilityStatement read back as addresses: one search
per resource type declaring a
search-typeinteraction, the read-by-id shape, and one row per declared operation -$generateon Questionnaire,$translateon ConceptMap,$evaluateat the service base as a POST carrying a runnable body. The page reads one Questionnaire and one mapped concept off the server so those three rows answer on the first press instead of carrying a{id}; where the guide publishes neither, the row says the placeholder has to be replaced. Choosing a row fills the builder and never sends it. - The answer is the status, the round trip, and the body in the read-only
block the receipt and Server pages render JSON in. An
OperationOutcomearrives in that same block under the status the server gave it, because on this facade a refusal is a resource saying why; a request nothing responded to says so in its own words instead. - Two ways out of the browser: Open in a new tab takes a
GETto its own address with_format=jsonappended, and Copy as curl writes the current request as a single-quoted command that survives a query string, a JSON body, and an expression carrying its own quotes. Where this browser is signed in the command names theAuthorizationheader and its scheme with a placeholder in place of the credential, so a command pasted into a ticket carries no secret. - The last twenty requests this browser sent - method, address, status - kept in the browser's own storage behind guarded reads and writes, each row putting the whole request back in the builder, query rows and body included.
Server page and links out¶
/serverrenders/metadatain full: declared operations including$translateand$generate, per-type interactions and search parameters, and the store mode - with the conformance document itself behind a Raw CapabilityStatement toggle, since this document is the facade's whole contract and the tables above it show the parts a browser needed.- Links out to the DHIS2 instance the guide was generated from: a new-tab
external-link mark beside the selected organisation unit's name, on every
data set / program / program-stage row of the rail's form shelves, and on
every concept row of the data-element dictionary. Each opens that object's
own page in the instance's Metadata Management app
(
{base}/dhis-web-metadata-management/index.html#/{collection}/{uid}, where the collection is the plural of the object's type -dataElements,dataSets,programs,programStages,organisationUnits- each driven against a running 2.43.2), withrel="noreferrer noopener"and an accessible name stating which object and where it goes. - The address is the base url of the profile the serve run resolved, served
on
/facade/uiconfigwith any userinfo stripped - so a run that resolved no profile carries no links at all, rather than a link that goes nowhere.
Metadata health¶
/facade/metadata-healthis thed2w fhir validateanalysis rendered over the served selection, off aGET /facade/metadata-healthendpoint that reruns the validator over the connection the process already holds - the same passes, the same graders, and the same sentence per finding, so one defect read in a terminal and read in a browser is one defect.- Live runs only, and a compiled run is told why. The endpoint answers on
every run: a process with no DHIS2 instance behind it answers
available: falsewith the reason in words, because "there is nothing here to check and here is why" is a state a screen renders rather than a status code it has to interpret./facade/uiconfigcarries the same fact asmetadata_health.enabled, so the navigation entry is drawn only where the page has something to show. - Findings are shelved by severity first and DHIS2 collection second, each row naming the object and its uid, the DHIS2 field at fault, the problem, and what the grade costs - the build stops, the published resource is degraded, or the object is outside what this project publishes. The problem is the validator's sentence less the head it opens with: a terminal line has to name its subject, and a row whose first two cells are the object and the field would be saying it a third time.
- The page opens as a summary. The severity counts and the per-locale coverage meters are what a reader meets; every table under them is closed, and each closed heading states its severity, its DHIS2 collection, and how many rows are behind it. The one filter box narrows the findings and the translation lists together and holds every section open while anything is typed.
- Translation coverage is the analysis the command does not do, and it is coverage rather than deficiency. The locales are the union of the tags the selection's own translations carry, read one bounded request per resource kind rather than one per object and with no system-settings read. Each locale is told through whichever side of it is the shorter list: below half the selection's translatable strings it is sparse and the page names the objects that carry it, stating no absence at all; at or above half it is a majority locale and the page names the objects nobody has written it for yet. An English-main instance holding three Spanish translations reads as three Spanish translations, not as thousands of missing ones.
- No absent translation is graded. Nothing about a translation is a finding or a warning anywhere on the page or in the payload - the severity tiles count the validator's own grades, and the validator grades names and codes.
- Reporting only. Nothing on the page writes to DHIS2 and nothing offers to; acting on a finding is a near-term roadmap item.
Command palette¶
- Cmd+K on macOS, Ctrl+K everywhere else, plus a magnifying-glass button in the header for anyone who was never told about the chord. Every chord this app binds sits on a letter, because one over the bracket, brace, pipe or backslash keys is one a Nordic keyboard cannot press without Alt - and no row in the palette carries a chord of its own.
- Pages, forms, receipts, the register, appearance, the view, help, and the session, on shelves in that order. The pages are the shell's own navigation table mapped onto the palette, so what the rail offers and what the palette reaches cannot drift; the forms are every published Questionnaire by title with its served id beneath; the receipts are the newest few at rest and the ones a typed id prefix names, capped, because an id is what a forward run prints and what somebody has in hand.
- A register lookup hands its value to the register page in the URL
(
#/tracked-entities?q=...) rather than running a search of its own, under the register's own threshold - so which parameter this server answers and how long to wait for the typing to stop stay decided in one place, and the result is a link that can be sent. The register page reads the value out of the query string, and writes what is typed into it back withreplace. - Each row is one line: a leading icon by kind, the name, the line about it beside rather than beneath, and the kind itself - Page, Form, Receipt, Theme - at the far edge. A footer bar states what the key under the reader's finger would do to the highlighted row (Open, Switch, Run) and spells the chord, which is where somebody who opened it by button first learns there is one.
- Nothing in it changes what the server holds. Every action navigates, repaints, lays the screen out, or ends the session; submitting, forwarding, and withdrawing all stay on their own screens.
- It reads nothing until it is opened. The Questionnaire search runs once per tab (a served guide cannot change under a running server) and the spool is re-read on each open, over whatever the last read produced - so the rows are there instantly and no page load carries the cost.
- The action list is a pure function (
lib/palette.ts) of what this run offers, so the claim that the palette reaches every page is asserted rather than believed.
Settings, and the keys¶
- One gear at the foot of the sidebar opens a settings dialog, not a menu: a
section rail down the left, the selected section filling the right, and a
search box at the top of the rail that filters the rows of every section at
once and moves to the section holding the hit. Appearance carries the seven
themes under Theme and the light or dark ground under Mode;
Keyboard shortcuts carries the list
?puts up. A choice applies the instant it is made and the dialog stays open in front of it. The header keeps the collapse control, the page's name, who is signed in, and the server light, and carries no appearance control. Collapsed to icons the gear stays where it is, with the tooltip every rail entry has; on a narrow screen the section rail lies down into a strip above the pane. - The rail is a tab list and the pane its panel, so one tab stop reaches it,
the arrows walk it, and the section a reader is in is announced. The section
asked for is the one selected and its tab is what takes focus -
?and the palette's own row open the dialog on the keys, the gear opens it on the appearance. ?puts every key this app answers on screen, matched on the character rather than on a physical key plus Shift, because a?is Shift and the slash on one layout and Shift and the plus on another. It never fires while an input, a textarea, a select, a rich-text region, or a CodeMirror editor has focus.- Cmd+B on macOS, Ctrl+B everywhere else, collapses the sidebar and puts it back - the platform's own modifier rather than either, because Ctrl+B on macOS is the "back one character" that text fields and CodeMirror both answer. It fires while a box or an editor has focus, since clearing the screen is worth most mid-form, and stands aside only for a rich-text region.
- The list is
lib/shortcuts.tsand the rules are pure functions over a described key press, so "not while somebody is typing" and "which modifier on which platform" are asserted rather than buried in an effect. Both chords are palette rows as well.
Themes¶
- Seven designed themes, each complete on both grounds: Clinical (the default - near-achromatic surfaces and one clinical blue), Indigo (deep blue surfaces under a violet identity), Paper (warm surfaces and an ink blue), Contrast (the widest separation between text and its surface), Terminal (phosphor green, and a ground to match), DHIS2 (steel-blue chrome over the familiar gray), and FHIR (warm white under the flame).
- Theme and mode are two axes and stay two controls. next-themes owns light
or dark as a
darkclass;lib/theme.tsowns the theme asdata-themeon the same element. The settings dialog's Appearance section carries the two under their own headings, and the palette carries both on its Appearance shelf. - Applied before the first paint by an inline script in
index.htmlreading the samelocalStoragekey the module writes, so a reload never flashes one theme under another. The two copies of the theme-name list are kept in step by a test that readsindex.html. - Every theme states the whole palette - surfaces, identity, the spool's
lifecycle colours, and the
.tok-*source colours the CodeMirror editors are painted from - hung offhtml[data-theme]for the light ground andhtml.dark[data-theme]for the dark one, so the dark block outranks its light sibling by specificity rather than by source order. A unit test readsindex.cssand fails a theme that leaves a token behind; a Playwright case paints every token pair into a canvas and puts it through the WCAG contrast formula, on every theme and both grounds. - Geometry is not a theme axis: no theme redeclares
--radius, so the whole surface still rescales from one number. - Terminal moves one status colour and says so: its identity is the phosphor
green, so
forwardedandcompletedbecome a cyan rather than share the identity's hue withreceived. Every other theme keeps the lifecycle colours unchanged, and--map-selectionstays a second hue category from every identity so a selected organisation unit is never a stronger shade of the wash beneath it. - The organisation-unit map follows both axes. It watches
<html>for the class and the attribute, and rebuilds its layers from the tokens on either - a theme change repaints the boundaries with nothing reloaded.
Mounting and coverage¶
- The bundle mounts in two pieces around the router table: its asset tree
ahead of the read catch-alls that would otherwise claim
/assets/<file>, its shell after everything - so no FHIR path is shadowed and an unserved resource type is still an OperationOutcome rather than a page. - Routing is hash-based, so a reload needs no SPA fallback.
--uiwithout a built bundle refuses in one line namingmake uirather than serving a blank page.- Every
--uirun names the bundle it serves, from the stampmake uiwrites into it: the build time and a fingerprint of the frontend source that build read. In a checkout the stamp is graded as well as printed, so a bundle older than the source beside it refuses rather than serving JavaScript the checkout has moved past; an installed wheel carries no frontend source and is never graded.make installbuilds the bundle wherepnpmis on PATH and says so where it is not, so an API-only install still needs no node. - Covered by vitest unit tests over its wire layer and by a Playwright
suite (
make e2e-frontend) that boots a reald2w fhir serve --uion its own port over a fixture IG project and drives the capture loop end to end twice: at the API level ($generate, post, the receipt appearing on the Responses page) and through the renderer (open the form, fill with test data, submit, land on the listing). - The documentation images are produced, not curated:
make screenshotbuilds the bundle and runs two skipped-by-default producer specs intodocs/img/fhir/- one over the fixture project from stated seeds, and, whereD2W_SCREENSHOT_PROJECTnames a project, one over its ownd2w fhir serve --livefor the three surfaces a compiled guide cannot draw (Metadata health, a tracked entity's record, and the record under the Responses table). The live shoot serves a copy of the project'sfhir.tomlwith the spool pointed at a temporary directory, and only ever reads.
Forward captures into DHIS2¶
d2w fhir forward is the third verb, and it closes the loop
IG -> form -> QuestionnaireResponse -> DHIS2. It reads the receipts out of
.serve/responses/received/ and drains them.
Translation context¶
- Assembled from the very artifacts the facade serves: the compiled
ig/fsh-generated/resourcesmerged with the predefinedig/input/resourcestree. - Or, for a project holding no compiled guide, the same documents built off
the instance through
fetch_live_artifacts- the forward-side twin ofserve --live, reading one metadata pass through the same builders - so a receipt captured with no build step drains with none. The absence of the compiled tree is the whole trigger, and[forward] live = falserestores the refusal namingd2w fhir generateandmake sushi. - Plus one id-only
fields=id,valueTyperead against/api/dataElementsand another against/api/trackedEntityAttributes, for the one fact the compiled IG cannot carry: R4 spellsBOOLEANandTRUE_ONLYas the same#booleanitem type.
Translation¶
Each response goes through dhis2w_fhir.conversion all-or-nothing.
- An aggregate envelope carries all three DHIS2 keys, with its
attributeOptionComboresolved off the response'sD2AttributeOptionCombocoding against the vocabulary the form declares, on the same concept-code /dhis2-id/ ConceptMap tiers a coded answer resolves through and under the same lenient/strict dial. A form that declares one and a response that names none is refused asmissing-attribute-option-comborather than posted, because DHIS2 refuses that write itself; a combo the vocabulary does not hold isunresolvable-attribute-option-combo; a combo named against a form that declares none is noted and left off, since its data set or program rides the default category combo. Each refusal names the DHIS2 error the write would have earned -E8023on a data value set,E1055orE1115on a program capture. - A tracker registration becomes the
/api/trackertrackedEntitiesentry it creates, carrying the client-minted tracked entity UID, the tracked entity type the form's$DHIS2-TETidentifier names (absent, it is refused asmissing-tracked-entity-type, since a program without one cannot register anybody), oneTrackerAttributeper answered tracked entity attribute through the same value-type serialisation and the same coded-answer dial a data element's answer goes through, and the singleACTIVEenrollment it mints -enrolledAtrequired (missing-enrollment-date),occurredAtwritten only where the response states an incident date, both read back to the zone-less wall clock DHIS2 stores, andattributeOptionCombowritten from the combo the response names where the program declares a vocabulary, because DHIS2 checks an enrollment's combo against the program's own category combo. - An event of either kind carries the DHIS2 UID derived from the receipt's
own logical id (SHA-256 over
<response id>:event:0, shaped by the drawer the synthesis path mints tracked entity and enrollment UIDs with), so one receipt always names one event: a dry run and the import behind it report the same object, and a receipt forwarded twice is refused as an object the instance already holds rather than filed as a second copy of one visit. It also carries theattributeOptionCombothe response names where its program declares a vocabulary, because DHIS2 answersE1055to an event of a program whose category combo is not the default one and which names no combo.
Posting¶
- One payload per response: an aggregate envelope to
/api/dataValueSets, everything else to/api/trackerunderimportStrategy=CREATE&async=false, through one client opened for the whole run. - People before the payloads that create an enrollment, and those before
everything that answers into one, so the person a registration of the same
drain enrols and the enrollment a stage response answers against both exist
by the time DHIS2 reads them (
E1313otherwise). No dependency tracking sits behind the ordering, and the report still reads back in spool order.
Dry run is the default¶
- Every payload still reaches the real endpoint under that endpoint's own
validate-only mode (
dryRun=trueon/api/dataValueSets,importMode=VALIDATEon/api/tracker, the v42 spellings taken from the generated OpenAPI), so DHIS2's own rules decide each outcome while nothing is written and no receipt moves. - The terminal closes with one DRY RUN banner naming
--importas the way to commit, under the counts it explains. The summary'smoderow names the posture above them, so the run states it at both ends without printing the same forty words twice.d2w fhir withdrawreads the same way. [forward] import = truemakes the bare run of a project whose drains are routine commit instead, with--import/--dry-runstill outranking it either way - flag, then table, then default, resolved inforward_responsesso the CLI and the MCP tool cannot resolve it differently.- A dry run counts a stage event whose enrollment a registration of the same run creates as unverifiable rather than rejected - it writes nothing, so there is no enrollment to check the event against - and gives it its own count and section, while a stage event naming an enrollment no registration of the run creates stays a rejection.
- A DHIS2 rejection exits 1; a dry run whose only failures are unverifiable exits 0.
What DHIS2 said¶
- A DHIS2 refusal arrives as a 409 and is recorded as one response's outcome rather than raised as the run's, with every word DHIS2 said kept.
- The two endpoints disagree on the shape:
/api/dataValueSetsanswers aWebMessagewrapping theImportSummary,/api/trackeranswers theTrackerImportReportbare with no envelope at all - so each family recognises its own report by the fields only that report carries. - Every row lands as a typed
ForwardImportIssue(error_code,subject,message) whether it came fromresponse.conflicts[]orvalidationReport.errorReports[], with the generatedImportSummary/TrackerImportReportriding alongside untouched. - Rejections roll up by cause - error code plus the message with its
identifiers generalised away, except a UID naming a program rule the guide
published, which is read back as that rule's own name so an
E1300refusal says which rule refused rather than which twelve characters did (the raw UID stays untouched on the response's own.report.json). Quoted and bare alike, and by the same shape test: DHIS2 backticks the organisation-unit list of anE8025and leaves the attribute option combo in the same sentence bare, so a row standing for three responses refused on three different combos names none of them rather than the first one's - while what DHIS2 quotes is not thereby an identifier, since anE1302backticks a value type, a whole explanatory clause and the offending value inside it. A run of characters reads as an identifier only when every token in it is eleven characters starting with a letter and turns like a UID rather than like a word - it carries a digit, or it turns from lower case to upper more often than a word does - so theDataElement, theNUMBERand the-Infinityof anE1302sentence all stay prose. Each response is counted once per distinct cause, so202 rejectedreads as the three rules it broke, rendered as aResponses | Code | What DHIS2 saidtable on the terminal and at the head of the written report. - A cause whose responses all met it in the same words is stated as DHIS2 stated it, its identifiers intact - a cause that ended one response, and a cause that ended five byte-identical ones alike. The generalisation is what turns twenty rejections differing only in the object they name into one row; where nothing differs it removes the sentence and buys the reader nothing back, sending them to the report file for something that fits on the line in front of them.
The spool as ledger¶
- With
--import, an accepted receipt is renamed into.serve/responses/forwarded/and a rejected one intorejected/, each beside an atomically written<id>.report.jsonholding its import outcome - a rejection needs one to say why it was refused, and an acceptance needs one because the import counts are what say how much of it landed, whichGET /facade/spoolthen carries on the row asimported. - A conversion-refused receipt stays in
received/untouched, because the fix for it is in the guide or in the data and the next run is the retry - except the one refusal no fix could ever reach. - A response reporting itself
entered-in-errorasks for a withdrawal this toolchain does not build, and is therefore filed torejected/with a sidecar naming the doctrine (TERMINAL_REFUSAL_CATEGORIES, one explicit set rather than a flag on a whim; see Corrections and withdrawals) rather than translated again by every drain for ever.d2w fhir requeueis the way back for an operator who disagrees. - Each receipt is filed the instant DHIS2 answers about it - the sidecar
written, then the rename - inside the posting loop rather than in a pass at
the end. So a drain that is killed halfway leaves everything it posted in
forwarded/orrejected/with its report, and everything it had not reached untouched in the queue; a rename that finds the file already gone is graded onto the report as a lost race rather than throwing the drain and DHIS2's answer away. - One drain at a time. A run holds an exclusive
flockon.serve/responses/.drain.lockfor its whole length with its own process id written inside, so a second drain of the same project fails at once naming the holder rather than posting every payload twice and racing its renames, and the kernel releases it however the run ended.fhir servenever takes it. - A file in
received/that will not read as a receipt is quarantined tomalformed/with its reason and named on the report while the drain proceeds with the rest, and abandoned temporary files older than an hour are swept as the drain starts. - An instance that fails mid-drain - a 5xx, or a connection that never
completes - stops the run rather than being read as a verdict on the payload
that met it: whatever was already posted stays filed, that receipt and
everything behind it stay in
received/asnot-posted, and the report names what stopped it and how many were never sent. - The terminal states the distinction in its own closing line: a DHIS2 rejection points at the import summary, a translator refusal points at the guide.
Data set completeness¶
- An aggregate response whose
statusiscompletedalso registers data-set completeness: a second write to/api/completeDataSetRegistrationsnaming the very(dataSet, period, organisationUnit, attributeOptionCombo)tuple the values landed under, claiming the day the response records itselfauthored, and made only after DHIS2 has taken the values - since a completeness claim about data the instance refused would be a lie. in-progressimports its values and registers nothing.--register-completeness/--no-register-completeness(default on,[forward] register_completenessstating it once for the project, andregister_completenesson the MCP tool) turns the whole run's second write off. A dry run posts nothing and states the tuple it would register instead.- A refused registration is reported as such without un-importing the
values, which stay imported and are re-claimed by forwarding the same tuple
again - DHIS2 answers a registration it already holds with
updated, not a conflict. - Each outcome is typed on
ForwardCompletenessOutcome(registered/would-register/not-claimed/not-registered/refused) and rendered as its own terminal table, summary row, and written-report section carrying the four keys, because a registration has no UID to look it up by. - The envelope's own
completeDateis deliberately never written, since on 2.42 it registers completeness even when every value was refused and even underdryRun=true(DHIS2_ISSUES.md 76, 77).
Values a previous submission already sent¶
- A drain names every aggregate value it sends that a forwarded receipt already sent, with the receipt that sent it and when that receipt arrived. DHIS2 replaces such a value in place and counts the write exactly as it counts a first entry (DHIS2_ISSUES.md 85), so no import summary can separate a correction from a first entry - the spool answers what the wire cannot.
- The record is the sidecar. A forwarded receipt's
<id>.report.jsoncarries the identity of every value its payload landed on - data element, category option combo, period, organisation unit, attribute option combo - and the day the receipt arrived. Identity only, never the numbers. - Only
forwarded/counts: a receipt DHIS2 refused never landed its values, and one still in the queue has not been sent. Receipts filed earlier in the same drain do count, so a drain holding two captures of one report says that the second replaced the first. - A dry run states it too, as the prediction it is - the moment there is still something to be done about it.
[forward] overwritesdecides what the drain does about it, with--overwrites allow|refuseoutranking it for one run - flag, then table, then default, resolved inforward_responseslike the sibling dials.allow- the default - posts the value and names it, which is DHIS2's own last-write-wins semantics taken as a posture rather than inherited by omission.refusesends no payload holding such a value at all, and refuses the whole response rather than part of it - a payload posted in part would tear one submission across two postures. The refusal is non-terminal: the receipt stays inreceived/with an<id>.refusal.jsonunder categoryoverwrite-refusednaming every covered value, the receipt that sent it, and when that receipt arrived, sod2w fhir spoolshows it as refused-but-queued with the reason and a later drain underallowposts it. A dry run underrefusestates what it would refuse and files nothing. A refused response claims no data-set completeness, since its values were never sent.- The refused responses are counted under the run's own refused number, and
read apart from a translator refusal wherever the reason matters:
ForwardOutcome.overwrite_refused, aRefused as an overwritesection of the written report, and a terminal note naming--overwrites allowas the way to post them. - The dial never reaches a tracker payload. An event's DHIS2 identity is derived from the receipt's own id, so it collides rather than overwriting - see Corrections and withdrawals, which carries the decision (D8) in full.
- The reading is built once per drain and only when the drain carries an aggregate payload, so a tracker-only run reads nothing at all. It opens each forwarded receipt's import report once and nothing else, and it never truncates or samples - a reading that quietly stopped part-way would answer "no earlier submission" about a value that has one.
- Typed as
AggregateCell/OverwrittenValue/ForwardOverwriteonForwardOutcome.overwritten_values, and rendered as a terminal note, a--detailstable, its own written-report section, and the--jsonpayload. A receipt inforwarded/whose report records no values is counted onforwarded_without_valuesand named, rather than passed over in silence.
Corrections and withdrawals¶
The posture today, in one place, because "is this supported?" is the question this section exists to close. The full argument and the design the remaining slices follow are in Corrections and withdrawals.
- A second capture of an aggregate report overwrites the first in place.
The envelope names no
importStrategy, so DHIS2 applies its ownCREATE_AND_UPDATE; the values are replaced and the report says which ones (above).[forward] overwrites = "refuse"is the deployment posture that leaves such a response in the queue instead of sending it. - A second capture of a tracker visit creates a duplicate, because an
event's UID is derived from the receipt's own id and every capture mints a
fresh receipt. Re-forwarding the same receipt is the case DHIS2 refuses,
with
E1030- one receipt names one event, and that is the guarantee that holds. [forward] correctionsand[forward] withdrawalsare the deployment's posture towards a marked submission, whereoverwritesis its posture towards an unmarked one.correctionstakes"off"(the default) or"amend";withdrawalstakes"off"(the default) or"retract". Both resolve inservice.forward_responsesalongside every sibling dial - the flag, thenfhir.toml, then the default - with--correctionsand--withdrawalsond2w fhir forwardoverriding either for one run, and both are named in--detailsand--jsonwherever a project has turned one on. A drain acts on neither: it imports.- A project publishing forms is not thereby a project that reaches back into DHIS2, which is why both default to off. Turning one on is a sentence somebody wrote rather than a default nobody read.
d2w data tracker deleteis the raw escape hatch for the kinds the FHIR path does not retract, outside it and behind a confirmation prompt, withd2w data aggregate deletebeside it.
Withdraw an event you forwarded¶
d2w fhir withdraw <receipt id>... deletes from DHIS2 the event a forwarded
receipt landed, and files the receipt under the spool's fourth state.
[forward] withdrawals = "retract"gates the whole command, with--withdrawals retractstating it for one run. Off, the command posts nothing at all and the refusal names the key.- The identity is recomputed, never looked up. The object deleted is
receipt_event_uid(<the receipt's id>), so a withdrawal needs no compiled guide, no metadata read, and no translation - which makes it answerable about a project captured throughd2w fhir serve --live. - A dry run is the default, exactly as it is for a drain: the delete goes
to
/api/trackerunderimportMode=VALIDATE, so DHIS2 answers whether it would take it while nothing is deleted and no receipt moves.--importcommits. withdrawn/is the spool's fourth state, and the only one a receipt reaches without being posted again. The receipt file is never rewritten; a<id>.report.jsonholding what DHIS2 answered the delete lands beside it first, and the import report that recorded what it landed stays inforwarded/- two answers to two questions, neither rewritten.- Withdrawal is terminal, and the copy says what remains rather than "deleted". DHIS2 burns the UID it deletes and refuses it under every import strategy afterwards, so a withdrawn receipt can never be forwarded again; what stays in the instance is a hidden copy of the event carrying its values, which no ordinary read returns.
- Only a forwarded receipt that landed one event can be withdrawn, and every id is checked before anything is posted. An aggregate report and a registration are refused by name with the kind they are - each needs a guard the event leg does not.
- A delete DHIS2 refuses leaves the receipt in
forwarded/with the import report that says what it landed, names itrefusedin the run, and exits 1. - Typed as
WithdrawReport/WithdrawnReceipt/WithdrawalRecord/WithdrawalKind, with--jsoncarrying the whole report, andWithdrawalNotEnabledError/WithdrawalUnsupportedErrorfor the two refusals that are about the project rather than about DHIS2. d2w fhir spoolcounts the fourth state and reads the record of the delete back for the reason column.
Output¶
[serve] strict_codesis the default coded-answer dial, so a project that captures strictly forwards strictly, and--strict-codes/--no-strict-codesoverrides it.- The condensed terminal writes every response's outcome to
reports/fhir-forward-report.mdwith one counted hint, while--detailsprints the per-response table with a Why column carrying each response's first reason, and--jsoncarries the wholeForwardReport. The written report's header states what the run replaced as well as what it counted. - The scaffolded Makefile gains
make forward/make forward-import.
Inspect and requeue the spool¶
Two operator verbs sit beside the drain and touch no instance at all.
d2w fhir spoolstates how many receipts wait in each state and how many files are in the holding pen.--detailsadds a row per receipt with the short reason read off the sidecar, and--jsoncarries the wholeSpoolStateReport.d2w fhir requeue <id>... | --all-rejectedrenames refused receipts back intoreceived/for the next drain, leaving the import report behind inrejected/as the record of what DHIS2 last answered about that payload, clearing any leftover<id>.refusal.jsoninreceived/so the requeued receipt reads as one no drain has refused, and refusing an id that is not there before anything moves - so a run of five never leaves an operator working out which three it reached.d2w fhir spoolstates four states,withdrawn/beside the three the drain files into.--detailsdegrades at 80 columns rather than folding. The form's UID and the sentence saying why a receipt is where it is carry a floor and an ellipsis, and the timestamp and the capture identity are dropped in that order when the terminal is too narrow for them - both are on the receipt's own file and in--json, and what is left is the idrequeueandwithdrawtake, the state, the form, and the reason. 80 columns is what a non-TTY pipe gets.- Neither opens a client nor needs a profile, because every fact either states is in the project directory, which is what makes them answerable while the instance is down.
Check an instance¶
d2w fhir doctor is the conformance runner: it scaffolds a throwaway project
against the ambient profile and drives the entire chain through it in ten
typed phases.
- connect - version detected, plugin tree named.
- scaffold - a coherent probe: the first data set, the first
WITHOUT_REGISTRATION program, and the first WITH_REGISTRATION program by
name, plus the organisation-unit subtree those forms are actually assigned
inside, since DHIS2 refuses a response naming a unit a form is not assigned
to.
--all-targetstakes the lot instead. The selection is the probe's own; the[generate]posture is not. Every key that decides what a run produces rather than what it selects -hostile_names, the[generate.naming]table,concept_code_source,identifier_system_base,timezone,locales, andtracked_entity_types- is copied from the project doctor was run in, so the phases after this one answer "does the toolchain run against this instance as this project is configured" rather than "does it run under the scaffold's defaults". Run from a directory nofhir.tomlsits in or above, the scaffold's own answers stand. - generate - the full pipeline, every note kept as a finding, screened
through the
[generate] hostile_namesposture the scaffold copied off the calling project - the same gated2w fhir generatebuilds. A project statingrefusefails this phase on the same DHIS2 named2w fhir generateexits 1 on, and the phase's evidence states the posture it ran under so a reader knows which of the two answers the outcome belongs to. - compile - real SUSHI when the machine offers one (
sushion PATH or thefhir-igdocker image the scaffold builds), and SKIPPED with that reason otherwise, because a compile is evidence rather than a gate every machine can meet. - validate - the scope-aware code report folded in.
- serve - the store built in process, no port bound and no subprocess started, from the compiled guide or from the live builders written where a compiler would have written them.
- capture -
$generateover every published form, posted straight back through an in-processhttpx2.AsyncClientover the ASGI app, holding the endpoint to its 201 invariant, registrations before their stages. - forward - the corpus drained at the real instance in validate-only mode, rejections rolled up by cause.
- oracle (
--live) - the instance judges the served output: every served UID resolved back against the DHIS2 collection it names, plus a seeded sample per family deep-compared field by field, with the field path stated on every mismatch and the DHIS2 object always the authority. - drift - the only phase whose subject is not the throwaway project. Run
from a directory a
fhir.tomlsits in or above, it reads that guide's published artifacts off disk - the trees the served store andcheck-artifactsread - and names every organisation unit, option, tracked entity attribute, data element, and program stage the instance now holds inside that project's own selection scope that the guide does not, in both directions and on renames alike. The worked examples the guide compiled beside its profiles are counted and graded nowhere - which instances those are is the guide's own word,definition.resource[]of the compiledImplementationGuide, read through the samedhis2w_fhir.implementation_guidethat holds them out of whatd2w fhir servesearches and counts. Warning-class throughout: a guide is out of date rather than broken, so drift never exits 1. Tracked entity types are left tod2w fhir validate'sunmapped-tracked-entity-typechecklist and cross-referenced in one line rather than re-reported. Skipped, with the reason, from a directory holding no project or from a project that was generated but never compiled.
The instance comes from --profile/-p on the command, then the root -p, then
DHIS2_PROFILE, then the fhir.toml of a nearby project - the same resolution
d2w fhir validate runs, with the command's own flag ahead of it, so a shell
that names the profile after the verb is not turned down.
Each phase reports PASS / WARN / FAIL / SKIPPED / BLOCKED with a stated reason,
a failure never stops a phase that does not depend on it, and only a FAIL exits
1. The run renders a phase table, a findings table, and a verdict line on
stderr, carries the typed DoctorReport under --json, and writes
reports/fhir-doctor-report.md as the artifact a handover is read from.
CLI-only by design: a write-heavy orchestration with no read-only shape an MCP tool could honestly advertise.
Configuration¶
A committed fhir.toml, discovered by walking up from the working directory,
carries the whole project's settings. Every table of that document declares its
full key set and refuses anything else.
| Table | What it sets |
|---|---|
| top level | profile - the connection profile the project reads |
[ig] |
id, canonical, name, title, publisher, status |
[generate] |
identifier_system_base, concept_code_source, timezone, locales |
[generate.naming] |
source plus prefix and the eight artifact tokens |
[generate.option_sets] |
Terminology selection |
[generate.categories] |
Category selection, include_default |
[generate.organisation_units] |
root, max_level, geometry, terminology |
[generate.data_sets] |
Aggregate form selection, enabled |
[generate.event_programs] |
Event program selection (WITHOUT_REGISTRATION), enabled |
[generate.tracker_programs] |
Tracker program selection (WITH_REGISTRATION), enabled |
[generate.tracked_entity_forms] |
Person-only registration form selection, enabled |
[generate.tracked_entity_types] |
UID to FHIR resource type map |
[generate.examples] |
per_target, source |
[serve] |
host, port, strict_codes, capture, ui, spool_dir, basemaps, tracked_entities, data_sets, search |
[serve.tracked_entities] |
enabled, listing, events, page_size, page_size_limit, tracked_entity_types, search_attributes |
[serve.data_sets] |
responses, page_size, page_size_limit, data_sets, period_limit |
[serve.search] |
backend (dhis2, projection) |
[[serve.basemaps]] |
name, url - one table per raster tile source the map offers |
[serve.projection] |
store (none, sqlite), path, overlap_seconds |
[forward] |
live, import, register_completeness, overwrites, corrections, withdrawals |
- An unknown key stops the command. A misspelled
max_lvl = 4produceserror: fhir.toml: unknown key 'max_lvl' in [generate.organisation_units]with adid you mean 'max_level'?beneath it - one such line per unknown key,difflib-matched against the very names that table accepts, and no suggestion where nothing is close, instead of setting nothing and saying nothing. An array of tables answers the same way and under the name a reader writes: a typo in[[serve.basemaps]]is reportedin [serve.basemaps], without the entry's index, and matched against the two keys a tile source has. - Two values unset silently:
root = ""andmax_level = 0. - Flags beat the table beats the defaults on
[serve]and[forward]alike, and--strict-codes/--no-strict-codesreaches all three levels.[serve] captureand[serve] spool_dirhave no flag at all, and neither do[serve.tracked_entities],[serve.data_sets],[serve.search], and[serve.projection], because each states what the server is rather than what one run does. - The
[forward] importkey is spelledimportin the file (the field isimport_responsesin Python, becauseimportis a keyword), and the file accepts no other spelling of it. fhir.example.tomlcarries a one-line comment per option pointing at its section in the guide, and the scaffoldedfhir.tomlheader names both the example file and the series.
Progress and output¶
Every d2w fhir command with an instance behind it narrates its steps on
stderr - a spinner on a terminal, plain [k/N] lines when redirected - and
takes --progress / --no-progress. Tables, notes, and progress are stderr;
stdout carries the --json payload alone, so --json implies a silent stderr.
The library surface¶
Every capability behind a d2w fhir command is importable from dhis2w_fhir
itself, so an embedding application calls what the command calls. Names below are
from dhis2w_fhir import ...; the API reference
renders each module.
| Capability | Names |
|---|---|
| Generation, whole guide or one target | generate_full, generate_foundation, generate_option_sets, generate_categories, generate_questionnaires, generate_examples, generate_organisation_units, generate_pages, plus GenerateReport / GenerateFullReport |
| The build refusals | BuildAbortingCodeError, BuildAbortingNameError, and the two predicates behind them, build_aborting_code and build_aborting_name |
| The same refusal read off disk | check_publishable_artifacts, ArtifactCheckReport, ArtifactFinding |
| Scaffolding | init_project, refresh_project, read_project_scaffold_state, ProjectScaffoldState, normalize_project_name, preserves_every_line |
| Validation, producing a report rather than rendering one | validate_codes, resolve_validation_context, resolve_validation_scope, resolve_code_source, ValidationContext, display_code |
| The conformance runner | run_doctor, DoctorOptions, DoctorReport, DoctorPhase, DoctorOutcome, DoctorPhaseResult, DoctorFinding, PhaseOutcome, CaptureOutcome, FamilyOutcome, resolve_doctor_profile, resolve_published_project, render_doctor_markdown, phase_evidence, generate_findings, drift_findings, and the graders grade, grade_capture, grade_forward, grade_oracle, grade_drift |
| Drift between a published guide and the instance | detect_drift, read_published_guide, compare_organisation_units, compare_option_set, compare_form, registry_scope_line, DriftReport, DriftFinding, DriftSubject, DriftKind, PublishedGuide, PublishedForm, PublishedOptionSet, PublishedObject, InstanceForm, InstanceOptionSet, InstanceOption, InstanceObject, DRIFT_REMEDY |
| What a guide calls a worked example | load_declared_examples, declared_examples, reference_key, DeclaredExamples, GuideDocument, PublishedResourceKey, ImplementationGuideContents, ImplementationGuideDefinition, ImplementationGuideResource, IMPLEMENTATION_GUIDE_RESOURCE_TYPE |
| The posture one generate run screens DHIS2 names under | project_gate, HostileNameGate, HostileRewrite, HostileRewriteConfirmation, HostileNamePosture |
| Translating a captured response into DHIS2 | The whole dhis2w_fhir.conversion surface, name for name: translate_response, translate_responses, build_conversion_context, build_project_context, load_compiled_artifacts, ConversionContext, ConversionResult, ConversionReport, ConversionPayload, ConversionTargetKind, ConversionRefusal, ConversionNote, and the rest |
| Refusal records on the spool | record_refusal, read_refusal_record, ForwardRefusalRecord, RefusalReason, SPOOL_RELATIVE_PATH, REFUSAL_RECORD_SUFFIX, QUARANTINE_REASON_SUFFIX, DRAIN_LOCK_FILE_NAME, ORPHAN_TEMPORARY_FILE_AGE_SECONDS |
| The profile a run resolves | GenerationProfile, resolve_generation_profile |
- Every capability that reads DHIS2 takes the connection as an argument.
client=onvalidate_codes,run_doctor,forward_responses, and each generate target, with theProfileform kept as the convenience wrapper the commands use. A handed-in client is used as it stands and left open - its lifetime belongs to whoever entered it - so an application already holding an authenticated connection makes one connection rather than one per call.fetch_live_ig_inputsandfetch_live_artifactshave always taken one. run_doctorstates what it does to the machine in its own docstring: it mints a workspace, shells out tosushiordocker run, writes compiled resources, runs an ASGI application in process, and posts a synthetic corpus under validate-only mode. The graders are pure and callable without any of it.- A test asserts the surface.
test_fhir_package_surface.pyparses the:::directives out ofdocs/api-dhis2w-fhir.mdand fails when a module the API reference renders exports a name the package does not, so the docs and the imports cannot drift apart quietly.test_fhir_conversion_surface.pyholds the conversion layer to the same claim from the other side: every namedhis2w_fhir.conversionstates it exports is on the package root and is the same object, so a caller reads one import path rather than guessing which names were re-exported and which were not.
No MCP tools¶
The surface is CLI-only, and the plugin registers nothing on the MCP server.
Most of it could be nothing else - scaffolding, every generate target, and
doctor write a file tree onto whatever machine the server runs on, and
serve binds a port and stays up. validate and forward broke neither rule
and are still not tools: each mirrored its command closely enough to earn
nothing. What an agent drives instead is the served facade, which answers FHIR
over HTTP.
Documentation¶
The graded d2w fhir series is this site, routed from
the series index - the "I am a..." router
(implementer / M&E configurer / integration developer / operator) and the full
101/201/301/401 page map.
101 - Understand
- Glossary - every DHIS2 and FHIR term the series uses, and what the toolchain does with it.
- What
d2w fhiris and why - why a ministry publishes an IG, what each verb produces, what adopting the toolchain costs. No commands. - FHIR for DHIS2 people - every FHIR term the series uses, explained in DHIS2 terms.
- Quickstart: from nothing to a served IG - scaffold, sync, profile, validate, generate, and compile, each command with captured real output.
201 - Operate a project
- Check an instance with doctor - the whole chain against one instance, phase by phase.
- Set up an IG project -
d2w fhir initand its flags, the pinneduvtoolchain, profile resolution order,init --refreshandmake update. - Validate the instance - the FHIR-safety
check: severity as build impact, the scope column, the
--code-sourcedial, report files, the CI exit-1 gate. - Generate the IG source - the generate targets, directory ownership and sync, selection narrowing, notes and validate echoes, site pages.
- Build and publish the guide - the
scaffolded Makefile, the three build knobs, registry scale, the two caches,
publishing
ig/output/. - Serve the guide -
d2w fhir servein both modes,[serve]in practice with the flag-beats-table-beats-default rule, receipts as the storage model, the strict/lenient dial across all four things it grades, the viewer posture, the spool on disk, and load sets. - Capture in the browser - the capture UI
page by page, with screenshots produced by a committed, skipped-by-default
Playwright spec against the fixture suite server
(
frontend/e2e/docs-screenshots.spec.ts), including how to re-shoot them. - Forward captures into DHIS2 - the
dry-run-first workflow on DHIS2's own validate-only modes, the six steps of a
run, the four receipt states, refusal versus rejection, the drain lock,
reading the queue with
fhir spooland putting a refused receipt back withfhir requeue, taking back a forwarded event withfhir withdraw, the translated-payload field tables, and a worked run with the rejection rollup. - Troubleshooting - every literal
d2w fhirrefusal plus the SUSHI / IG publisher failure modes, as symptom, cause, fix.
301 - Configure fhir.toml
Every option gets the same per-option treatment: plain words, a concrete change scenario, an example, the default and leave-it-out behaviour, and the exact refusal text a mistake produces, captured from real misconfigured runs.
- The settings file: fhir.toml - what the
file is, how commands discover it, the
fhir.toml/fhir.example.tomlsplit, TOML editing rules, the unknown-key refusal and itsdid you meansuggestion, the two silent-unset values, and the three read-before-you-decide options. - Who the guide is -
profileand the[ig]table. - How things are generated - the
[generate]options, the[generate.naming]pieces and their shared token rule, and thenaming.sourcere-identification warning. - What goes in - the selection tables,
include_default,[generate.tracked_entity_types],[generate.examples], and the organisation-unit scope with themax_levelcost warning. - Serving it - the
[serve]options with thehostexposure warning, thecaptureviewer posture, thespool_dirreceipt tree and thebasemapsoutbound-call note; the[serve.tracked_entities]register block; and the[forward]section -live,import,register_completeness,overwrites,corrections, andwithdrawals.
401 - Integrate and extend
- The capture contract - the five response profiles, the requirements CapabilityStatement, the logical tracked-entity subject, minted identifiers and what a server can honestly check about them, and the required-question and numeric-bound rules.
- Consume the FHIR API - the
served read set and searches,
$translateand$generatewith real requests and responses, the capture POST with its validation phases, and the facade's own API under/facade-/facade/spooland/facade/uiconfigamong them, with its contract at/facade/openapi.json. - Identifiers and the D2 extensions -
the
D2PeriodandD2AttributeValueextensions, the identifier families, NamingSystems, and the UID fall-back rules. - Terminology and ConceptMaps - the option-set and category CodeSystem/ValueSet pairs, the per-object ConceptMaps, the two-group shape, and UID-versus-code target guidance.
- Custom subject types -
[generate.tracked_entity_types]end to end: the admitted resource types, everything one mapping feeds, and the union rule the two tracker response profiles publish under. - Regeneration and hand-authoring - the generated-header contract, the directories generation owns outright, what is scaffolded as yours, what to commit, and the duplicate-definition recovery.
FHIR Evaluation Engine¶
Package: dhis2w-fhir-engine | Install: uv add dhis2w-fhir-engine
Evaluate FHIRPath expressions, CQL libraries, and ELM against FHIR-shaped data,
and score CQL quality measures into a FHIR R4 MeasureReport.
The grammar, parser, AST, and evaluator layers are FHIR-version-neutral - FHIRPath
is normative and CQL is 1.5, and neither names a FHIR release. Everything that does
bind to a release reaches those layers as a FhirVersionBinding value out of
dhis2w_fhir_engine.r4, so R5 lands as a sibling subpackage rather than a fork of
the evaluator.
The package runs the official HL7 CQL and FHIRPath R4 compliance suites as part of its own test run.
FHIRPath's aggregates are all four named functions beside the general aggregate()
fold: sum(), min(), max() and avg(), over numbers, quantities, strings and
dates as each accepts. An empty collection aggregates to the empty collection
rather than to zero, and a collection mixing kinds is refused with a message naming
both. See FHIRPath.
It ships the console script d2w-fhir-engine with fhirpath, cql, and elm
sub-apps over the same engine. Every command taking --data reads it by one rule:
a Bundle becomes the data source retrieves read, any other resource becomes the
context resource the evaluation is about. cql measure takes both halves off a
Bundle - each Patient entry is a person to evaluate, and the whole Bundle is
what the numerator retrieves from.
A measure report holds every decision per group: populations_for(group_id) and
stratifier_values_for(group_id) read one group's membership, and each group is
counted, scored, and stratified from its own state. A definition that cannot be
evaluated is recorded in report.errors with the patient, group, definition, and
message rather than counted as nonmembership; the group holding it carries no
measure_score, and to_fhir() emits status: "error" with a contained
OperationOutcome listing the failures.
It owns the R4 resource models at dhis2w_fhir_engine.r4.resources: Patient,
Bundle, QuestionnaireResponse, Composition, Extension, and the rest.
Every model is closed, frozen, and alias-aware, so
model_dump_json(exclude_none=True, by_alias=True) reproduces the wire document
key for key. dhis2w_fhir.r4 is the capture-facing facade re-exporting that
family, so a name is defined once and imported from whichever package a caller
already works in.
Every entry point that ingests a resource - the evaluation contexts, the FHIRPath, CQL, and ELM evaluators, the data sources, the measure evaluator - accepts either the wire dict or a pydantic model of it. A model is dumped once on entry and evaluation reads dicts from there on, so nothing the engine does reaches back into the caller's model.
It has no DHIS2 dependency and no web-framework dependency: it evaluates
expressions over FHIR-shaped JSON and returns values. It is the FHIR foundation
of the workspace rather than a leaf of it - dhis2w-fhir and dhis2w-fhir-serve
both depend on it.
-
Remote terminology lookups go over
httpx2.FHIRTerminologyServicereaches an external FHIR terminology server through a synchronoushttpx2.Client, because the terminology protocol the evaluator drives is synchronous. That call is whyhttpx2is a runtime dependency of this package. -
dhis2w_fhir_engineAPI reference - the importable surface, module by module. - FHIRPath, CQL, Quality measures, and The FHIR version binding - the 501 guide series.
examples/engine/- nine runnable examples, one feature apiece.
d2w fhir FHIR IG generation (SUSHI/FSH + pre-built JSON, package dhis2w-fhir)
init Scaffold a dockerized SUSHI IG project + fhir.toml
(--data-set / --event-program / --tracker-program seed
the questionnaire targets; --refresh updates an existing
project's scaffold-managed files, rewriting only where no
line on disk is lost, and refuses any flag it would ignore)
generate All seven targets in one run, off a single pass over the
instance (8 requests where the solo targets total 25),
reported as one summary row per target; a guide naming
[generate.organisation_units.registry] is refused before
the instance is dialled when neither a checkout nor a
package supplies that registry, in the words serve
--live, forward and check-artifacts refuse it in, and a
guide whose [generate.naming] source differs from its
registry checkout's gets a note naming both; the notes
go to reports/fhir-generate-notes.md with one counted
hint on the terminal, counting the kinds that only
restate a validate finding apart (--details prints them
inline)
generate foundation DHIS2 identifier aliases + the D2Period / D2FormType /
D2AttributeValue / D2OrganisationUnit /
D2TrackerEnrollment extensions
generate option-sets Option sets as pre-built CodeSystem/ValueSet JSON
generate categories Categories as pre-built CodeSystem/ValueSet JSON
(category options as the concepts)
generate questionnaires
Data sets + event programs + tracker program stages as
Questionnaire instances
generate examples Example QuestionnaireResponses (synthetic or real instance data)
generate org-units Organisation units as Organization/Location instances
generate pages Narrative site pages + per-artifact intros (markdown)
generate load-set Synthetic QuestionnaireResponse JSON under load/ for
posting at a running facade (--per-target, --output-dir;
deliberately not part of a full run - a load set is not
IG source)
validate FHIR-safety of the instance's codes (sweep + three deep
passes + md/csv/pdf reports written into --output-dir),
severity graded by build impact on the configured
selection (scope: selection/instance), including a
code-stem preview of a code-sourced [generate.naming]
source (code-stem-fallback warnings, code-stem-refusal
errors matching generate's refusal). The terminal is a
status view: the summary with the selection split and
the code-coverage fraction (objects whose code can
serve as an identity stem), a rollup row per (severity,
scope, category), every error individually, and one
line naming the report file; --details lists every
finding, --fail/--no-fail gates the exit code
serve Serve the IG as a FHIR read + capture facade (package
dhis2w-fhir-serve, via the dhis2w-cli[serve] extra;
--live builds the store off the instance at startup,
--strict-codes/--no-strict-codes governs codes outside
the served terminology, --ui adds the browser capture
UI, --auth none|token|dhis2|jwt with --auth-scope
write|all
says who is served and how much of the surface the
posture covers, and host/port/authentication/strict
codes fall back to the [serve]
table of fhir.toml - where capture and spool_dir carry
no flag, capture=false being the viewer posture and
spool_dir naming the receipt tree; ConceptMap
\(translate is answered over the published maps;
Questionnaire/{id}/\)generate answers a served form with a
synthetic response postable straight back, optionally from a
named seed, a named subject (the organisation unit, as
Location/ or ) -
a named pin is never swapped, and one this DHIS2 instance
does not accept is a 422 naming the rule that closed it;
a question an ASSIGN program rule computes is left
unanswered (E1307) and a submitted answer to one is a
warning naming the rule; stored responses are receipts; the
profile is the root d2w -p, resolved before the start banner)
forward Drain the capture spool back into DHIS2: translate every
received QuestionnaireResponse into its /api/dataValueSets
envelope or /api/tracker event and post it. DRY RUN IS THE
DEFAULT - every payload goes to the real endpoint under its
own validate-only mode (dryRun=true / importMode=VALIDATE),
so DHIS2's rules decide each answer while nothing is written
and no receipt moves; --import commits and then files each
receipt by what it became (accepted -> forwarded/, rejected
-> rejected/ beside
Every command with an instance behind it narrates its steps on stderr - a spinner on a terminal, plain [k/N] lines when redirected - and takes --progress/--no-progress. Tables, notes, and progress are stderr; stdout carries the --json payload alone, so --json implies a silent stderr.
examples/: thed2w fhirsurface in its own group - 36 CLI scripts, one feature apiece (init/generate/validate, serve, forward, doctor, spool, sync) and 43 Python library examples grouped as build a response, read a form, convert to DHIS2, send and verify, say who a person is, summarise a record, and drive the toolchain. Every library one runs inmake verify-examplesagainst a shared_fixture.pythat scaffolds a project, builds a translation context off the instance, and starts a live facade on a port the operating system picks. There are no MCP examples because there are no MCP tools: what an agent drives is the served facade, over HTTP.examples/igs/: nine completed2w fhir initproject trees, one per feature story - minimal aggregate, disaggregated aggregate, event program, tracker registration, strict terminology, district registry, mixed facility, patient summary, and therefused-namesexhibit whose generate is refused by design. Each is committed as its inputs alone (fhir.toml, the SUSHI skeleton, the Makefile, the Dockerfile); nothingd2w fhir generateor SUSHI writes is, andmake clean-artifactssweeps what a run of the catalog left on disk.make verify-igsrefreshes, validates, generates, and dockerized-SUSHI-compiles all nine - an on-demand target, because it needs a reachable DHIS2 instance and docker. The exhibit compiles nothing and is read on what its refusal left instead: the foundation target underig/input/fsh/, nothing after it, and no compile beside it. Above that,.github/workflows/publisher-check.ymlruns one full HL7 IG Publisher build ofaggregate-minimalweekly, so a publisher release that tightens validation shows up as a red scheduled run rather than in a user's project;make publisher-check-summary QA=<qa.json>reads the same report locally.