Documents & Attachments — How it works
Overview
The document engine (M13-006) stores files that belong to a patient, an encounter or a staff member — scanned referral letters, consent forms, identity documents, provider signatures — without putting bytes in the database. The row holds metadata and a storage key; the object lives in S3 (or LocalStack locally). Downloads are gated on a virus-scan verdict.
Data model & ownership
| Table | Purpose |
|---|---|
document | The metadata row: tenant_id, facility_id, one of patient_id/encounter_id/staff_profile_id, document_type_concept_id, title, content_type, size_bytes, checksum_sha256, storage_key, scan_status, scan_detail. |
Migration: document/001. It carries three constraints worth knowing:
ck_document_has_subject— a document must belong to something: a patient, an encounter or a staff profile.uq_document_storage_key— partial unique on live rows, so two documents can never point at one object.ix_document_patient/ix_document_staff— partial indexes for the two list paths.
The document type is a concept, not an enum and not a string: a facility that files a new kind of paperwork adds a concept, not a release.
form_submission_id (migration document/003, M34-001 part) — the form submission a FILE form
field attached this document to; null for every document nothing has attached. Stamped by the form
engine in the accepting submission's transaction, so the link and the submission commit or roll back
together. Logical reference, no foreign key, like every other subject column. The form engine's
side of the contract — who may reference a document, and when the stamp happens — is in the form
engine doc under Reference fields.
Who may read a document
Every read is decided in DocumentServiceImpl, not in the controller. Engines call each other
in-process, so a controller guard would only move the gap to the next caller.
The document's subject decides, and there are three:
| Subject | Rule |
|---|---|
patient_id | PatientAccessGuard.authorizeOrNotFound(patientId, READ, "Document not found") — the same object-level decision the clinical engine makes. |
encounter_id only | The encounter is resolved through the clinical engine's EncounterService, which authorizes the patient behind it. Its "Encounter not found" is translated to this path's own message. |
staff_profile_id only | Not patient-scoped. The session's tenant and the document.document.read permission are the whole control — see the gap below. |
form_submission_id set (M34-001) | Uploader, a routing participant of the owning submission (initiator or task assignee/actor on its workflow instance), or holder of document.document.read. List: GET /documents?formSubmissionId= — newest first. |
The tenant term comes first and applies to all three. It is read from the signed session
(RequestContextHolder.requireCurrentTenantId()), never from the request, and a document belonging
to another tenant answers exactly as a document that does not exist. The two list paths carry the
tenant in the query rather than filtering afterwards.
A denial is indistinguishable from a miss. Every refusal is ResourceNotFoundException carrying
the identical "Document not found" message an unknown id produces (SEC-011). A 403 where a miss
gives 404 would confirm that the id names a real document somebody else holds — and document ids
travel in referral letters, URLs and support tickets.
Key rules & invariants
- Bytes are never in the row.
storage_keynames an S3 object;S3DocumentStorageis the only thing that reads or writes it. Configured byuhp.documents(bucket,max-bytes, default 25 MiB =DOCUMENTS_MAX_BYTES=26214400). ⚠max-bytesis not only an upload cap: the object is read into memory to checksum it, so it bounds per-request heap too. Raise it for larger scans knowing both halves. - Unknown is not the same as safe. Download is refused unless
scan_status = CLEAN; bothPENDINGand a failed scan are refused, so wiring no scanner cannot become a way to serve unscanned files. - Downloads are served as attachments with
Content-Disposition: attachmentandX-Content-Type-Options: nosniff, so a stored HTML or SVG file cannot execute in the viewer's origin. - Nothing is logged about a document's content, name or subject. A filename is frequently a diagnosis.
- Provider signatures live here (M13-006B). A provider's signature is a
person_attributeof the seededSignaturetype whose value is a document id, resolved to/api/v1/document/documents/{id}/content. A legacy off-platform URL resolves to nothing rather than being rendered — an<img src>a clinician's browser fetches from a third party is an exfiltration channel, and the whole point of owning the storage was to close it.
Virus scanning
A scheduled worker picks up documents that have not been scanned, streams them to a shared
clamd, and records the verdict (DOC-SCAN-001). Until that landed, recordScanResult had no
caller at all and no document was downloadable in any deployment.
| Scan status | Answer |
|---|---|
CLEAN | the bytes |
INFECTED | 400 — "found to be infected and will not be served" (a verdict) |
PENDING / FAILED | 400 — naming that the scan has not produced a verdict, rather than blaming the caller |
Turning it on
document:
scan:
provider: clamav # `none` (default) scans nothing
host: clamd.uhp.svc
port: 3310
The worker also needs the scheduler profile, the same deployment that runs notification
dispatch — the API pods deliberately run no timers.
⚠ provider: none means documents are never scanned and therefore never downloadable. It does not
mean documents are fine. That asymmetry is deliberate: a deployment nobody has given a scanner
serves nothing, rather than serving everything on the strength of nobody having looked. The safe
state is the one you get by doing nothing.
Rules that are not negotiable
- No endpoint records a scan result, and none may be added. A caller who can upload a document
and then declare it clean has not been scanned; that removes the control rather than wiring it.
NoScanResultEndpointTestscans every request mapping incoreand fails the build if one ever callsrecordScanResultorrecordScanFailure— because the symptom of a missing scanner (documents "stuck" atPENDING) looks exactly like a bug, and the tempting fix is the wrong one. - A failed scan is not a clean one. An unreachable
clamd, an unreadable object, an oversize refusal and an unrecognised reply are allFAILED. Unknown is not safe. - An infected document is kept, never deleted. It is the evidence of what was uploaded, and it is
already unreachable because only
CLEANis served. - Retries are bounded (
max-attempts,retry-after). A transientclamdrestart recovers on its own; a file the scanner genuinely cannot read stops consuming a slot in every pass instead of being rescanned forever. - One resident signature set. ClamAV's database is 100+ MB and needs
freshclamupdates, soclamdis shared infrastructure the platform talks to — not a library copied into each module, which would mean N copies to keep current and N ways to be stale. - Nothing patient-derived is logged. The scan log carries the document id, the outcome and the signature name — never the title, the patient, or any bytes.
⚠ This compounds with CONF-002 (!348). uhp.documents.* — bucket, endpoint and keys — is not
overridable in the shipped jar, so the engine runs with cloudProvider=local,
bucket=uhp-documents, endpointUrl=null whatever an operator configures. Taken together: an upload
may be landing somewhere nobody intended, and it can never be read back. Neither half is visible
from the API, which returns a cheerful 201. Fixing the scanner without fixing CONF-002 would make
those misplaced objects downloadable, so the storage configuration is the one to land first.
An infected document is kept, never deleted: it is the evidence of what was uploaded, and it is
already unreachable because only CLEAN is served.
API
Endpoints under /api/v1/document:
| Verb | Path | Notes |
|---|---|---|
| POST | /documents | multipart upload; lands PENDING |
| GET | /documents/{id} | metadata; 404 when unknown or not readable by this caller |
| GET | /documents/{id}/content | bytes; CLEAN only |
| GET | /documents?patientId=&staffProfileId= | list, confined to the session's tenant |
Permissions document.document.read and document.document.write are declared by
DocumentModuleDescriptor, which the catalog seeder reads to create the permission rows. A code
no descriptor declares can never be granted to any role, super_admin included (SEC-016).
Known gaps
These are recorded rather than hidden.
- The scanner must be configured to exist. DOC-SCAN-001 wired the worker, but a deployment that
leaves
document.scan.provideratnone, or runs noschedulerpod, still scans nothing — so uploads succeed and downloads do not. That is the safe default, not a defect, but it is worth checking before concluding the engine is broken. - A staff document has no owner-level rule. A signature, a certificate and an identity document
are the same thing to the access check: readable by anyone in the tenant holding
document.document.read. That is deliberate — a provider signature is served to every clinician who opens an encounter that provider signed, so restricting staff documents to their owner would stop signatures rendering. Splitting them needs the administrative document-type lookup that M13-006C describes and that does not exist yet. - No void/delete endpoint, and no document-version history.
Related features
- Demographic — provider signatures resolve through
person_attribute. - Clinical — encounter-scoped attachments, and the access guard this engine reuses.
- Concept dictionary — document types are concepts.
Why it is this way
Nothing is downloadable until a scanner says it is clean. The scan verdict gates retrieval, so an infected upload cannot be handed back out — and the platform refuses to guess in the absence of a scanner rather than defaulting to permissive.
Content and metadata are separated. Metadata is freely readable by anyone entitled to the record;
the bytes are served only through the guarded endpoint. That is why the FHIR DocumentReference
facade publishes description, size and hash but never a retrieval URL.
Traps
⚠ provider defaults to none, and none means documents are never marked clean —
DocumentScanProperties. A deployment that never configures a scanner will accept uploads happily
and refuse every download, which reads as a storage fault rather than a configuration one. It is
on the go-live checklist for exactly that reason.
⚠ There is deliberately no "assume clean" implementation — DocumentScanner. The obvious
convenience — treat unscanned as clean when no provider is configured — is the one thing that would
make the whole gate decorative.
⚠ Scanner implementations must stream. A document is arbitrarily large, and buffering it whole is how one upload takes the process down.
⚠ The scheduler never lets an exception escape — DocumentScanScheduler. An uncaught error from a
scheduled method stops the schedule silently, so scanning would stop for everyone because of one bad
document.
⚠ There is a retry ceiling. Without one, a permanently unscannable document is rescanned on every pass forever, and the queue never drains.