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:

  1. Convert a source document — upload a PDF, image or HTML mock-up from the forms list ("From File"). See PDF-TO-FORM.md.
  2. Or describe the form — "From Prompt" creates a draft from a natural-language description.
  3. 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.
  4. 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 satisfies properties vacuously 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.