Authoring, versioning and publishing clinical forms, and how immutable published versions work.
Form Builder
Overview
There is no drag-and-drop builder. It was Form.io-only and was removed with that engine — see ADR-004. Form authoring is AI-first:
- Convert a source document — upload a PDF, image or HTML mock-up from the forms list ("From File"). See PDF-TO-FORM.md.
- Or describe the form — "From Prompt" creates a draft from a natural-language description.
- Refine by prompt — on the form's preview page, "Refine with AI" edits the draft in place (optionally with a reference image attached). Published versions are immutable, so a refine after publish forks a new draft; the published version stays in use for data entry until the draft is published. Such a form shows a Draft pending badge beside "Published" in the forms list and on the preview page, where the Publish button reappears and the status line names the version clinicians are filling.
- Publish — makes the version available for data entry.
A form's structure is a JSON Forms UI schema, so nothing stops a developer from editing the schema directly through the API; there is simply no visual editor surface in the product.
Clinical controls
Rendered by both the React and Angular renderers from options.omf.control.
The canonical vocabulary is OMF_CONTROL_NAMES in form-core's control
registry; parity tests in both renderer packages (and a pinned list in the AI
prompt tests) fail CI if a control is added without being implemented in both
frameworks:
| Control | Purpose |
|---|---|
scoringMatrix |
Grid with domain-grouped rows, checkboxes, point values, auto-sum |
checklistMatrix |
Repeating label rows against per-column tick boxes (needs omf.rows/omf.columns) |
checkboxGroup |
Multi-select ("choose all that apply") — an array of coded options rendered as one checkbox row; also the automatic fallback for any enum/oneOf array, even one mislabelled checklistMatrix |
recordTable |
Repeating encounter log — add/remove records with an expandable tabbed detail panel |
colorCodedGrid |
Table with coloured rows, highlights the active row based on score |
clinicalReferenceTable |
Read-only reference table (dosing guides, contraindications) |
riskStratification |
Computed badge showing risk level, updates reactively |
scoreSummary |
Live total with risk bands |
vitalSignsChart |
Multi-parameter observation chart |
signatureDate |
Signature + printed name + auto-date |
Scored sections also carry omf.accentColor, omf.icon, omf.points and
omf.pointLegend to reproduce colour-coded paper domains. A section with
scored fields shows a live "Σ n" subtotal chip in its header automatically —
the INNERMOST one: a Group that merely contains scoring sections stays quiet,
because a box grouping qSOFA and SIRS totals nothing on the paper. Two flags
override that per section: omf.showSectionTotal: true puts the chip on an
outer Group whose combined total the source does print, and
omf.hideSectionTotal: true removes it from a section that would draw one
(the refine chat can set either — "remove the Σ 0 from that box"). Neither
touches the scoring itself: every item still feeds the grand total and the
per-section breakdown. Scoring shown on screen is advisory: the server recalculates on
submission and is authoritative.
History, units and repeat timestamps (omf.history, omf.unit, recordTable.effectiveAtPath)
Forms filled repeatedly for one patient (vitals every two hours) can show a field's previous values — a chip under the control, a trend, a flowsheet — when the host supplies the prior fills. The design is ADR-005; the vocabulary is:
| Key | On | Meaning |
|---|---|---|
omf.history |
a Control, or a Group | { show: 'inline' | 'popover' | 'none', count?, trend? }. On a Group it is the default for every reading-bearing Control inside (nearest section wins); a Control's own value overrides, show: 'none' opts one field out (ADR-006). Absent everywhere means none. Rides in the exported definition, so it behaves the same in every host. |
omf.unit |
a numeric Control | UCUM string ('mm[Hg]', 'Cel', '%', '/min'). Carried onto every projected observation. History never converts — two readings in different units are shown as two values with their units. Displayed as the clinical symbol (mmHg, °C) via form-core's displayUnit; the stored code is unchanged. |
omf.recordTable.effectiveAtPath |
a recordTable |
Dot path inside one record to its clinical time ('observedAt'). Rows projected from that record take the record's own time instead of the response's. |
omf.effectiveAt: true |
a date/date-time Control | This field is the clinical time of the whole response. The API sets submission.effective_at from it at completion (else the client's value, else creation time); a host storing its own data should do the same. |
In OpenMedForm's own Records pages, Fill again on a record (list row or detail page) opens the
fill screen with that patient's details pre-filled — /fill/<slug>?mrn=…&encounter=… — so the next
round is charted for the same patient without retyping the MRN (a typo there would start a new
patient with no history). The clinician confirms the details before the round starts.
Designers set all three in the Dictionary panel (a "Previous values" selector on each section header and each field row, a Unit box on numeric fields — see Clinical Terminology) or by asking the refine chat; a converted observation chart arrives with them already set (PDF to Form).
Three form-core functions do the work and are the only place the rules live:
resolveHistoryConfig(definition) gives each field's effective history after
section inheritance, projectObservations(definition, data, { effectiveAt })
flattens a response into Observation rows, and alignHistory(definition, observations) decides
which prior rows belong to which field — by terminology binding first (so a
series survives a rename, a move, or a different form), by index-free data path
second, never by label. Bind the fields you want to trend (see
Clinical Terminology); an unbound field gets
path-only history that breaks the moment the field moves.
A verdict per instrument (omf.bands on a Group)
A screening sheet usually prints a verdict beside each instrument's total —
qSOFA "Positive if ≥ 2" out of 3, SIRS "Positive if ≥ 2" out of 4. Put
omf.bands on that instrument's Group and the section's own subtotal picks the
matching band, which renders as a verdict chip beside Σ n:
{ "type": "Group", "label": "qSOFA (1 pt each)",
"options": { "omf": { "bands": [
{ "maxScore": 1, "label": "Negative", "color": "#2e7d4f" },
{ "minScore": 2, "label": "Positive", "color": "#b3392c" }
] } },
"elements": [ /* one boolean Control per criterion, each with omf.points */ ] }
Bounds are inclusive and both optional. Bands go on the Group of the instrument they belong to: a sheet carrying several independent instruments gives each its own Group and its own bands.
Not scoreSummary for this. That control stratifies the whole form's
total, so on a sheet with qSOFA and SIRS it would add the two together into a
number that means nothing clinically. Use it only where the source really does
print one combined score for the entire sheet.
When the threshold is on a typed number rather than a total of tick-boxes ("MEWS: positive if score > 3", entered as one value), there is no section total to band — use a rule-gated Label instead, see Computed outcomes.
The verdict prints too: a filled sheet's legend reads qSOFA (1 pt each) · Σ 2 — Positive. A blank sheet prints neither number nor verdict, because "Σ 0 —
Negative" beside a box nobody has answered is a wrong clinical reading, not a
neutral placeholder.
Callouts (an accented Label)
A Label carries read-only text — instructions, footnotes, bulleted blocks —
and renders as plain body text. Give it omf.accentColor and it becomes a
callout instead: bordered and bold in that colour, washed with a tint of it.
That is the banner a paper form puts around a result, an alert or a warning.
{ "type": "Label", "text": "Overall result: CAM-ICU POSITIVE (Delirium Present)",
"options": { "omf": { "accentColor": "#b3392c", "icon": "⚠️" } } }
Same key that colours a Group, so there is no new vocabulary, and a Label
without an accent is exactly the plain text it has always been — existing
instruction blocks are unaffected.
Prefer hex. The background wash is derived from the accent by form-core's
accentTint, shared by React, Angular and print so the three cannot drift. A
colour that maths cannot read (a CSS variable, a named colour) still paints the
border and the text; only the wash is skipped. On paper the tint is mixed
against white rather than laid on with alpha, since print pipelines routinely
drop alpha compositing.
Conversion caveat. A source that expresses its banner colour through a
stylesheet class (.result-banner.pos { color: … }) cannot pass that colour
on: <style> is stripped as non-content, so the converter sees the class name
and never the declarations. Inline style="color:#b3392c" does convert. For a
synthesised outcome the colour therefore comes from what the result means.
Two shapes of scoring
Which one a form uses is decided by the paper, not by preference:
| Paper shows | Emit | Scores when |
|---|---|---|
a tick-box row with a points column (Acute MI …… 1) |
boolean Control + omf.points: 1 |
the box is ticked |
| one dropdown or one set of mutually-exclusive radios whose choice carries the score | enum Control + omf.optionPoints: { NO: 0, YES: 25 } |
that option is selected |
omf.optionPoints is keyed by the stored code, so codes stay clean and
language-independent — Morse Fall's ambulatory aid is
{ NONE_BEDREST_NURSE_ASSIST: 0, CRUTCHES_CANE_WALKER: 15, FURNITURE: 30 },
never a code like CRUTCHES_CANE_WALKER_15. A code the map does not price
contributes nothing rather than a guess, so a response saved against an older
version of the form fails safe.
Enum option labels
A stored code is language-independent and usually unreadable. Display text comes
from the dataSchema's oneOf:
{ "oneOf": [{ "const": "NO", "title": "No" }, { "const": "YES", "title": "Yes" }] }
or, for a schema that already carries a plain enum, from
omf.optionLabels: { "NO": "No" }. Both renderers resolve options through
resolveEnumOptions in @openmedform/form-core, so React and Angular cannot
label the same schema differently. With neither, the renderer shows the bare
code — visibly wrong on purpose, since an empty control would hide the mistake.
Note that the translations bundle is not wired into either renderer today,
so it cannot supply option labels.
Conditional rows in a table
Any UI element may carry a JSON Forms rule (SHOW / HIDE / ENABLE /
DISABLE); form-core's evaluateRule() is the single implementation, so a
condition means the same thing in React, in Angular and on the server.
An OmfTableRow is the one place this needs saying. A row is not dispatched
through the framework — the table renderer maps it straight onto a <tr>,
because the row is the layout — so a rule on a row was ignored by both renderers
until it was handled explicitly. Both now resolve their rows through
filterVisibleElements() in form-core, which is what makes a stepwise
assessment work: CAM-ICU asks Feature 2 only once Feature 1 is present.
{
"type": "OmfTableRow",
"label": "Feature 2: Inattention",
"elements": [{ "type": "Control", "scope": "#/properties/feature2" }],
"rule": {
"effect": "SHOW",
"condition": { "scope": "#/properties/feature1", "schema": { "const": "PRESENT" } }
}
}
Put the rule on the row, not on the Controls inside it — a gated row appears
and disappears as a unit, where gated Controls leave an empty row behind. A
DISABLE on a row is ANDed into every cell it contains. A conditionally-shown
field is never listed in required: it is absent whenever the condition is
false. Conversion emits these rules automatically for a mock-up that reveals
sections with JavaScript — see
Progressive disclosure.
On paper, the print engine evaluates the same rules with the same form-core
code, and which way it resolves follows what the sheet is for: a blank form
prints every conditional section (it is there to be filled in by hand), while a
filled one omits a section the response never triggered (a question that was
never asked does not belong in the record). renderPrintHtml's rules: 'apply' | 'ignore' overrides either default. Only visibility applies — ENABLE/DISABLE
have no meaning on paper, so a disabled field still prints.
Computed outcomes
A condition's scope may be #, which resolves to the whole response rather
than one field, so its schema can combine several answers with ordinary JSON
Schema — properties + required for AND, anyOf for OR. That is how a form
states a derived clinical result without any expression language: one Label
per outcome, each carrying the condition for its own case.
{ "type": "Label", "text": "Overall result: CAM-ICU POSITIVE (Delirium Present)",
"rule": { "effect": "SHOW", "condition": { "scope": "#", "schema": {
"type": "object",
"properties": { "feature1": { "const": "PRESENT" }, "feature2": { "const": "PRESENT" } },
"required": ["feature1", "feature2"],
"anyOf": [
{ "properties": { "feature3": { "const": "PRESENT" } }, "required": ["feature3"] },
{ "properties": { "feature4": { "const": "PRESENT" } }, "required": ["feature4"] }
] } } } }
Give each outcome an omf.accentColor so it renders as the banner the
source draws rather than as body text — an accented Label is a bordered, bold,
tinted callout. Colour by clinical sense, not decoration: abnormal / positive /
"action required" red, normal / negative green, cautionary amber. A Label
without an accent is unchanged plain instruction text.
Two rules to get right:
- List every field the case depends on in
required. Without it an empty response satisfiespropertiesvacuously and every outcome shows at once. - Keep outcomes mutually exclusive — exactly one may match any response.
This is for a result the source states in prose. A numeric total mapped to
risk levels is scoreSummary with omf.bands, and scoring stays authoritative
on the server either way — an outcome Label is display, never the record.
Conversion emits these automatically when a mock-up computes a result banner in JavaScript; see Computed result text.
Removing Forms
The forms list (/forms) offers two removal actions:
- Archive (soft delete) — sets the form status to
ARCHIVED. The form and all its data are retained; it is only hidden from active workflows. This is the default, recoverable option. - Delete permanently (hard delete) — irreversibly removes the form and all related data in a single transaction: every form version (including draft schemas), every submission (clinical records), and all AI chat history. A confirmation dialog fetches and displays the exact counts (via
GET /api/forms/:id/deletion-summary) before the user confirms.
Both actions are tenant-scoped. Prefer Archive for anything that has clinical submissions; permanent delete destroys those records with no recovery.