Skip to content

FHIR plugin

d2w fhir turns DHIS2 metadata into a FHIR Implementation Guide source tree: a SUSHI project whose FSH (FHIR Shorthand) definitions and pre-built FHIR JSON resources are generated from /api/optionSets, /api/categories, /api/organisationUnits, /api/dataSets, /api/programs, and /api/attributes.

d2w fhir init [DIRECTORY]           Scaffold a dockerized SUSHI IG project (--profile seeds fhir.toml)
d2w fhir init --refresh             Bring an existing project's scaffold-managed files up to date
d2w fhir generate foundation        Identifier aliases + the D2Period / D2FormType / D2AttributeValue extensions
d2w fhir generate option-sets       Option sets -> CodeSystem/ValueSet pairs
d2w fhir generate categories        Categories -> CodeSystem/ValueSet pairs
d2w fhir generate questionnaires    Data sets + event programs + tracker stages -> Questionnaire instances
d2w fhir generate examples          Example QuestionnaireResponses against those Questionnaires
d2w fhir generate org-units         Org units -> Organization/Location instances
d2w fhir generate pages             Narrative site pages + per-artifact intros
d2w fhir generate all               All seven targets in one run
d2w fhir validate                   FHIR-safety of the instance's codes (exit 1 on errors; --no-fail)

The plugin ships as its own workspace member, dhis2w-fhir, and mounts through the dhis2.plugins entry point - the same mechanism third-party plugins use. It is version-neutral: the wire client auto-detects the DHIS2 major on connect, so one package serves v41/v42/v43 with no per-tree copies.

The FHIR IG guide is the task-oriented companion to this page: quickstart, the complete fhir.toml reference, and the regeneration contract.

MCP exposes only the read surface: fhir_validate (readOnlyHint). Scaffolding and generation - the page generation included - are CLI-only by design - they write a file tree onto whatever machine the MCP server runs on, the wrong shape for an agent protocol (the same judgment as the browser plugin and the security audit runner).

Project layout and fhir.toml

d2w fhir init scaffolds a complete project:

fhir.toml                   Minimal generation config (committed; no secrets)
fhir.toml.example           Every available option with its default, documented
pyproject.toml              The project's own uv project: dhis2w-cli + dhis2w-fhir,
                            pinned by a committed uv.lock and run through `uv run d2w`
Makefile                    setup / upgrade / generate / validate / cache-init /
                            sushi / build / refresh / clean / clean-all / help via
                            docker. cache-init makes the shared package-cache volume
                            writable by the publisher user; sushi and build depend on it
Dockerfile                  ghcr.io/fhir/ig-publisher-localdev + fsh-sushi
.gitignore                  The build output, caches, publisher side products,
                            ig/input/resources/, reports/, and .venv - never uv.lock,
                            the pinned toolchain
ig/sushi-config.yaml        SUSHI IG identity (id, canonical, publisher)
ig/ig.ini                   IG publisher entry point (fhir2.base.template)
ig/fsh.ini                  Raises the publisher's internal SUSHI timeout to 1800s
ig/input/fsh/aliases.fsh    Hand-authored alias stub (never regenerated)
ig/input/pagecontent/index.md   Hand-authored home page; includes the four
                            publisher fragments (cross-version-analysis,
                            dependency-table, globals-table, ip-statements).
                            `generate pages` writes its siblings and never
                            touches this file.
ig/input/ignoreWarnings.txt

sushi-config.yaml carries publisher.name and, only when d2w fhir init --publisher-url supplies a real home page, publisher.url. The IG publisher links that URL from every generated page, so pointing it at the canonical of an IG that is not yet published produces one broken link per page - 15,425 of them on the Sierra Leone demo. Omitting it is the default.

Its menu: names the six generated site pages between Home and Artifacts, and it carries no pages: section: SUSHI publishes every markdown file under ig/input/pagecontent/ on its own, so a page added by generate pages needs no configuration to appear.

Its parameters: block carries excludexml / excludettl (JSON is the only wire format worth a file and a rendered page per resource) plus one path-resource glob per predefined-resource sub-folder - registry/ for the org-unit instances, terminology/ for the option-set pairs, categories/ for the category pairs:

  path-resource:
    - input/resources/registry/*
    - input/resources/terminology/*
    - input/resources/categories/*

SUSHI recurses into sub-folders of input/resources and loads what it finds into the virtual sushi-local#LOCAL package; the IG Publisher does not recurse. The globs are what carry those resources into the published ImplementationGuide. A missing glob is therefore silent at compile time and lossy at publish time, which is what d2w fhir init --refresh exists to repair - see Scaffold refresh.

It carries no groups: section. SUSHI's grouping matches by exact resource reference, with no wildcard and no FSH-side groupingId, so grouping a real instance's artifacts would mean enumerating every one of its thousands of instances in sushi-config.yaml. The Artifacts page falls back to the template's own categorisation by resource type, which is the same shape those groups would have had.

d2w fhir generate discovers the nearest fhir.toml by walking up from the working directory, the same idiom as .dhis2/profiles.toml. The file is committed project config: it may pin a d2w profile by name, but explicit -p / DHIS2_PROFILE always wins, and credentials never live in it.

# fhir.toml stays minimal - the profile pointer and the [ig] identity.
# Every other option lives in fhir.toml.example with its default.
profile = "myserver"                # optional

[ig]
id = "dhis2.fhir.example"
canonical = "http://example.org/fhir"
name = "Dhis2FhirExample"
title = "DHIS2 FHIR Example IG"
publisher = "Example Organisation"

Options worth calling out from fhir.toml.example:

[generate]
identifier_system_base = "http://dhis2.org/fhir"
concept_code_source = "id"          # "id" or "code"
locales = []                        # BCP-47 or DHIS2 tags; empty = every locale found

[generate.naming]
source = "id"                       # "id" or "name"
prefix = "D2"                       # "" drops it; profiles keep a D2 token
option_set = "OS"                   # e.g. "OptionSet"; "" drops the token
category = "CAT"                    # category CodeSystem/ValueSet names
organisation_unit = "OU"            # e.g. "OrgUnit" -> D2OrgUnit_Level_CS
data_set = "DS"                     # data set Questionnaire names
program = "PR"                      # event program Questionnaire names
program_stage = "PS"                # tracker program stage Questionnaire names

[generate.option_sets]
# include_ids = ["Qdm5fPK5Ra9"]     # UIDs; absent = all

[generate.categories]
# include_ids = ["O5P6e8yu1T6"]     # UIDs; absent = all

[generate.data_sets]
# include_ids = ["BfMAe6Itzgt"]     # UIDs; absent or empty = all

[generate.event_programs]
# include_ids = ["VBqh0ynB2wv"]     # WITHOUT_REGISTRATION UIDs; absent or empty = all

[generate.tracker_programs]
# include_ids = ["IpHINAT79UW"]     # WITH_REGISTRATION UIDs; absent or empty = all

[generate.examples]
per_target = 1                      # example responses per questionnaire target; 0 disables
source = "synthetic"                # "synthetic" (generated) or "instance" (real values)

[generate.organisation_units]
# root = "ImspTQPwCqd"
# max_level = 4
terminology = false

Every selection reads the same way: absent or empty means all, a non-empty list filters. fhir.toml.example shows every unset-by-default key as a commented, real-shaped example rather than a magic placeholder, so the file parses to exactly the defaults.

identifier_system_base is live: generate foundation writes it into foundation/d2-aliases.fsh as the $DHIS2-* aliases, declares each of those URLs as a NamingSystem in foundation/d2-naming-systems.fsh, and derives the ^property URIs the terminology concepts carry.

The full configuration reference, with the id-first-then-code workflow and the canonical naming-token registry, is in the FHIR IG guide.

Artifact names merge the prefix and kind tokens and underscore the segments after them (D2 + OS + _Qdm5fPK5Ra9 + _CS - short tokens read by context); ids join the kebab of each non-empty token, with the UID kept verbatim (d2-os-Qdm5fPK5Ra9-cs), so renaming or dropping a token reshapes the whole IG consistently. With naming.source = "name" the same set reads D2OS_BirthType_CS / d2-os-birth-type-cs. The two profile names always carry a token (default D2) because FSH cannot name a profile identically to its parent core resource.

Scaffold refresh

d2w fhir init --refresh re-renders the scaffold for an existing project and writes only where nothing on disk is lost. read_project_scaffold_state recovers the inputs from the project itself - [ig] and the selection tables from fhir.toml, [FSH] timeout from ig/fsh.ini, and the publisher URL plus the copyright year from ig/sushi-config.yaml, the two values no other file records - so build_scaffold_files renders what this project's scaffold would be today rather than a default one.

preserves_every_line is the whole decision: it walks the render as a single forward iterator and asks whether every line currently on disk appears in it, in order. When it does, the file is a strict subsequence of the render, so rewriting adds and never removes, and the file is refreshed. When it does not, the file holds something the scaffold would not produce and it is left byte-identical and reported as edited - rendered by the CLI as skipped (you edited it; your version stays). A file the project lacks is created; one that already matches is unchanged. fhir.toml is skipped by relative path before any comparison: it is the user's configuration, not a scaffold-managed file.

Two consequences the design accepts. A scaffold line the user deliberately deleted is restored, because a deletion leaves the file a subsequence of the render - the same shape as a project that predates a scaffold addition, and the rule cannot tell them apart. And a file that cannot be read as UTF-8 is reported as edited rather than replaced, so unreadable content is never overwritten on a guess. --force, which rewrites everything, is rejected alongside --refresh at the CLI: they are opposite answers to the same question.

Foundation -> identifier systems, D2Period, and the capture contract

generate foundation writes ig/input/fsh/foundation/, the part of the IG that depends on fhir.toml alone and never opens a client:

  • d2-aliases.fsh - $DHIS2-OU / $DHIS2-OU-CODE / $DHIS2-OS / $DHIS2-OS-CODE / $DHIS2-DS / $DHIS2-DS-CODE / $DHIS2-PROGRAM / $DHIS2-PROGRAM-CODE / $DHIS2-DE / $DHIS2-COC / $DHIS2-PS / $DHIS2-PS-CODE / $DHIS2-TE / $DHIS2-TRACKER-ENROLLMENT, built from identifier_system_base. Generating these rather than scaffolding them is what frees ig/input/fsh/aliases.fsh to be a pure hand-authored stub.
  • d2-naming-systems.fsh - one NamingSystem per identifier system: a UID and a code declaration for each of the organisation unit, option set, category, data set, program, data element, category option combo, and program stage (D2OrgUnitIdentifierSystem, D2OptionSetCodeIdentifierSystem, D2DataSetIdentifierSystem, ...), plus a UID declaration alone for the tracked entity and the tracker enrollment. The split is a property of the DHIS2 object, not a gap: IdentifierSystemSubject.has_code is false for a data object, because DHIS2 gives a tracked entity and an enrollment no code attribute, so declaring a code system for them would declare a system nothing can ever populate. Each declaration is kind = #identifier with a single preferred uri uniqueId and a description stating the convention, including the code slot's UID fall-back. Without them the validator has no definition behind a DHIS2 identifier.system and warns on every artifact that carries one. R4 makes NamingSystem.date mandatory, so the declarations carry a pinned date rather than a run timestamp - a generated one would rewrite the file on every run.
  • d2-period.fsh - the D2Period extension plus D2PeriodType_CS/_VS.
  • d2-form-type.fsh - the D2FormType extension plus D2FormType_CS/_VS (aggregate, event, tracker, tracker-event). Its context covers Questionnaire and QuestionnaireResponse: the form states what kind of DHIS2 form it is, and so does every response captured against it, which is what lets a consumer branch without re-reading the questionnaire. aggregate, event, and tracker-event each have a generator; tracker is the registration form, declared in the same terminology so the code set is stable when that generator lands.
  • d2-attribute-value.fsh - the D2AttributeValue extension, a complex extension of attributeId (string, 1..1), attributeCode (string, 0..1) and value (string, 1..1), contexted on the five resource types that carry one: Organization, Location, CodeSystem, ValueSet, Questionnaire. A DHIS2 attribute value is an arbitrary key-value pair any metadata object may hold - a national registry id on a facility, an external warehouse key - so it maps onto an extension rather than onto any one FHIR element. attributeCode is optional because DHIS2 leaves most attributes uncoded (eleven of twelve on the Lao instance), and an uncoded attribute gets no sub-extension at all rather than an empty one. value is a string whatever the attribute's declared valueType, because that is the only shape DHIS2 sends - one real attribute carries a whole GeoJSON document that way.
  • d2-organisation-unit.fsh - the D2OrganisationUnit extension, value[x] only Reference(<location profile>) with valueReference 1..1, contexted on QuestionnaireResponse. It exists because a tracker-event response spends its subject on the patient, so the unit the event was captured at needs a slot of its own.
  • d2-tracker-enrollment.fsh - the D2TrackerEnrollment extension, value[x] only Identifier with valueIdentifier.system fixed to {base}/id/tracker-enrollment and valueIdentifier.value 1..1, contexted on QuestionnaireResponse. It is named for the tracker enrollment specifically rather than for enrollment in general, so a future enrollment kind gets its own namespace instead of overloading this one.
  • d2-responses.fsh - the D2AggregateResponse, D2EventResponse, and D2TrackerEventResponse profiles on QuestionnaireResponse, one per form kind. Each slices the extensions its kind has to carry (D2Period 1..1 on the aggregate one, D2TrackerEnrollment and D2OrganisationUnit 1..1 on the tracker one, D2FormType 1..1 on all three, fixed to the kind's own code), requires questionnaire, requires subject, and requires authored on the two event kinds. The aggregate and event profiles restrict subject to Reference(D2Location); the tracker one restricts it to Reference(Patient) and makes it a logical reference - subject.identifier 1..1 with system fixed to {base}/id/tracked-entity - because the IG publishes no Patient instances and the tracked entity resolves against DHIS2 instead. The three flags on ResponseProfileDeclaration (period_required, authored_required, tracker_context_required) are what the one shared template branches on, so a fourth form kind is a declaration rather than a template. The slice names are the extension names, which is what lets an instance address them as extension[D2Period] the way the examples already did against the bare resource.
  • d2-capture-server.fsh - the D2CaptureServer CapabilityStatement, kind = #requirements so R4 forbids software and implementation. It declares create on QuestionnaireResponse with every response profile as supportedProfile - the template loops over the same declarations d2-responses.fsh renders, so the two cannot disagree about how many there are - and read + search-type on Questionnaire, CodeSystem, ValueSet, Location, and Organization. R4 makes CapabilityStatement.date mandatory, so it takes a pinned literal for the same byte-stability reason the NamingSystem declarations do.

The response profiles are the reason foundation reads OrganisationUnitNaming: subject only Reference(<location profile>) and the D2OrganisationUnit extension's valueReference both have to name the very profile the org-unit target emits, under whatever [generate.naming] prefix is configured. naming.py is a leaf module, so the dependency adds no cycle. The profiles are only half the contract - the other half is that every complete generated example declares InstanceOf: D2AggregateResponse / D2EventResponse / D2TrackerEventResponse instead of the bare resource, so SUSHI and the publisher validate the examples against the profiles on every run and a profile that drifts from what generation produces fails the build.

D2Period exists because a FHIR Period is a pair of instants while a DHIS2 period is a typed interval: 202401 is the January instance of the Monthly type, and the type is what makes it comparable and round-trippable. The extension carries iso (string, 1..1), type (code, 1..1, required-bound to the period type ValueSet) and period (Period, 0..1). Its context names the two resources that actually carry it - QuestionnaireResponse and MeasureReport - rather than a bare Element, which the publisher's QA reads as an unbounded extension. dhis2w_fhir.period holds the matching parser: parse_period("2024BiW2") returns the type and the resolved dates for all twenty-three period types DHIS2 registers, transcribed from Period.Input.of and DateUnitPeriodTypeParser in dhis2-core. recent_periods is its inverse, built on the parser rather than beside it: each type declares only how its ISO strings are spelled for a year, the parser decides which of those exist and when they end, and the enumerator keeps the ones already past. That is what keeps the two from drifting apart. Both are part of the package's importable surface - see the dhis2w_fhir API reference.

D2AttributeValue is defined in foundation/ and emitted everywhere else, so the shared halves live in two leaf modules. attributes.py holds the projection AttributeValueIn (the attribute UID and the value, which is all DHIS2 sends: the wire shape is {"attribute": {"id": "..."}, "value": "..."}) and AttributeCodeIndex, the uid -> code mapping whose code_for returns None for an attribute the instance left uncoded. foundation/attribute_values.py holds the context list, the three sub-extension names, the canonical-URL helper, and attribute_value_extensions, the one builder every emitter calls - so the extension's structure is decided in one place and the Organization, Location, CodeSystem, ValueSet, and Questionnaire emitters only decide where the result hangs. The service resolves the index once per generate run through resolve_attribute_code_index, unpaged: DHIS2 pages /api/attributes 50 at a time by default, and an instance defining more than a page of them would otherwise lose the tail of the join with no error. Every target of one run therefore joins against the identical mapping, the same guarantee the option-set identity plan gives names. The Questionnaire emitter is the one that does not build R4 models - it renders FSH, so it projects each value onto a _AttributeValueView of quoted literals and the template writes the extension[D2AttributeValue][+] soft-index block, skipping the attributeCode line entirely when the code is None.

Option sets -> terminology

Two pre-built FHIR JSON documents per option set under ig/input/resources/terminology/ - CodeSystem-d2-os-<UID>-cs.json and ValueSet-d2-os-<UID>-vs.json - carrying a D2OS_<UID>_CS CodeSystem plus a matching ValueSet (naming tokens configurable). The file name and the id keep the UID's own case - FHIR ids permit mixed case, so the id reads straight back to the DHIS2 object. With naming.source = "name" the artifacts take kebab-cased name slugs instead, and ids stay within FHIR's 64-character id limit: an over-long option-set name is truncated and suffixed with the set's UID (noted in the report), which also keeps bounded ids unique. Every concept carries both DHIS2 identifiers: with the default concept_code_source = "id" the option UID is the concept code and the DHIS2 option code rides along as a dhis2-code concept property; with "code" they swap (the UID becomes a dhis2-id property). The code path is gated by a FHIR code-datatype validity check; an option whose code is missing or invalid falls back to the UID with a note in the report, so generation is total. Concept codes are unique within a set by construction: the codes are assigned in DHIS2 sort order by concept_assignments and a taken code falls back to the option's UID; where that UID is taken too - a peer carries it as its own DHIS2 code - the option is skipped with its own aggregate note rather than emitted as a duplicate concept the publisher would reject. Every target that names a concept reads that one assignment, so the examples cannot code an answer the CodeSystem has no concept for. The set's own DHIS2 attribute values ride onto both halves of the pair as D2AttributeValue extensions; the values on the individual options do not, because a CodeSystem.concept has no carrier chosen for them yet.

Names are decided once, for the whole selection, by option_set_identities: truncation and collision suffixes both depend on the peers a set is assigned against, so a per-set name cannot be reconstructed from a UID. The resulting OptionSetIdentityPlan is the boundary object every other target reads option-set names from - the terminology emitter for its files, a question's answerValueSet, an example's answer coding, and the narrative pages' CodeSystem-<id> links. The service builds the plan from the identical selection in each generate path, so a name-sourced run's Canonical(D2OS_Sex_Aa1aaaaaaaa_VS) names the ValueSet that same run writes. A bound set the plan somehow omits still emits a UID-derived name and is reported by an aggregate note, never left dangling.

The pair ships as predefined resources: sync_json_artifacts writes them into ig/input/resources/terminology/, sushi-config.yaml declares path-resource: input/resources/terminology/*, and the publisher loads them verbatim into sushi-local#LOCAL with no FSH parse. That is what keeps hundreds of option sets out of the compile - see Toolchain performance.

Writing JSON makes the target owner of two directories rather than one. A definition SUSHI compiles from FSH and a predefined resource of the same identity are a duplicate, and SUSHI rejects the pair, so the target follows sync_json_artifacts with clean_generated_files over ig/input/fsh/terminology/. Only a file carrying the generated header is removed, which leaves anything hand-authored in that directory alone. Every page-facing title and description it writes goes through page_string, the JSON counterpart of the page_text the FSH emitters use: a predefined resource reaches the same breadcrumb template an FSH-authored one does, so it takes the same HTML-escaping.

The FSH name is what carries across the FSH/JSON boundary. A Questionnaire is FSH and binds its question with answerValueSet = Canonical(D2OS_<UID>_VS), an FSH name rather than a URL, and it resolves against a JSON document because SUSHI fishes predefined resources by their name element. Every emitted CodeSystem and ValueSet carries exactly the FSH name option_set_identities handed the questionnaire target, which is why one plan serving both emitters is load-bearing rather than tidy.

Categories -> terminology

A DHIS2 category is one axis of a disaggregation and its category options are the values along that axis, which is structurally an option set and its options. So generate categories emits the same pair through the same machinery: two pre-built R4 JSON documents per category under ig/input/resources/categories/ - CodeSystem-d2-cat-<UID>-cs.json and ValueSet-d2-cat-<UID>-vs.json - with the concepts built by build_concepts, the option-set component's own concept-code assignment, so the code fall-backs and the duplicate-skip rule are decided in one place for both sources. category_identities mirrors option_set_identities: slugs, FSH names, and artifact ids assigned once over the whole selection, with truncation and collision suffixes that depend on the peers a category is assigned against.

The pair carries the CAT naming token (D2CAT_Sex_CS / _VS), the category's own UID and code as identifier business identifiers under <base>/id/category and <base>/id/category-code, and the category's DHIS2 attribute values as D2AttributeValue extensions on both halves. Under naming.source = "id" the slug is the category UID verbatim, mixed case included.

Its own sync directory is structural, not cosmetic. sync_json_artifacts owns its target outright - it deletes every *.json the run did not produce, because JSON carries no header to mark - so two JSON targets sharing a directory would delete each other's documents on every run. CATEGORY_DIRECTORY is therefore categories, beside TERMINOLOGY_DIRECTORY and REGISTRY_DIRECTORY, and the scaffolded sushi-config.yaml declares a third path-resource glob for it.

There is no category_option naming token, deliberately. A category option is a concept inside its category's CodeSystem, exactly as an option is a concept inside its option set's, and neither has a token: a concept is not an artifact, so nothing names it. The CO token stays reserved in the canonical token registry for a future artifact that publishes category options in their own right.

[generate.categories] include_ids selects, absent or empty = all, unmatched UIDs noted - identical to [generate.option_sets]. There is no closure: nothing generated today binds a category, so the list stands alone rather than being unioned with what the forms reference. DHIS2's own default category is emitted like any other and is filterable like any other.

Data sets, event programs, and tracker stages -> Questionnaires

generate questionnaires owns four sync directories under ig/input/fsh/, split by what the files describe rather than by which command wrote them:

data-sets/<UID>.fsh                      One Questionnaire per DHIS2 data set
event-programs/<UID>.fsh                 One Questionnaire per DHIS2 event program
tracker-programs/<program UID>/<stage UID>.fsh
                                         One Questionnaire per tracker program stage
data-dictionary/data-elements.fsh        D2DE_CS / _VS over every referenced element
data-dictionary/category-option-combos.fsh   D2COC_CS / _VS over every option combo

tracker-programs/ is the only nested layout, and it is nested because a national instance's stage count is what makes a flat directory unreadable: grouping by program UID means a program's forms are one folder. The FSH sweep serves it by tracking produced files by path relative to the sync root and walking subdirectories, so a deletion is still scoped to header-bearing generated files, and a subdirectory the sweep emptied is removed with them. The JSON sweep keeps its flat whole-directory-ownership semantics - it has no nested target.

D2DE_CS carries two concept properties: dhis2-code (the DHIS2 code, falling back to the UID) and domain - a code valued #aggregate or #tracker from the data element's DHIS2 domainType, omitted along with its declaration when the instance answers none. Both take a <identifier_system_base>/property/<code> URI, the same scheme the option-set terminology uses.

The targets are [generate.data_sets] / [generate.event_programs] / [generate.tracker_programs] include_ids, absent or empty = all, exactly like the terminology and registry selections. The service makes one sync_artifacts call per directory, each swept against its own files alone, and merges the four reports into the single GenerateReport whose target_directory reads data-sets, event-programs, tracker-programs, data-dictionary. The two support pairs are FSH under ig/input/fsh/data-dictionary/, a different tree from the option-set target's ig/input/resources/terminology/, so its cleanup can never reach them. The command is still d2w fhir generate questionnaires - it names the action, not a folder.

One Questionnaire is Usage: #definition, id the bare UID, url the IG canonical plus /Questionnaire/<uid>, status and experimental, both DHIS2 identifiers ($DHIS2-DS / $DHIS2-PROGRAM / $DHIS2-PS and their code slots), and name composed from the naming tokens (D2DS_BfMAe6Itzgt, D2PS_A03MvHHogjR). subjectType states who the form is answered for: #Location for a data set and an event program (a DHIS2 form is answered for an organisation unit), #Patient for a tracker stage (it is answered for the enrolled person, and the organisation unit moves onto the response's D2OrganisationUnit extension).

A tracker stage's identity is the stage's - its UID, its code, its description, its attribute values - and the program travels beside it as ProgramContextIn, which shapes three things: the title reads <program> - <stage>, the file path nests under the program UID, and a third identifier slice carries the program UID under $DHIS2-PROGRAM. That slice is the grouping handle a plain FHIR server can search on (Questionnaire?identifier={base}/id/program|<programUid> returns a program's stages), which is why the program is an identifier rather than only a title. _tracker_program_sources emits the stages in DHIS2's own order - sortOrder, then name, then UID - because programStages is a Java Set on the wire and its order is neither the form's nor stable across requests.

Sections become #group items; data elements become questions whose type comes from the DHIS2 valueType table, or #choice plus an answerValueSet when the element is option-set bound; a MULTI_TEXT question is that #choice plus repeats = true, which is the whole of what MULTI_TEXT means; a compulsory program-stage element is required; on an aggregate source a non-default category combo turns the question into a group with one child per option combo, linkId <deUid>.<cocUid> - the same key a DHIS2 data value carries. Disaggregation is aggregate-only by construction: a data set's values land on /api/dataValueSets, where every value carries a category option combo, while an event data value has no categoryOptionCombo slot on the wire - so an event or tracker-stage question stays flat whatever combo its data element declares, because a form must not ask a question the capture endpoint cannot accept an answer to. A cell asks the element's own question one option combo at a time, so each child takes the element's effective item type, its answerValueSet, its repeats, and its bounds; only the linkId, the text, and the code differ. A section holding such a group also carries the standard questionnaire-itemControl extension coded #gtable, which is the DHIS2 data-entry grid stated in FHIR terms. The source's own DHIS2 attribute values follow the D2FormType extension as D2AttributeValue extensions, in DHIS2's order; the data elements' attribute values do not travel, because they would land on D2DE_CS concepts.

Three tables, routed by programType. [generate.data_sets] picks aggregate data sets, [generate.event_programs] picks WITHOUT_REGISTRATION programs, and [generate.tracker_programs] picks WITH_REGISTRATION programs. The two program tables are read independently in _fetch_program_sources, each on its own terms, and the two selection modes handle a mismatched shape differently - the split is deliberate. When a table's include_ids is explicit, its UIDs are fetched by name and every one is routed to that table's type; a program of the other type raises by name, and the message points at the table the program does belong under - the operator named that UID, so silence would be a lie. When include_ids is absent or empty the whole instance is that table's target, so refusing would make the mode unusable on any real database: the sweep routes each program by its live programType and collects the types neither table maps into a single aggregate note. With both tables empty one unfiltered fetch serves both, because the split is a property of the response, not of the request. Listed UIDs that resolve to nothing, and data elements no section references, are aggregate notes in both modes.

The option-set closure keeps the IG internally consistent: when [generate.option_sets] include_ids narrows the terminology, the option sets the selected targets bind to are unioned in and listed in a note - including in target all-mode, where the closure covers every form on the instance. An empty option-set include list already means every option set, so the closure short-circuits there and the targets are not fetched twice.

Example responses -> QuestionnaireResponse instances

generate examples owns one sync directory, ig/input/fsh/examples/, holding one Usage: #example QuestionnaireResponse per example, named <targetUID>-<n>.fsh. The targets are the questionnaire targets - the same _fetch_questionnaire_sources call, so all-mode and the routing rules behave identically, a tracker program contributes one target per stage, and no example can point at a Questionnaire the IG lacks.

[generate.examples] carries per_target (0 disables the target, which still sweeps the directory clean; the ceiling is MAXIMUM_EXAMPLES_PER_TARGET = 10, so the field validates in 0..10 and a larger value is a config error rather than a thousand-file run) and source. The two sources meet at one emitter: each produces a list of ExampleResponseIn - identity, organisation unit, status, optional period, optional authored, and a flat list of (dataElement, categoryOptionCombo, value) answers holding DHIS2 wire strings - and build_example_artifacts does the typing, the structure mirroring, and the rendering once.

  • synthetic is the default, and deliberately so: an example is published, and real values off a production instance would travel with it. No data endpoint is called. build_synthetic_responses seeds a random.Random with the leading 64 bits of sha256("<targetUID>:<n>") - never hash(), which is salted per process - so a regenerate is byte-identical across machines and restarts. Every question is answered and every option combo of a disaggregated element filled; TRUE_ONLY is always true; an option-set-bound question draws a real concept from the set the IG publishes. The only value that moves with the calendar is the anchor: a data-set example takes the newest completed period of its period type, and its temporal values are drawn from that window. A tracker-event example draws its tracked entity and enrollment UIDs off the same seeded generator, so the contract's two required UIDs are deterministic placeholders rather than identifiers borrowed from a live database.
  • instance reads the server. Data sets walk recent_periods(periodType, 6, today) newest-first against GET /api/dataValueSets (root org unit, children=true) and stop at the first period holding values, which are then grouped by the DHIS2 reporting key (orgUnit, period, attributeOptionCombo), richest group first. Both program kinds read GET /api/tracker/events ordered occurredAt:desc, tolerating both the instances and events envelope keys at the boundary: an event program selects by program, a tracker stage by programStage plus its program, which DHIS2 demands even though the stage pins it (BUGS.md #67). A stage read also asks for enrollment and trackedEntity, the two UIDs the tracker-event contract requires. A target the instance answers nothing for is one aggregate note, never a failure.

An example that reaches the emitter without both tracker UIDs declares InstanceOf: QuestionnaireResponse rather than the tracker profile and is tallied in one aggregate note. Degrading rather than dropping is the same choice the answer casting makes: a real captured form is worth publishing, and a document that cannot meet a contract must not claim it.

The emitted items mirror the questionnaire exactly - section groups nest their questions, a disaggregated element nests one child per option combo under <deUid>.<cocUid> - but only the branches an answer reaches are emitted, so a partial data value set produces a partial, still-valid response. Answers are cast from the data element's valueType; an option code resolves to a valueCoding into that set's CodeSystem, carrying the very concept code concept_assignments handed the terminology target - fall-backs, collisions, and all - so an answer can only ever name a concept the run really wrote. An answer selecting an option that received no concept code is left unanswered and counted. Anything that will not cast falls back to valueString and is counted, as is a captured value for a data element the form does not ask for.

Two normalisations happen at the FHIR edge rather than being left to fail in SUSHI: a zone-less DHIS2 timestamp gains Z (BUGS.md #62 - R4 requires an offset on a dateTime that carries a time, and DHIS2 serves local timestamps under fields its OpenAPI types as Instant), and a bare HH:MM gains its seconds. A temporal value that still does not match the R4 primitive is answered as a string, and an unusable occurredAt drops authored entirely - with a note either way. A third upstream quirk is handled a layer earlier, at the wire-parse boundary: categoryOptionCombos comes back in a different order on every request (BUGS.md #64, the same Java Set shape as dataSetElements in #63), so the option combos are sorted by name and UID once at parse time, giving the questionnaire's child items, the answers here, and the D2COC_CS concepts one shared order - which is what keeps a disaggregated example past the validator's QuestionnaireResponse: Structural Error: items are out of order.

An answer to an ORGANISATION_UNIT question is a valueReference at Location/<uid>, matching the #reference item type the questionnaire declares and the Location instances the registry target publishes. REFERENCE and TRACKER_ASSOCIATE point at DHIS2 objects the IG publishes nothing for yet, so a synthetic example leaves them unanswered alongside the attachment and geometry types, in one aggregate note.

Organisation units -> instances

The definitional half is FSH, under ig/input/fsh/organization/:

  • profiles.fsh - D2Organization and D2Location. Both take their ^status from [ig] status and both slice identifier on system into dhis2id 1..1 and dhis2code 1..1. Organization.type binds to the level ValueSet extensible, not required: an IG that adds group-set codings later must not be made non-conformant by the binding. D2Location also declares the location-boundary-geojson extension as a named boundary 0..1 slice, so the profile states the geometry contract its instances carry instead of leaving the extension loose.
  • org-unit-levels.fsh - D2OU_Level_CS/_VS covering the levels observed in the selection.
  • org-units-terminology.fsh (only with terminology = true) - the whole selection as one D2OU_CS with level / parent / dhis2-code concept properties, for flows that want the hierarchy as codes instead of resources.

The registry itself is pre-built R4 JSON, under ig/input/resources/registry/ - Organization-<uid>.json and Location-<uid>.json, two files per unit, id the bare UID. Each carries both identifier slices and its profile in meta, with partOf mirroring the hierarchy on both sides as a relative reference (Organization/<uid>, Location/<uid>), omitted for the root or when the parent falls outside the selection - noted, never silent. A unit whose closedDate has passed carries active: false / status: "inactive". Both halves also carry the unit's DHIS2 attribute values as D2AttributeValue extensions - 244 of the Lao instance's 300 organisation units have at least one, which makes the registry the densest carrier of them. On the Location the emission order is part of the byte-stability contract rather than incidental: _extensions puts the GeoJSON boundary first and the attribute values after it in DHIS2's own order, so a regenerate of an unchanged unit produces the identical file and sync_json_artifacts reports it unchanged.

Why JSON. SUSHI loads input/resources and the sub-folders path-resource declares as predefined resources: they land in the virtual sushi-local#LOCAL package exactly as written, with no FSH parse and no conversion pass. The registry is the largest thing in the IG and it is pure data - no profiling, no invariants, nothing FSH's authoring conveniences buy - so the compile has nothing to do with it. The measured cost of the alternative is in the guide under Registry scale.

The documents are serialised from the pydantic models in dhis2w_fhir/r4/ - Organization, Location, and the element types they compose - rather than rendered from a template, because JSON is a data structure and jinja would be building one out of text. Every model is frozen, alias-aware, and extra="forbid", so a round trip through model_validate / model_dump_json(exclude_none=True, by_alias=True) reproduces the document key for key.

ig/input/resources/ is gitignored by the scaffold. It is generated output that make generate rebuilds from the instance in a few minutes, and a national hierarchy plus its option-set terminology puts tens of thousands of files there; ig/input/fsh/ stays committed, so the reviewable diff is the definitional one.

Every artifact representing a DHIS2 object exposes both DHIS2 identifiers wherever FHIR has a slot, and the code slot repeats the UID when the DHIS2 code is missing or not FHIR-valid - so dhis2code can be 1..1 and consumers never special-case absence. d2w fhir validate warns on every organisation unit without a code, which is what drives those fall-backs out over time. Every identifier system those slots name is declared by a foundation NamingSystem. Every generated CodeSystem also points back at its ValueSet through valueSet (spelled ^valueSet in the FSH ones) and gives each concept property a <base>/property/<code> URI so the property has a defined meaning outside this IG. Every generated definitional resource - every response profile, every extension, every CodeSystem/ValueSet pair, every Questionnaire - states its publication status and its experimental flag from [ig] status: #draft and true while the IG is draft, #active and false once it is active. The flag is always populated, because ShareableCodeSystem / ShareableValueSet make it mandatory. NamingSystem instances take the status - R4 gives them the same publication-status codes - but no experimental element, which R4 NamingSystem does not have. The Organization and Location instances are outside this: their active / status carries the organisation unit's closedDate, a different question with the same element names.

The tree is fetched with a 500-per-page loop ordered by path:asc (stable output), filtered by [generate.organisation_units] root (DHIS2 path:like) and max_level.

Narrative pages -> pagecontent

generate pages owns one sync directory outside the FSH tree, ig/input/pagecontent/, and writes markdown rather than FSH. SUSHI publishes everything it finds there without a pages: block, and the IG publisher injects a <Type>-<id>-intro.md into the top of the matching artifact page - so the same directory carries both halves of the narrative layer:

pagecontent/forms.md                     Data set + event program + tracker stage catalog
pagecontent/registry.md                  Organisation unit registry summary
pagecontent/terminology.md               Option sets + the support CodeSystems
pagecontent/identifiers.md               The two identifier slices + NamingSystems
pagecontent/periods.md                   D2Period + every DHIS2 period type
pagecontent/capture.md                   The capture contract, worked per form kind
pagecontent/Questionnaire-<UID>-intro.md One per generated Questionnaire
pagecontent/CodeSystem-<id>-intro.md     Option sets carrying a DHIS2 description
pagecontent/Organization-<UID>-intro.md  Org units carrying a DHIS2 description

The six site pages are the scaffolded menu: Home, Forms, Registry, Terminology, Identifiers, Periods, Capture, Artifacts. The two intro kinds that are gated on a DHIS2 description emit nothing when there is none - most organisation units have no description, and an intro page repeating the title would be noise.

capture.md is the narrative half of the capture contract. It works an aggregate response, an event response, and a tracker event response step by step against forms this project actually selected, with a real ISO period resolved through parse_period from the pinned reference date and a real organisation unit off the registry. The tracker walkthrough is the one that needs prose most, because its shape is the least guessable: the logical Patient subject carrying no reference, the D2TrackerEnrollment identifier, the D2OrganisationUnit reference standing in for the subject an aggregate response spends on the unit, and a pointer at d2w data tracker enrollment list for where the two UIDs come from - DHIS2, not this guide. A project with no tracker stage selected gets the rules without the worked example rather than a missing section. The page then tabulates the answer typing for every DHIS2 value type. That table is not written twice: the value type to answer element mapping is answer_element in resources/examples, which _typed_answer dispatches on and the page reads directly, and the event status table is the examples component's own STATUS_BY_EVENT_STATUS. Only the prose spelling rule per value type is the page's own. forms.md groups the same way the FSH tree does: a "Tracker programs" section with one heading per program and that program's stages catalogued beneath it, and each stage's Questionnaire-<UID>-intro.md names the program it belongs to, so a form found on its own says what it is part of. build_page_artifacts takes the IG canonical for this page alone - the capture contract has to state the canonical URL rule a client resolves a Questionnaire by, and that lives in [ig] rather than [generate].

The target adds no endpoint. PagesIn is the three projections the other targets already fetch - QuestionnaireSourceIn, OptionSetIn, OrganisationUnitIn - so a page can never disagree with the FSH about what was generated. Link targets are derived rather than reconstructed: option_set_identities is the one place an option set's slug and CodeSystem-<id> are decided, read by the emitter for its file names, by the questionnaire and example targets for their option-set names, and by the terminology page for its links, and periods.md tabulates PERIOD_TYPE_DEFINITIONS with examples resolved through recent_periods + parse_period from a pinned reference date, so a regenerate never moves with the calendar.

Markdown is the reason names.markdown_text exists. The same publisher quirk that forced page_text onto the FSH page furniture applies to page bodies, and a markdown table adds a second escape: &, then < and >, then | as \| with whitespace flattened in table-cell context. Every DHIS2-derived string on a page goes through it; the FSH layer is untouched by it.

Translations

DHIS2 holds a translation as a {locale, property, value} triple with a Java-style locale tag. i18n.py is the shared leaf over them: TranslationIn is the projection the service wraps the raw dicts into at the fetch boundary, normalize_locale renders the tag as BCP-47 (pt_BR -> pt-BR), and name_translations selects the NAME entries, filters them to [generate] locales (empty = all), deduplicates by locale, and sorts - so an unchanged instance regenerates an unchanged file. Emission splits by what FHIR offers on the target: CodeSystem concepts (options and, with terminology = true, organisation units) take designations, while the option-set CS/VS titles and the Organization.name / Location.name of every instance take the standard http://hl7.org/fhir/StructureDefinition/translation extension with its lang and content sub-extensions. In the pre-built JSON that extension hangs off the _x sibling of the primitive - _title on a CodeSystem or ValueSet, _name on an Organization or Location - carried by r4.Element, the R4 root a primitive's extensions hang from, and built by i18n.translated_element. Only NAME is emitted. The deep option-set validation pass suffixes a finding's name with the subject's first matching translation; the instance-wide sweep does not fetch translations at all.

Regeneration contract

Every generated file starts with a header line chosen by extension: // Generated by d2w fhir generate - do not edit for FSH, and the HTML comment <!-- Generated by d2w fhir generate - do not edit --> for the markdown pages, which renders invisibly. A generate run first writes its target subdirectory, then deletes the header-bearing .fsh / .md files in that subdirectory it did not just produce - hand-authored FSH and the hand-authored pagecontent/index.md in the same trees are never touched, and re-running converges instead of stacking files. Files whose content already matches are not rewritten, so a no-op regenerate leaves both the timestamps and git status untouched.

JSON has no comment syntax, so a header cannot mark the pre-built files. sync_json_artifacts owns its directory outright instead - registry/ for the org-unit target, terminology/ for the option-set target, categories/ for the category target: it deletes every *.json in that directory the run did not produce, without an is_generated_file check, and leaves files of other extensions and nested sub-directories alone. That is exactly why the three have a directory each rather than sharing one. All three are gitignored, so nothing hand-authored belongs there.

Toolchain performance

Generation is the cheap half and the toolchain is where the minutes go; the measured numbers live in the guide under Build time and the two caches. Four structural facts explain the shape of it. The registry and the option-set terminology are both predefined JSON, so the compile scales with the forms and the five CodeSystems that are FSH rather than with the hierarchy or the option-set count - and the publisher's rendering pass, which writes a page per resource, is where that volume lands instead. The publisher runs its own SUSHI over the same FSH, so a chain that compiles first and then publishes pays for the compile twice - which is why make refresh goes straight from validate to build. The phases are serial, SUSHI then the validator then Jekyll, so nothing overlaps. And on a cold machine the package cache dominates the front of the run, because the publisher fetches the core packages, hl7.terminology.r4, and hl7.fhir.uv.extensions.r4 before doing any work, while a container started with docker run --rm throws them away again.

Two mitigations ship in the scaffold:

  • Package cache volume. make sushi and make build mount the named volume fhir-ig-cache at /home/publisher/.fhir, so the package downloads survive the container. make clean-all removes the volume when you want the cold path back.
  • Terminology cache. The publisher writes its tx cache into ig/input-cache/. make clean deliberately leaves it alone (only clean-all removes it), and .gitignore keeps it out of git. A warm tx cache is what takes the validation phase from minutes to seconds on a re-run.

Iterate without the publisher. d2w fhir generate plus make sushi is the edit loop - SUSHI alone compiles the FSH and tells you whether it is valid without rendering a site. d2w fhir serve (roadmap) is the rest of that loop. make build is a release step, not an inner-loop step.

The upstream DHIS2 and tooling quirks that shape this code - including the two that surface while reading a publisher run - are catalogued in fhir roadmap and review guide, section 4. Two publisher behaviours worth knowing at the point you read its output:

  • The QA summary contradicts its own link checker. The same run prints ... 1099935 links, 0 broken links (0%) from the HTML checker and Errors: 0, Warnings: 6710, Info: 177, Broken Links: 15425 in the QA summary. The 15,425 are not broken links at all: they are the "canonical link and is therefore unsafe with regard to versions" warning, one per page carrying the publisher.url. Dropping publisher.url removes the whole class.
  • Error generating combined package: .../output/package.tgz (No such file or directory), exit 0. An upstream call-ordering defect, not a project problem: PublisherGenerator.genCombinedPackage() opens output/package.tgz, but since publisher 2.2.9 it runs before npm.finish() writes that file (commit f868684, "generate combined package before shutting down the terminology system", moved the call). The exception is swallowed and logged. There is nothing an IG author can add to fix it - the publisher clears output/ at startup, so a stale file cannot satisfy it either, and it reproduces on single-language IGs unrelated to this project. Only output/package-combined.tgz is lost; output/package.tgz itself is written correctly a moment later. Unreported upstream as of publisher 2.3.0.

Validation

d2w fhir validate (MCP: fhir_validate) checks the whole instance against the R4 primitives (https://hl7.org/fhir/R4/datatypes.html#primitive) in three passes.

The instance-wide sweep over GET /api/metadata?fields=id,name,code is the broad one: every metadata object's code, in every collection the endpoint returns, with invalid codes as errors and per-type duplicates as warnings. It passes defaults=EXCLUDE, so DHIS2's auto-generated default objects stay out of the counts.

The deep option-set pass previews concept_code_source = "code" generation (invalid/duplicate option codes are errors, missing codes warn, spaced-but-valid codes are infos) and, under naming.source = "name", the ids the emitter would assign. _option_set_naming_findings is per-set and covers the lengths; _option_set_slug_findings takes the whole selection at once and covers collisions, which a per-set check structurally cannot see - a slug is assigned against its peers, so whether a name yields a readable id depends on the other names in the run. Over-long slugs are excluded from the collision report because long-name already accounts for their suffix.

The deep attribute pass reads the sweep's own attributes collection - no request of its own - and reports every attribute the instance left uncoded. The emitter writes the attributeCode sub-extension only for a coded attribute, so an uncoded one leaves every value it carries resolvable by DHIS2 UID alone across all five contexted resource types. It is info: that is the emitted IG working as designed, and a coverage signal rather than a defect. FhirValidationReport carries attribute_count beside the option-set, option, resource-type, and object counts, rendered in the Markdown report, the PDF, and the CLI table.

Every pass additionally raises template-hostile-name (warning, in either code source) on any object whose name holds <, >, or & - the characters the publisher's template injects into HTML unescaped. That one is about the published pages rather than about codes, which is why it does not move with --code-source; the sweep covers every metadata object and the deep option-set pass covers option names, which the sweep excludes.

Its sibling template-hostile-code reads the object's code for the same three characters, because the two failures are not the same size. A name deforms the page it appears on; a code rides an identifier value, and the publisher writes identifier values into a table cell unescaped and then strict-parses the page it just produced, so the build aborts with Unable to Parse HTML - node 'td' has unexpected content. It aborts in the publisher's final pass, after every resource has been rendered, which is what makes catching it in a seconds-long validate worth an error rather than a warning. The publisher escapes the other DHIS2 text that reaches a page - concept displays, designations, dhis2-code property values, translation extensions all survive a raw < - so the identifier table is the single carrier, and the code is the single field that reaches it.

The check is doubly restricted, and both restrictions are load-bearing, because an error here asserts that a build will fail. It fires only on _CODE_IDENTIFIER_COLLECTIONS - optionSets, categories, organisationUnits, dataSets, programs - the five whose objects emit a resource carrying the code as an identifier value; every other collection either emits nothing or carries its code through an escaped surface. And only < is an error: it opens a tag, which is the failure that was observed, while > is text to an HTML parser and a bare & is widely tolerated, so both are warnings rather than claims. Unrestricted, the check raised 23 errors on the national instance it was written against - dashboards, data elements, program indicators - of which one was the object that failed the build.

What the deep passes do not repeat, and why is stated in validation/__init__.py's own module docstring, and it is the reason the deep layer is thin rather than a mirror of every emitter. The sweep already applies both checks instance-wide to every collection the emitters read - dataElements, categoryOptionCombos, dataSets, programs, sections, programStageSections, organisationUnits, attributes - and every emitted resource id is a DHIS2 UID except the name-sourced slugs, which are checked here. Two passes are deliberately absent. An organisation-unit deep pass would need _fetch_organisation_units, the single unbounded read in the plugin, and would find nothing the sweep does not already report: the registry's ids and concept codes are UIDs, its names and codes are swept, and organisationUnits is the one collection where the sweep already treats a missing code as a finding. A questionnaire-target deep pass would repeat the sweep for the same reason - its sources are all top-level /api/metadata collections.

The option-pass severities are gated on the effective code source - the --code-source flag, else concept_code_source. In id mode invalid-code, missing-code, and duplicate-code downgrade to info with the reason in the message: generation is not reading those codes yet, so they are a readiness signal for switching to code mode rather than a defect. The instance-wide sweep keeps its severities either way. d2w fhir validate --code-source code is the readiness probe for that switch.

The terminal shows errors and warnings; infos roll up per category (--all lists them). Reports are written in three formats - --report takes a path stem (default reports/fhir-validate-report under the project root, gitignored by the scaffold) and --format a comma list of md, csv, pdf:

  • Markdown, grouped by resource type;
  • CSV (severity,category,resource_type,uid,name,code,message), for spreadsheets and for diffing two runs;
  • PDF, with a summary cover page, a clickable table of contents carrying per-type severity breakdowns, and one bookmarked section per resource type with severity-tinted rows. It is typeset in Noto Sans with a Noto Sans Lao fallback (both vendored under validation/fonts/ with their OFL licence), so Lao-script DHIS2 names render instead of dropping to boxes.

Exit 1 on errors makes it a CI gate; --no-fail suppresses that. A fhir.toml is not required - validation targets the instance. MCP's fhir_validate takes the same code_source and returns the report; writing files stays CLI-only.

Code layout

Everything lives in the dhis2w-fhir workspace member, split into components that each own their code, their schemas, and their templates. There is no central models module: a component's pydantic models sit in its own schemas.py.

Flat modules carry what every component shares: names.py (slug, FSH literal, and URI helpers), i18n.py (the TranslationIn projection, locale normalisation, and NAME selection), attributes.py (the AttributeValueIn projection and the AttributeCodeIndex join every emitter reads an attribute's code from), notes.py (the one aggregate-note formatter), writer.py (the FshArtifact / FshBuild and JsonArtifact / JsonBuild contracts every emitter returns, plus the header-aware sync behind the first and the directory-owning sync behind the second), r4/schemas.py (the FHIR R4 resource and element models the pre-built JSON is serialised from), and config.py (the fhir.toml document - IgConfig, NamingConfig, GenerateConfig, FhirProjectConfig, FhirProject - with discovery, load, and save). service.py holds the shared orchestration and its own GenerateReport / GenerateAllReport; cli.py / mcp.py stay thin over it. plugin.py exports the descriptor referenced by the dhis2.plugins entry point; dhis2w-cli and dhis2w-mcp depend on the package so d2w fhir is present by default.

The components:

  • scaffold/ - the twelve files d2w fhir init writes (InitOptions, ScaffoldFile, ScaffoldReport, and normalize_project_name, which turns the IG id into the PEP 508 name of the scaffolded pyproject.toml), plus refresh.py: read_project_scaffold_state recovering the scaffold inputs off disk into a ProjectScaffoldState, preserves_every_line deciding whether a rewrite loses anything, and refresh_project behind d2w fhir init --refresh. Refresh is a CLI path rather than package API, so it stays out of the top-level re-exports.
  • resources/option_sets/ - the pre-built CodeSystem/ValueSet pair per option set, its TERMINOLOGY_DIRECTORY sync directory, option_set_identities and the OptionSetIdentityPlan / OptionSetIdentityIndex every other target reads option-set names from, build_concepts (the concept-code assignment the category component shares), plus max_slug_length (validation previews the same id bound) and the OptionSetIn / OptionIn / ConceptSourceIn / OptionSetSelection schemas. It builds r4.schemas models and ships no templates - JSON is a data structure, not a text layout.
  • resources/categories/ - the pre-built CodeSystem/ValueSet pair per DHIS2 category, its own CATEGORY_DIRECTORY sync directory, category_identities and the CategoryIdentity / CategoryIdentityPlan it assigns over the whole selection, category_fsh_name, max_category_slug_length, and the CategoryIn / CategorySelection schemas. It reads resources/option_sets/ for the shared concept assignment and is read by none of them.
  • resources/questionnaires/ - the Questionnaire instance per data set / event program / tracker program stage plus the two support terminology pairs, the exported ITEM_TYPES_BY_VALUE_TYPE table mapping every DHIS2 valueType on v41/v42/v43 to its FHIR item type (guarded by a test that reads the three generated ValueType enums, so a codegen refresh cannot introduce a silent string), the four sync directory names (DATA_SET_DIRECTORY / EVENT_PROGRAM_DIRECTORY / TRACKER_PROGRAM_DIRECTORY / DATA_DICTIONARY_DIRECTORY, collected as QUESTIONNAIRE_DIRECTORIES), with TargetSelection, the QuestionnaireSourceIn / QuestionnaireSectionIn / QuestionnaireItemIn / ProgramContextIn / CategoryComboIn / CategoryOptionComboIn projections, and QuestionnaireNaming deriving every name from the DS / PR / PS / DE / COC tokens - option-set names are not among them, they come in on the OptionSetIdentityPlan. Item nesting is resolved in Python into a flat list of view-models carrying their FSH soft-index paths (item[=].item[+]), so the template stays a layout, not a recursion.
  • resources/examples/ - the Usage: #example QuestionnaireResponse per example, its EXAMPLES_DIRECTORY sync directory, the ExampleSelection / ExampleResponseIn / ExampleAnswerIn projections (the option sets come in on the option-set component's own OptionSetIn), the seeded build_synthetic_responses, and the answer typing (including the R4 temporal normalisations and their calendar, clock, and offset checks). It depends on resources/questionnaires/ for the source projection and naming, never the other way round.
  • resources/pages/ - the narrative markdown layer: the six site pages, the per-artifact intros, PagesIn (the fetched-input view the pages render from), and the per-page view-models. It reads the other components' naming helpers and projections - including the examples component's answer typing, which capture.md tabulates rather than restates - and is read by none of them.
  • resources/organisation_units/ - split by FHIR resource: naming.py derives every artifact name and id from the [generate.naming] tokens, organization.py builds the profiles artifact, the REGISTRY_DIRECTORY constant, and the Organization instances, location.py the Location instances (position, boundary extension, partOf), terminology.py the level pair and the optional whole-selection pair. The two instance builders return r4.schemas models that organization.py serialises into JsonArtifacts; only the profiles and the terminology go through jinja. Group / group-set emission lands here next.
  • foundation/ - the instance-independent artifacts: the DHIS2 identifier aliases, the NamingSystem declarations, the D2Period / D2FormType / D2AttributeValue / D2OrganisationUnit / D2TrackerEnrollment extensions, the three response profiles, and the CapabilityStatement, with FoundationNaming deriving their names from the prefix token. attribute_values.py additionally holds the D2AttributeValue builder the resource emitters call, which is why the only definitional component is also imported at emit time.
  • period/ - the DHIS2 ISO period grammar: PeriodValue, the period-type catalogue the CodeSystem is generated from, parse_period, and the recent_periods inverse the example target discovers data with.
  • validation/ - the two check passes, report.py rendering the Markdown and CSV, pdf.py the PDF, and the finding/report schemas.

resources/ is reserved for DHIS2 resource domains, which is why scaffold/, validation/, and r4/ stay top level - the last of those is FHIR's own R4 vocabulary, shared by whichever resource domain emits JSON.

Dependencies point one way: config.py composes the per-component selection tables (OptionSetSelection, OrganisationUnitSelection, and the shared TargetSelection behind both data-definition tables), and no component imports config.py at runtime - an emitter receives its GenerateConfig as a parameter and annotates it under TYPE_CHECKING. dhis2w_fhir/__init__.py re-exports the whole public surface, so from dhis2w_fhir import GenerateConfig keeps working however the components are arranged.

No FSH, TOML, YAML, or Markdown body - the narrative pages and their intros included - is assembled by string concatenation in Python. Every component that emits one ships a templates/ directory of jinja2 templates loaded through a PackageLoader scoped to that subpackage (StrictUndefined, trim_blocks, lstrip_blocks, keep_trailing_newline - the same settings dhis2w-codegen uses, so control tags never leak blank lines and rebuilds stay byte-stable). The Python side resolves every conditional into a pydantic view-model and renders; the templates hold the layout.

JSON is the exception, and for the same reason: it is a data structure, not a text layout. The registry and terminology documents are built as r4.schemas models and serialised with model_dump_json(exclude_none=True, by_alias=True, indent=2), which is byte-stable for the same input and cannot emit a malformed document the way a template can. resources/option_sets/ therefore has no templates/ directory at all.

The service opens the version-neutral dhis2w_core.client_context.open_client and maps generated OptionSet / OrganisationUnit / DataSet / Program / Attribute schemas into the *In projections at the boundary, attributeValues[attribute[id],value] among the fields each of the first four asks for. Geometry becomes a frozen GeoPoint: Point coordinates directly, and for Polygon/MultiPolygon the area-weighted (shoelace) centroid of the outer ring with the largest absolute area - not a bounding-box midpoint, which lands outside concave boundaries. Both are nominal paths that raise no note; the report's position and boundary counters carry the numbers.

Every unit whose geometry parses carries the full GeoJSON into Location through the standard location-boundary-geojson extension, wrapped in a Feature whose properties hold the UID, name, and level, with the attachment's title and size set. That includes geometry types no position can be derived from - LineString, MultiPoint, GeometryCollection - which are embedded without a position and rolled into one note naming the types. Only geometry with unusable or empty coordinates is malformed, and that alone yields neither position nor boundary.

Roadmap and review material

Everything roadmap-shaped about this plugin - the near-term, mid-term, and long-term items, the terminology source candidates, the settled decisions behind the current design, the open decisions still needing an owner call, the four review dimensions, and the measured build numbers - lives in the FHIR roadmap and review guide. This page describes what the package is; that page describes where it is going and what to look at.