Form Lifecycle
State machine and transitions for form instances.
State Machine
┌─────────┐
Create form │ pending │
from template └────┬────┘
│
│ Patient/specialist starts filling
▼
┌─────────────┐
│ in_progress │
└──────┬──────┘
│
│ All required fields filled
▼
┌───────────┐
│ completed │
└─────┬─────┘
│
│ Patient/specialist confirms
▼
┌────────┐
│ signed │ ← IMMUTABLE from this point
└────────┘States
pending
Definition: Form created, no responses yet.
Characteristics:
- Form instance exists with snapshotted fields
valuesJSONB is empty or has auto-filled values only- No user interaction yet
Allowed operations:
- Read form
- Update values (transitions to
in_progress) - Delete form (admin only)
Transitions to:
in_progress— First value saved by patient/specialist
in_progress
Definition: Patient/specialist started filling, but not all required fields are complete.
Characteristics:
valuesJSONB has partial data- At least one field has a user-provided value
- Some required fields may still be empty
Allowed operations:
- Read form
- Update values (stays
in_progressor transitions tocompleted) - Upload files
- Delete form (admin only)
Transitions to:
completed— All required fields filled
completed
Definition: All required fields filled. Form is ready for signing.
Characteristics:
valuesJSONB has all required fields populatedcompleted_attimestamp set- Form can still be edited (not yet signed)
Allowed operations:
- Read form
- Update values (can revert to
in_progressif required fields cleared) - Upload/delete files
- Sign form (transitions to
signed) - Delete form (admin only)
Transitions to:
signed— Patient/specialist confirms via/v1/forms/{id}/signin_progress— If required field is cleared
signed
Definition: Patient/specialist confirmed. Form is now IMMUTABLE.
Characteristics:
signed_attimestamp setvaluesfrozen (cannot be modified)fieldsfrozen (cannot be modified)filesfrozen (cannot be added/removed)- Audit log records signing event
Allowed operations:
- Read form
- NO modifications allowed (all update/delete operations return 409 Conflict)
Immutability rules:
valuescannot be updated (409 Conflict)fieldscannot be modifiedfilescannot be changedsigned_attimestamp is the legal proof- Audit log records who signed and when
Why immutable? HIPAA requires that signed medical forms preserve their structure and content exactly as they were when completed. Template changes or value edits cannot retroactively affect signed forms.
End-to-End Flow
Creating a Form
1. Appointment created from template
└─ Template has associated form templates
2. For each form template:
a. Read form_template (fields, title, description, version)
b. SNAPSHOT: Copy fields into form.fields (frozen from this moment)
c. Record form.template_version = form_template.version
d. AUTO-FILL: Read custom_field_values for fields that reference custom_field_id
→ SELECT cfv.custom_field_id, cfv.value
FROM custom_field_values cfv
WHERE cfv.entity_type = 'patient'
AND cfv.entity_id = ?
AND cf.key IN ('city', 'blood_type', ...)
e. Build initial values: {"q1": "Amsterdam", "q2": "A+"}
f. Status = "pending"Loading a Form
1. SELECT * FROM forms WHERE id = ?
→ ONE query, ONE row
2. Return to frontend:
{
"id": 42,
"title": "Patient Intake",
"status": "pending",
"fields": [...], ← field definitions (what to render)
"values": {...}, ← current answers
"files": {...} ← file attachments
}Saving a Form
1. Validate: form.status must NOT be "signed" (immutable after signing)
2. Validate input against form.fields (required, types, options)
3. Read existing values
4. Merge new values from request
5. Store merged values → update form.values (plain JSONB, fully queryable)
6. PROFILE SYNC: For fields that reference custom_field_id, upsert custom_field_values
→ INSERT INTO custom_field_values (custom_field_id, entity_type, entity_id, value)
ON CONFLICT (custom_field_id, entity_type, entity_id) DO UPDATE SET value = EXCLUDED.value
7. SEGMENT EVAL: Evaluate segments that reference this form template
→ For each matching segment, add/remove patient from segment_members
8. Update status:
- If partially filled → "in_progress"
- If all required fields filled → "completed", set completed_at
9. File uploads: upload to S3, store reference in files JSONBSigning a Form
1. Validate: form.status must be "completed" (all required fields filled)
2. Set status = "signed", signed_at = NOW()
3. FROM THIS POINT: form.values and form.fields are IMMUTABLE
4. Any attempt to update a signed form returns 409 Conflict
5. Audit log records the signing eventState Transition Rules
| From | To | Trigger | Validation |
|---|---|---|---|
pending | in_progress | First value saved | - |
in_progress | completed | All required fields filled | All required fields must have values |
completed | signed | Explicit /sign call | Form status must be completed |
completed | in_progress | Required field cleared | - |
signed | - | NONE (immutable) | - |
Business Rules
Multi-User Filling
Forms can be filled by both patients and specialists:
- Patient fills their fields before/during appointment
- Specialist fills specialist-specific fields (often
private: true) during appointment - Both write to the same
form.valuesJSONB - Last write wins (standard JSONB merge)
form.updated_attracks when form was last modified- Audit log records each save with the
user_idwho made it
Conflict Prevention
- Specialist and patient rarely edit simultaneously (appointment workflow is sequential)
- If they do: last write wins
- Each save is a full audit event: the audit log captures who saved what when
Field-Level Authorship
Not tracked in v1. If tracking which user filled which field becomes a requirement, add a field_authors JSONB column:
{
"pain_level": {
"user_id": 100,
"role": "patient",
"at": "2025-01-15T10:00:00Z"
},
"clinical_notes": {
"user_id": 5,
"role": "specialist",
"at": "2025-01-15T10:30:00Z"
}
}This is deferred to v2 because it adds complexity without a current business need.
Audit Trail
Every form mutation is logged to audit_log:
| Event | Action | Logged Data |
|---|---|---|
| Form created | CREATE | entity_type=form, entity_id=form.id, changes={...} |
| Values saved | UPDATE | entity_type=form, entity_id=form.id, changes={before, after} |
| File uploaded | UPDATE | entity_type=form, entity_id=form.id, changes={files} |
| Form signed | UPDATE | entity_type=form, entity_id=form.id, changes={status: signed} |
| Form deleted | DELETE | entity_type=form, entity_id=form.id |
HIPAA compliance: Audit log captures:
- Who made the change (
user_id) - When (
created_at) - What changed (
changesJSONB with before/after diff) - IP address and user agent
- Request path (e.g.,
PUT /v1/forms/42)
Error Handling
Updating a Signed Form
PUT /v1/forms/42
{"values": {"city": "Rotterdam"}}
→ 409 Conflict
{
"error": {
"code": "form_already_signed",
"message": "Cannot update a signed form",
"details": {
"form_id": 42,
"signed_at": "2025-01-15T12:00:00Z"
}
}
}Signing an Incomplete Form
POST /v1/forms/42/sign
→ 400 Bad Request
{
"error": {
"code": "form_not_completed",
"message": "Form must be completed before signing",
"details": {
"missing_fields": ["pain_level", "consent_signature"]
}
}
}Validation Error on Save
PUT /v1/forms/42
{"values": {"pain_level": "Invalid"}}
→ 400 Bad Request
{
"error": {
"code": "validation_error",
"message": "Form validation failed",
"details": {
"errors": [
{
"field": "pain_level",
"message": "value \"Invalid\" not in allowed options"
}
]
}
}
}