Registry stack documentation: machine-readable Markdown.
Index of all pages: https://docs.registrystack.org/dev/llms.txt
Full corpus: https://docs.registrystack.org/dev/llms-full.txt

# Issue an immunization summary from DHIS2

> Author and verify a signed, multi-concept immunization summary from synthetic DHIS2 Tracker events.

import QuickstartMeta from '../../../components/QuickstartMeta.astro';

Complete [Return a governed value](../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.

<QuickstartMeta
  outcome="A verified assertion containing five governed immunization readings from DHIS2."
  time="About 30 minutes"
  level="Institution source with synthetic data"
  prerequisites={[
    'The completed governed-value tutorial',
    'The Evidence Gateway toolset',
    'Access to the public DHIS2 demo',
    'curl and an editor',
  ]}
/>

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

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](https://docs.dhis2.org/en/develop/using-the-api/dhis-core-version-243/tracker.html)
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 `false` or `0`.
- Two different values for the same concept make the source record inconsistent.

In either case, Evidence Gateway returns no assertion.

## Choose a synthetic child

Open the [public DHIS2 demo](https://play.im.dhis2.org/stable-2-43-1/) and sign in with its
provider-published shared credentials:

```text
Username: admin
Password: district
```

In 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

Create an owner-only curl configuration at `.local/dhis2.curl`:

```sh
umask 077
mkdir -p .local
touch .local/dhis2.curl
chmod 600 .local/dhis2.curl
```

Open the file and add the public demo credential:

```text
user = "admin:district"
```

Set the source and subject values in your terminal:

```sh
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:

```sh
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.json
```

The 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

Download the full DHIS2 OpenAPI document and create an editable local project:

```sh
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-immunization
```

`evidencectl 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

Ask `evidencectl` to draft the selected operation and response fields from the retained OpenAPI
document:

```sh
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:

```text
sources/child-tracker.yaml
adapters/child-tracker-prepare.rhai
adapters/child-tracker-extract.rhai
schemas/child-tracker-parameters.schema.yaml
schemas/child-tracker-response.schema.yaml
schemas/child-tracker-facts.schema.yaml
```

## Define the tracked-entity selector

Create `selectors/child-tracked-entity-v1.yaml`:

```yaml
maximumAggregateBytes: 64
fields:
  tracked_entity_id:
    type: string
    minimumBytes: 11
    maximumBytes: 11
```

A 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

Replace `sources/child-tracker.yaml` with:

```yaml
transport: http-json
baseUrl: https://play.im.dhis2.org
posture: record-transformed
authentication:
  kind: basic
  usernameRef: secret:file/dhis2-username
  passwordRef: secret:file/dhis2-password
request:
  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: 8
responseSchema: schemas/child-tracker-response.schema.yaml
extractScript: adapters/child-tracker-extract.rhai
factSchema: schemas/child-tracker-facts.schema.yaml
```

`baseUrl` 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:

```sh
touch secrets/dhis2-username secrets/dhis2-password
chmod 600 secrets/dhis2-username secrets/dhis2-password
```

Open `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

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:

```rhai
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:

```yaml
type: object
additionalProperties: false
required: [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

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:

```yaml
type: object
additionalProperties: false
required: [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

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:

```rhai
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:

```yaml
type: object
additionalProperties: false
required:
  - record_reference
  - bcg_recorded
  - opv_dose_count
  - penta_dose_count
  - measles_recorded
  - yellow_fever_recorded
properties:
  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

Create `questions/immunization-summary.yaml`:

```yaml
id: immunization-summary
question: Which immunizations are recorded for this child?
purpose: care-continuity
subject:
  role: child
  selector: tracked_entity_id
  profile: child-tracked-entity-v1
  derivation: true
source:
  ref: child-tracker
answers:
  - 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: boolean
derivation: derivations/immunization-summary.rhai
disclosure:
  allow:
    - bcg_recorded_as_administered
    - opv_dose_count_recorded
    - penta_dose_count_recorded
    - measles_recorded_as_administered
    - yellow_fever_recorded_as_administered
```

`source.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`:

```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

Compile the editable source and question into one immutable local generation, then start Registry
Mint and Evidence Gateway:

```sh
evidencectl dev --detach
```

```text
Evidence ready at http://127.0.0.1:8080
Mint ready at http://127.0.0.1:8081
```

This 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

Prepare a closed request and its independent verification expectations for the chosen subject:

```sh
evidencectl request prepare immunization-summary \
  --purpose care-continuity \
  --subject "child:tracked_entity_id=$DHIS2_TRACKED_ENTITY_ID" \
  --name immunization-summary
```

Use 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:

```sh
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.json
```

The 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

Verify the response against the retained request and trust expectations:

```sh
evidencectl verify immunization-summary.jws.json \
  --context .evidence/requests/immunization-summary/verification.json \
  --output immunization-summary.verified.json
```

```text
VERIFIED
```

Inspect the verified payload:

```sh
python3 -m json.tool immunization-summary.verified.json
```

The selected demo record determines the boolean and dose-count values.
The supported values have this shape:

```json
[
  {
    "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

Stop the local services:

```sh
evidencectl dev stop
```

Inspect the last operation:

```sh
evidencectl audit show --last-operation
```

The 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:

```sh
evidencectl dev clean
cd ..
rm -f .local/dhis2-response.json \
  .local/dhis2.curl \
  .local/dhis2.openapi.yaml
```

Keep 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.

## Next

- [See Evidence Gateway refuse unsafe requests](../refuse-unsafe-evidence-requests/)
- [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/)
- [Verify an assertion as a consumer](../verify-an-assertion-as-a-consumer/)