Collect insurance card images; watch them flow through the three-layer scanner.
File uploads and scanning
The public renderer doesn't bundle file bytes into the submit
POST. Each file uploads on its own request the moment a user
picks it, lands in form_submission_attachments with
scan_status='pending', and only gets claimed by the
submission INSERT once the three-layer scanner clears it. That
split is why the upload widget has a state to display in the
first place — by the time the renderer is ready to POST, the
attachment row already exists and is moving through scanner
states server-side.
1. Add the file fields
In the Forms builder, open Patient Intake and switch to the
insurance page. From the field palette, drag in a File
upload field for insurance_card_front. Configure it:
accepted_mime:image/jpeg, image/pngmax_size:5(MiB)multiple:false
Drag in a second file field for insurance_card_back with the
same three settings. We keep multiple off because each card
has exactly one front and one back — two single-file fields
read more cleanly than one multi-file field, and they let the
renderer label the slots in the UI without extra prose.
The accepted_mime list is enforced two ways: the renderer's
<input accept> filters the OS file picker, and the MIME-magic
scanner re-checks the bytes after upload. The browser-side
filter is convenience; the magic check is the gate.
2. Save and re-publish
Click Save, then Publish. The published version row freezes the file-field config the same way it froze the routing graph in the previous tutorial. The public renderer at the invite URL picks up the new fields on the next request.
┌─────────┐ ┌──────────┐ ┌───────┐
│ pending │ ───▶ │ scanning │ ───▶ │ clean │
└─────────┘ └────┬─────┘ └───┬───┘
│ │
▼ ▼
┌──────────┐ submit handler claims
│ rejected │ attachments atomically
└──────────┘ with submission INSERT
Every uploaded attachment walks this graph. pending is the
row's birth state — the bytes have landed on disk but no
scanner has touched them yet. scanning covers the three
layers in sequence: MIME magic on the leading bytes,
VirusTotal hash lookup, and an LLM content classifier for
image and document payloads. clean is the only terminal
state the renderer will POST against; rejected carries a
human-readable scan_reason the widget surfaces in place of
the green check.
3. Submit a happy-path image
Open the public invite URL. Walk through demographics and
contact with an adult age (so routing keeps you on the
insurance page). Pick a normal JPEG for
insurance_card_front — a phone photo of any rectangular
card works.
Watch the widget. The label transitions through pending →
scanning → clean, usually within a second or two for a
small image. Repeat for insurance_card_back. Once both
slots are green, the Submit button enables and the
submission POSTs.
4. Submit a deliberately bad file
Reload the public form and reach the insurance page again.
This time, take any tiny .exe (Windows binaries, even
trivial ones, work — what matters is the leading bytes are not
a JPEG or PNG header) and rename it to card.png so the
browser MIME filter lets it through the file picker.
Upload it as insurance_card_front. The widget shows
pending, flips to scanning, then lands on rejected. The
rejection reason renders inline — something like "MIME magic
mismatch: declared image/png, detected application/x-dosexec."
The slot stays red and the Submit button stays disabled
until you replace the file with a clean image.
The other two scanner layers behave the same way from the
renderer's perspective: a VirusTotal hash hit produces a
"known malicious file" reason; an LLM content rejection
produces a category-specific reason ("explicit content
detected", etc.). Same rejected state, different
scan_reason.
Verify it worked
Submit the form once with two clean card images.
- The bad-upload attempt was rejected with a human-readable
reason in the widget; nothing reached the database beyond
the original
form_submission_attachmentsrow markedrejected. - The clean upload reached
clean, the submission POSTed, and both attachment rows were claimed atomically with the submission INSERT. - After promoting the submission to a
patiententity, the two attachments appear inentity_filestied to the promoted record — the promotion path re-parents the attachments by id, no re-upload required.
If all three line up, the file-upload pipeline is live.
Next
Continue with Anti-abuse.