Unreleased documentation. These pages follow the main branch and can change before the next release. For supported guidance, use v0.15.2.
Issue an immunization summary from DHIS2
For the assertion provider
Complete Return a governed value before starting this tutorial. You will apply that pattern to the public DHIS2 Tracker demo, combine two immunization events into one governed answer, and verify the signed assertion returned across the Evidence Gateway HTTP boundary.
This tutorial uses synthetic data from the public DHIS2 demo. Do not substitute a real child record. The demo maintainers can reset its data, credentials, and synthetic subjects.
Define the answer before the source
Section titled “Define the answer before the source”The DHIS2 Child Programme records several immunization readings across its Birth and Baby Postnatal events. The assertion returns five governed concepts:
| Concept | Form | Meaning |
|---|---|---|
| BCG recorded as administered | Boolean | What the record currently shows for a BCG dose, administered or not. |
| OPV dose count | Bounded integer | The oral polio vaccine dose number the record currently shows, from 0 through 3. |
| Pentavalent dose count | Bounded integer | The pentavalent vaccine dose number the record currently shows, from 0 through 3. |
| Measles recorded as administered | Boolean | What the record currently shows for a measles dose, administered or not. |
| Yellow fever recorded as administered | Boolean | What the record currently shows for a yellow fever dose, administered or not. |
This is a recorded immunization summary, not a conclusion that the child is fully vaccinated or up to date. Those conclusions require an approved immunization schedule, the child’s age, a jurisdiction, and rules for exceptional or late doses.
The summary also reports the record’s current state, not the state of a closed visit.
The DHIS2 Tracker API
gives an event the ACTIVE status by default, and states that only a super user or a user
holding the F_UNCOMPLETE_EVENT authority can modify a completed event.
COMPLETED therefore marks an event closed to further editing, not an event whose data values
are the only recorded ones.
The distinction decides whether this tutorial returns anything at all.
A sample of 2000 Child Programme tracked entities taken from the public demo on 2026-08-07 held
3885 ACTIVE events against 7 COMPLETED ones, and no record carried all five immunization
readings on COMPLETED events alone.
The demo maintainers can reset that data at any time, so treat the counts as one dated
observation rather than a standing property of the dataset.
The source adapter accepts Birth and Baby Postnatal events in both statuses.
Each assertion describes the record at the time of the request, and a later correction to an
event changes what the next assertion says.
The source adapter also applies two conservative readings:
- An absent source value does not become
falseor0. - Two different values for the same concept make the source record inconsistent.
In either case, Evidence Gateway returns no assertion.
Choose a synthetic child
Section titled “Choose a synthetic child”Open the public DHIS2 demo and sign in with its provider-published shared credentials:
Username: adminPassword: districtIn Tracker Capture, select the Child Programme and the Ngelehun CHC organisation unit.
Choose a synthetic child whose Birth and Baby Postnatal events carry a value in all five
immunization fields.
Those events may be ACTIVE or COMPLETED.
Note its tracked entity identifier and keep it out of tracked files. The identifier selects the source record. It will be used for the DHIS2 read and the Evidence Gateway request, but it will not appear in the signed assertion.
See the DHIS2 boundary
Section titled “See the DHIS2 boundary”Create an owner-only curl configuration at .local/dhis2.curl:
umask 077mkdir -p .localtouch .local/dhis2.curlchmod 600 .local/dhis2.curlOpen the file and add the public demo credential:
user = "admin:district"Set the source and subject values in your terminal:
export DHIS2_BASE_URL='https://play.im.dhis2.org/stable-2-43-1'export DHIS2_PROGRAM_ID='IpHINAT79UW'export DHIS2_TRACKED_ENTITY_ID='<synthetic-tracked-entity-id>'Read the same bounded fields that Evidence Gateway will use:
curl --silent --show-error --fail \ --config .local/dhis2.curl \ --get \ --url "$DHIS2_BASE_URL/api/tracker/trackedEntities/$DHIS2_TRACKED_ENTITY_ID" \ --data-urlencode "program=$DHIS2_PROGRAM_ID" \ --data-urlencode 'fields=trackedEntity,enrollments[program,events[programStage,status,dataValues[dataElement,value]]]' \ --output .local/dhis2-response.jsonThe response contains the tracked entity, its programme enrollment, the two events, and their data values. Some event data values are unrelated to the five concepts. Evidence Gateway will receive the bounded response, discard unrelated values during extraction, and release only the five declared concepts.
This distinction is deliberate.
The source uses the record-transformed posture because DHIS2 cannot filter the dataValues
array to individual data-element identifiers in this response shape.
Create the authoring project
Section titled “Create the authoring project”Download the full DHIS2 OpenAPI document and create an editable local project:
curl --silent --show-error --fail \ --config .local/dhis2.curl \ --header 'Accept: application/x-yaml' \ --url "$DHIS2_BASE_URL/api/openapi.yaml" \ --output .local/dhis2.openapi.yaml
evidencectl new dhis2-immunization \ --openapi .local/dhis2.openapi.yaml \ --profile local
cd dhis2-immunizationevidencectl new retains the OpenAPI document and creates empty selectors/, sources/,
adapters/, schemas/, questions/, derivations/, and fixtures/ directories.
It generates only disposable local Evidence Gateway signing, audit, and subject-binding material.
It does not invent the source policy or the answer.
Draft the DHIS2 source
Section titled “Draft the DHIS2 source”Ask evidencectl to draft the selected operation and response fields from the retained OpenAPI
document:
evidencectl source suggest \ --project . \ --source-id child-tracker \ --operation 'GET /api/tracker/trackedEntities/{uid}' \ --select /trackedEntity \ --select '/enrollments/*/program' \ --select '/enrollments/*/events/*/programStage' \ --select '/enrollments/*/events/*/status' \ --select '/enrollments/*/events/*/dataValues/*/dataElement' \ --select '/enrollments/*/events/*/dataValues/*/value'The full DHIS2 response schema contains unions and uid formats outside the closed authoring
subset that evidencectl accepts.
The command reports those skipped fields and the bounds that still need review.
Continue only when the command exits successfully and writes the six files listed next.
The command creates one editable source, two scripts, and three schemas:
sources/child-tracker.yamladapters/child-tracker-prepare.rhaiadapters/child-tracker-extract.rhaischemas/child-tracker-parameters.schema.yamlschemas/child-tracker-response.schema.yamlschemas/child-tracker-facts.schema.yamlDefine the tracked-entity selector
Section titled “Define the tracked-entity selector”Create selectors/child-tracked-entity-v1.yaml:
maximumAggregateBytes: 64fields: tracked_entity_id: type: string minimumBytes: 11 maximumBytes: 11A DHIS2 identifier is exactly eleven characters. This bounded field is the only value a caller may send for the child role, and it selects the source record without reaching the assertion.
Configure the bounded source
Section titled “Configure the bounded source”Replace sources/child-tracker.yaml with:
transport: http-jsonbaseUrl: https://play.im.dhis2.orgposture: record-transformedauthentication: kind: basic usernameRef: secret:file/dhis2-username passwordRef: secret:file/dhis2-passwordrequest: method: GET pathTemplate: /stable-2-43-1/api/tracker/trackedEntities/{uid} pathBindings: uid: {from: selector, role: child, profile: child-tracked-entity-v1, field: tracked_entity_id} fixedHeaders: - name: Accept value: application/json selectorInputs: - role: child alternatives: - profile: child-tracked-entity-v1 fields: [tracked_entity_id] prepareScript: adapters/child-tracker-prepare.rhai adapterParameters: programId: IpHINAT79UW responseFields: trackedEntity,enrollments[program,events[programStage,status,dataValues[dataElement,value]]] adapterParametersSchema: schemas/child-tracker-parameters.schema.yaml preparationLimits: query: required jsonBody: forbidden maximumQueryPairs: 2 maximumQueryNameBytes: 16 maximumQueryValueBytes: 256 maximumStringBytes: 256 maximumNormalizedBytes: 4096 projection: - /trackedEntity - /enrollments/*/program - /enrollments/*/events/*/programStage - /enrollments/*/events/*/status - /enrollments/*/events/*/dataValues/*/dataElement - /enrollments/*/events/*/dataValues/*/value redirects: deny timeoutMilliseconds: 10000 maximumResponseBytes: 262144 concurrencyLimit: 8responseSchema: schemas/child-tracker-response.schema.yamlextractScript: adapters/child-tracker-extract.rhaifactSchema: schemas/child-tracker-facts.schema.yamlbaseUrl carries an origin only, so the demo’s /stable-2-43-1 prefix belongs to the path
template.
The path binding accepts only the authorized child.tracked_entity_id selector.
The caller cannot replace the DHIS2 origin, programme, field selection, response size, or source
credential.
Add the source credential as owner-only files:
touch secrets/dhis2-username secrets/dhis2-passwordchmod 600 secrets/dhis2-username secrets/dhis2-passwordOpen secrets/dhis2-username and enter admin.
Open secrets/dhis2-password and enter district.
Do not add quotes or a trailing newline.
The values stay under the project’s ignored secrets/ directory and are never copied into the
source definition.
Prepare the bounded read
Section titled “Prepare the bounded read”The tracked entity identifier reaches DHIS2 through the path binding.
The programme and field selection reach it as the only two query parameters the source allows.
Replace adapters/child-tracker-prepare.rhai with:
fn prepare(selectors, context) { let parameters = context["parameters"]; #{ query: [ #{name: "program", value: parameters["programId"]}, #{name: "fields", value: parameters["responseFields"]} ], body: () }}Replace schemas/child-tracker-parameters.schema.yaml with:
type: objectadditionalProperties: falserequired: [programId, responseFields]properties: programId: {const: IpHINAT79UW} responseFields: const: trackedEntity,enrollments[program,events[programStage,status,dataValues[dataElement,value]]]The schema closes both parameters around their reviewed values, so a later edit cannot widen the programme or the field selection. The script reads only those parameters and the authorized selector. It cannot read the source credential, the caller identity, the purpose, or the signing keys.
Close the response bounds
Section titled “Close the response bounds”Evidence Gateway validates the projected response before extraction runs, and every array in a
schema must state its bound.
Replace schemas/child-tracker-response.schema.yaml with:
type: objectadditionalProperties: falserequired: [trackedEntity]properties: trackedEntity: {type: string, minLength: 11, maxLength: 11} enrollments: type: array maxItems: 4 items: type: object additionalProperties: false required: [program] properties: program: {type: string, minLength: 11, maxLength: 11} events: type: array maxItems: 32 items: type: object additionalProperties: false required: [programStage, status, dataValues] properties: programStage: {type: string, minLength: 11, maxLength: 11} status: {type: string, minLength: 1, maxLength: 16} dataValues: type: array maxItems: 64 items: type: object additionalProperties: false required: [dataElement, value] properties: dataElement: {type: string, minLength: 11, maxLength: 11} value: {type: string, minLength: 1, maxLength: 256}A child with no enrollment and an enrollment with no event both stay valid, and the extraction script decides what they mean. A response that exceeds any bound is refused before a script sees it.
Normalize the two events
Section titled “Normalize the two events”The OpenAPI document describes the nested response shape, but it cannot decide how repeated event
values become one answer.
Open adapters/child-tracker-extract.rhai and replace its draft body with this reviewed rule:
fn source_boolean(value) { if value == "true" { return true; } if value == "false" { return false; } throw("source_protocol_error");}
fn dose_count(value) { let count = parse_integer(value); if count < 0 || count > 3 { throw("source_protocol_error"); } count}
fn merge_reading(current, next) { if is_missing(current) { return next; } if current != next { throw("source_protocol_error"); } current}
fn extract(source_response, context) { let parameters = context["parameters"]; let bcg = (); let opv = (); let penta = (); let measles = (); let yellow_fever = ();
let enrollments = get_path(source_response, "/enrollments"); if is_missing(enrollments) { return #{outcome: "no_match"}; }
for enrollment in enrollments { if enrollment["program"] == "IpHINAT79UW" { let events = get_path(enrollment, "/events"); if !is_missing(events) { for event in events { let stage = event["programStage"]; let accepted_stage = stage == "A03MvHHogjR" || stage == "ZzYYXq4fJie"; // A DHIS2 event is ACTIVE by default, and COMPLETED marks // it closed to further editing rather than marking its // readings final. Both statuses carry recorded data // values, so the extractor reads both. let status = event["status"]; let accepted_status = status == "COMPLETED" || status == "ACTIVE"; if accepted_stage && accepted_status { for reading in event["dataValues"] { let element = reading["dataElement"]; let value = reading["value"]; if element == "bx6fsa0t90x" { bcg = merge_reading(bcg, source_boolean(value)); } else if element == "ebaJjqltK5N" { opv = merge_reading(opv, dose_count(value)); } else if element == "vTUhAUZFoys" { penta = merge_reading(penta, dose_count(value)); } else if element == "FqlgKAG8HOu" { measles = merge_reading(measles, source_boolean(value)); } else if element == "rxBfISxXS2U" { yellow_fever = merge_reading( yellow_fever, source_boolean(value) ); } } } } } } }
let facts = #{record_reference: source_response["trackedEntity"]}; if !is_missing(bcg) { facts["bcg_recorded"] = bcg; } if !is_missing(opv) { facts["opv_dose_count"] = opv; } if !is_missing(penta) { facts["penta_dose_count"] = penta; } if !is_missing(measles) { facts["measles_recorded"] = measles; } if !is_missing(yellow_fever) { facts["yellow_fever_recorded"] = yellow_fever; } #{outcome: "match", facts: facts}}The extractor reads every bounded event and data value.
It accepts ACTIVE and COMPLETED Birth and Baby Postnatal events, converts DHIS2 strings into
typed facts, and refuses conflicting readings.
Reading both statuses widens what merge_reading compares: a concept recorded once on an
ACTIVE event and again on a COMPLETED event must carry the same value, or the source record
is inconsistent and Evidence Gateway returns no assertion.
Unrelated data elements are never added to facts.
Replace schemas/child-tracker-facts.schema.yaml with the closed extraction result:
type: objectadditionalProperties: falserequired: - record_reference - bcg_recorded - opv_dose_count - penta_dose_count - measles_recorded - yellow_fever_recordedproperties: record_reference: {type: string, minLength: 11, maxLength: 64} bcg_recorded: {type: boolean} opv_dose_count: {type: integer, minimum: 0, maximum: 3} penta_dose_count: {type: integer, minimum: 0, maximum: 3} measles_recorded: {type: boolean} yellow_fever_recorded: {type: boolean}A missing immunization reading now fails the fact contract. Evidence Gateway does not convert absence into a negative answer.
Author the multi-concept question
Section titled “Author the multi-concept question”Create questions/immunization-summary.yaml:
id: immunization-summaryquestion: Which immunizations are recorded for this child?purpose: care-continuitysubject: role: child selector: tracked_entity_id profile: child-tracked-entity-v1 derivation: truesource: ref: child-trackeranswers: - concept: bcg_recorded_as_administered type: boolean - concept: opv_dose_count_recorded type: bounded-integer minimum: 0 maximum: 3 - concept: penta_dose_count_recorded type: bounded-integer minimum: 0 maximum: 3 - concept: measles_recorded_as_administered type: boolean - concept: yellow_fever_recorded_as_administered type: booleanderivation: derivations/immunization-summary.rhaidisclosure: allow: - bcg_recorded_as_administered - opv_dose_count_recorded - penta_dose_count_recorded - measles_recorded_as_administered - yellow_fever_recorded_as_administeredsource.ref names the reviewed source, whose closed fact schema decides what the derivation
receives.
The answers list declares the exact concepts and their forms.
The disclosure.allow list must match those concepts in order.
Adding another answer or changing an integer bound is a reviewed project change.
Create derivations/immunization-summary.rhai:
fn answer(facts, selectors, context) { if facts["record_reference"] != selectors["child"]["values"]["tracked_entity_id"] { throw("derivation_input_error"); } #{ bcg_recorded_as_administered: facts["bcg_recorded"], opv_dose_count_recorded: facts["opv_dose_count"], penta_dose_count_recorded: facts["penta_dose_count"], measles_recorded_as_administered: facts["measles_recorded"], yellow_fever_recorded_as_administered: facts["yellow_fever_recorded"] }}The first comparison prevents a response for another tracked entity from being evaluated.
The returned map must contain exactly the aliases declared under answers.
Evidence Gateway validates every value against its declared form before signing.
Start the project
Section titled “Start the project”Compile the editable source and question into one immutable local generation, then start Registry Mint and Evidence Gateway:
evidencectl dev --detachEvidence ready at http://127.0.0.1:8080Mint ready at http://127.0.0.1:8081This command validates the source schema, collection bounds, extractor, fact schema, question, answer forms, derivation, disclosure list, and secret bindings before either service becomes ready. Local assurance keeps the project editable and does not require production fixtures.
Request the real assertion
Section titled “Request the real assertion”Prepare a closed request and its independent verification expectations for the chosen subject:
evidencectl request prepare immunization-summary \ --purpose care-continuity \ --subject "child:tracked_entity_id=$DHIS2_TRACKED_ENTITY_ID" \ --name immunization-summaryUse this only with the public synthetic record. In a real deployment, selectors need an input path that does not expose identifiers in shell history or process arguments.
Send that request across the Evidence Gateway HTTP boundary:
curl --silent --show-error --fail-with-body \ --config .evidence/requests/immunization-summary/authorization.curl \ --request POST \ --url http://127.0.0.1:8080/v1/evidence \ --header 'Content-Type: application/json' \ --header 'Accept: application/jose+json' \ --data-binary @.evidence/requests/immunization-summary/request.json \ --output immunization-summary.jws.jsonThe caller contacts Evidence Gateway. Evidence Gateway authenticates and authorizes the caller, performs one bounded authenticated DHIS2 read, derives the five concepts, audits the disclosure, and returns a signed flattened JSON Web Signature (JWS).
Verify before reading
Section titled “Verify before reading”Verify the response against the retained request and trust expectations:
evidencectl verify immunization-summary.jws.json \ --context .evidence/requests/immunization-summary/verification.json \ --output immunization-summary.verified.jsonVERIFIEDInspect the verified payload:
python3 -m json.tool immunization-summary.verified.jsonThe selected demo record determines the boolean and dose-count values. The supported values have this shape:
[ { "providesValueFor": "urn:registrystack:evidence:local:concept:immunization-summary:bcg_recorded_as_administered", "value": false }, { "providesValueFor": "urn:registrystack:evidence:local:concept:immunization-summary:opv_dose_count_recorded", "value": 1 }, { "providesValueFor": "urn:registrystack:evidence:local:concept:immunization-summary:penta_dose_count_recorded", "value": 1 }, { "providesValueFor": "urn:registrystack:evidence:local:concept:immunization-summary:measles_recorded_as_administered", "value": true }, { "providesValueFor": "urn:registrystack:evidence:local:concept:immunization-summary:yellow_fever_recorded_as_administered", "value": true }]The verified assertion contains no tracked entity identifier, event identifier, event date, programme enrollment, unrelated health reading, or source credential.
Inspect the audit and clean up
Section titled “Inspect the audit and clean up”Stop the local services:
evidencectl dev stopInspect the last operation:
evidencectl audit show --last-operationThe audit records the authorized access and the five disclosed concept identifiers. It does not record their values or the DHIS2 response.
Remove the stopped local generation and the local DHIS2 artifacts:
evidencectl dev cleancd ..rm -f .local/dhis2-response.json \ .local/dhis2.curl \ .local/dhis2.openapi.yamlKeep the project editable while evaluating the source. Before deployment, add project-specific fixtures, replace the demo account with a least-privilege service account, review the source acquisition posture, and build a reviewed production candidate.