The OpenMedForm data model — tenants, forms, versions, submissions and their relationships.
Data Model
Entity Relationship
Tenant 1──N User
Tenant 1──N Form
Tenant 1──N Submission
User 1──N Form (createdBy)
User 1──N Submission (submittedBy)
Form 1──N FormVersion
Form 1──1 FormVersion (currentVersion)
Form 1──N Submission
FormVersion 1──N Submission
Tables
tenant
Multi-tenant isolation root.
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| name | VARCHAR(255) | |
| slug | VARCHAR(100) | Unique |
| is_active | BOOLEAN | Default true |
| settings | JSONB | Tenant-level config |
| created_at | TIMESTAMP | |
| updated_at | TIMESTAMP |
user
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| tenant_id | UUID FK | → tenant |
| VARCHAR(255) | Unique per tenant | |
| password_hash | TEXT | bcrypt |
| full_name | VARCHAR(255) | |
| role | ENUM | SUPER_ADMIN, TENANT_ADMIN, FORM_DESIGNER, CLINICIAN, VIEWER |
| is_active | BOOLEAN | |
| last_login_at | TIMESTAMP |
form
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| tenant_id | UUID FK | → tenant |
| name | VARCHAR(255) | |
| slug | VARCHAR(255) | Unique per tenant |
| description | TEXT | |
| category | VARCHAR(100) | e.g. "vte-assessment" |
| tags | TEXT[] | |
| form_type | ENUM | PATIENT, NON_PATIENT (default PATIENT) |
| archived_at | TIMESTAMP? | When the form was archived. Hidden from the default list; the clock a retention policy would run off |
| status_before_archive | form_status_enum? | Status to restore on unarchive. Recorded rather than derived — a form archived awaiting review must return to REVIEW |
| status | ENUM | DRAFT, CONVERTING, REVIEW, PUBLISHED, ARCHIVED, RETIRED |
| current_version_id | UUID FK | → form_version (nullable). The version served for data entry (fill screen, new submissions, export). Follows an in-place draft edit and a publish; a fork made by editing a published form does not move it — the published version stays current until the fork is published |
| created_by_id | UUID FK | → user |
form_version
Immutable once published (enforced: on publish a SHA-256 content_hash of the
canonical payload is stored; edits after publish fork a new draft, and
GET /forms/:id/versions/:versionId/integrity recomputes the hash to detect
tampering). Each edit creates a new version. JSON Forms only — see ADR-004.
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| form_id | UUID FK | → form |
| version | INT | Auto-increment per form |
| data_schema | JSONB | JSON Schema 2020-12 (nullable) |
| ui_schema | JSONB | UI/layout schema (nullable) |
| print_schema | JSONB | A4 print schema (nullable) |
| translations | JSONB | Translation bundle (nullable) |
| scoring_rules | JSONB | Scoring config used by the server-side engine |
| metadata | JSONB | Display settings, theme overrides |
| conversion_metadata | JSONB | Per-field confidence/warnings from AI conversion. Also carries structureProbe — written by the server, not the model — recording what a PDF/image page-structure pre-pass detected and rejected, so a reviewer can see what the pipeline actually passed to the model |
| content_hash | VARCHAR(64) | SHA-256 of canonical published payload (immutability) |
| changelog | TEXT | |
| published_at | TIMESTAMP | NULL = draft |
submission
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| tenant_id | UUID FK | → tenant |
| form_id | UUID FK | → form |
| form_version_id | UUID FK | → form_version |
| submitted_by_id | UUID FK | → user |
| status | ENUM | IN_PROGRESS, COMPLETED, SIGNED, AMENDED, VOIDED |
| data | JSONB | Submission data (jsonforms: Ajv-validated server-side on complete) |
| scores | JSONB | Server-calculated scores |
| risk_level | VARCHAR(50) | Denormalized for queries |
| patient_mrn | VARCHAR(50) | Optional patient link |
| encounter_id | VARCHAR(100) | Optional encounter link |
| patient_context | JSONB | Full patient context for patient forms |
| signed_at | TIMESTAMP | |
| signed_by | VARCHAR(255) | |
| effective_at | TIMESTAMP | Clinical time of the response — when the readings were taken, not saved. Set at completion from the client's effectiveAt, else a Control flagged omf.effectiveAt, else created_at. History sorts on it (ADR-005) |
Index (tenant_id, patient_mrn, effective_at) serves per-patient history.
observation
One scalar reading flattened out of a completed submission (ADR-005). A read model: fully
derivable from submission.data + the pinned version, rebuilt on every complete
(delete-by-submission, then insert) and by apps/api/scripts/backfill-observations.ts. Exists so
"this patient's last five heart rates" is one indexed query. Cascades with its submission.
| Column | Type | Notes |
|---|---|---|
| id | BIGSERIAL PK | |
| tenant_id | UUID | Scope of every query |
| submission_id | UUID FK | → submission, ON DELETE CASCADE |
| form_id, form_version_id | UUID | Provenance |
| patient_mrn, encounter_id | VARCHAR | Copied from the submission; the history key |
| path | VARCHAR(500) | Dotted data path, record indices included (hourly.2.hr) |
| code_system, code | VARCHAR | Primary terminology binding of the field (omf.coding[0]), when bound |
| label | VARCHAR(500) | |
| value_num / value_text / value_bool | FLOAT / TEXT / BOOLEAN | Exactly one set |
| unit | VARCHAR(50) | UCUM from omf.unit |
| effective_at | TIMESTAMP | Clinical time (per record for a recordTable with effectiveAtPath) |
| row | JSONB | The full form-core Observation (all codings, option binding, label, source) — returned to renderers unchanged |
Indexes: (tenant_id, patient_mrn, code, effective_at), (tenant_id, patient_mrn, path, effective_at),
(tenant_id, patient_mrn, form_id, effective_at), (submission_id).
form_asset
Binary assets referenced by a form version (logos, reference images). Log-style
scalar FKs (no relations), matching audit_log.
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| tenant_id | UUID | |
| form_version_id | UUID | nullable |
| filename | VARCHAR(255) | |
| mime_type | VARCHAR(100) | |
| size_bytes | INT | |
| checksum | VARCHAR(64) | |
| storage_key | VARCHAR(500) | external store key (nullable) |
| data | BYTEA | inline bytes (nullable) |
| created_at | TIMESTAMP |
conversion_job / conversion_warning
Async PDF→form conversion tracking (consumed in Phase 6). Persisted warnings ensure uncertain elements are never silently dropped.
conversion_job: id, tenant_id, form_id?, status ENUM (PENDING, RUNNING, REVIEW,
COMPLETED, FAILED), provider, model,
source_file_name, page_count, similarity_score, error, created_by_id, created_at,
completed_at. conversion_warning: id, conversion_job_id FK, type, message,
binding?, source_page?, confidence?, created_at.
form_ai_message
The refine conversation for a form — one row per chat bubble in the preview
page's Refine-with-AI panel. role USER carries the instruction; role
ASSISTANT the outcome, with status ERROR when the refinement failed (an
instruction that did not apply is part of the story). Scoped to the form, not a
version, so history survives the draft fork a published-form refine makes.
Log-style scalar FKs like audit_log; writes are best-effort and never fail
the refinement. Columns: id, tenant_id, form_id, role, content, status,
had_image (the image itself is not stored), created_by_id?, created_at.
Bounded read: the panel loads the newest 400 rows.
(Successor to the Form.io builder's table of the same name, dropped in migration 20260801190000 with the engine — see ADR-004.)
audit_log
Now actively written (closes issue #1) by AuditService on form create/publish/
delete, submission complete/sign, and authentication (auth.register,
auth.login, auth.login.failed). Best-effort: a logging failure is logged and
swallowed so it can never roll back the audited clinical operation.
| Column | Type | Notes |
|---|---|---|
| id | BIGSERIAL PK | |
| tenant_id | UUID | |
| user_id | UUID | |
| action | VARCHAR(100) | e.g. "form.publish", "submission.sign", "auth.login" |
| resource_type | VARCHAR(50) | |
| resource_id | UUID | |
| details | JSONB | |
| ip_address | VARCHAR(45) | |
| created_at | TIMESTAMP |
ai_usage
One row per LLM call (generate / refine / convert), written best-effort by
AiUsageService via a metering wrapper around every provider. Aggregated by the
SUPER_ADMIN analytics console (GET /api/admin/stats) and the usage console
(GET /api/admin/usage, grouped by user / form / tenant / provider /
operation; the operation view adds output-token p50/p95 per call).
cached_input_tokens records how much of the input the provider served from
its prompt cache (issue #129) — a subset of input_tokens, billed at a deep
discount, zero for providers that report no caching.
Operational metering — not tenant-query-scoped for domain reads, but carries
tenant_id/user_id/form_id for attribution.
Form attribution. form_id is nullable and has no FK (log-style, like
audit_log), because: refine/designer flows know the form up front and set it
directly; create flows meter the LLM call before the form exists and
backfill via AiUsageService.attachFormId once it does; a run that never
produces a form (e.g. a failed conversion) correctly stays unattributed; and
usage history must outlive the form being deleted. The usage console reports
unattributed rows as "Unattributed" rather than dropping them, so grouped rows
always reconcile with the platform total.
| Column | Type | Notes |
|---|---|---|
| id | BIGSERIAL PK | |
| tenant_id | UUID | |
| user_id | UUID (nullable) | |
| form_id | UUID (nullable) | attributed form; no FK — see above |
| provider | VARCHAR(50) | e.g. "claude", "openai" |
| model | VARCHAR(100) | model that reported the usage |
| operation | VARCHAR(50) | e.g. "ai.generate", "ai.refine", "conversion.jsonforms" |
| input_tokens | INT | |
| output_tokens | INT | |
| total_tokens | INT | |
| created_at | TIMESTAMP |
auth_exchange_code
One-time codes that trade for an access token after Google SSO, so the JWT never travels in a redirect URL. See security/AUTH-AND-RBAC.
| Column | Type | Notes |
|---|---|---|
| id | UUID | |
| code_hash | VARCHAR(64) | SHA-256 of the code, unique. The plaintext exists only in the redirect URL |
| user_id | UUID | Log-style scalar, no FK |
| expires_at | TIMESTAMP | 60s after minting |
| used_at | TIMESTAMP? | Set on first use; the usedAt IS NULL filter makes the claim atomic |
| created_at | TIMESTAMP |
A table rather than in-process state because the redirect and the exchange are two requests that can be served by different instances. Swept opportunistically when new codes are minted.