Skip to content

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]]).

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 standalone image content type. emergent returns imageless ltiResourceLink items, leaving DL-3 partial.
  • Open Badges 3.0Achievement and the issuer Profile each take an optional Image = {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:

  1. Uploaded object — a teacher/admin uploaded bytes we hold (durable, platform-owned).
  2. External URL — a teacher/admin pointed us at an image they host elsewhere.
  3. 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 sets Content-Type from 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 /jwks route is public. The entity uuid is unguessable; the only thing exposed is a title we already publish on the credential/launch.
  • Durable + strongly cached. ETag over 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.

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 — stable ETag, 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’s width/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.

  • LTI Deep Linking — the offering content items (ltiResourceLink, and the link/html/file items completed alongside this) gain thumbnail: {url: /img/offering/:id, width, height}. A standalone image content item is emitted only when the DL request’s accept_types includes image (low value for an offering, but cheap to honour). → DL-3 done.
  • OB 3.0 / CLR 2.0assembleAchievementCredential sets Achievement.image = {id: /img/achievement/:definitionId, type:"Image", caption?} and the issuer Profile.image = {id: /img/organization/:orgId, type:"Image"}. → OB/CLR image coverage.

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.

  • 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/domain later — YAGNI now.)
  • No sync of entity_image to 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]]).
  • One mechanism closes DL-3 and adds OB 3.0 + CLR 2.0 image coverage in a single lane.
  • The /img/:kind/:id contract 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-images bucket and the entity_image table 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.