A representative image is a pointer at an asset, not a bespoke upload
0088 — A representative image is a POINTER at an asset, not a bespoke upload
Section titled “0088 — A representative image is a POINTER at an asset, not a bespoke upload”- Deciders: planning agent, with the operator’s standing “only the most robust fix” instruction
- Context: #1027 (collection cover override), PR #1072. Builds on #1026 / PR #1069.
- Related: ADR 0063 (one expression of a rule), ADR 0064 (the picture plane), ADR 0083 (what federates), ADR 0071 (preview ladder client contract)
Context
Section titled “Context”Several surfaces want “the image that represents this thing” where the thing is not itself an image: a collection (#1027), and — already visible on the roadmap — a followed-team rail with team-selected hero images (#982). The obvious two designs are:
- A bespoke upload — the object owns its own image bytes.
- A pointer at an existing asset.
We already had one instance of shape (1) in the tree: POST /admin/system/appearance/logo.
It is the natural precedent to reach for, and reaching for it would have been wrong.
Decision
Section titled “Decision”A representative image is a nullable pointer at an ordinary asset, gated per-viewer, falling back to a derived default when unset or unreadable. It is not a new owned object.
For #1027 that is collections.cover_asset_id UUID NULL REFERENCES assets(id) ON DELETE SET NULL,
with the composed mosaic (#1026) as the fallback.
1. Why not the upload shape, stated from the precedent rather than against it
Section titled “1. Why not the upload shape, stated from the precedent rather than against it”POST /admin/system/appearance/logo had to build, and maintain:
- content type derived by decoding the bytes, never client-declared, because the logo is served on a public unauthenticated path;
- raster-only, SVG refused as an executable document format;
- a 2 MiB ceiling and a 16–1024px edge bound;
- a content-addressed
appearance:logopin; - a
logo_historyMRU capped at 5, releasing the evicted entry’s pin to GC.
⭐ Every one of those exists because the instance logo has no asset to point at. An operator’s logo is not a work in the archive. A collection’s cover is — or can trivially be made one.
Pointing at an asset inherits storage, the col rendition, the read predicate, federation
identity and the GC lifecycle that already exist and are already tested. The upload shape
would re-derive all of it per surface.
2. The deciding argument is ONE REPRESENTATION, not cost
Section titled “2. The deciding argument is ONE REPRESENTATION, not cost”The client contract for a cover is {asset_id} plus “fetch /assets/{asset_id}/variants/col”.
A bespoke upload makes a cover sometimes an asset id and sometimes a storage hash, so every
consumer branches on which kind it got.
That is the defect ADR 0063 exists to prevent, one level up: not a rule expressed twice, but a value represented two ways. This milestone paid for that class three times — #902 (a column that materialises a security decision vs a conjunct that evaluates it), #1023 (a display-name ladder transcribed four times, three of them wrong), #1026 (a cover composed in two places).
“Upload a dedicated banner” is not lost. It becomes: upload it as an ordinary asset, then point at it. One extra step, zero new machinery, and the banner gains versioning, permissions and GC for free.
3. Not restricted to members either
Section titled “3. Not restricted to members either”The tempting middle option — “point at one of this collection’s own members” — was rejected. It guarantees relevance but dies when the member is removed, which is the exact failure #1027’s own text names. A free pointer survives it.
4. The gate is the PICTURE plane, and the fallback is mandatory
Section titled “4. The gate is the PICTURE plane, and the fallback is mandatory”Per ADR 0064 the question “may this caller see this image” is the picture plane, not the field
plane, and not the mutation capability — a team-scoped assets.admin holder gets the fields
of assets they administer and explicitly not the picture.
Two obligations follow, and the second is the one that will be forgotten:
- On write, the setter must be able to picture the asset they choose.
- ⭐ On read, a cover the viewer may not picture MUST fall back to the derived default — never render blank. A blank tile is precisely the crowding bug #1026 had just fixed; reintroducing it through the override would have been the same defect through a new door.
5. It does not federate
Section titled “5. It does not federate”ADR 0083 excludes anything that “names something that exists only on the sender”. A local
asset_id is exactly that. The pointer stays home; a receiver composes its own default.
ON DELETE SET NULL is the behaviour, not the cheap option: RESTRICT would let one collection’s
curation decision block an unrelated asset’s deletion, and CASCADE would delete the collection.
Amendment 2026-08-14 (#982, PR #1085) — the first consumer NARROWS §4, and finds a cache §4 could not have anticipated
Section titled “Amendment 2026-08-14 (#982, PR #1085) — the first consumer NARROWS §4, and finds a cache §4 could not have anticipated”#982 (team hero images) is this ADR’s first real consumer. Two things came back that change it.
1. In a NAVIGATION STRIP, the gate is not per-viewer — it is public-only
Section titled “1. In a NAVIGATION STRIP, the gate is not per-viewer — it is public-only”§4 says the question “may this caller see this image” is the picture plane, gated per viewer, with a mandatory fallback. That does not transfer to a nav strip. A rail showing some teams’ pictures and not others depending on who is looking is noise, not security — the viewer cannot act on it and cannot tell it from a bug.
So for a team hero the rule tightens:
The asset must be
sensitivity='public'AND itsteam_idmust be that team. Same answer for every viewer. Validated at SELECTION and RE-CHECKED AT RENDER.
⭐ This is a NARROWING, not an exception. §4’s obligations both survive — a write-side check and a mandatory fallback — but the read-side predicate stops being caller-dependent. The test for which form applies: does the surface let a viewer act on the difference? An asset page can show one caller a placeholder and another the picture, because the placeholder is addressed to that caller and #881 gives them a request-access route. A nav strip addresses nobody in particular.
As implemented, with a deliberate asymmetry between the two checks:
| condition | selection | render |
|---|---|---|
sensitivity='public', team_id matches, deleted_at IS NULL | ✅ | ✅ |
stored object + col rendition | ⛔ not checked | ✅ |
The write side omits renderability because renditions are async — refusing a just-uploaded asset is an error the admin cannot act on. The read side’s fallback carries the slack.
⭐ And the stored pointer is left INTACT when a hero stops qualifying. Restoring the asset’s sensitivity brings the picture back with no admin action. A withdrawn hero is a suppressed answer, not a deleted choice — worth copying to any future consumer.
2. ⛔ The re-check must run AFTER the cache, for a reason §4 does not state
Section titled “2. ⛔ The re-check must run AFTER the cache, for a reason §4 does not state”fetchTeam reads through a by-id LRU, and nothing about an asset’s sensitivity can invalidate
it: the team row does not change when the asset does, and the asset does not know which teams
point at it. Mapping the hero inside the row-to-API converter would have passed a naive
flip-to-restricted test and then served a withdrawn picture for the life of the cache entry.
ADR 0013’s amendment already forbids caller-dependent data in a shared entry. This is a second, distinct reason to compute after the cache: CROSS-ENTITY INVALIDATION. The value is not caller-dependent at all here — it is another table’s mutable state, and the cached entity has no way to learn it changed.
Rule: a derived value re-checked against a DIFFERENT entity’s mutable state is computed after the cache, whether or not it is caller-dependent.
The implementation stamps the hero on both the hit and miss branches, never maps the raw column in the converter, and carries a cached-path twin of the flip test — which an implementation that re-checks only on cache-miss fails.
Consequences
Section titled “Consequences”- New “representative image” surfaces (#982’s team hero images, any future profile banner) have a shape to copy, and a reason not to copy the logo endpoint.
- The picker and the API may legitimately differ in generality — #1027 shipped a member-only picker over an any-asset API, recorded as #1074. That gap is acceptable precisely because the pointer model makes “add it, then pick it” work.
- ⚠️ The upload shape is not banned.
appearance:logoremains correct for the logo, because an instance logo is genuinely not archive content. The test is the one in §1: is the image a thing this system already stores as an asset, or is it chrome? Chrome may own its bytes; content points. - A surface adopting this owes a derived fallback. If there is nothing sensible to fall back to, that is a signal the pointer model may not fit — reconsider rather than shipping a blank.