Files
Signage/docs/architecture/decisions/0007-native-images.md
T
ekklesiaandClaude Opus 5.5 f8b131f4ed
CI / typecheck (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 24s
CI / build (pull_request) Successful in 36s
CI / test (pull_request) Successful in 38s
CI / package (pull_request) Successful in 29s
CI / integration (pull_request) Failing after 1m6s
docs(adr): point platform cross-references at the repository's current location
Link targets only: the Ekklesia repository moved from
git.riverofglorymn.org/River_of_Glory_Church to git.zayin.net/Zayin, and the
old host no longer answers. No decision text changes, which is the
cross-reference correction the ADR rules allow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:05 -05:00

109 lines
5.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.zayin.net/Zayin/Ekklesia) (host-provided storage),
[ADR 0042](https://git.zayin.net/Zayin/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.