Custom Field Versioning
Overview
Custom field versioning ensures that form instances preserve their exact field definitions at the time of creation, even when field schemas are later updated. This is critical for HIPAA compliance - signed forms must be immutable.
Why Versioning?
| Scenario | Without Versioning | With Versioning |
|---|---|---|
| Admin adds option to select field | Old forms retroactively show new option (wrong!) | Old forms preserve original options (correct) |
| Admin changes field type | Data corruption, validation failures | Old forms keep original type |
| Audit/compliance review | Can't prove what form looked like when signed | Exact snapshot preserved forever |
| Template rollback | Lost forever | Can restore any previous version |
Version Lifecycle
1. Initial Creation
POST /v1/custom-fields
{
"entity_type": "patient",
"key": "blood_type",
"label": "Blood Type",
"field_type": "select",
"options": ["A+", "A-", "B+", "B-", "O+", "O-"]
}
Response:
{
"id": 42,
"version": 1,
"published": true,
"published_at": "2025-01-15T10:00:00Z"
}Backend actions:
- Create custom_fields row (version: 1, published: true)
- Archive to custom_field_versions:sql
INSERT INTO custom_field_versions ( custom_field_id, version, fields_snapshot, published_at ) VALUES ( 42, 1, '{"key":"blood_type","label":"Blood Type","field_type":"select",...}', '2025-01-15T10:00:00Z' )
2. Form Instance Snapshots Field Version
When form instance created:
1. Read custom_field from form_template
2. Lookup current published version (v1)
3. Snapshot into form_instance.fields:
{
"custom_field_id": 42,
"version": 1, ← Links to custom_field_versions
"key": "blood_type",
"label": "Blood Type",
"field_type": "select",
"options": ["A+", "A-", "B+", "B-", "O+", "O-"]
}This snapshot is immutable. Even if the custom field is later deleted, the form instance preserves its field definition forever.
3. Editing (Draft State)
PATCH /v1/custom-fields/42
{
"options": ["A+", "A-", "B+", "B-", "O+", "O-", "AB+", "AB-"]
}
Response:
{
"id": 42,
"version": 1, ← Still v1
"published": false, ← Now in draft state
"published_at": "2025-01-15T10:00:00Z"
}Backend actions:
- Set
published = false(draft state) - Update fields in-place (mutable while draft)
- Forms still use v1 (last published version from custom_field_versions)
4. Publishing New Version
POST /v1/custom-fields/42/publish
Response:
{
"id": 42,
"version": 2, ← Incremented
"published": true,
"published_at": "2025-02-01T14:30:00Z"
}Backend actions:
- Archive v1 to custom_field_versions (if not already archived)
- Archive current draft as v2:sql
INSERT INTO custom_field_versions ( custom_field_id, version, fields_snapshot, published_at ) VALUES ( 42, 2, '{"key":"blood_type","options":["A+","A-",...,"AB+","AB-"]}', '2025-02-01T14:30:00Z' ) - Update custom_fields:
version = 2, published = true
Result:
- Old form instances: still snapshot v1 (6 options)
- New form instances: snapshot v2 (8 options)
- Templates: unchanged (reference field_id: 42, version determined at form creation)
Snapshot Resolution
When creating a form instance, the backend resolves field versions:
-- Get current published version
SELECT version, fields_snapshot
FROM custom_field_versions
WHERE custom_field_id = 42
AND version = (SELECT version FROM custom_fields WHERE id = 42 AND published = true)
-- Or if custom_field is in draft state, use last published version
SELECT version, fields_snapshot
FROM custom_field_versions
WHERE custom_field_id = 42
ORDER BY version DESC
LIMIT 1Key rule: Always use the latest published version at the time of form instance creation.
Version History API
Get All Versions
GET /v1/custom-fields/42/versions
Response:
[
{
"version": 2,
"published_at": "2025-02-01T14:30:00Z",
"fields": {
"key": "blood_type",
"label": "Blood Type",
"field_type": "select",
"options": ["A+", "A-", "B+", "B-", "O+", "O-", "AB+", "AB-"]
}
},
{
"version": 1,
"published_at": "2025-01-15T10:00:00Z",
"fields": {
"key": "blood_type",
"label": "Blood Type",
"field_type": "select",
"options": ["A+", "A-", "B+", "B-", "O+", "O-"]
}
}
]Get Specific Version
GET /v1/custom-fields/42/versions/1
Response:
{
"version": 1,
"published_at": "2025-01-15T10:00:00Z",
"fields": {...}
}Rollback to Previous Version
POST /v1/custom-fields/42/rollback
{
"version": 1
}
Backend:
1. Load version 1 snapshot from custom_field_versions
2. Restore fields to custom_fields (published = false, draft state)
3. Admin can edit if needed, then publish as v3Draft vs Published Workflow
| State | Forms Behavior | Admin Actions |
|---|---|---|
| Draft | Use last published version | Can edit freely |
| Published | Use current published version | Read-only (must edit → draft → publish) |
Example workflow:
v1 published
└─ Forms use v1
Admin edits → draft state
└─ Forms STILL use v1 (last published)
Admin publishes → v2 published
└─ New forms use v2, old forms keep v1
Admin edits v2 → draft state
└─ Forms STILL use v2 (last published)Immutability Guarantees
Form Instances
Once a form instance is created:
- Field definitions are frozen (type, options, label, validation)
- Even if custom field is deleted, form instance preserves the snapshot
- HIPAA compliance: signed forms never change structure
Custom Field Versions Table
- Append-only (never updated or deleted)
- Each version is a permanent historical record
- Enables audit trails and compliance reviews
System Fields & Versioning
System fields (those with system_key set) are versioned the same way:
custom_fields
- id: 5
- system_key: "patient_birthdate" ← Immutable
- key: "data-nasterii" ← Can change (localization)
- label: "Data nașterii" ← Can change
- version: 2
custom_field_versions
- version: 1 → {system_key: "patient_birthdate", key: "birthdate", label: "Birthdate"}
- version: 2 → {system_key: "patient_birthdate", key: "data-nasterii", label: "Data nașterii"}Key point: system_key never changes across versions (stable identifier for PDF templates).
Performance Considerations
Snapshot Size
Each form instance stores a full field snapshot. For a form with 50 fields:
- 50 field definitions × ~200 bytes = ~10KB per form
- 10,000 forms × 10KB = ~100MB
This is acceptable for the immutability guarantee.
Query Optimization
-- Efficient: Query current published version
SELECT * FROM custom_fields WHERE id = 42 AND published = true
-- Efficient: Query specific version
SELECT * FROM custom_field_versions WHERE custom_field_id = 42 AND version = 1
-- Index needed
CREATE INDEX idx_custom_field_versions_lookup
ON custom_field_versions(custom_field_id, version)Migration Notes
From old architecture (no versioning):
- All existing custom fields become v1
- Archive current state to custom_field_versions
- Set published = true, version = 1
- Existing form templates continue referencing custom_field_id
- New forms automatically use v1 snapshot
No data loss - historical forms preserve their structure (they already had field snapshots in forms.fields JSONB).
Related Documentation
- README.md — Custom fields overview
- system-fields.md — System field concepts
- api.md — Versioning API endpoints