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(migration000006), and its RLS helper iscurrent_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)andUNIQUE (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 reachescustom_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.
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?
| Data | Where it lives | Shared across orgs? |
|---|---|---|
| Date of birth, sex, phone | patient_profiles (portable profile) | ✅ Yes |
| Blood type, allergies, chronic conditions | patient_profiles (portable profile) | ✅ Yes |
| Occupation, residence, emergency contact, insurance | patient_profiles (portable profile) | ✅ Yes |
| Referral source, VIP status, training surface, billing notes | Custom 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
- Admin creates a field: "Referral Source" with options: Physiotherapist, GP, Online, Word of mouth
- Form template references it: Intake form includes "Referral Source" without duplicating the definition
- Patient fills form: Answer is snapshotted with field v1's options
- Admin updates field: Adds "Social Media" option → published as v2
- New patients: See the updated options automatically
- Old patients: Their submitted forms still show the original options (historical accuracy)
- 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 type | Use case | Examples |
|---|---|---|
patient | Org-specific patient attributes | Referral source, VIP status, billing notes, training preference |
specialist | Provider credentials | Medical license #, certifications, languages |
appointment | Appointment metadata | Internal priority, billing code, room number |
organization | Org-level configuration | Custom 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:
| Mechanism | What it links to | Profile sync |
|---|---|---|
custom_field_id | An org-scoped custom field | Writes to custom_field_values |
profile_field_key | A patient_profiles column | Writes 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_key → key 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):
{
"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_basicandclinicaldata. Column-level encryption is reserved forauth_secretandpii_regulatedonly, and is mechanically enforced bycmd/check-classification. See decisions.md → Why most PII is plaintext (and what isn't). - Fully queryable for filtering
is_privatecontrols document visibility, not encryption and not access during form filling. Note the resulting trap: a field that is bothrequiredandis_privatecan 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 checkfails the build otherwise.
Examples
Specialty-specific fields
Sports clinic:
{"key": "training_surface", "label": "Preferred Training Surface",
"field_type": "select", "options": ["Court", "Grass", "Artificial turf", "Sand"]}Dermatology clinic:
{"key": "skin_type", "label": "Skin Type",
"field_type": "select", "options": ["I", "II", "III", "IV", "V", "VI"]}Internal tracking
{"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 RomanianDocumentation 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
Related features
- Patients (../patients/) — Portable profile model (
patient_profiles) - Forms (../forms/) — Reference custom fields in templates, snapshot versions in instances
Segments— out of scope (settled 2026-08-02); belongs to a later patient-data-segmentation feature. The GIN index oncustom_field_valueskeeps the door open without building it.