A canonical entity image is a *resolved* URL — uploaded, else external, else a generated title-card — served from one polymorphic endpoint
Status: accepted (2026-06-22) — whole lane (foundation → authoring → Deep-Linking thumbnails → OB/CLR
image wiring) built this session. Migrations (the entity_image table + the entity-images bucket
line) are regenerated per the runbook; the DB apply is Anton’s
([[feedback_db_migrations_anton_domain]], [[feedback_never_edit_generated_migrations]]).
Builds on ADR-0033 (emergent as content + LTI provider — the Deep Linking content-item assembly
whose thumbnail/icon this image populates, and which named “only the ltiResourceLink content
type is emitted” as the open DL-3 gap), ADR-0015/0016/0017 (the per-org issuer Profile, the VC/JOSE
proof, issuer key custody — the OB 3.0 + CLR 2.0 credential lane that here gains
Achievement.image and Issuer Profile.image), ADR-0023 (the QTI authoring asset pipeline —
the private Supabase Storage bucket + AssetStorageAdapter + signed-serving pattern this reuses for
the uploaded layer), ADR-0034 (Offering authority levels + the sentinel platform org — the Offering
is one of the three image-bearing entities), and ADR-0028 (the conformance overlay — DL-3 flips and
the OB/CLR overlays strengthen on the back of this). Pre-launch, no legacy
([[feedback_no_legacy_pre_launch]]); work lands on develop ([[feedback_develop_branch_no_new_branches]]).
The trigger
Section titled “The trigger”Three standards we implement expect an image to hang off an entity, and we emit none:
- LTI Deep Linking — every content item carries optional
icon/thumbnail({url, width?, height?}), and there is a standaloneimagecontent type. emergent returns imagelessltiResourceLinkitems, leaving DL-3 partial. - Open Badges 3.0 —
Achievementand the issuerProfileeach take an optionalImage = {id: URI, type:"Image", caption?}. Badge art is the canonical Open Badges affordance; emergent’s assembled credentials have none. - CLR 2.0 bundles those same OB bodies, so it inherits the gap.
The naive framing — “add an image field to the Offering” — is wrong twice over. First, the three images hang off different entities: the DL thumbnail is the Offering’s, but the OB images are the Achievement-definition’s and the Organization-as-issuer’s. Second, “a field holding an image” conflates what an entity’s image is with where its bytes live. This ADR separates both.
Decision 1 — the canonical image is resolved, never merely stored
Section titled “Decision 1 — the canonical image is resolved, never merely stored”Every image-bearing entity has one canonical image URL that always resolves, by walking a fixed precedence until something answers:
- Uploaded object — a teacher/admin uploaded bytes we hold (durable, platform-owned).
- External URL — a teacher/admin pointed us at an image they host elsewhere.
- Generated SVG title-card — a deterministic fallback we render on the fly.
The fallback is not a placeholder-of-last-resort to be embarrassed about; it is the invariant that the URL is never broken. A credential minted for an entity whose author never set an image still embeds a URL that resolves — forever, from our own origin — to a legible title-card. This matters far more for OB/CLR (the URL is frozen into a verifiable credential that a wallet may dereference years later) than for DL (an ephemeral selection), which is exactly why an external author URL ranks above the generated card but below an uploaded copy: an author who cares enough to upload gets durability; an author who only pasted a URL accepts its rot risk; everyone else is covered.
Decision 2 — three kinds, one polymorphic mechanism
Section titled “Decision 2 — three kinds, one polymorphic mechanism”A single server-side table backs all three entity kinds:
entity_image ( id, entity_kind ∈ {offering, achievement, organization}, entity_id uuid, source ∈ {uploaded, external}, object_key?, external_url?, content_type?, width?, height?, caption?, <audit/LWW>, UNIQUE (entity_kind, entity_id))entity_id is a polymorphic uuid, not a foreign key — the same provenance-uuid stance ADR-0034
takes for offering lineage: the referent lives in one of three tables, the (kind, id) pair is
validated in the service, and no cross-table FK is forced onto a column that means three things.
A row exists only when an override is set; its absence means “fall through to the generated
card”, so the common case costs no storage and no write.
The table is server-side authoring metadata and is not synced to the learner PGlite client
(clients only ever consume the URL — they never need the resolution state). The bucket is a new
private entity-images bucket (image/*, modest size cap) appended to the consolidated
storage-buckets migration ([[no_ddl_supabase_schemas]] — storage.buckets is Supabase-owned, so it
stays hand-written SQL; bucket creation is Anton’s domain).
Decision 3 — the URL contract (the hard-to-reverse part)
Section titled “Decision 3 — the URL contract (the hard-to-reverse part)”GET {API_PUBLIC_URL}/img/:kind/:id, where kind ∈ {offering, achievement, organization} and id
is the entity uuid.
- Extension-less. The backing bytes may be PNG/JPG/WebP (uploaded), an arbitrary external type,
or our
image/svg+xml(generated); the server setsContent-Typefrom what it resolved. Baking a.svg(or any) extension into the contract would lie the moment an author uploads a PNG. - Public, no auth. Wallets, credential displayers and LTI platforms dereference these without an
emergent session — the same reason the LTI
/jwksroute is public. The entity uuid is unguessable; the only thing exposed is a title we already publish on the credential/launch. - Durable + strongly cached.
ETagover the resolved content;Cache-Control: public. The generated card is a pure function of (kind, title, id) → effectively immutable.
This URL is embedded verbatim into signed verifiable credentials (OB Image.id), so its shape is
expensive to change after the first credential is issued. That irreversibility is the reason this is
an ADR.
Decision 4 — serve by proxy; keep the bucket private
Section titled “Decision 4 — serve by proxy; keep the bucket private”The route resolves precedence and streams the bytes itself (proxying the uploaded object, fetching/redirecting the external URL, or rendering the SVG inline). The alternative — a public bucket plus a 302 to its object URL — was rejected: proxying keeps one stable public URL regardless of backing, keeps every bucket private (consistent with every other emergent bucket — item-assets, submissions, runtime-packages are all private), and means swapping an entity from generated → external → uploaded never changes the URL a credential already froze. Image payloads are small and the response is strongly cacheable, so the proxy cost is bounded.
Decision 5 — the generated title-card
Section titled “Decision 5 — the generated title-card”A dependency-free pure function renderTitleCardSvg({ kind, title, subtitle?, seed }):
- Deterministic. The background derives from a hash of the entity id (
seed), so the same entity always yields the same card — stableETag, no surprise churn in a wallet. - No external fonts or asset fetches. A system font stack and inline SVG only; the generator is a string function with no I/O, unit-testable offline.
- Per-kind dimensions.
achievement → 512×512(badges are square by convention);offering/organization → 1200×630(an Open-Graph-shaped card). DL declares these as the content item’swidth/height.
We deliberately do not build an image-generation service (arbitrary sizes/formats/params on demand). The card is one deterministic artifact per entity; vector output scales freely, so consumers that want it larger or smaller just render the SVG at their size.
What it feeds
Section titled “What it feeds”- LTI Deep Linking — the offering content items (
ltiResourceLink, and thelink/html/fileitems completed alongside this) gainthumbnail: {url: /img/offering/:id, width, height}. A standaloneimagecontent item is emitted only when the DL request’saccept_typesincludesimage(low value for an offering, but cheap to honour). → DL-3 done. - OB 3.0 / CLR 2.0 —
assembleAchievementCredentialsetsAchievement.image = {id: /img/achievement/:definitionId, type:"Image", caption?}and the issuerProfile.image = {id: /img/organization/:orgId, type:"Image"}. → OB/CLR image coverage.
Authorization
Section titled “Authorization”The public GET is unauthenticated (above). The authoring writes (POST/PUT/DELETE to set an
uploaded or external image, or clear it) are gated by the existing claim-aware authority: an
Offering’s image is writable by that offering’s teacher/admin (the same policy that guards offering
authoring); achievement-definition and organization images are org-admin scoped. No new authorization
primitive.
What we are NOT doing
Section titled “What we are NOT doing”- No image-generation-as-a-service (Decision 5) — one deterministic card per entity, not a resize/transform endpoint.
- No client-side card rendering — the generator is server-only; frontends consume the URL. (If a
learner-web offering card ever needs the identical image before a round-trip, the pure function
can move to
packages/domainlater — YAGNI now.) - No sync of
entity_imageto the learner client — it is authoring metadata; the client uses the URL. - No backfill / legacy path — pre-launch, the fallback covers every existing entity for free ([[feedback_no_legacy_pre_launch]]).
Consequences
Section titled “Consequences”- One mechanism closes DL-3 and adds OB 3.0 + CLR 2.0 image coverage in a single lane.
- The
/img/:kind/:idcontract becomes a public, durable, credential-embedded surface — changing it later is a breaking change to already-issued credentials, so it is fixed deliberately here. - A new private
entity-imagesbucket and theentity_imagetable require a DB reset+apply (Anton). - The polymorphic (kind, id) key trades a DB-enforced FK for service-level validation — the same trade ADR-0034 already accepts for lineage.