dirigent-integration 0.23.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. dirigent_integration/__init__.py +29 -0
  2. dirigent_integration/py.typed +0 -0
  3. dirigent_integration/shelves/dhis2-analytics-to-csv-report.yaml +174 -0
  4. dirigent_integration/shelves/dhis2-export-to-s3.yaml +66 -0
  5. dirigent_integration/shelves/dhis2-metadata-snapshot.yaml +143 -0
  6. dirigent_integration/shelves/dhis2-tracker-weekly-window.yaml +162 -0
  7. dirigent_integration/shelves/dhis2-values-per-org-unit-to-parquet.yaml +186 -0
  8. dirigent_integration/shelves/dhis2-values-to-parquet.yaml +64 -0
  9. dirigent_integration/shelves/fhir/README.md +79 -0
  10. dirigent_integration/shelves/fhir/dhis2-to-fhir-observations.yaml +212 -0
  11. dirigent_integration/shelves/fhir/fhir-capture-bundle-to-data-values.yaml +230 -0
  12. dirigent_integration/shelves/fhir/fhir-conceptmap-driven-mapping.yaml +222 -0
  13. dirigent_integration/shelves/fhir/fhir-encounter-to-event.yaml +248 -0
  14. dirigent_integration/shelves/fhir/fhir-measure-report-to-analytics-check.yaml +205 -0
  15. dirigent_integration/shelves/fhir/fhir-nightly-window-sync.yaml +182 -0
  16. dirigent_integration/shelves/fhir/fhir-patient-to-tracked-entity.yaml +225 -0
  17. dirigent_integration/shelves/fhir/fhir-questionnaire-response-to-data-values.yaml +187 -0
  18. dirigent_integration/shelves/fhir/fhir-subscription-webhook-to-dhis2.yaml +218 -0
  19. dirigent_integration/shelves/inbound/README.md +32 -0
  20. dirigent_integration/shelves/inbound/csv-drop-to-data-values.yaml +176 -0
  21. dirigent_integration/shelves/inbound/fan-out-per-facility-import.yaml +202 -0
  22. dirigent_integration/shelves/inbound/fhir-observations-to-data-values.yaml +193 -0
  23. dirigent_integration/shelves/inbound/http-json-to-data-values.yaml +151 -0
  24. dirigent_integration/shelves/inbound/outside-to-dhis2-with-checks.yaml +246 -0
  25. dirigent_integration/shelves/inbound/parquet-lakehouse-to-dhis2.yaml +158 -0
  26. dirigent_integration/shelves/inbound/webhook-payload-to-tracker-event.yaml +176 -0
  27. dirigent_integration/shelves/inbound/weekly-window-pull-and-import.yaml +175 -0
  28. dirigent_integration/shelves/parquet-to-dhis2-import.yaml +195 -0
  29. dirigent_integration-0.23.2.dist-info/METADATA +231 -0
  30. dirigent_integration-0.23.2.dist-info/RECORD +33 -0
  31. dirigent_integration-0.23.2.dist-info/WHEEL +4 -0
  32. dirigent_integration-0.23.2.dist-info/entry_points.txt +3 -0
  33. dirigent_integration-0.23.2.dist-info/licenses/LICENSE +15 -0
@@ -0,0 +1,248 @@
1
+ # One clinical Encounter and its Observations collapsed into one DHIS2 program stage event.
2
+ #
3
+ # THE FLOW, END TO END. Find an Encounter on a general-purpose FHIR server, read every
4
+ # Observation that names it, fold the two into a single `/api/tracker` event whose data values
5
+ # are the observations, and post it with importMode=VALIDATE.
6
+ #
7
+ # WHY NOT THE FACADE. `d2w fhir serve` serves no Encounter and no Observation -- it publishes
8
+ # DHIS2 as forms and captures, so its whole clinical surface is the capture pair, and asking
9
+ # it for an Observation answers an OperationOutcome with code `not-supported`. Encounters come
10
+ # from the other kind of FHIR server: a hospital's own, an OpenMRS, a HAPI. That is the point
11
+ # of this example, so `fhir_base` defaults to the public HAPI R4 test server. It is a shared
12
+ # sandbox anyone can write to, so the resources it holds change under you; every id below is a
13
+ # parameter for that reason, and the searches are written to survive not finding one.
14
+ #
15
+ # THE MODELLING QUESTION THIS ANSWERS. FHIR splits a visit into one Encounter plus one
16
+ # Observation per measurement. DHIS2 tracker puts the whole visit in one event: an event has
17
+ # an occurredAt, an org unit, an enrollment, and a bag of dataValues. So the cardinalities do
18
+ # not line up, and the translation is a FOLD -- many Observations into one event -- keyed on
19
+ # Observation.encounter. Nothing else in this document is hard.
20
+ #
21
+ # WHAT ARRIVES. One Encounter:
22
+ #
23
+ # {"resourceType": "Encounter", "id": "137202491", "status": "finished",
24
+ # "class": {"code": "AMB"}, "subject": {"reference": "Patient/137202428"},
25
+ # "period": {"start": "2026-07-20T09:00:00Z"}}
26
+ #
27
+ # and its Observations, each a code and a value:
28
+ #
29
+ # {"resourceType": "Observation", "status": "final",
30
+ # "code": {"coding": [{"system": "http://loinc.org", "code": "29463-7",
31
+ # "display": "Body weight"}]},
32
+ # "encounter": {"reference": "Encounter/137202491"},
33
+ # "valueQuantity": {"value": 61.5, "unit": "kg"}}
34
+ #
35
+ # WHAT LEAVES. One `/api/tracker` document holding one event:
36
+ #
37
+ # {"events": [{"event": "EvAaBbCcDd1", "program": "IpHINAT79UW",
38
+ # "programStage": "A03MvHHogjR", "enrollment": "EnAaBbCcDd1",
39
+ # "trackedEntity": "coxwIkMqNBB", "orgUnit": "DiszpKrYNg8",
40
+ # "occurredAt": "2026-07-20", "status": "COMPLETED",
41
+ # "dataValues": [{"dataElement": "UXz7xuGCEhU", "value": "61.5"}]}]}
42
+ #
43
+ # WHICH CODE MAPS TO WHICH DATA ELEMENT.
44
+ #
45
+ # Encounter.period.start (or Encounter.period.end) -> occurredAt
46
+ # Encounter.status "finished" -> event status COMPLETED
47
+ # Observation.code.coding LOINC code -> dataElement, through `loinc_to_de`
48
+ # Observation.valueQuantity.value -> that data value's value
49
+ #
50
+ # WHERE A READER CHANGES IT. `loinc_to_de` is the mapping and the only place codes appear:
51
+ # each entry is one LOINC code and the DHIS2 data element uid it lands in. The stage the event
52
+ # belongs to is `program_stage`, and the program and enrollment are `program` and
53
+ # `enrollment_uid`. An observation whose LOINC code is not in the table is dropped and counted
54
+ # -- conceptmap-driven-mapping.yaml is the same mapping fetched from a server instead.
55
+ #
56
+ # WHAT THIS DOES NOT DO. It does not resolve the Encounter's Patient to a DHIS2 tracked entity.
57
+ # That is a real lookup against a real identifier crosswalk, and patient-to-tracked-entity.yaml
58
+ # is the half that does it; here the entity and its enrollment are parameters.
59
+ #
60
+ # dg run --local examples/fhir/fhir-encounter-to-event.yaml
61
+ # dg run --local examples/fhir/fhir-encounter-to-event.yaml -p encounter_id=137202491
62
+
63
+ format: dirigent/v1
64
+ kind: pipeline
65
+ code: fhir-encounter-to-event
66
+ name: Encounter and Observations to one event
67
+ description: |
68
+ Fold one FHIR `Encounter` and every `Observation` naming it into a single DHIS2 tracker
69
+ event, posted with `importMode=VALIDATE`.
70
+
71
+ The cardinalities do not line up: FHIR spreads a visit over an Encounter plus one
72
+ Observation per measurement, and a DHIS2 event holds the whole visit with a bag of data
73
+ values. The translation is a fold keyed on `Observation.encounter`.
74
+
75
+ Read against a general clinical FHIR server, because `d2w fhir serve` publishes DHIS2 as
76
+ forms and captures and serves no `Encounter` or `Observation` at all.
77
+
78
+ tags: [fhir, dhis2, cross-boundary]
79
+
80
+ requires:
81
+ blocks:
82
+ - http.request
83
+ - transform.jq
84
+
85
+ connections:
86
+ # A public FHIR R4 sandbox, open and unauthenticated, standing in for a hospital's own
87
+ # server. Repoint it and only the two searches below have to survive the move.
88
+ fhir-clinical:
89
+ kind: http
90
+ config:
91
+ base_url: https://hapi.fhir.org/baseR4
92
+ timeout: 60s
93
+ dhis2-demo:
94
+ kind: dhis2
95
+ config:
96
+ base_url: https://play.im.dhis2.org/stable-2-43-1
97
+ basic_username: admin
98
+ basic_password: district
99
+ timeout: 60s
100
+
101
+ params:
102
+ type: object
103
+ properties:
104
+ encounter_status:
105
+ type: string
106
+ description: Which encounters to consider. A visit that has not finished has not
107
+ produced a completed event.
108
+ default: finished
109
+ program:
110
+ type: string
111
+ default: IpHINAT79UW
112
+ program_stage:
113
+ type: string
114
+ description: The stage the visit lands in; one Questionnaire per stage on the facade.
115
+ default: A03MvHHogjR
116
+ org_unit:
117
+ type: string
118
+ default: DiszpKrYNg8
119
+ tracked_entity:
120
+ type: string
121
+ description: The DHIS2 person the Encounter's Patient resolves to. A real crosswalk is
122
+ patient-to-tracked-entity.yaml.
123
+ default: coxwIkMqNBB
124
+ enrollment_uid:
125
+ type: string
126
+ default: EnAaBbCcDd1
127
+ event_uid:
128
+ type: string
129
+ description: The event's own uid, minted by the client. Deriving it from the source
130
+ resource is what makes a rehearsal and an import name the same event, and a
131
+ re-forward collide (E1030) rather than duplicate.
132
+ default: EvAaBbCcDd1
133
+ loinc_to_de:
134
+ type: object
135
+ description: One LOINC code per DHIS2 data element uid. An observation coded outside
136
+ this table is dropped and counted.
137
+ default:
138
+ "29463-7": UXz7xuGCEhU
139
+ "8302-2": lw1SqmMlnfh
140
+ "8480-6": XorIxxprsOp
141
+
142
+ steps:
143
+ # `_sort=-_lastUpdated` asks the busiest end of a shared sandbox for something that exists
144
+ # today. On a real server this is the visit selector: a patient, a date range, a location.
145
+ find_encounter:
146
+ block: http.request
147
+ config:
148
+ connection: fhir-clinical
149
+ path: /Encounter
150
+ method: GET
151
+ query:
152
+ status: "${params.encounter_status}"
153
+ _sort: "-_lastUpdated"
154
+ _count: 1
155
+ headers:
156
+ Accept: application/fhir+json
157
+
158
+ # `encounter` is a reference search parameter: it takes `Encounter/<id>`, and it is how the
159
+ # measurements of one visit are gathered without walking every Observation on the server.
160
+ # A server that also supports it would answer the same question in one call with
161
+ # `_revinclude=Observation:encounter` on the search above; two calls is the portable shape.
162
+ read_observations:
163
+ block: http.request
164
+ depends_on: [find_encounter]
165
+ config:
166
+ connection: fhir-clinical
167
+ path: /Observation
168
+ method: GET
169
+ query:
170
+ encounter: "Encounter/${steps.find_encounter.output.body.entry.0.resource.id}"
171
+ _count: 50
172
+ headers:
173
+ Accept: application/fhir+json
174
+
175
+ # The fold. One event out, however many observations came in, plus the codes that mapped to
176
+ # nothing so the receipt can say so.
177
+ fold_to_event:
178
+ block: transform.jq
179
+ depends_on: [find_encounter, read_observations]
180
+ config:
181
+ input:
182
+ encounters: "${steps.find_encounter.output.body}"
183
+ observations: "${steps.read_observations.output.body}"
184
+ codes: "${params.loinc_to_de}"
185
+ program: "${params.program}"
186
+ program_stage: "${params.program_stage}"
187
+ org_unit: "${params.org_unit}"
188
+ tracked_entity: "${params.tracked_entity}"
189
+ enrollment: "${params.enrollment_uid}"
190
+ event: "${params.event_uid}"
191
+ program: |
192
+ . as {$encounters, $observations, $codes, $program, $program_stage,
193
+ $org_unit, $tracked_entity, $enrollment, $event}
194
+ | (($encounters.entry // [])[0].resource) as $e
195
+ | if $e == null then error("no encounter matched; widen the search") else . end
196
+ # A date, not an instant: DHIS2 keeps an event's occurredAt as a zone-less wall clock,
197
+ # so the instant is cut at the T rather than reformatted.
198
+ | (($e.period.start // $e.period.end // "") | split("T") | first) as $occurred
199
+ | [($observations.entry // [])[].resource
200
+ | select(.status == "final" or .status == "amended")
201
+ | {code: ([(.code.coding // [])[]
202
+ | select(.system == "http://loinc.org").code][0]),
203
+ value: (.valueQuantity.value // .valueInteger
204
+ // .valueString // .valueCodeableConcept.coding[0].code)}
205
+ | select(.code != null and .value != null)] as $seen
206
+ | {document: {events: [{
207
+ event: $event,
208
+ program: $program,
209
+ programStage: $program_stage,
210
+ enrollment: $enrollment,
211
+ trackedEntity: $tracked_entity,
212
+ orgUnit: $org_unit,
213
+ occurredAt: $occurred,
214
+ status: (if $e.status == "finished" then "COMPLETED" else "ACTIVE" end),
215
+ dataValues: [$seen[] | select($codes[.code] != null)
216
+ | {dataElement: $codes[.code], value: (.value | tostring)}]}]},
217
+ encounter: $e.id,
218
+ unmapped_codes: [$seen[] | select($codes[.code] == null).code] | unique}
219
+
220
+ rehearse_import:
221
+ block: http.request
222
+ depends_on: [fold_to_event]
223
+ config:
224
+ connection: dhis2-demo
225
+ path: /api/tracker
226
+ method: POST
227
+ query:
228
+ # /api/tracker's dry run. /api/dataValueSets spells the same intention dryRun=true.
229
+ importMode: VALIDATE
230
+ headers:
231
+ Content-Type: application/json
232
+ body: "${steps.fold_to_event.output.value.document}"
233
+ success_status: [200, 201, 409]
234
+
235
+ receipt:
236
+ block: transform.jq
237
+ depends_on: [rehearse_import]
238
+ config:
239
+ input:
240
+ report: "${steps.rehearse_import.output.body}"
241
+ folded: "${steps.fold_to_event.output.value}"
242
+ program: |
243
+ {encounter: .folded.encounter,
244
+ data_values: (.folded.document.events[0].dataValues | length),
245
+ unmapped_codes: .folded.unmapped_codes,
246
+ status: .report.status,
247
+ errors: [((.report.validationReport // .report.response.validationReport).errorReports // [])[]
248
+ | {code: .errorCode, message: .message}]}
@@ -0,0 +1,205 @@
1
+ # A FHIR MeasureReport held against DHIS2 analytics for the same period, and the gap reported.
2
+ #
3
+ # THE FLOW, END TO END. Read a MeasureReport for one measure and one period off a FHIR server,
4
+ # ask DHIS2's own analytics for the indicator that is supposed to say the same thing over the
5
+ # same period and org unit, subtract one from the other, and POST the verdict to a receiver.
6
+ #
7
+ # WHY THIS EXISTS. Two systems computing the same number from the same events is the normal
8
+ # state of a country's data, and the useful question is not "what is the number" but "do the
9
+ # two agree, and by how much". This is a reconciliation, so it writes NOTHING to DHIS2 and it
10
+ # is safe to run on a schedule.
11
+ #
12
+ # WHERE THE MEASUREREPORT COMES FROM. Not from `d2w fhir serve`: that facade publishes DHIS2 as
13
+ # forms and captures and serves no Measure and no MeasureReport. A report is produced by
14
+ # SCORING a measure -- a CQL library with the five conventional population defines (Initial
15
+ # population, Denominator, Denominator exclusion, Numerator, Numerator exclusion) -- over a
16
+ # Bundle of FHIR data, and a measure evaluation engine is what turns that scoring into an R4
17
+ # MeasureReport. The report is then a resource like any other, so this document fetches it
18
+ # from wherever it was published. `fhir_base` defaults to a public HAPI R4 sandbox.
19
+ #
20
+ # WHAT ARRIVES -- the report. A proportion measure scored over a period:
21
+ #
22
+ # {"resourceType": "MeasureReport", "status": "complete", "type": "summary",
23
+ # "measure": "http://example.org/Measure/measles-coverage",
24
+ # "period": {"start": "2026-08-01", "end": "2026-08-31"},
25
+ # "group": [{"population": [
26
+ # {"code": {"coding": [{"code": "initial-population"}]}, "count": 4},
27
+ # {"code": {"coding": [{"code": "denominator"}]}, "count": 4},
28
+ # {"code": {"coding": [{"code": "numerator"}]}, "count": 3}],
29
+ # "measureScore": {"value": 0.75}}]}
30
+ #
31
+ # WHAT ARRIVES -- the grid. DHIS2 analytics answers a grid, not objects: `headers` names the
32
+ # columns and `rows` is a list of lists, so a value is read by finding its header's index.
33
+ # That indirection is the one awkward thing about analytics and it is why the jq below looks
34
+ # up `value` rather than indexing a fixed position.
35
+ #
36
+ # {"headers": [{"name": "dx"}, {"name": "pe"}, {"name": "ou"}, {"name": "value"}],
37
+ # "rows": [["fbfJHSPpUQD", "202608", "ImspTQPwCqd", "0.71"]]}
38
+ #
39
+ # WHAT LEAVES. One discrepancy record, POSTed whether or not it found a gap, because "we
40
+ # checked and they agree" is a fact worth delivering too:
41
+ #
42
+ # {"measure": "...", "period": "202608", "org_unit": "ImspTQPwCqd",
43
+ # "fhir_score": 0.75, "dhis2_value": 0.71, "difference": 0.04, "discrepant": true}
44
+ #
45
+ # WHICH CODES LINE UP WITH WHAT. `measure_url` and `indicator` are the two halves of one claim
46
+ # -- that this FHIR measure and this DHIS2 data element or indicator count the same thing --
47
+ # and a reader changes both together or neither. `period` is a DHIS2 ISO period; the FHIR side
48
+ # carries a date range, so the two are matched on the range the period covers.
49
+ #
50
+ # WHEN IT FINDS NOTHING. A missing report and an empty grid are ordinary answers, not failures:
51
+ # the diff says `unavailable` and the receiver hears about it. A reconciliation that crashed
52
+ # when one side was quiet would be silent exactly when something is wrong.
53
+ #
54
+ # dg run --local examples/fhir/fhir-measure-report-to-analytics-check.yaml
55
+ # dg run --local examples/fhir/fhir-measure-report-to-analytics-check.yaml -p tolerance=0.1
56
+
57
+ format: dirigent/v1
58
+ kind: pipeline
59
+ code: fhir-measure-report-to-analytics-check
60
+ name: MeasureReport against DHIS2 analytics
61
+ description: |
62
+ Hold a FHIR `MeasureReport` against the DHIS2 analytics value for the same period and org
63
+ unit, and deliver the gap.
64
+
65
+ A reconciliation, so it writes nothing: safe on a schedule. The report is produced by
66
+ scoring a CQL measure over FHIR data -- `d2w fhir serve` publishes no `Measure` or
67
+ `MeasureReport` of its own -- and read here from wherever it was published.
68
+
69
+ tags: [fhir, dhis2, cross-boundary]
70
+
71
+ requires:
72
+ blocks:
73
+ - http.request
74
+ - dhis2.analytics_query
75
+ - transform.jq
76
+ - webhook.post
77
+
78
+ connections:
79
+ fhir-reports:
80
+ kind: http
81
+ config:
82
+ base_url: https://hapi.fhir.org/baseR4
83
+ timeout: 60s
84
+ dhis2-demo:
85
+ kind: dhis2
86
+ config:
87
+ base_url: https://play.im.dhis2.org/stable-2-43-1
88
+ basic_username: admin
89
+ basic_password: district
90
+ timeout: 60s
91
+ # Where a discrepancy is delivered. Postman Echo stands in for the receiver so the document
92
+ # runs; hmac_secret would let webhook.post sign the body, which is what a receiver that
93
+ # trusts nobody wants.
94
+ discrepancy-sink:
95
+ kind: http
96
+ config:
97
+ base_url: https://postman-echo.com
98
+ timeout: 30s
99
+
100
+ params:
101
+ type: object
102
+ properties:
103
+ measure_url:
104
+ type: string
105
+ description: The measure whose report is read. Half of the claim that the two systems
106
+ count the same thing.
107
+ default: http://cms.gov/fhir/hqr/Measure/synthetic-test-measure
108
+ indicator:
109
+ type: string
110
+ description: The DHIS2 data element or indicator uid that is the other half of it.
111
+ default: fbfJHSPpUQD
112
+ org_unit:
113
+ type: string
114
+ default: ImspTQPwCqd
115
+ period:
116
+ type: string
117
+ description: A DHIS2 ISO period; the FHIR side is matched on the range it covers.
118
+ default: "202608"
119
+ tolerance:
120
+ type: number
121
+ description: How far apart the two may be before the difference is called a discrepancy.
122
+ default: 0.02
123
+
124
+ steps:
125
+ # `measure` is the canonical of the measure, which is what makes a report findable without
126
+ # knowing its id. `_sort=-date` takes the most recent scoring when a measure was scored more
127
+ # than once for the same period.
128
+ read_report:
129
+ block: http.request
130
+ config:
131
+ connection: fhir-reports
132
+ path: /MeasureReport
133
+ method: GET
134
+ query:
135
+ measure: "${params.measure_url}"
136
+ _sort: "-date"
137
+ _count: 1
138
+ headers:
139
+ Accept: application/fhir+json
140
+ # A search that matched nothing is a 200 with an empty Bundle, so nothing special is
141
+ # needed here; this only says a 404 on the endpoint itself is still a failure.
142
+ success_status: [200]
143
+
144
+ # The same question, asked of DHIS2. An aggregate query takes its axes as `dimension`
145
+ # strings in DHIS2's own vocabulary: dx is the data, pe the period, ou the org unit.
146
+ read_analytics:
147
+ block: dhis2.analytics_query
148
+ config:
149
+ connection: dhis2-demo
150
+ mode: aggregate
151
+ dimension:
152
+ - "dx:${params.indicator}"
153
+ - "pe:${params.period}"
154
+ # A dimension the answer is not broken down BY, only filtered to. Putting ou here rather
155
+ # than in dimension is what makes the grid one row instead of one row per unit.
156
+ filter:
157
+ - "ou:${params.org_unit}"
158
+
159
+ # The subtraction. Both sides may be absent, and each absence is reported as itself rather
160
+ # than folded into a zero: a measure nobody scored and a coverage of 0% are different facts.
161
+ diff:
162
+ block: transform.jq
163
+ depends_on: [read_report, read_analytics]
164
+ config:
165
+ input:
166
+ reports: "${steps.read_report.output.body}"
167
+ grid: "${steps.read_analytics.output.body}"
168
+ measure: "${params.measure_url}"
169
+ period: "${params.period}"
170
+ org_unit: "${params.org_unit}"
171
+ tolerance: "${params.tolerance}"
172
+ program: |
173
+ . as {$reports, $grid, $measure, $period, $org_unit, $tolerance}
174
+ | (($reports.entry // [])[0].resource) as $report
175
+ | ($report.group[0].measureScore.value) as $score
176
+ # The grid's columns are named in headers, so the value column is found by name and
177
+ # never by position: DHIS2 adds columns, and a fixed index would start reading one.
178
+ | ([($grid.headers // []) | to_entries[] | select(.value.name == "value") | .key][0]) as $col
179
+ | (if $col == null then null
180
+ else [($grid.rows // [])[] | .[$col] | tonumber?][0] end) as $value
181
+ | {measure: $measure,
182
+ period: $period,
183
+ org_unit: $org_unit,
184
+ fhir_score: $score,
185
+ dhis2_value: $value,
186
+ populations: [(($report.group[0].population // [])[])
187
+ | {code: .code.coding[0].code, count: .count}],
188
+ difference: (if $score == null or $value == null then null
189
+ else (($score - $value) | fabs) end),
190
+ verdict: (if $score == null then "unavailable: no report was scored for this measure"
191
+ elif $value == null then "unavailable: analytics answered no value"
192
+ elif (($score - $value) | fabs) > $tolerance then "discrepant"
193
+ else "agrees" end)}
194
+
195
+ # Delivered on every run. A receiver that only ever hears about disagreements cannot tell a
196
+ # healthy silence from a pipeline that stopped running.
197
+ report_discrepancy:
198
+ block: webhook.post
199
+ depends_on: [diff]
200
+ config:
201
+ connection: discrepancy-sink
202
+ path: /post
203
+ body: "${steps.diff.output.value}"
204
+ headers:
205
+ X-Dirigent-Check: fhir-measure-vs-dhis2-analytics
@@ -0,0 +1,182 @@
1
+ # A nightly incremental pull: everything a FHIR server changed last night, type by type.
2
+ #
3
+ # THE FLOW, END TO END. A cron schedule fires at five and hands the run the interval that just
4
+ # closed. One step fans out over the resource types being synced, each item asking the server
5
+ # for what changed inside that interval, and a join reads the settled fan-out back into one
6
+ # summary that says how much of each type moved and which types could not be reached.
7
+ #
8
+ # THE WINDOW IS A PROPERTY OF THE RUN, NOT OF THIS DOCUMENT. Nothing below declares it: the
9
+ # cadence computes it, and every schedule-fired run carries `${run.window.start}` up to but not
10
+ # including `${run.window.end}`. Half-open is the whole point of an incremental sync -- `ge` on
11
+ # the start and `lt` on the end means two consecutive nights tile the timeline without
12
+ # double-reading a resource that landed exactly on the boundary, and without leaving a hole.
13
+ # An ad hoc run has to be given one, and a run carrying no window REFUSES the reference rather
14
+ # than resolving it to nothing and asking the server for its entire history.
15
+ #
16
+ # THE FACADE HAS NO _lastUpdated. `d2w fhir serve` publishes a fixed store of definitional
17
+ # resources and a spool of receipts; its searches take `_id`, `url`, `identifier` and
18
+ # `questionnaire`, and there is no modified-since anywhere. Its incremental surface is
19
+ # `GET /facade/spool`, whose receipts carry a lifecycle and a `received_at`, paged by an opaque
20
+ # token. So this document is written against a general FHIR server -- `fhir_base` defaults to a
21
+ # public HAPI R4 sandbox -- and the shape it teaches is the one a spool pull would use too.
22
+ #
23
+ # WHAT ARRIVES. One searchset Bundle per resource type, each holding what changed:
24
+ #
25
+ # {"resourceType": "Bundle", "type": "searchset", "total": 41,
26
+ # "link": [{"relation": "self", "url": "..."},
27
+ # {"relation": "next", "url": ".../Patient?_getpages=...&_getpagesoffset=50"}],
28
+ # "entry": [{"resource": {"resourceType": "Patient", "id": "137202428",
29
+ # "meta": {"lastUpdated": "2026-09-04T22:14:03Z"}}}]}
30
+ #
31
+ # WHAT LEAVES. One summary record per run:
32
+ #
33
+ # {"window": {"from": "2026-09-04T05:00:00Z", "to": "2026-09-05T05:00:00Z"},
34
+ # "types": [{"type": "Patient", "total": 41, "read": 50, "more_pages": true}],
35
+ # "changed": 41, "types_reached": 3, "types_asked": 4}
36
+ #
37
+ # WHY items: continue. Each resource type is its own item and its own attempt. Under the
38
+ # default, `fail_fast`, one type the server chokes on fails the whole night's sync. Under
39
+ # `items: continue` that type's failure is recorded against its own run item, the others
40
+ # finish, and the run reports `completed_with_errors`. The cost is worth knowing: a failed item
41
+ # is ABSENT from the step's output -- not null, absent -- so the join sees only what worked,
42
+ # which is why the summary counts the types it asked for as well as the ones it reached.
43
+ #
44
+ # WHY rule: all_done ON THE JOIN. The default `all_success` edge would skip the summary exactly
45
+ # on the night something went wrong, which is the night it is worth having.
46
+ #
47
+ # WHY THE ABSOLUTE URL. A half-open range needs `_lastUpdated` twice in one search, and the
48
+ # `query` map holds one value per key, so the search is written as a URL. That is also the
49
+ # honest shape for FHIR paging: `next` is an opaque link to be followed verbatim, never a page
50
+ # number to be constructed, and a real sync follows it until it is gone. This one reads the
51
+ # first page and reports whether there were more, which is the smallest thing that does not
52
+ # quietly lie about completeness.
53
+ #
54
+ # WHERE A READER CHANGES IT. `resource_types` is the fan-out and the whole scope of the sync;
55
+ # `page_size` is what one page holds. Add a type to the list and the grid grows on the next
56
+ # firing with nothing else edited.
57
+ #
58
+ # dg run --local examples/fhir/fhir-nightly-window-sync.yaml --window 2026-09-01..2026-09-02
59
+ # dg apply examples/fhir/fhir-nightly-window-sync.yaml
60
+ # dg backfill fhir-nightly-window-sync --schedule nightly \
61
+ # --from 2026-09-01T05:00:00Z --to 2026-09-08T05:00:00Z --dry-run
62
+
63
+ format: dirigent/v1
64
+ kind: pipeline
65
+ code: fhir-nightly-window-sync
66
+ name: Nightly windowed FHIR sync
67
+ description: |
68
+ Pull everything a FHIR server changed inside the interval a nightly schedule just closed,
69
+ one fan-out item per resource type, and join the results into one summary.
70
+
71
+ The window belongs to the run, not to this document: `${run.window.start}` and
72
+ `${run.window.end}` are half-open ISO instants, so consecutive firings tile the timeline
73
+ without overlap or gap. `items: continue` lets one unreachable type cost only itself.
74
+
75
+ tags: [fhir, dhis2, cross-boundary]
76
+
77
+ requires:
78
+ blocks:
79
+ - http.request
80
+ - transform.jq
81
+
82
+ connections:
83
+ # Carried for its timeout and TLS setting; the searches below give absolute URLs, because a
84
+ # half-open range needs the same query parameter twice and the query map holds one per key.
85
+ fhir-source:
86
+ kind: http
87
+ config:
88
+ base_url: https://hapi.fhir.org/baseR4
89
+ timeout: 60s
90
+ # Not read by this document, which only pulls. It is here because the sync exists to feed
91
+ # DHIS2, and the shelf's other documents are what turn each of these types into an import:
92
+ # Patient through patient-to-tracked-entity.yaml, Encounter through encounter-to-event.yaml,
93
+ # QuestionnaireResponse through capture-bundle-to-data-values.yaml.
94
+ dhis2-demo:
95
+ kind: dhis2
96
+ config:
97
+ base_url: https://play.im.dhis2.org/stable-2-43-1
98
+ basic_username: admin
99
+ basic_password: district
100
+ timeout: 60s
101
+
102
+ params:
103
+ type: object
104
+ properties:
105
+ fhir_base:
106
+ type: string
107
+ description: The server to sync from. Not the facade -- it answers no _lastUpdated.
108
+ default: https://hapi.fhir.org/baseR4
109
+ resource_types:
110
+ type: array
111
+ description: The scope of the sync. One fan-out item each.
112
+ default: [Patient, Encounter, Observation]
113
+ items:
114
+ type: string
115
+ page_size:
116
+ type: integer
117
+ description: How many resources one page holds. A server may answer fewer; it never
118
+ answers more.
119
+ default: 50
120
+
121
+ steps:
122
+ # for_each is expanded when the run is created, so it reads params, run and item -- never
123
+ # another step's output, because the cardinality of the grid has to be known before anything
124
+ # executes. That is why the types are a parameter and not something discovered from
125
+ # /metadata: a run's shape is fixed before its first request.
126
+ pull:
127
+ block: http.request
128
+ for_each: "${params.resource_types}"
129
+ items: continue
130
+ config:
131
+ url: "${params.fhir_base}/${item}?_lastUpdated=ge${run.window.start}&_lastUpdated=lt${run.window.end}&_count=${params.page_size}&_sort=_lastUpdated"
132
+ method: GET
133
+ headers:
134
+ Accept: application/fhir+json
135
+ # A type this server does not serve answers 404 or an OperationOutcome, and under
136
+ # items: continue that costs one item rather than the night.
137
+ success_status: [200]
138
+
139
+ # The join. A fan-out step's output is the list of its items' outputs in item order, so the
140
+ # object carrying each response is unwrapped once here.
141
+ summarise:
142
+ block: transform.jq
143
+ depends_on: [pull]
144
+ rule: all_done
145
+ config:
146
+ input:
147
+ pages: "${steps.pull.output}"
148
+ asked: "${params.resource_types}"
149
+ from: "${run.window.start}"
150
+ to: "${run.window.end}"
151
+ program: |
152
+ . as {$pages, $asked, $from, $to}
153
+ | [$pages[] | .body
154
+ | {type: ((.entry // [])[0].resource.resourceType
155
+ // (.link[0].url | split("/") | last | split("?") | first)),
156
+ # `total` is the server's count of the whole searchset, a different number from
157
+ # how many rode in this page: a sync reporting the page size would say the same
158
+ # thing on a quiet night and a busy one. A server is allowed to omit it on a
159
+ # large searchset, and HAPI does, so it is nullable and the count below says so.
160
+ total: .total,
161
+ read: ((.entry // []) | length),
162
+ more_pages: ([(.link // [])[] | select(.relation == "next")] | length > 0)}] as $seen
163
+ | {window: {from: $from, to: $to},
164
+ types: $seen,
165
+ changed: ([$seen[] | .total // .read] | add // 0),
166
+ counted_from_pages: [$seen[] | select(.total == null) | .type],
167
+ types_reached: ($seen | length),
168
+ types_asked: ($asked | length),
169
+ # The gap between the two is what items: continue bought, and saying it out loud is
170
+ # what keeps a partial sync from reading like a complete one.
171
+ types_missed: ($asked - [$seen[] | .type])}
172
+
173
+ triggers:
174
+ schedules:
175
+ - code: nightly
176
+ name: Nightly, Oslo time
177
+ description: Reads the day that closed at five this morning.
178
+ cron: "0 5 * * *"
179
+ # The zone belongs to the schedule, so the window it derives is a day of Oslo's clock
180
+ # rather than a fixed 24 hours: the spring-forward night is 23 hours long and the
181
+ # fall-back night is 25, and a pair of instants says so honestly.
182
+ timezone: Europe/Oslo