109 lines
5.8 KiB
Markdown
109 lines
5.8 KiB
Markdown
# 0007: A native image is content-addressed, archived once, and never baked
|
||
|
||
- Status: Accepted
|
||
- Date: 2026-08-27
|
||
- Related: [0001](0001-signage-owns-timing-providers-own-content.md), Ekklesia
|
||
[ADR 0030](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (host-provided storage),
|
||
[ADR 0042](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (streaming bodies)
|
||
|
||
## Context
|
||
|
||
Native images are the only content Signage owns, and the one thing it must do well with no other
|
||
module installed. A church's actual usage is an operator dragging twenty exported slides onto a page,
|
||
some of them the same file they uploaded last week.
|
||
|
||
## Decision
|
||
|
||
### Identity is the content hash
|
||
|
||
An asset's identity is the **SHA-256 of the uploaded bytes**, and the database enforces it as unique.
|
||
Uploading the same bytes twice returns the existing asset instead of storing a second copy, and two
|
||
operators uploading the same file — even simultaneously — produce one asset and one set of stored
|
||
objects: the loser of that race finds the winner's asset and reports success, because the image the
|
||
operator uploaded is stored.
|
||
|
||
Object keys are scoped to the asset (`assets/<asset-id>/…`), and until that asset's row commits they
|
||
are **temporary**: nothing references them, nothing can select them, and a failed ingest deletes
|
||
every one it wrote. Scoping them to the asset rather than to a throwaway upload id is what makes a
|
||
successful upload cost exactly one write of each object instead of two — the commit is the database
|
||
row, not a second copy of 25 MiB.
|
||
|
||
### One file per request, streamed, bounded
|
||
|
||
Uploads use `bodyMode: "streamed"` with a **25 MiB** Signage limit, inside the platform's 32 MiB
|
||
absolute ceiling. The unit is deliberately one file: a dashboard offering twenty images issues twenty
|
||
individually bounded requests, each of which can fail cheaply, rather than one request whose failure
|
||
wastes everything already sent. Nothing in Signage can express an aggregate upload.
|
||
|
||
Because a `413` can arrive *after* the handler starts in streamed mode, **nothing is written anywhere
|
||
until the whole request has been read and validated**. An upload that is aborted, oversized,
|
||
malformed, or unsupported has therefore written zero objects and zero rows. Once validation passes,
|
||
the objects are written to the temporary asset-scoped keys above and the row commits last; any
|
||
failure in between removes every object that attempt wrote. A partial write is never promoted to an
|
||
asset.
|
||
|
||
### Accepted, and refused
|
||
|
||
Accepted: still PNG, JPEG, and WebP. Refused: SVG, GIF and any animation, PDF, audio, video,
|
||
multipage input, anything malformed, anything over 25 MiB, and anything whose decoded size exceeds
|
||
**40 million pixels** — the last because decoded pixels, not file size, are what a decompression bomb
|
||
actually costs.
|
||
|
||
Format is decided by **decoding the bytes**, never by the filename or the declared content type.
|
||
|
||
### Three stored objects, and what each is for
|
||
|
||
| Object | Purpose |
|
||
| --- | --- |
|
||
| Original | Archived **byte-for-byte, unmodified**. It is the church's file; Signage is not entitled to rewrite it. |
|
||
| Display WebP | Inside-fit within 1920×1080, quality 82. What a television actually renders. |
|
||
| Thumbnail WebP | Inside-fit within 480×270, quality 72. What the library grid renders. |
|
||
|
||
Both renditions are auto-oriented, **never cropped**, **never upscaled**, stripped of metadata, and
|
||
flattened against black. The original's real dimensions, orientation, and useful metadata stay in
|
||
PostgreSQL, where they are queryable without touching storage.
|
||
|
||
### Presentation is resolved at render time, never baked
|
||
|
||
A 16:9 image (within a documented tolerance of 0.02) fills the frame on its own: no blurred
|
||
duplicate, no pillarbox, no floating-card shadow. Anything else is shown **whole** with
|
||
`object-fit: contain` over a blurred, solid, or black background, optionally darkened, with a
|
||
foreground shadow.
|
||
|
||
None of that is written into a stored rendition. Changing a slot's background from blurred to black
|
||
is a database update, not a re-encode, and the same rendition serves both. These controls apply to
|
||
native images only — an external provider owns its rendering completely.
|
||
|
||
### Keys are persisted; URLs are not
|
||
|
||
Signage stores **logical object keys** and asks the host for a fresh read URL when something is about
|
||
to be delivered. A read URL is a bearer capability with an expiry in it; storing one in a table would
|
||
be storing a credential that stops working. Signage never configures, names, or identifies a storage
|
||
service.
|
||
|
||
### Deletion is reference-aware
|
||
|
||
Deleting an asset referenced by a timeline slot, by a pinned item, or by a live static hold is refused
|
||
with a sanitized code naming what refers to it. An asset with a null expiration is permanent; local
|
||
age never invents one. Rendition identity is immutable — a rendition is replaced by uploading
|
||
different bytes, which by definition produces a different asset.
|
||
|
||
## Consequences
|
||
|
||
- Twenty uploads are twenty independent, individually recoverable requests, and the dashboard stays
|
||
responsive because none of them blocks another.
|
||
- Re-uploading last week's file costs one hash and one row read.
|
||
- A partially failed ingest leaves a temporary object that is removed, never an asset in a state
|
||
playback could select.
|
||
- Changing presentation is instant and lossless, because nothing was baked.
|
||
|
||
## Alternatives
|
||
|
||
- **A generated UUID as identity.** Rejected: it makes deduplication impossible and retries dangerous.
|
||
- **Trust the declared content type.** Rejected: it is attacker-controlled and routinely wrong even
|
||
when it is not.
|
||
- **Bake the background and shadow into the rendition.** Rejected: every presentation change becomes a
|
||
re-encode, and the archived original stops being the source of truth.
|
||
- **One multi-file upload request.** Rejected: one bad byte wastes every byte already sent, and the
|
||
platform's ceiling is per request for exactly that reason.
|