Skip to content

4. Clinical observation forms

The clinical-obs-forms repo holds Bahmni Form Builder JSON exports. bin/bahmni_form_import.sh replays what the Implementer Interface's "Import" button does — concept UUID fix-up, POST /form, save body, save translations — over the EMR's REST API, so those exports become live forms without anyone clicking through the UI. It then does the one thing that button does not: it publishes the form (§4.5).

Info

install.sh does none of this. The forms deploy over the REST API, and at the moment the upgrade finishes the EMR needs 30+ minutes before it answers one. The entire step — importer, credentials, runner, schedule and the first import — belongs to catch-up.sh, with import-forms.sh as the forms-only entry point.

4.1 What gets installed

Path What
/usr/local/bin/bahmni-form-import.sh The importer itself
/usr/local/bin/eregister-form-import.sh The wrapper cron/systemd runs
/etc/eregister/form-import.env Credentials, mode 0600, root-owned
/var/lib/v1/.bahmni_form_import_state.json Per-form sha256 + version
/var/lib/v1/form-import/ Scratch dir for importErrors.txt
/var/log/eregister-form-import.log The log
eregister-form-import.timer or /etc/cron.d/eregister-form-import Daily at 03:30

The runner is a bare-environment wrapper: no lib/, no PATH assumptions, its own log. It sources the env file, optionally refreshes the clone, then runs the importer over the whole folder.

4.2 The order of operations in a catch-up run

_cu_forms_import
 ├─ is the runner installed and are there credentials?   → GAP if not
 ├─ _cu_forms_clone_state   ← what is on disk, and is it current?
 ├─ "Run the form import now?"                           → SKIP if declined
 ├─ re-check the EMR password                            → GAP if rejected
 ├─ _forms_retire_stale     ← retire the outgoing set
 └─ run_form_import         ← deploy the incoming set, and publish it
_cu_forms_decode            ← always, even if nothing was imported

Is the clone current?

The import reads JSON off the disk. Getting current JSON there is somebody else's job — step 1 of the catch-up run, and the runner's own refresh — and both of those can decline: a dirty tracked file, a detached HEAD, an off-release branch, a failed fetch, an unreadable repo.

Each of those is reported in its own row and was then forgotten, so the import went ahead against whatever was on disk and reported a clean imported 8/8 while deploying a months-old release. That is how a site stays frozen at one commit for weeks with nothing in the report looking wrong.

So the import now says what it is about to deploy, before asking:

[ℹ] Importing from /var/lib/v1/clinical-obs-forms — main @ 18758c6, committed 2026-09-11.
[✔] That clone was brought up to date at the top of this run.

and when it could not be refreshed:

[⚠] This clone was NOT refreshed this run:
[⚠]   uncommitted changes to tracked files — left untouched; --force-repos to reset it onto main
[⚠] So the forms about to be deployed are whatever 18758c6 holds — which
[⚠] may be older than the release.

A run that deploys from an unrefreshed clone gets a GAP import row, however well the import itself went — imported 8/8 must not stand as though the site were current:

  ✘ GAP  forms  import  imported 8/8 form(s) — from a clone that was NOT refreshed this run

The retirement sits inside the import step, after the confirmation and the credential check. That is deliberate: retiring the 2026 forms and then not deploying the new ones would leave the site with nothing to fill in. The two halves must not come apart.

4.3 Retiring the forms a release replaces

Straight before the import, every live form whose name matches the configured pattern is marked retired:

UPDATE form SET retired = 1, retired_by = 1, date_retired = NOW(),
       retire_reason = 'deploying latest forms with the latest changes - kgatman'
WHERE name LIKE '%2026%' AND retired = 0;

This is how the year's outgoing set stops being offered at the same moment the incoming one lands.

OpenMRS retires rather than deletes. The rows stay, and every observation ever recorded against those forms keeps resolving. One UPDATE undoes it, and the run prints it:

[ℹ] Undo it with:
[ℹ]   UPDATE form SET retired = 0, retired_by = NULL, date_retired = NULL,
[ℹ]          retire_reason = NULL WHERE retire_reason = 'deploying latest forms with the latest changes - kgatman';

Why AND retired = 0

It is the script's own addition to the statement, for the same reason idgen.sh carries it: without it, every run rewrites date_retired on rows retired months ago, so the site loses the date they were actually retired and the report claims a change it did not make.

Why the import state is edited too

The importer deploys a form only when its file changed — and retiring a form does not change its file. Without intervention the very forms just retired would be skipped as "unchanged", and the site would be left with no live copy of them at all.

So the retirement blanks the recorded sha256 for exactly the retired form names. It deliberately keeps the recorded version: the importer deploys max(state version, server version) + 1, and a retired form is not in the server's answer, so dropping the version could make it redeploy a number that already exists on a retired row.

[ℹ] Cleared the recorded hash of the retired forms so the import redeploys them.

This is best-effort. If jq is missing or the state file cannot be rewritten, you get a warning naming the manual fallback:

sudo /usr/local/bin/bahmni-form-import.sh --force -r /var/lib/v1/clinical-obs-forms

Before it writes, it shows you

[ℹ] 12 live form(s) in 'openmrs' match name LIKE '%2026%':
    • ANC Intake 2026 (Nurse's)
    • HIV Treatment and Care Follow Up 2026
    …
[⚠] Retiring them stops them being offered. The rows are kept and the
[⚠] observations already recorded against them are NOT touched — this is reversible.
Retire these 12 form(s) before importing? [y/N]:

Configuration

Variable Default Meaning
EREGISTER_FORM_RETIRE 1 0 (or --no-retire-forms) skips it
EREGISTER_FORM_RETIRE_NAME_LIKE %2026% SQL LIKE pattern matched against form.name
EREGISTER_FORM_RETIRE_REASON deploying latest forms with the latest changes - kgatman Written to retire_reason
EREGISTER_FORM_RETIRE_BY 1 users.user_id recorded as the retiring user

Values are escaped before being interpolated into the SQL: backslashes first (MySQL's own escape character), then single quotes.

Report rows

Status Row When
FIXED N form(s) retired (reason: …) Rows were retired
OK no live form matches name LIKE '%2026%' in openmrs Nothing to do
SKIP left alone (--no-retire-forms) / declined — N form(s) left live Disabled or declined
GAP openmrsdb:openmrs not reachable — nothing retired Database still booting
GAP N form(s) still live after the UPDATE The write did not take

no-db is a GAP rather than a SKIP on purpose: the forms the release replaces are still being offered, and nothing else on the site will retire them.

Note

The nightly eregister-form-import job does not retire. Retiring a form set is a release action, not something that should happen unattended at 03:30.

4.4 The import

What counts as a form to import

The folder is scanned for *.json, but only Form Builder exports are imported — a file with a top-level formJson key. Anything else is skipped and reported separately, not counted as a failure:

=== 13ff9c8e-d4c1-4be0-a102-7832fee80054.json
  skipped: a form the EMR itself wrote (no formJson wrapper) — not an export

=== notes.json
  skipped: not a Bahmni form export (no formJson key)

imported 8/8 form(s), 0 unchanged, 8 published, 0 failed
ignored 34 file(s) in the folder that are not form exports

The denominator is the number of exports, not the number of files. Folding non-exports in turned "8 forms, all fine" into the alarming "imported 0/76", and made the run exit non-zero over files that were never meant to be imported.

Warning

<form-uuid>.json files in the forms folder are a problem to fix, not to live with. The EMR writes one per deployed form, and if its clinical_forms directory is bind-mounted onto the clinical-obs-forms clone they land in a git checkout. See §11.7.

Change detection

The importer keys its state file on server URL + form name and stores a sha256 of the source file:

{
  "https://localhost|HIV Treatment and Care Follow Up 2026": {
    "version": "3",
    "sha256": "ab12…",
    "form_uuid": "…",
    "file": "forms/HIV….json",
    "imported_at": "2026-08-24T09:12:03Z"
  }
}

So:

  • a file unchanged since its last import → skipped, nothing deployed
  • a same-named file holding a new export → counts as new work, deployed as the next version
  • a file merely re-checked-out by the auto-pull job (new mtime, same bytes) → skipped

Hashing the export rather than the fixed-up payload means the question asked is "did the author change the form?", independent of concepts being re-resolved on every run.

Keying on the URL as well as the name keeps a dev run from convincing a later prod run that a form is already up to date.

Unchanged does not mean deployed

Before skipping a form whose file has not changed, the importer asks the server about it. Two states force a redeploy even though the export is byte-identical:

State on the server Why it must redeploy
Newest version is retired Exactly what the release retirement does to the outgoing set. Skip, and the site is left with no live copy of a form whose file will never change again
The form is missing Restored database, hand-deleted form, or a state file carried over from another server
=== HIV Follow Up 2026_1.json
  unchanged since version 3, but its newest version (3) is retired — redeploying
  last version 3 (state 3, server 0) — deploying 4

This is the safety net under the retire step's state-file edit (§4.3): if that edit could not be made, the site still converges on the next run.

Retired forms are invisible to the default REST search, so the lookup asks a second time with includeAll=true rather than reading "not listed" as "absent". A form that is genuinely retired is not published — that would put a form nobody can reach back on the published list.

Versioning

A changed form goes out as a new version; nothing live is overwritten.

last version 3 (state 3, server 3) — deploying 4

The target is max(state version, server version) + 1. The state file is the record of what the script deployed, but the server is the authority — someone may have saved a newer version in the Implementer Interface, or the state file may have been lost.

Concept validation

A form whose concepts are not in the dictionary is not deployed. The run writes <form>.importErrors.txt into /var/lib/v1/form-import/ listing the unresolved names. That is usually a sign the concept dictionary has not caught up — see chapter 5.

--skip-validation bypasses it; --dry-run validates without deploying.

Concurrency

Two overlapping runs would both read the same "last version" and deploy the same number, so runs are serialised with flock on <state file>.lock. Where flock is absent (stock macOS) the lock is simply skipped.

Importer flags

/usr/local/bin/bahmni-form-import.sh [options] <file-or-dir>...
Flag Meaning
--url URL EMR base URL
--user / --password Credentials
-k, --insecure Accept the stack's self-signed certificate
-r, --recursive Recurse into directories
-f, --force Deploy even when the file is unchanged
--no-publish Leave the form in Draft (what the Import button does)
--publish-only Publish what is already deployed; import nothing
--dry-run Validate concepts only; deploy nothing
--skip-validation Deploy without resolving concept references
--version N Pin the version instead of bumping
--no-bump Do not increment the version
--state FILE Use a different state file
--delay N Pause between forms
-v, --verbose More output

4.5 Publishing

This is the step the Implementer Interface leaves to you. Importing a form there puts it in Draft; you then click "Publish" separately, and until you do the form is not offered in the clinical app at all.

Deploying dozens of forms and then clicking Publish dozens of times is exactly the work this script exists to remove, so it publishes by default, over the same endpoint that button uses:

POST /openmrs/ws/rest/v1/bahmniie/form/publish?formUuid=<uuid>

Using the Bahmni endpoint rather than just flipping the published column means whatever else Bahmni does on publish comes along with it. Where that endpoint is missing — an older bahmnicore, or a stack without the module — the importer falls back to the core OpenMRS form resource:

POST /openmrs/ws/rest/v1/form/<uuid>     {"published": true}

Either way the result is read back from the server before it is reported: a 200 from the publish endpoint is not the same thing as a published form.

  form uuid 4f2a…
  saved body, version 4
  published version 4
  imported 'ANC Intake 2026' as version 4

It re-asserts

A form that is skipped as unchanged is still checked, and published if it is not:

=== ANC Intake 2026.json
  unchanged since version 3 — skipping (--force to import anyway)
  published version 3

That is what fixes a site whose forms were deployed before any of this existed: they sit there as drafts, their files have not changed, so no ordinary import would ever touch them again. One normal catch-up run now publishes the lot.

It costs one GET per unchanged form, and a POST only when there is something to fix. A run where everything is already published makes no writes at all.

Warning

The flip side is deliberate. A form you unpublish by hand is published again by the next run. Use --no-publish / EREGISTER_FORM_PUBLISH=0 on a site where that matters.

Publishing without importing

For a site whose forms are already deployed and only need publishing:

sudo /usr/local/bin/bahmni-form-import.sh -k --publish-only \
     -r /var/lib/v1/clinical-obs-forms

No concept resolution, no POST /form, no version bump — seconds per form rather than minutes, and it cannot change what any form contains.

=== ANC Intake 2026.json
  published version 3

=== HIV Follow Up 2026.json

published 1/2 form(s); nothing was imported (--publish-only)

--dry-run alongside it reports what each form's current state is without changing anything:

sudo /usr/local/bin/bahmni-form-import.sh -k --publish-only --dry-run \
     -r /var/lib/v1/clinical-obs-forms

Which version gets published

The newest version of that form name on the server. Older versions are left exactly as they are — publishing v4 does not touch v1–v3.

Turning it off

Where How
catch-up.sh --no-publish (and --no-forms implies it)
The importer --no-publish
Anywhere EREGISTER_FORM_PUBLISH=0, which reaches the daily job as BAHMNI_PUBLISH=0 in /etc/eregister/form-import.env

4.6 Decoding the deployed form JSON

A deployed form can come back out of the EMR with the markup in its labels HTML-escaped — &amp; &lt; &gt; where the author wrote & < >. The clinical app then renders the entity text itself, and forms that reference those fields throw errors.

The fix is textual: decode the three entities in place, in the folder the EMR keeps inside its own container (/home/bahmni/clinical_forms by default).

Why repeatedly

The escaping nests. &amp;lt; is &lt; that was escaped a second time, and one pass over it only gets back as far as &lt; — so a single pass can leave a file that is still wrong, and still wrong in a way the same pass would fix.

It therefore runs again until a pass finds nothing left to change, rather than a fixed number of times: five levels of nesting take five passes, a site with one stops after the second, and EREGISTER_FORM_DECODE_MAX_PASSES (default 5) caps it so a pathological file cannot spin forever.

Idempotent, and cheap on a clean folder: the first pass matches nothing and it stops there. That is what makes it safe on every catch-up run.

It runs before the EMR is recreated at the end of a catch-up, so the reloaded instance reads the decoded files.

Return codes

Code Meaning
0 Clean — nothing left escaped
1 Could not run (no docker compose, no stack dir, exec failed)
3 The folder does not exist inside the container — set EREGISTER_FORM_DECODE_DIR
4 Still escaped after the maximum passes — raise EREGISTER_FORM_DECODE_MAX_PASSES

Decode on its own

sudo ./catch-up.sh --decode

Seconds instead of a full run. See §3.6.

Note

--decode and --no-decode are not a toggle pair. --no-decode skips this step inside a normal run; --decode makes this step the only thing the run does. Passing both is an error.

4.7 import-forms.sh — forms on their own

Sets up (or re-installs and re-schedules) the whole form-import machinery without the rest of the catch-up:

curl -fsSL --retry 8 --retry-max-time 180 \
  https://raw.githubusercontent.com/Lesotho-eRegister-v1/upgrade-to-v1/refs/heads/main/import-forms.sh | bash

or, from the checkout:

bash ./import-forms.sh

Note: this script does not retire forms. The retirement is a release action owned by catch-up.sh.

4.8 Running the import by hand

# the scheduled job, right now
sudo /usr/local/bin/eregister-form-import.sh

# validate concepts only, deploy nothing
sudo /usr/local/bin/bahmni-form-import.sh -k --dry-run -r /var/lib/v1/clinical-obs-forms

# re-deploy everything, ignoring the state file
sudo /usr/local/bin/bahmni-form-import.sh -k --force -r /var/lib/v1/clinical-obs-forms

# publish everything that is deployed but still in Draft
sudo /usr/local/bin/bahmni-form-import.sh -k --publish-only -r /var/lib/v1/clinical-obs-forms

# what happened last night
sudo tail -n 50 /var/log/eregister-form-import.log

# which forms does the site think it has deployed?
sudo jq 'keys' /var/lib/v1/.bahmni_form_import_state.json

Inspecting forms in the database

-- every version of the 2026 forms, live and retired, published or draft
SELECT form_id, name, version, published, retired, date_retired
FROM form WHERE name LIKE '%2026%' ORDER BY name, version;

-- anything still sitting in Draft
SELECT name, version FROM form
WHERE published = 0 AND retired = 0 ORDER BY name;

-- what this script retired
SELECT COUNT(*) FROM form
WHERE retire_reason = 'deploying latest forms with the latest changes - kgatman';

4.9 Configuration summary

Variable Default
EREGISTER_IMPORT_FORMS 1 (--no-forms disables)
EREGISTER_BAHMNI_URL https://localhost
EREGISTER_BAHMNI_USER superman
EREGISTER_BAHMNI_PASS (prompted)
EREGISTER_FORMS_DIR <base>/v1/clinical-obs-forms
EREGISTER_FORM_IMPORT_INSECURE 1 (self-signed cert)
EREGISTER_FORM_IMPORT_CRON 30 3 * * *
EREGISTER_FORM_IMPORT_ONCALENDAR *-*-* 03:30:00
EREGISTER_FORM_IMPORT_SELF_PULL 1 — refresh the clone before importing
EREGISTER_FORM_PUBLISH 1 (--no-publish disables)
EREGISTER_FORM_RETIRE 1 (--no-retire-forms disables)
EREGISTER_FORM_RETIRE_NAME_LIKE %2026%
EREGISTER_FORM_RETIRE_REASON deploying latest forms with the latest changes - kgatman
EREGISTER_FORM_RETIRE_BY 1
EREGISTER_FORM_DECODE 1 (--no-decode disables)
EREGISTER_FORM_DECODE_DIR /home/bahmni/clinical_forms
EREGISTER_FORM_DECODE_MAX_PASSES 5