ADR-0045: Image Attachments as Content Parts (Rich-Content Slice R2)¶
- Status: accepted
- Superseded in part by: ADR-0057 — the markdown-text-as-record storage half, FOR NEW (PM_DOC) VERSIONS; the rendering-policy principles and the content_part mechanism carry over unchanged.
- Date: 2026-07-03 (accepted 2026-07-03, Nicolas: "go on with R2")
- Deciders: Nicolas Burri
- Refines: ADR-0017 (multi-part content — first real second part type), ADR-0029 (draft-freeze: adds a draft attachment working area), ADR-0001 (storage); relates to ADR-0043 (blob deletion), the DECIDED checkpoint rich-content-design.md (C-2a/C-3a)
Context¶
Slice R1 makes document bodies markdown. Authors need images in SOPs (REQ-DOC-005's content
scope). External image URLs are ruled out (Nicolas, 2026-07-03): a controlled record must not
depend on — or leak read patterns to — sources outside the QMS, and its rendering must be
reproducible forever. ADR-0017 anticipated multi-part versions; only the single text part is
realized so far. Drafts hold mutable working content outside content_part (ADR-0029), so
draft-stage images need a working-area treatment consistent with that model.
Decision¶
- Draft attachment working area. A draft version's images live in a
draft_attachmenttable (scope-keyed, RLS like all content):(version_id, name, media_type, byte_size, blob_hash), unique name per version. Bytes go into the existing content store immediately on upload (hash-addressed, per-scope — cross-scope dedup stays forbidden: existence must not leak across tenants). Names are author-chosen, normalized (safe charset), and are the markdown reference key:. - Endpoints (author-gated like draft editing; RLS-scoped):
POST /api/documents/versions/{id}/attachments(multipart upload; replaces on same name while DRAFT),DELETE .../attachments/{name}(DRAFT only),GET .../attachments/{name}(any state the caller may read; serves the blob with its stored media type,Content-Disposition: inline,X-Content-Type-Options: nosniff). - Freeze materializes image parts. On submit, each draft attachment becomes a
content_partrow (part kindIMAGE, ordered after the text part) in the version manifest; the draft rows are cleared. Byte-exact reproduction (REQ-DOC-007) therefore covers images: the frozen manifest pins their hashes. The read endpoint serves frozen versions fromcontent_part, drafts fromdraft_attachment— one URL shape for both. - Lifecycle rides the part model. Copy-on-derive copies image parts with the text part (same operation-scoped source read); disposal deletes image blobs through the existing post-commit deletion path (ADR-0043) with the same scope-count guard; new-version pre-seeding copies the predecessor's frozen attachments into the new draft's working area (content follows the same rule today).
- Format and size policy (initial): PNG and JPEG only, verified by magic bytes (not just
the declared media type); 10 MB per image; 25 images per version. SVG is deferred —
scriptable content needs its own serving policy before admission. Limits are config keys
(
lqms.content.attachment.*), violations are 4xx with humanized messages. - Renderer contract (with R1/C-3a): the markdown renderer resolves ONLY
attachment:-scheme image references, via the authenticated GET endpoint;http(s)image sources are stripped at sanitization. No external fetch ever occurs at authoring or view time.
Consequences¶
- First real use of ADR-0017's multi-part manifest; the freeze path, derive, and disposal gain image coverage in their existing test suites (separation case: attachment read across scopes must 404).
- Search indexing ignores image parts (no OCR; out of scope until asked).
- Backup/restore size grows with image adoption — the ADR-0025 backup-window guidance gains a sizing note when this ships.
- Draft working area gets a second table; the draft-erasure path (reject/cancel cleanup) must
clear
draft_attachmentrows and, via the ADR-0043 guard, their now-unreferenced blobs.
Clarification (2026-07-03, at realization — semantics refined, decision unchanged)¶
REJECT restores the frozen IMAGE parts back into the draft working area, symmetric with the
text body returning to draft_content — a rework draft must be complete, not stripped of its
images. The erasure bullet above applies as written to CANCEL (and disposal); on REJECT the
ADR-0043 guard naturally retains the restored blobs. Also fixed at realization: the part kind
column is derived metadata and deliberately NOT part of the manifest line, so pre-R2 frozen
manifests reproduce byte-identical.