Skip to content

Custom Fields — Org-Specific Field Library

Define org-specific fields once, use them in forms and profiles, update them without breaking historical records.

NOT BUILT — reconciled 2026-08-02

No custom_fields, custom_field_versions or custom_field_values table exists. Ships in migration 000042 as the first half of F3, after F1 and F2.1.

Corrections to this page:

  • The portable-profile table is patient_profiles (migration 000006), and its RLS helper is current_human_patient_profile_ids(). This page previously named a table and helper that do not exist; both have been renamed in place throughout.
  • Uniqueness is UNIQUE (organization_id, entity_type, key) and UNIQUE (organization_id, system_key) — never global. See below; this is a foundation-level correction, not a preference.
  • PKs are UUIDs, not integers.
  • field_type = 'national_id' never reaches custom_field_values.value. See the CNP section below.
  • F8 Segments is out of scope, so "smart patient segmentation" has no consumer today. The GIN index on values keeps the door open without building it.

See features.md → F3.1 and leo-port-map.md §3 → F3.


What this enables

Org-specific extensibility: Every clinic has different needs. A sports clinic tracks "preferred training surface". A physio clinic tracks "referral source". Custom fields let each org extend patient, specialist, appointment, and organization records with fields the platform doesn't model natively.

Non-breaking field updates: Admin adds a new option to a select field? Old form submissions keep the original options (snapshot). New forms automatically get the updated options.

Profile syncing: When a patient fills a form field that is linked to a custom field, their profile value updates automatically — pre-filling future forms.

Smart patient segmentation: Create segments like "All patients referred by a physiotherapist" by querying custom field values.


What belongs here vs the patient profile

Before creating a custom field for a patient, ask: Is this a universal fact about the person, or is it specific to how our clinic tracks them?

DataWhere it livesShared across orgs?
Date of birth, sex, phonepatient_profiles (portable profile)✅ Yes
Blood type, allergies, chronic conditionspatient_profiles (portable profile)✅ Yes
Occupation, residence, emergency contact, insurancepatient_profiles (portable profile)✅ Yes
Referral source, VIP status, training surface, billing notesCustom fields (org-scoped)❌ No

Custom fields are for org-specific extras — the things that genuinely belong to the clinic's context, not to the patient as a universal person. See Patient Profile → for the portable profile model.


How it works

  1. Admin creates a field: "Referral Source" with options: Physiotherapist, GP, Online, Word of mouth
  2. Form template references it: Intake form includes "Referral Source" without duplicating the definition
  3. Patient fills form: Answer is snapshotted with field v1's options
  4. Admin updates field: Adds "Social Media" option → published as v2
  5. New patients: See the updated options automatically
  6. Old patients: Their submitted forms still show the original options (historical accuracy)
  7. Profile sync: Latest answer appears in patient's org profile, pre-fills next form

Technical Reference

Everything below is intended for developers.

Entity types

Custom fields can be defined for any entity:

Entity typeUse caseExamples
patientOrg-specific patient attributesReferral source, VIP status, billing notes, training preference
specialistProvider credentialsMedical license #, certifications, languages
appointmentAppointment metadataInternal priority, billing code, room number
organizationOrg-level configurationCustom settings not modeled in the orgs table

Versioning & immutability

Custom fields are versioned on publish to ensure historical integrity:

Day 1: Admin creates "referral_source" field
  - type: select
  - options: ["Physiotherapist", "GP", "Online", "Word of mouth"]
  - version: 1, published: true

Day 10: Patient fills form → snapshots field v1

Day 30: Admin edits "referral_source" → adds "Social Media"
  - Archives v1 to custom_field_versions
  - Publishes v2 with 5 options

Result:
  - Old form still shows 4 options (v1 snapshot)
  - New forms automatically get 5 options (v2)

Linking form fields to custom fields vs the portable profile

A form template field can be linked in two ways:

MechanismWhat it links toProfile sync
custom_field_idAn org-scoped custom fieldWrites to custom_field_values
profile_field_keyA patient_profiles columnWrites to patient_profiles directly

Use custom_field_id for org-specific fields. Use profile_field_key for fields that represent universal patient facts (date of birth, blood type, etc.). A field can have one or the other, never both.

patient_profiles already carries name, date_of_birth, sex, phone, occupation, residence, blood_type, allergies, chronic_conditions, emergency_contact_name, emergency_contact_phone, insurance_entries as native columns. The legacy system expressed several of those as per-tenant string pointers into its EAV store — unvalidated, and a whole class of bug. Those bindings are dropped: native columns exist for all of them. CNP is the only patient-identity field with no home, and it gets a dedicated encrypted column rather than an EAV row.

Both writes are audited, and neither is implicit

Auto-fill copies into the form instance's snapshot at creation. Saving the form performs a separate, separately audited write-back to custom_field_values / patient_profiles. Historical form instances never mutate as a side effect. The legacy design — a middleware that silently redirects form answers into a shared user-scoped value row — means answering a question in one form rewrites every other form the patient ever filled, and no historical record is stable.

See Form Auto-Fill → for the full mechanics.

Data model

custom_fields (current published version)
  ├── id
  ├── organization_id
  ├── entity_type ('patient' | 'specialist' | 'appointment' | 'organization')
  ├── key            (admin-chosen identifier, localizable)
  ├── label          (display text, localizable)
  ├── field_type     (text | textarea | select | date | checkbox | radio | number | email | phone)
  ├── options        (for select/radio/checkbox)
  ├── is_private     (specialist-only visibility in PDFs)
  ├── system_key     (stable ID for system-critical fields — NULL for org-created fields)
  └── sort_order

custom_field_versions (historical snapshots)
  ├── custom_field_id
  ├── version
  ├── fields_snapshot (JSONB — full field definition at that version)
  └── published_at

custom_field_values (per-entity data)
  ├── organization_id
  ├── custom_field_id → custom_fields.id
  ├── entity_type    (matches custom_fields.entity_type)
  ├── entity_id      (patients.id | specialists.id | appointments.id | organizations.id)
  └── value          (plaintext TEXT — queryable)

Uniqueness is per-org, never global

UNIQUE (organization_id, entity_type, key)
UNIQUE (organization_id, system_key)

This is not a style preference. The legacy system makes the field key globally unique across all tenants, which is why its cross-tenant template copy leaves the copied template pointing at another clinic's field definitions. Under RLS that resolves to nothing, or — worse, in a system without RLS — to someone else's data.

Template copy therefore remaps by system_keykey within the target org. A reference that cannot be resolved inside the target org fails the copy; it never silently resolves across a tenant boundary. Cross-tenant sharing is P49 platform-tier + clone. Never move — moving re-parents historical clinical records across a tenant boundary.

Every table here carries organization_id NOT NULL and its own RLS policies calling current_app_has_permission.

CNP (national ID) never enters this store — settled 2026-08-02

A custom_field_values.value TEXT column can never legally hold a CNP. A custom field of type national_id either routes to the dedicated encrypted patient_profiles column, or is rejected outright — it must not fall through to the EAV path.

CNP is pii_regulated, which means: encrypted BYTEA via internal/core/crypto, stored once on the patient-owned profile, never duplicated per-org; opt-in per template (form and PDF alike), default off; an explicit data-classification.md egress target for the PDF renderer, which calls classification.AllowedFor rather than hand-building a field list (P39); and reads that are permissioned and audited on the reveal endpoint.

The legacy system stores CNP as a plaintext value row and prints it on every report and prescription. That is the failure this rule exists to prevent.

One-off form fields

What if a form needs a field that should not sync to any profile? (e.g., "Chief complaint today" — appointment-specific):

json
{
  "custom_field_id": null,
  "profile_field_key": null,
  "key": "chief_complaint_today",
  "type": "textarea",
  "label": "What brings you in today?"
}

Values are saved in forms.values only. No profile sync, no reuse.

System fields

Some custom fields have a stable system_key for use by PDF templates and external integrations. The system_key never changes even if the admin renames the field's key or label for localization. See System Fields →.

Storage

Custom field values are stored as plaintext TEXT, with one hard exception (national_id, above):

  • Layered defense — RLS + audit + at-rest disk encryption + encrypted backups + restricted DB access — is the platform's answer for pii_basic and clinical data. Column-level encryption is reserved for auth_secret and pii_regulated only, and is mechanically enforced by cmd/check-classification. See decisions.md → Why most PII is plaintext (and what isn't).
  • Fully queryable for filtering
  • is_private controls document visibility, not encryption and not access during form filling. Note the resulting trap: a field that is both required and is_private can never block a patient submit, because private fields never reach the patient's payload. Reject that combination at template publish time.
  • GDPR is the day-one driver here, not HIPAA — HIPAA readiness is a nice-to-have the same controls happen to satisfy.
  • Every new column added in the migration needs a data-classification.md registry entry in the same PR; make check fails the build otherwise.

Examples

Specialty-specific fields

Sports clinic:

json
{"key": "training_surface", "label": "Preferred Training Surface",
 "field_type": "select", "options": ["Court", "Grass", "Artificial turf", "Sand"]}

Dermatology clinic:

json
{"key": "skin_type", "label": "Skin Type",
 "field_type": "select", "options": ["I", "II", "III", "IV", "V", "VI"]}

Internal tracking

json
{"key": "referral_source", "label": "How did you hear about us?", "field_type": "select",
 "options": ["Physiotherapist", "GP", "Online", "Word of mouth"]}

{"key": "vip_status", "label": "VIP Patient", "field_type": "checkbox", "is_private": true}

Localization of system fields

system_key: "patient_insurance_number"  ← stable, referenced by PDF templates
key: "numar-asigurare"                  ← admin renamed to Romanian
label: "Număr asigurare"                ← admin renamed to Romanian

Documentation structure

  • index.md (this file) — Overview and field library model
  • Schema → architecture/data-model.md → Area 6 (Custom Fields + Profile Fields) — tables, indexes, RLS policies
  • api.md — HTTP endpoints and request/response formats
  • versioning.md — Detailed versioning workflow and snapshot logic
  • system-fields.md — System field concepts and PDF template mapping
  • entity-profiles.md — Org-specific profile data and auto-fill integration
  • Patients (../patients/) — Portable profile model (patient_profiles)
  • Forms (../forms/) — Reference custom fields in templates, snapshot versions in instances
  • Segmentsout of scope (settled 2026-08-02); belongs to a later patient-data-segmentation feature. The GIN index on custom_field_values keeps the door open without building it.