dirigent-integration 0.26.0__tar.gz → 0.27.0__tar.gz

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 (34) hide show
  1. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/PKG-INFO +5 -5
  2. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/README.md +3 -3
  3. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/pyproject.toml +4 -2
  4. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/pyproject.toml.orig +4 -2
  5. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/README.md +1 -1
  6. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-questionnaire-response-to-data-values.yaml +1 -1
  7. dirigent_integration-0.27.0/src/dirigent_integration/shelves/fhir/fhir-subscription-to-dhis2.yaml +249 -0
  8. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/README.md +1 -1
  9. dirigent_integration-0.26.0/src/dirigent_integration/shelves/inbound/webhook-payload-to-tracker-event.yaml → dirigent_integration-0.27.0/src/dirigent_integration/shelves/inbound/endpoint-payload-to-tracker-event.yaml +72 -56
  10. dirigent_integration-0.26.0/src/dirigent_integration/shelves/fhir/fhir-subscription-webhook-to-dhis2.yaml +0 -218
  11. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/LICENSE +0 -0
  12. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/__init__.py +0 -0
  13. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/py.typed +0 -0
  14. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-analytics-to-csv-report.yaml +0 -0
  15. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-export-to-s3.yaml +0 -0
  16. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-metadata-snapshot.yaml +0 -0
  17. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-tracker-weekly-window.yaml +0 -0
  18. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-values-per-org-unit-to-parquet.yaml +0 -0
  19. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-values-to-parquet.yaml +0 -0
  20. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/dhis2-to-fhir-observations.yaml +0 -0
  21. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-capture-bundle-to-data-values.yaml +0 -0
  22. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-conceptmap-driven-mapping.yaml +0 -0
  23. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-encounter-to-event.yaml +0 -0
  24. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-measure-report-to-analytics-check.yaml +0 -0
  25. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-nightly-window-sync.yaml +0 -0
  26. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-patient-to-tracked-entity.yaml +0 -0
  27. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/csv-drop-to-data-values.yaml +0 -0
  28. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/fan-out-per-facility-import.yaml +0 -0
  29. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/fhir-observations-to-data-values.yaml +0 -0
  30. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/http-json-to-data-values.yaml +0 -0
  31. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/outside-to-dhis2-with-checks.yaml +0 -0
  32. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/parquet-lakehouse-to-dhis2.yaml +0 -0
  33. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/weekly-window-pull-and-import.yaml +0 -0
  34. {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/parquet-to-dhis2-import.yaml +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-integration
3
- Version: 0.26.0
3
+ Version: 0.27.0
4
4
  Summary: The control center for the dirigent plugin ecosystem: assemble every pack, run everyone's tests and examples together.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
7
- Requires-Dist: dirigent-plugin==0.26.0
7
+ Requires-Dist: dirigent-plugin==0.27.0
8
8
  Requires-Python: >=3.13
9
9
  Description-Content-Type: text/markdown
10
10
 
@@ -160,8 +160,8 @@ vocabulary the shelf is built on -- subject, capture, ConceptMap.
160
160
  `dhis2.analytics_query` for the same period, and the gap delivered by `webhook.post`.
161
161
  - **`examples/fhir/dhis2-to-fhir-observations.yaml`** -- the outbound leg: a data value set
162
162
  published as a transaction `Bundle` of conditionally-updated `Observation`s.
163
- - **`examples/fhir/fhir-subscription-webhook-to-dhis2.yaml`** -- the subscription contract,
164
- landing on a webhook whose payload mapping no caller can reach past.
163
+ - **`examples/fhir/fhir-subscription-to-dhis2.yaml`** -- the subscription contract, landing on
164
+ an endpoint source whose first step picks the only fields any later step sees.
165
165
  - **`examples/fhir/fhir-nightly-window-sync.yaml`** -- a half-open `_lastUpdated` window, a
166
166
  fan-out per resource type under `items: continue`, and a join that says what it missed.
167
167
 
@@ -169,7 +169,7 @@ vocabulary the shelf is built on -- subject, capture, ConceptMap.
169
169
 
170
170
  `examples/inbound/` is a shelf of eight pipelines going one direction -- an outside system, a
171
171
  transform, DHIS2 -- teaching the shapes an inbound integration actually takes: a public JSON
172
- API, a csv dropped in a bucket, a pushed webhook, a parquet table from a lakehouse, a FHIR
172
+ API, a csv dropped in a bucket, an encounter pushed to an endpoint, a parquet table from a lakehouse, a FHIR
173
173
  Observation feed, a windowed weekly pull, a fan-out across facilities, and the defensive
174
174
  version with every guard on. Each names every block it needs, carries its DHIS2 connection
175
175
  pointed at the public play server, and imports as a dry run by default, so
@@ -150,8 +150,8 @@ vocabulary the shelf is built on -- subject, capture, ConceptMap.
150
150
  `dhis2.analytics_query` for the same period, and the gap delivered by `webhook.post`.
151
151
  - **`examples/fhir/dhis2-to-fhir-observations.yaml`** -- the outbound leg: a data value set
152
152
  published as a transaction `Bundle` of conditionally-updated `Observation`s.
153
- - **`examples/fhir/fhir-subscription-webhook-to-dhis2.yaml`** -- the subscription contract,
154
- landing on a webhook whose payload mapping no caller can reach past.
153
+ - **`examples/fhir/fhir-subscription-to-dhis2.yaml`** -- the subscription contract, landing on
154
+ an endpoint source whose first step picks the only fields any later step sees.
155
155
  - **`examples/fhir/fhir-nightly-window-sync.yaml`** -- a half-open `_lastUpdated` window, a
156
156
  fan-out per resource type under `items: continue`, and a join that says what it missed.
157
157
 
@@ -159,7 +159,7 @@ vocabulary the shelf is built on -- subject, capture, ConceptMap.
159
159
 
160
160
  `examples/inbound/` is a shelf of eight pipelines going one direction -- an outside system, a
161
161
  transform, DHIS2 -- teaching the shapes an inbound integration actually takes: a public JSON
162
- API, a csv dropped in a bucket, a pushed webhook, a parquet table from a lakehouse, a FHIR
162
+ API, a csv dropped in a bucket, an encounter pushed to an endpoint, a parquet table from a lakehouse, a FHIR
163
163
  Observation feed, a windowed weekly pull, a fan-out across facilities, and the defensive
164
164
  version with every guard on. Each names every block it needs, carries its DHIS2 connection
165
165
  pointed at the public play server, and imports as a dry run by default, so
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "dirigent-integration"
3
- version = "0.26.0"
3
+ version = "0.27.0"
4
4
  description = "The control center for the dirigent plugin ecosystem: assemble every pack, run everyone's tests and examples together."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
7
7
  license = "LicenseRef-Proprietary"
8
8
  license-files = ["LICENSE"]
9
- dependencies = ["dirigent-plugin==0.26.0"]
9
+ dependencies = ["dirigent-plugin==0.27.0"]
10
10
 
11
11
  [project.entry-points."dirigent.plugins.v1"]
12
12
  integration = "dirigent_integration:plugin"
@@ -34,10 +34,12 @@ assembly = [
34
34
  ]
35
35
  dev = [
36
36
  "dirigent-testing",
37
+ "mypy>=2",
37
38
  "pytest>=8",
38
39
  "pytest-asyncio>=0.24",
39
40
  "pyyaml>=6",
40
41
  "ruff>=0.16",
42
+ "types-pyyaml>=6",
41
43
  ]
42
44
 
43
45
  [tool.uv]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-integration"
3
- version = "0.26.0"
3
+ version = "0.27.0"
4
4
  description = "The control center for the dirigent plugin ecosystem: assemble every pack, run everyone's tests and examples together."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -10,7 +10,7 @@ license-files = ["LICENSE"]
10
10
  # A published install carries the cross-boundary shelves and nothing else: each document
11
11
  # names the packs it needs under requires, and the Examples screen marks what is missing.
12
12
  dependencies = [
13
- "dirigent-plugin==0.26.0",
13
+ "dirigent-plugin==0.27.0",
14
14
  ]
15
15
 
16
16
  # dirigent-testing is dev-only: the packs' suites import it, and nothing at runtime does,
@@ -41,10 +41,12 @@ assembly = [
41
41
  ]
42
42
  dev = [
43
43
  "dirigent-testing",
44
+ "mypy>=2",
44
45
  "pytest>=8",
45
46
  "pytest-asyncio>=0.24",
46
47
  "pyyaml>=6",
47
48
  "ruff>=0.16",
49
+ "types-pyyaml>=6",
48
50
  ]
49
51
 
50
52
  # All component packages ride their tracked branch, unpinned: the integration always assembles
@@ -60,7 +60,7 @@ option set is in the regenerated map, and the pipeline that reads it needs no ed
60
60
  | [fhir-conceptmap-driven-mapping.yaml](fhir-conceptmap-driven-mapping.yaml) | The mapping fetched rather than written: a `ConceptMap` flattened into a lookup at run time, applied to coded answers, with `equivalence` honoured so an inexact match is reported instead of imported. |
61
61
  | [fhir-measure-report-to-analytics-check.yaml](fhir-measure-report-to-analytics-check.yaml) | A reconciliation that writes nothing: a `MeasureReport` held against `dhis2.analytics_query` for the same period and org unit, and the gap delivered by `webhook.post` whether or not there is one. |
62
62
  | [dhis2-to-fhir-observations.yaml](dhis2-to-fhir-observations.yaml) | The outbound direction: a data value set published as a transaction `Bundle` of `Observation`s, each a conditional update keyed on the aggregate cell's natural key so republishing updates rather than duplicates. |
63
- | [fhir-subscription-webhook-to-dhis2.yaml](fhir-subscription-webhook-to-dhis2.yaml) | The subscription contract -- `criteria`, `channel.type`, `channel.endpoint`, and what `channel.payload` decides -- landing on a webhook whose payload mapping is strict enough that no caller can reach a parameter it does not name. |
63
+ | [fhir-subscription-to-dhis2.yaml](fhir-subscription-to-dhis2.yaml) | The subscription contract -- `criteria`, `channel.type`, `channel.endpoint`, and what `channel.payload` decides -- landing on an endpoint source the FHIR server calls with a fixed `Authorization` header, whose first step unwraps the notification so strictly that nothing it does not name reaches a later step. |
64
64
  | [fhir-nightly-window-sync.yaml](fhir-nightly-window-sync.yaml) | The interval a firing covers rather than the moment it fired at: a half-open `_lastUpdated` range, a fan-out per resource type under `items: continue`, and a join under `rule: all_done` that says what it did not reach. |
65
65
 
66
66
  ## Running them
@@ -93,7 +93,7 @@ params:
93
93
 
94
94
  steps:
95
95
  # The capture, written inline so the document runs with nothing set up. In service it is the
96
- # body of a webhook delivery (fhir-subscription-webhook-to-dhis2.yaml) or one entry of a
96
+ # body of an endpoint delivery (fhir-subscription-to-dhis2.yaml) or one entry of a
97
97
  # searchset Bundle (capture-bundle-to-data-values.yaml); either way this step's output is
98
98
  # exactly one QuestionnaireResponse, and nothing downstream knows where it came from.
99
99
  receive:
@@ -0,0 +1,249 @@
1
+ # A FHIR Subscription notification delivered to an endpoint source, translated, and imported.
2
+ #
3
+ # THE FLOW, END TO END. A FHIR server holds a Subscription that watches for new captures. When
4
+ # one arrives it POSTs a notification to this pipeline's endpoint; the request becomes the
5
+ # source's output, `unwrap` picks the notified resource out of its body, the document translates
6
+ # it into a data value set, and the import runs as a dry run.
7
+ #
8
+ # THE SUBSCRIPTION CONTRACT, WHICH IS THE POINT OF THIS FILE. A FHIR Subscription is a standing
9
+ # request: "when a resource matching this search appears, tell me here". Three fields carry it.
10
+ #
11
+ # criteria a search string, exactly as it would be typed against the server, e.g.
12
+ # "QuestionnaireResponse?questionnaire=http://example.org/fhir/Questionnaire/lyLU2wR22tC"
13
+ # channel.type how to be told. `rest-hook` is an HTTP POST, which is what a dirigent
14
+ # endpoint source takes; the others are websocket, email, and message.
15
+ # channel.endpoint where to POST it -- this source's route on the instance,
16
+ # `$DG_URL/endpoints/fhir/notifications`.
17
+ #
18
+ # HOW THE SERVER PRESENTS THE TOKEN. A rest-hook subscription can carry fixed headers that the
19
+ # server sends with every notification: `channel.header` in R4, a `parameter` named for the
20
+ # header in R5. Set one to `Authorization: Bearer <token>`, with the token the apply printed,
21
+ # and every delivery authenticates as this source. A server that cannot send headers cannot
22
+ # reach it, which is the point: the endpoint takes no anonymous POST.
23
+ #
24
+ # What arrives in that POST depends on `channel.payload`, and the three choices are a real
25
+ # decision rather than a formality:
26
+ #
27
+ # absent a PING. An empty body: "something matched, come and look". The pipeline
28
+ # then has to search the server itself, which is more work and is also the
29
+ # only choice that never puts patient data in a request body.
30
+ # id-only the notification names the resource; the pipeline reads it back.
31
+ # full-resource the resource rides in the notification. One request, no read-back, and
32
+ # the delivery is now carrying clinical data -- so it is signed, and the
33
+ # endpoint is not on the public internet.
34
+ #
35
+ # This document takes full-resource, because it is the shape that shows the whole translation
36
+ # in one file. In R4 that body is the bare resource; R5 and the R4 backport wrap it in a
37
+ # history Bundle whose first entry is a SubscriptionStatus and whose second is the resource,
38
+ # which is the shape mapped below and the one most servers now send.
39
+ #
40
+ # `d2w fhir serve` HAS NO SUBSCRIPTION. Its capture surface is `POST /QuestionnaireResponse`
41
+ # and its notification surface is the receipt spool -- `GET /facade/spool`, whose lifecycle
42
+ # moves received -> forwarded or rejected. A pipeline pulls that spool on a schedule instead
43
+ # (fhir-nightly-window-sync.yaml is that shape). This document is for the FHIR server that DOES
44
+ # support Subscription, which is most of them, and it is written so the same translation serves
45
+ # either way.
46
+ #
47
+ # WHAT ARRIVES. The notification body:
48
+ #
49
+ # {"resourceType": "Bundle", "type": "history",
50
+ # "entry": [
51
+ # {"resource": {"resourceType": "SubscriptionStatus", "type": "event-notification",
52
+ # "eventsSinceSubscriptionStart": "17"}},
53
+ # {"resource": {"resourceType": "QuestionnaireResponse", "status": "completed",
54
+ # "questionnaire": ".../Questionnaire/lyLU2wR22tC",
55
+ # "subject": {"reference": "Location/dczh6Jfd4no"},
56
+ # "extension": [{"url": ".../d2-period",
57
+ # "extension": [{"url": "iso", "valueString": "202608"}]}],
58
+ # "item": [...]}}]}
59
+ #
60
+ # WHAT LEAVES. The `/api/dataValueSets` envelope, same as every inbound document here:
61
+ #
62
+ # {"dataSet": "lyLU2wR22tC", "period": "202608", "orgUnit": "dczh6Jfd4no",
63
+ # "dataValues": [{"dataElement": "GMd99K8gVut",
64
+ # "categoryOptionCombo": "qNCMOhkoQju", "value": "426.3"}]}
65
+ #
66
+ # WHERE A READER CHANGES IT. The `unwrap` step, and nothing else. It is STRICT: it names two
67
+ # paths into the body, a body that does not have them fails the run before anything is
68
+ # translated, and nothing else the caller POSTs reaches a later step, because no later step
69
+ # reads the body. A server sending the bare R4 resource rather than a Bundle changes `capture`
70
+ # to the body itself, and the counter, which a bare resource does not carry, to null.
71
+ #
72
+ # WHAT LIVES ON THE INSTANCE AND NOT IN GIT. The source's token, whether it is paused, and
73
+ # every input it has taken. The apply shows the token exactly once, and
74
+ # `dg source rotate-token` mints another, which the Subscription's header then has to carry.
75
+ #
76
+ # RUN IT BY HAND, here, with no server. No --input takes the source's sample, a whole
77
+ # notification Bundle:
78
+ #
79
+ # dg run --local examples/fhir/fhir-subscription-to-dhis2.yaml
80
+ #
81
+ # ON A SERVER, apply it, keep the token the apply printed, and deliver a notification the way
82
+ # the FHIR server would:
83
+ #
84
+ # dg apply examples/fhir/fhir-subscription-to-dhis2.yaml
85
+ # dg source call fhir-subscription-to-dhis2 notified --source-token "$TOKEN" --body @notification.json
86
+ # curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/fhir+json' \
87
+ # --data @notification.json "$DG_URL/endpoints/fhir/notifications"
88
+ # dg source inputs fhir-subscription-to-dhis2 notified
89
+ #
90
+ # EXPECT THIS RUN TO SUCCEED with network access to the play server, the import a dry run.
91
+
92
+ format: dirigent/v1
93
+ kind: pipeline
94
+ code: fhir-subscription-to-dhis2
95
+ name: A FHIR Subscription notification imported
96
+ description: |
97
+ Take a FHIR `Subscription` rest-hook notification on an endpoint source, translate the
98
+ notified `QuestionnaireResponse` into a DHIS2 data value set, and rehearse the import.
99
+
100
+ The subscription contract is three fields: `criteria` (a search string), `channel.type`
101
+ (`rest-hook` is an HTTP POST), and `channel.endpoint` (the source's route). The server
102
+ presents the source's token as a fixed `Authorization` header on the subscription.
103
+ `channel.payload` decides whether the delivery is a ping, an id, or the resource itself --
104
+ this takes the resource, wrapped in the history `Bundle` R5 and the R4 backport send.
105
+
106
+ tags: [fhir, dhis2, sources, transform, jq, cross-boundary]
107
+
108
+ requires:
109
+ blocks:
110
+ - transform.jq
111
+ - dhis2.data_value_set_import
112
+
113
+ connections:
114
+ dhis2-demo:
115
+ kind: dhis2
116
+ config:
117
+ base_url: https://play.dhis2.org/2.43
118
+ basic_username: admin
119
+ basic_password: district
120
+ timeout: 60s
121
+
122
+ steps:
123
+ notified:
124
+ source:
125
+ kind: endpoint
126
+ path: /fhir/notifications
127
+ methods: [POST]
128
+ accepts: [application/fhir+json, application/json]
129
+ max_body: 1MB
130
+ # What a delivery carries: the history Bundle, a SubscriptionStatus and then the resource.
131
+ sample:
132
+ content_type: application/fhir+json
133
+ body:
134
+ resourceType: Bundle
135
+ type: history
136
+ entry:
137
+ - resource:
138
+ resourceType: SubscriptionStatus
139
+ type: event-notification
140
+ eventsSinceSubscriptionStart: "17"
141
+ - resource:
142
+ resourceType: QuestionnaireResponse
143
+ status: completed
144
+ authored: "2026-08-31T09:00:00Z"
145
+ questionnaire: http://example.org/fhir/Questionnaire/lyLU2wR22tC
146
+ subject:
147
+ reference: Location/dczh6Jfd4no
148
+ extension:
149
+ - url: http://example.org/fhir/StructureDefinition/d2-period
150
+ extension:
151
+ - url: iso
152
+ valueString: "202608"
153
+ - url: type
154
+ valueCode: Monthly
155
+ - url: http://example.org/fhir/StructureDefinition/d2-attribute-option-combo
156
+ valueCoding:
157
+ code: N7QFN41eTN8
158
+ item:
159
+ - linkId: GMd99K8gVut
160
+ item:
161
+ - linkId: GMd99K8gVut.qNCMOhkoQju
162
+ answer:
163
+ - valueDecimal: 426.3
164
+ - linkId: GMd99K8gVut.LbeIlyHEhKr
165
+ answer:
166
+ - valueDecimal: 628.2
167
+ - linkId: wfKKFhBn0Q0
168
+ answer:
169
+ - valueDecimal: 142.1
170
+
171
+ # The whole declaration of what a delivery may carry. `capture` is the history Bundle's second
172
+ # entry -- the first is the SubscriptionStatus -- and the counter comes off that status. A gap
173
+ # between two deliveries' counters means a notification was lost, which is the only way this
174
+ # channel says so.
175
+ unwrap:
176
+ block: transform.jq
177
+ depends_on: [notified]
178
+ config:
179
+ input: "${steps.notified.output.body}"
180
+ program: |
181
+ if .resourceType != "Bundle" then error("the notification is not a Bundle")
182
+ elif (.entry[1].resource.resourceType // null) != "QuestionnaireResponse"
183
+ then error("the notification's second entry is not a QuestionnaireResponse")
184
+ elif (.entry[0].resource.eventsSinceSubscriptionStart | type) != "string"
185
+ then error("the notification carries no SubscriptionStatus counter")
186
+ else {capture: .entry[1].resource,
187
+ events_since_start: .entry[0].resource.eventsSinceSubscriptionStart}
188
+ end
189
+
190
+ # Only a completed response is translated. An in-progress one is a form still being filled,
191
+ # and a notification is allowed to fire on it -- `criteria` matches a resource, not a state.
192
+ translate:
193
+ block: transform.jq
194
+ depends_on: [unwrap]
195
+ config:
196
+ input: "${steps.unwrap.output.value.capture}"
197
+ program: |
198
+ if .status != "completed" then
199
+ error("the notified response is \(.status), not completed; nothing to import")
200
+ else . end
201
+ | . as $r
202
+ | {dataSet: ($r.questionnaire | split("/") | last),
203
+ period: ([$r.extension[] | select(.url | endswith("/d2-period")).extension[]
204
+ | select(.url == "iso").valueString][0]),
205
+ orgUnit: ($r.subject.reference | ltrimstr("Location/")),
206
+ attributeOptionCombo: (
207
+ [$r.extension[] | select(.url | endswith("/d2-attribute-option-combo"))][0]
208
+ | .valueCoding.code),
209
+ dataValues: [
210
+ $r.item[] | recurse(.item[]?)
211
+ | select((.answer // []) | length > 0)
212
+ | (.linkId | split(".")) as $key
213
+ | (.answer[0] | (.valueDecimal // .valueInteger // .valueString // .valueBoolean)) as $v
214
+ | {dataElement: $key[0], value: ($v | tostring)}
215
+ + (if ($key | length) > 1 then {categoryOptionCombo: $key[1]} else {} end)]}
216
+ | del(.attributeOptionCombo | select(. == null))
217
+
218
+ # A notification arrives once and is not replayed, so the import's retry budget is what
219
+ # stands between a momentary DHIS2 and a lost capture. dry_run makes this a rehearsal;
220
+ # dropping it is the whole difference between this document and a live forwarder.
221
+ rehearse:
222
+ block: dhis2.data_value_set_import
223
+ depends_on: [translate]
224
+ retry:
225
+ # The budget counts the first try, so this is one attempt and two retries.
226
+ max_attempts: 3
227
+ backoff: 5s
228
+ config:
229
+ connection: dhis2-demo
230
+ data_values: "${steps.translate.output.value}"
231
+ dry_run: true
232
+ atomic_mode: ALL
233
+
234
+ receipt:
235
+ block: transform.jq
236
+ depends_on: [rehearse]
237
+ config:
238
+ input:
239
+ summary: "${steps.rehearse.output}"
240
+ events_since_start: "${steps.unwrap.output.value.events_since_start}"
241
+ document: "${steps.translate.output.value}"
242
+ program: |
243
+ {notification: .events_since_start,
244
+ data_set: .document.dataSet,
245
+ period: .document.period,
246
+ org_unit: .document.orgUnit,
247
+ status: .summary.status,
248
+ would_import: .summary.imported,
249
+ conflicts: (.summary.conflicts | length)}
@@ -24,7 +24,7 @@ setting (`storage_connections: {s3: <code>}`); the rest need only a network.
24
24
  | --- | --- |
25
25
  | [`http-json-to-data-values.yaml`](http-json-to-data-values.yaml) | The shortest inbound path: pull a public JSON API, reshape it, gate it on DHIS2's own id and period formats, import it as a dry run. |
26
26
  | [`csv-drop-to-data-values.yaml`](csv-drop-to-data-values.yaml) | The file drop: wait for a csv to land in a bucket, decode it, translate the sender's codes to uids through a lookup, drop the rows that carry no measurement. |
27
- | [`webhook-payload-to-tracker-event.yaml`](webhook-payload-to-tracker-event.yaml) | Push instead of poll: a webhook maps one posted encounter onto parameters, and that mapping is the whole security boundary. |
27
+ | [`endpoint-payload-to-tracker-event.yaml`](endpoint-payload-to-tracker-event.yaml) | Push instead of poll: an endpoint source takes one posted encounter, and the jq step that picks its named fields is the whole security boundary. |
28
28
  | [`parquet-lakehouse-to-dhis2.yaml`](parquet-lakehouse-to-dhis2.yaml) | Grain: a parquet table of encounter-level rows reduced to the figures DHIS2 stores, then a completeness sensor that observes rather than registers. |
29
29
  | [`fhir-observations-to-data-values.yaml`](fhir-observations-to-data-values.yaml) | Two standards meeting: LOINC codes mapped onto data elements, instants truncated to periods, and the org unit the payload cannot supply. |
30
30
  | [`weekly-window-pull-and-import.yaml`](weekly-window-pull-and-import.yaml) | The window: a schedule whose runs pull the week that closed, `${run.window.start}` to `${run.window.end}`, so a late run and a backfill read the same week. |
@@ -1,9 +1,8 @@
1
1
  # An event posted by an outside system, landing in DHIS2 the moment it is captured.
2
2
  #
3
3
  # The other examples on this shelf poll or wait. This one is pushed: a mobile capture app,
4
- # an EMR, a lab middleware POSTs one encounter to this pipeline's webhook and the run starts
5
- # with that encounter already in its parameters. Nothing is scheduled and nothing is
6
- # scanned.
4
+ # an EMR, a lab middleware POSTs one encounter to this pipeline's endpoint and the run starts
5
+ # with that request as its source's output. Nothing is scheduled and nothing is scanned.
7
6
  #
8
7
  # WHERE THIS LANDS, AND WHY IT IS NOT A TRACKER EVENT. An event-shaped capture is what
9
8
  # arrives, but `dhis2.tracker` in this pack READS `/api/tracker` -- tracked entities,
@@ -11,51 +10,61 @@
11
10
  # today. The encounter is therefore summed into the aggregate figures it contributes to and
12
11
  # imported as a data value set, which is what an aggregate DHIS2 wants from an event stream
13
12
  # anyway. When a tracker write block lands, only the reshape and the import change; the
14
- # intake, the mapping table and the guard do not.
13
+ # intake, the pick, the mapping table and the guard do not.
15
14
  #
16
15
  # The hops, and the shape at each one:
17
16
  #
18
17
  # 1. the POST -> {encounter: {facility, occurred_at, items: [{code, quantity}]}}
19
- # 2. the mapping -> three params, each one path into that body and nothing else
18
+ # 2. pick -> {facility, occurred_at, items}, three paths into that body and
19
+ # nothing else
20
20
  # 3. reshape -> {dataValues: [{dataElement, period, orgUnit, value}]}, one value per
21
21
  # distinct item code, quantities summed
22
22
  # 4. gate -> the same, refused unless the ids and the period are DHIS2's own
23
23
  # 5. import -> the instance's summary
24
24
  #
25
- # THE MAPPING IS THE WHOLE SECURITY BOUNDARY. A caller reaches exactly the three parameters
26
- # named under params_from_payload, and each mapped value is validated against the params
27
- # schema like any other run's. A payload carrying a connection code, a dry_run: false, or a
28
- # different org unit reaches none of them, because none of them is mapped.
25
+ # THE PICK IS THE WHOLE SECURITY BOUNDARY. The whole body is the source's output, but only
26
+ # `pick` reads it, and it hands on exactly three named fields, each checked for its type. A
27
+ # payload carrying a connection code, a dry_run: false, or a field nobody asked for reaches
28
+ # no later step, because no path names it; `dry_run` is a parameter, the pipeline's own to
29
+ # decide, and no call can choose it.
29
30
  #
30
31
  # The period is derived rather than accepted. A capture knows when it happened; it does not
31
32
  # get to say which DHIS2 month it belongs to, because that is the receiving instance's
32
33
  # vocabulary and a caller that gets it wrong would silently write into the wrong month.
33
34
  #
34
- # Locally, the payload is supplied as parameters, which is exactly what a delivery does:
35
+ # RUN IT BY HAND, here, with no server. No --input takes the source's sample:
35
36
  #
36
- # dg run --local examples/inbound/webhook-payload-to-tracker-event.yaml \
37
- # -p facility=DiszpKrYNg8 -p occurred_at=2026-06-14T09:20:00Z
37
+ # dg run --local examples/inbound/endpoint-payload-to-tracker-event.yaml
38
+ # dg run --local examples/inbound/endpoint-payload-to-tracker-event.yaml --input '{body: {
39
+ # encounter: {facility: DiszpKrYNg8, occurred_at: "2026-06-14T09:20:00Z",
40
+ # items: [{code: NVP_WITHIN_72H, quantity: 3}]}}}'
38
41
  #
39
- # Against an instance, where the endpoint exists:
42
+ # ON A SERVER, apply it, keep the token the apply printed, and post an encounter:
40
43
  #
41
- # dg apply examples/inbound/webhook-payload-to-tracker-event.yaml
42
- # dg webhook rotate-token webhook-payload-to-tracker-event capture-posted
43
- # curl -X POST "$DG_URL/hooks/$TOKEN" -d '{"encounter": {
44
- # "facility": "DiszpKrYNg8", "occurred_at": "2026-06-14T09:20:00Z",
44
+ # dg apply examples/inbound/endpoint-payload-to-tracker-event.yaml
45
+ # dg source call endpoint-payload-to-tracker-event posted --source-token "$TOKEN" --body '{
46
+ # "encounter": {"facility": "DiszpKrYNg8", "occurred_at": "2026-06-14T09:20:00Z",
45
47
  # "items": [{"code": "EXPOSED_REGISTERED", "quantity": 2}]}}'
48
+ # curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
49
+ # --data @encounter.json http://localhost:3333/endpoints/captures
50
+ # dg source inputs endpoint-payload-to-tracker-event posted
51
+ #
52
+ # The token lives on the instance and is shown once; `dg source rotate-token` mints another.
53
+ #
54
+ # EXPECT THIS RUN TO SUCCEED with network access to the play server, the import a dry run.
46
55
 
47
56
  format: dirigent/v1
48
57
  kind: pipeline
49
- code: webhook-payload-to-tracker-event
58
+ code: endpoint-payload-to-tracker-event
50
59
  name: A pushed encounter into DHIS2
51
60
  description: |
52
- An outside system POSTs one captured encounter; the run reshapes it into a DHIS2 data
53
- value set and imports it.
61
+ An outside system POSTs one captured encounter to an endpoint source; a jq step picks the
62
+ named fields, and the run reshapes them into a DHIS2 data value set and imports it.
54
63
 
55
64
  The pack's `dhis2.tracker` block reads `/api/tracker` and does not write it, so an
56
65
  event-shaped capture lands as the aggregate figures it contributes to.
57
66
 
58
- tags: [inbound, dhis2, webhook, transform, validate, cross-boundary]
67
+ tags: [inbound, dhis2, sources, transform, validate, cross-boundary]
59
68
 
60
69
  requires:
61
70
  blocks:
@@ -75,28 +84,6 @@ connections:
75
84
  params:
76
85
  type: object
77
86
  properties:
78
- facility:
79
- type: string
80
- description: The uid of the facility the encounter was captured at.
81
- default: DiszpKrYNg8
82
- occurred_at:
83
- type: string
84
- format: date-time
85
- description: When the encounter happened, which is what the DHIS2 period is derived from.
86
- default: "2026-06-14T09:20:00Z"
87
- items:
88
- type: array
89
- description: What was given or done, in the sending system's own item codes.
90
- default:
91
- - { code: EXPOSED_REGISTERED, quantity: 2 }
92
- - { code: NVP_WITHIN_72H, quantity: 1 }
93
- - { code: EXPOSED_REGISTERED, quantity: 1 }
94
- items:
95
- type: object
96
- required: [code, quantity]
97
- properties:
98
- code: { type: string }
99
- quantity: { type: integer }
100
87
  dry_run:
101
88
  type: boolean
102
89
  description: Whether the instance validates the import and writes nothing.
@@ -120,16 +107,55 @@ schemas:
120
107
  value: { type: string }
121
108
 
122
109
  steps:
110
+ posted:
111
+ source:
112
+ kind: endpoint
113
+ path: /captures
114
+ methods: [POST]
115
+ accepts: [application/json]
116
+ max_body: 64KB
117
+ sample:
118
+ body:
119
+ encounter:
120
+ facility: DiszpKrYNg8
121
+ occurred_at: "2026-06-14T09:20:00Z"
122
+ items:
123
+ - { code: EXPOSED_REGISTERED, quantity: 2 }
124
+ - { code: NVP_WITHIN_72H, quantity: 1 }
125
+ - { code: EXPOSED_REGISTERED, quantity: 1 }
126
+
127
+ # Three paths into the body, each one typed; anything else the caller sent stops here.
128
+ pick:
129
+ block: transform.jq
130
+ depends_on: [posted]
131
+ config:
132
+ input: "${steps.posted.output.body}"
133
+ program: |
134
+ .encounter as $e
135
+ | if ($e | type) != "object" then error("the body carries no encounter object")
136
+ elif ($e.facility | type) != "string" then error("encounter.facility must be a string")
137
+ elif ($e.occurred_at | type) != "string" then error("encounter.occurred_at must be a string")
138
+ elif ($e.items | type) != "array"
139
+ or ([$e.items[] | select((.code | type) != "string"
140
+ or (.quantity | type) != "number"
141
+ or .quantity != (.quantity | floor))] | length) > 0
142
+ then error("encounter.items must be a list of {code: string, quantity: integer}")
143
+ else {facility: $e.facility,
144
+ occurred_at: $e.occurred_at,
145
+ items: [$e.items[] | {code, quantity}]}
146
+ end
147
+
123
148
  # One encounter can carry the same item twice -- two children registered in one visit --
124
149
  # so the items are grouped and summed before they become data values. Importing them as
125
150
  # two values would have the second silently replace the first rather than add to it.
126
151
  reshape:
127
152
  block: transform.jq
153
+ depends_on: [pick]
128
154
  config:
129
155
  input:
130
- facility: "${params.facility}"
131
- occurred_at: "${params.occurred_at}"
132
- items: "${params.items}"
156
+ facility: "${steps.pick.output.value.facility}"
157
+ occurred_at: "${steps.pick.output.value.occurred_at}"
158
+ items: "${steps.pick.output.value.items}"
133
159
  lookup:
134
160
  EXPOSED_REGISTERED: x0PshcPLSk1
135
161
  NVP_WITHIN_72H: wZqi8EXN5x4
@@ -164,13 +190,3 @@ steps:
164
190
  connection: dhis2-demo
165
191
  data_values: "${steps.gate.output.value}"
166
192
  dry_run: "${params.dry_run}"
167
-
168
- triggers:
169
- webhooks:
170
- - code: capture-posted
171
- name: One encounter, pushed
172
- description: The capture app POSTs an encounter as it is recorded.
173
- params_from_payload:
174
- facility: "$.encounter.facility"
175
- occurred_at: "$.encounter.occurred_at"
176
- items: "$.encounter.items"
@@ -1,218 +0,0 @@
1
- # A FHIR Subscription notification delivered to a webhook, translated, and imported.
2
- #
3
- # THE FLOW, END TO END. A FHIR server holds a Subscription that watches for new captures. When
4
- # one arrives it POSTs a notification to this pipeline's webhook endpoint; the webhook maps the
5
- # notified resource onto a parameter, the document translates it into a data value set, and the
6
- # import runs as a dry run.
7
- #
8
- # THE SUBSCRIPTION CONTRACT, WHICH IS THE POINT OF THIS FILE. A FHIR Subscription is a standing
9
- # request: "when a resource matching this search appears, tell me here". Three fields carry it.
10
- #
11
- # criteria a search string, exactly as it would be typed against the server, e.g.
12
- # "QuestionnaireResponse?questionnaire=http://example.org/fhir/Questionnaire/lyLU2wR22tC"
13
- # channel.type how to be told. `rest-hook` is an HTTP POST, which is what a dirigent
14
- # webhook is; the others are websocket, email, and message.
15
- # channel.endpoint where to POST it -- the hooks URL the instance minted for this pipeline.
16
- #
17
- # What arrives in that POST depends on `channel.payload`, and the three choices are a real
18
- # decision rather than a formality:
19
- #
20
- # absent a PING. An empty body: "something matched, come and look". The pipeline
21
- # then has to search the server itself, which is more work and is also the
22
- # only choice that never puts patient data in a webhook body.
23
- # id-only the notification names the resource; the pipeline reads it back.
24
- # full-resource the resource rides in the notification. One request, no read-back, and
25
- # the delivery is now carrying clinical data -- so it is signed, and the
26
- # endpoint is not on the public internet.
27
- #
28
- # This document takes full-resource, because it is the shape that shows the whole translation
29
- # in one file. In R4 that body is the bare resource; R5 and the R4 backport wrap it in a
30
- # history Bundle whose first entry is a SubscriptionStatus and whose second is the resource,
31
- # which is the shape mapped below and the one most servers now send.
32
- #
33
- # `d2w fhir serve` HAS NO SUBSCRIPTION. Its capture surface is `POST /QuestionnaireResponse`
34
- # and its notification surface is the receipt spool -- `GET /facade/spool`, whose lifecycle
35
- # moves received -> forwarded or rejected. A pipeline pulls that spool on a schedule instead
36
- # (fhir-nightly-window-sync.yaml is that shape). This document is for the FHIR server that DOES
37
- # support Subscription, which is most of them, and it is written so the same translation serves
38
- # either way.
39
- #
40
- # WHAT ARRIVES. The notification body:
41
- #
42
- # {"resourceType": "Bundle", "type": "history",
43
- # "entry": [
44
- # {"resource": {"resourceType": "SubscriptionStatus", "type": "event-notification",
45
- # "eventsSinceSubscriptionStart": "17"}},
46
- # {"resource": {"resourceType": "QuestionnaireResponse", "status": "completed",
47
- # "questionnaire": ".../Questionnaire/lyLU2wR22tC",
48
- # "subject": {"reference": "Location/dczh6Jfd4no"},
49
- # "extension": [{"url": ".../d2-period",
50
- # "extension": [{"url": "iso", "valueString": "202608"}]}],
51
- # "item": [...]}}]}
52
- #
53
- # WHAT LEAVES. The `/api/dataValueSets` envelope, same as every inbound document here:
54
- #
55
- # {"dataSet": "lyLU2wR22tC", "period": "202608", "orgUnit": "dczh6Jfd4no",
56
- # "dataValues": [{"dataElement": "GMd99K8gVut",
57
- # "categoryOptionCombo": "qNCMOhkoQju", "value": "426.3"}]}
58
- #
59
- # WHERE A READER CHANGES IT. The webhook's `params_from_payload` at the foot of this file, and
60
- # nothing else. The mapping is STRICT: each parameter names one dotted path into the body, a
61
- # path the body does not have refuses the delivery, and a parameter the mapping does not
62
- # mention cannot be reached by any caller whatever they POST. A server sending the bare R4
63
- # resource rather than a Bundle changes `capture` to `$` -- which this syntax spells as the
64
- # resource's own top-level keys, so the mapping names them.
65
- #
66
- # WHAT LIVES ON THE INSTANCE AND NOT IN GIT. The endpoint's token, whether it is active, and
67
- # every delivery it has taken. `dg webhook rotate-token` shows a token exactly once.
68
- #
69
- # dg run --local examples/fhir/fhir-subscription-webhook-to-dhis2.yaml
70
- #
71
- # Against an instance, where the endpoint exists:
72
- #
73
- # dg apply examples/fhir/fhir-subscription-webhook-to-dhis2.yaml
74
- # dg webhook rotate-token fhir-subscription-webhook-to-dhis2 fhir-notification
75
- # curl -X POST "$DG_URL/hooks/$TOKEN" -H 'Content-Type: application/json' --data @notification.json
76
-
77
- format: dirigent/v1
78
- kind: pipeline
79
- code: fhir-subscription-webhook-to-dhis2
80
- name: A FHIR Subscription notification imported
81
- description: |
82
- Take a FHIR `Subscription` rest-hook notification on a webhook, translate the notified
83
- `QuestionnaireResponse` into a DHIS2 data value set, and rehearse the import.
84
-
85
- The subscription contract is three fields: `criteria` (a search string), `channel.type`
86
- (`rest-hook` is an HTTP POST), and `channel.endpoint` (the hooks URL). `channel.payload`
87
- decides whether the delivery is a ping, an id, or the resource itself -- this takes the
88
- resource, wrapped in the history `Bundle` R5 and the R4 backport send.
89
-
90
- tags: [fhir, dhis2, cross-boundary]
91
-
92
- requires:
93
- blocks:
94
- - transform.jq
95
- - dhis2.data_value_set_import
96
-
97
- connections:
98
- dhis2-demo:
99
- kind: dhis2
100
- config:
101
- base_url: https://play.dhis2.org/2.43
102
- basic_username: admin
103
- basic_password: district
104
- timeout: 60s
105
-
106
- params:
107
- type: object
108
- properties:
109
- capture:
110
- type: object
111
- description: The notified QuestionnaireResponse. The default is what a delivery would
112
- have carried, so the document runs without one.
113
- default:
114
- resourceType: QuestionnaireResponse
115
- status: completed
116
- authored: "2026-08-31T09:00:00Z"
117
- questionnaire: http://example.org/fhir/Questionnaire/lyLU2wR22tC
118
- subject:
119
- reference: Location/dczh6Jfd4no
120
- extension:
121
- - url: http://example.org/fhir/StructureDefinition/d2-period
122
- extension:
123
- - url: iso
124
- valueString: "202608"
125
- - url: type
126
- valueCode: Monthly
127
- - url: http://example.org/fhir/StructureDefinition/d2-attribute-option-combo
128
- valueCoding:
129
- code: N7QFN41eTN8
130
- item:
131
- - linkId: GMd99K8gVut
132
- item:
133
- - linkId: GMd99K8gVut.qNCMOhkoQju
134
- answer:
135
- - valueDecimal: 426.3
136
- - linkId: GMd99K8gVut.LbeIlyHEhKr
137
- answer:
138
- - valueDecimal: 628.2
139
- - linkId: wfKKFhBn0Q0
140
- answer:
141
- - valueDecimal: 142.1
142
- events_since_start:
143
- type: string
144
- description: The SubscriptionStatus counter. A gap between two deliveries means a
145
- notification was lost, which is the only way this channel says so.
146
- default: "0"
147
-
148
- steps:
149
- # Only a completed response is translated. An in-progress one is a form still being filled,
150
- # and a notification is allowed to fire on it -- `criteria` matches a resource, not a state.
151
- translate:
152
- block: transform.jq
153
- config:
154
- input: "${params.capture}"
155
- program: |
156
- if .status != "completed" then
157
- error("the notified response is \(.status), not completed; nothing to import")
158
- else . end
159
- | . as $r
160
- | {dataSet: ($r.questionnaire | split("/") | last),
161
- period: ([$r.extension[] | select(.url | endswith("/d2-period")).extension[]
162
- | select(.url == "iso").valueString][0]),
163
- orgUnit: ($r.subject.reference | ltrimstr("Location/")),
164
- attributeOptionCombo: (
165
- [$r.extension[] | select(.url | endswith("/d2-attribute-option-combo"))][0]
166
- | .valueCoding.code),
167
- dataValues: [
168
- $r.item[] | recurse(.item[]?)
169
- | select((.answer // []) | length > 0)
170
- | (.linkId | split(".")) as $key
171
- | (.answer[0] | (.valueDecimal // .valueInteger // .valueString // .valueBoolean)) as $v
172
- | {dataElement: $key[0], value: ($v | tostring)}
173
- + (if ($key | length) > 1 then {categoryOptionCombo: $key[1]} else {} end)]}
174
- | del(.attributeOptionCombo | select(. == null))
175
-
176
- # A notification arrives once and is not replayed, so the import's retry budget is what
177
- # stands between a momentary DHIS2 and a lost capture. dry_run makes this a rehearsal;
178
- # dropping it is the whole difference between this document and a live forwarder.
179
- rehearse:
180
- block: dhis2.data_value_set_import
181
- depends_on: [translate]
182
- retry:
183
- # The budget counts the first try, so this is one attempt and two retries.
184
- max_attempts: 3
185
- backoff: 5s
186
- config:
187
- connection: dhis2-demo
188
- data_values: "${steps.translate.output.value}"
189
- dry_run: true
190
- atomic_mode: ALL
191
-
192
- receipt:
193
- block: transform.jq
194
- depends_on: [rehearse]
195
- config:
196
- input:
197
- summary: "${steps.rehearse.output}"
198
- events_since_start: "${params.events_since_start}"
199
- document: "${steps.translate.output.value}"
200
- program: |
201
- {notification: .events_since_start,
202
- data_set: .document.dataSet,
203
- period: .document.period,
204
- org_unit: .document.orgUnit,
205
- status: .summary.status,
206
- would_import: .summary.imported,
207
- conflicts: (.summary.conflicts | length)}
208
-
209
- triggers:
210
- webhooks:
211
- # The whole declaration. `capture` takes the notified resource out of the history Bundle's
212
- # second entry -- the first is the SubscriptionStatus -- and the counter comes off that
213
- # status. Both are validated against params above before a run is created, so a delivery
214
- # that does not carry them is refused rather than half-run.
215
- - code: fhir-notification
216
- params_from_payload:
217
- capture: "$.entry.1.resource"
218
- events_since_start: "$.entry.0.resource.eventsSinceSubscriptionStart"