feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user