Form Auto-Fill & Profile Sync
Forms automatically pre-populate fields from patient profile data. When a patient fills a form, values sync back to the appropriate profile store for future auto-fill.
NOT BUILT — reconciled 2026-08-02
No forms / form_templates / custom_fields table exists, no Go domain, no routes, and zero forms.* permission rows in any migration. Ships in migrations 000042 + 000043. See index.md for the full correction list — the load-bearing ones are: consents, not user_consents; no users table / no user_id (actors are created_by_principal_id / signed_by_principal_id, the subject is patient_profile_id); UUID PKs, not integers; custom-field keys unique per org, never globally; and the instance carries a fields JSONB snapshot + template_version frozen at creation, which is the single most important structural fix in the port.
The portable-profile table is patient_profiles
This page previously named a table and an RLS helper that do not exist; both have been renamed in place. Verified: migration 000006 creates patient_profiles and the helper is current_human_patient_profile_ids(). Its native columns are name, date_of_birth, sex, phone, occupation, residence, blood_type, allergies, chronic_conditions, emergency_contact_name, emergency_contact_phone, insurance_entries — so the legacy "patient identity meta field" bindings for birthdate / residence / occupation / sex have native homes and are dropped. Only CNP has no home, and it gets a dedicated encrypted column, never a generic value row.
Auto-fill is a COPY, not a pointer. At instance creation the resolver copies known values into the instance snapshot; on save, a separate, separately audited write-back updates the profile or value store. Historical instances never mutate. A single-step "the form points at the shared value" design is exactly the legacy defect this replaces — there, answering a question in one form silently rewrote every other form the patient ever filled.
Two types of profile-linked fields
A form template field can be linked to a profile store in one of two ways:
| Mechanism | Links to | Auto-fill source | Profile sync target |
|---|---|---|---|
profile_field_key | A patient_profiles column | patient_profiles (cross-org) | patient_profiles |
custom_field_id | An org-scoped custom field | custom_field_values (org-scoped) | custom_field_values |
A field uses one or the other, never both. A field with neither is a one-off field — its value lives only in forms.values and never syncs anywhere.
profile_field_key: portable profile fields
Use profile_field_key for fields that represent universal facts about the patient — data that is the same regardless of which clinic they're at.
Valid values:
| profile_field_key | Patient persons column | Type |
|---|---|---|
date_of_birth | patient_profiles.date_of_birth | date |
sex | patient_profiles.sex | text |
occupation | patient_profiles.occupation | text |
residence | patient_profiles.residence | text |
blood_type | patient_profiles.blood_type | text |
allergies | patient_profiles.allergies | text[] |
chronic_conditions | patient_profiles.chronic_conditions | text[] |
emergency_contact_name | patient_profiles.emergency_contact_name | text |
insurance_entries | patient_profiles.insurance_entries | jsonb |
Example form field (snapshotted in forms.fields):
{
"key": "dob",
"label": "Date of Birth",
"field_type": "date",
"profile_field_key": "date_of_birth"
}Auto-fill on form creation: reads patient_profiles.date_of_birth for the patient.
Profile sync on form submit: writes the new value back to patient_profiles.date_of_birth.
Cross-org effect: because patient_profiles has no organization_id, a patient who updates their date of birth at Clinic A will see the updated value pre-filled at Clinic B on their next form.
custom_field_id: org-specific fields
Use custom_field_id for fields that are specific to the org's workflow — referral source, VIP status, training preference, billing notes, etc.
Example form field:
{
"key": "referral",
"label": "Referral Source",
"field_type": "select",
"custom_field_id": 42,
"version": 1,
"options": ["Physiotherapist", "GP", "Online", "Word of mouth"]
}Auto-fill on form creation: reads custom_field_values for this patient in this org.
Profile sync on form submit: upserts custom_field_values for this patient, org, and field.
Org-scoped: a value filled at Clinic A does not pre-fill the same field at Clinic B.
One-off fields (no sync)
For appointment-specific data that should not sync to any profile:
{
"key": "chief_complaint_today",
"label": "What brings you in today?",
"field_type": "textarea"
}Value lives in forms.values only. No auto-fill. No profile sync.
Auto-fill flow (form creation)
func createFormInstance(templateID, appointmentID) {
template := getTemplate(templateID)
appointment := getAppointment(appointmentID)
person := getPatientPerson(appointment.PatientPersonID)
fields := []FormField{}
values := map[string]any{}
for _, f := range template.Fields {
switch {
case f.ProfileFieldKey != nil:
// Snapshot the field definition
fields = append(fields, snapshotProfileField(f))
// Auto-fill from patient_profiles column
if value := getProfileFieldValue(person, *f.ProfileFieldKey); value != nil {
values[f.Key] = value
}
case f.CustomFieldID != nil:
// Snapshot the custom field version
customField := getCustomField(*f.CustomFieldID)
fields = append(fields, snapshotCustomField(f, customField))
// Auto-fill from custom_field_values (org-scoped)
entityID := resolveEntityID(customField.EntityType, appointment)
if value := getCustomFieldValue(customField.ID, customField.EntityType, entityID); value != nil {
values[f.Key] = value
}
default:
// One-off field — no auto-fill
fields = append(fields, snapshotOneOffField(f))
}
}
return createForm(appointment, template, fields, values)
}Profile sync flow (form submit)
func saveForm(formID int64, submittedValues map[string]any) {
form := getForm(formID)
appointment := getAppointment(form.AppointmentID)
// 1. Save all values to forms.values
form.Values = mergeValues(form.Values, submittedValues)
updateForm(form)
// 2. Sync profile-linked fields
for _, field := range form.Fields {
value, submitted := submittedValues[field.Key]
if !submitted {
continue // Not modified in this save — don't overwrite
}
switch {
case field.ProfileFieldKey != nil:
// Write to patient_profiles (cross-org portable profile)
updatePatientPersonField(appointment.PatientPersonID, *field.ProfileFieldKey, value)
case field.CustomFieldID != nil:
// Write to custom_field_values (org-scoped)
entityID := resolveEntityID(field.EntityType, appointment)
upsertCustomFieldValue(form.OrganizationID, *field.CustomFieldID, field.EntityType, entityID, value)
}
}
}Behaviour: last write wins
Profile values represent the current known state of the patient. If a patient fills the same field across multiple forms, the most recent submission wins:
Form A (Clinic A): occupation = "Engineer" → patient_profiles.occupation = "Engineer"
Form B (Clinic B): occupation = "Architect" → patient_profiles.occupation = "Architect"
Old Form A still shows "Engineer" (historical snapshot, immutable)
New forms at any clinic pre-fill "Architect"Entity type resolution for custom_field_id
Custom fields apply to different entity types. The form resolves the correct entity ID based on the field's entity_type:
func resolveEntityID(entityType string, appointment *Appointment) int64 {
switch entityType {
case "patient":
return appointment.PatientID // patients.id (org-patient link)
case "specialist":
return appointment.SpecialistID
case "appointment":
return appointment.ID
case "organization":
return appointment.OrganizationID
}
}Cross-org auto-fill comparison
| Field type | Patient books at Clinic A, then Clinic B |
|---|---|
profile_field_key: "date_of_birth" | Clinic B sees the DOB filled at Clinic A — pre-filled immediately |
profile_field_key: "blood_type" | Clinic B sees the blood type from Clinic A — pre-filled immediately |
custom_field_id (referral source) | Clinic B does NOT see Clinic A's referral source — starts empty |
Related documentation
- Patient Profile → — Portable profile model and data ownership
- Entity Profiles → — How profile reads and writes work
- Custom Fields → — Org-specific field library
- Form Lifecycle → — pending → signed state machine