ADR-0069: Arbitrary File Attachments — Download-Only Confinement¶
- Status: accepted (Nicolas 2026-07-14: "we will need attachment support for arbitrary files"; driven by the QMS import — PDF/Office records and draw.io sources, ADR-0068 M-6)
- Date: 2026-07-14
- Deciders: Nicolas Burri (need), design by Claude following the ADR-0065 confinement pattern.
- Relates to: ADR-0045 (draft attachments / frozen parts — the machinery this extends), ADR-0065 (SVG confinement precedent: display class + serving headers), ADR-0025/0016 (disposal severs content — file parts included), ADR-0068 (the importer that needs this).
Context¶
Attachments are today IMAGE-only (PNG/JPEG + the ADR-0065 SVG/scene-JSON pair), verified by magic
bytes, displayed exclusively <img>-confined. A real QMS holds records that are not images: PDFs,
Office documents, diagram sources. The import surfaced this immediately (45 PDFs, Office files, 94
draw.io sources).
Decision¶
- Two attachment classes. DISPLAY (the existing sniffed image set — embeddable in content via
<img>, unchanged) and FILE (everything else — any type, stored byte-exact with a sniffed-or- declared media type falling back toapplication/octet-stream). - FILE is download-only, always. The serving endpoint forces
Content-Disposition: attachmentfor the FILE class (plus the existingnosniff+ per-responseContent-Security-Policy: default-src 'none'; sandbox); a FILE attachment never renders in a browsing context, which neutralizes the HTML/PDF/active-content XSS family wholesale. Confinement, not scanning, stays the control (the record must round-trip byte-exact, REQ-DOC-015). No type blocklist: a QMS cannot predict record formats, and download-only makes type-based blocking security theater. - Same lifecycle as images. DRAFT-only upload/delete (EDIT_DRAFT-gated), same size/count
limits, audited; on submit FILE attachments freeze into
content_partrows (kind FILE) in the manifest; reject/withdraw unfreezes them back to the working area; disposal severs them; derive/copy carries them like images. (Schema-extension sweep: disposal/derive/revise/freeze/ audit/RLS tests must all see the new kind.) - UI: an Attachments section on the version detail (name, type, size, download link) and an upload affordance in the draft editor; in-content references are ordinary links to the attachment URL (the reading view already links, never embeds, non-image URLs).
- draw.io: valve-for-all (amended 2026-07-14 after visual QA on the real corpus — Nicolas:
"none of the conversions is usable… import as attachment and include as an image"): the importer
embeds each diagram's PNG preview as an ORDINARY image at the macro's position and carries the
.drawiosource as a FILE attachment; NO auto-conversion. The drawio→excalidraw converter (built, 20 tests, strict ≥90% mappability gate) remains an OPT-IN CLI (scripts/import/drawio2excalidraw.py) for redraw scaffolding: side-by-side QA showed the Johner corpus leans ondocument/processstencils and swimlane tables whose conversions misplace labels and drop cells — silently-degraded process diagrams in a QMS are worse than honest images. Editable diagrams arrive when a document is actually revised (redraw in Excalidraw, optionally seeded by the converter or the Mermaid bridge).
Consequences¶
- 415 disappears for non-image uploads; the API contract gains an attachment
kindin metadata. - The frozen manifest now attests arbitrary records — exactly what an eQMS is for.
- Malware screening is explicitly out of scope (as before for images): the QMS is an internal record store behind authentication; endpoint protection is the organization's control.
- The import (ADR-0068) can carry PDFs/Office/draw.io sources; its conversion report gains a drawio→excalidraw outcome per diagram.