A Student finishes the last Lesson in a Course. Something should happen — a moment, a reward, proof. This slice makes that proof real: a Certificate, issued automatically, with a single canonical page that anyone can open and verify without logging in.

The public Certificate verify page

The UI is a decorative sheet of paper. The interesting part is everything the paper is not allowed to do.

The trap: a credential that quietly becomes a lie

The naive model writes itself. A Certificate is “valid” if the Student currently has a Completion for every current Lesson; render the live Profile name on top. No snapshot, no extra columns, derive everything at read time.

That model is broken the day after you ship it. The Instructor adds Lesson 43 to a course 200 people already finished — and 200 shared, public credentials silently flip to “incomplete.” A Student edits their Profile name, or exercises GDPR erasure, and a Certificate someone printed last month now shows a different person — or nobody. A credential you can lose retroactively, without doing anything wrong, isn’t a credential.

So we wrote it down as ADR-0011: a Certificate is a permanent, immutable snapshot. At issuance it freezes the recipient name, the Course title, and the Lesson count, and never derives them live again. Adding or removing a Lesson afterwards does nothing to issued Certificates. Editing or scrubbing the Profile does nothing. The public verify URL stays stable forever.

flowchart TD
    A[Final Completion recorded] --> B{Already issued?}
    B -->|yes| Z[Return existing — never re-evaluate]
    B -->|no| C{Course has ≥1 Lesson
AND all Lessons completed?} C -->|no| Y[Not earned] C -->|yes| D{Profile name present?} D -->|no| E[Name prompt on the
Course-complete screen] D -->|yes| F[Issue: snapshot name +
course title + lesson count] E -.->|Student saves a name| F

The denormalized columns are deliberate. The ADR exists partly so a future reviewer who spots recipient_name sitting next to a perfectly good profiles table doesn’t “fix” it into a join.

Issuance is a domain rule, not a click handler

Where does “issue the Certificate” belong? It is tempting to drop it into the LiveView event that records the last Completion. But issuance isn’t a UI event — it’s a domain invariant with three gates (≥1 Lesson, all complete, name present) and an idempotency guarantee. So it lives in a Forgia.Certificates context, and the gate itself is a pure function with no database at all:

def issuable?(%{lesson_count: lc, completed_count: cc, name: name})
    when is_integer(lc) and is_integer(cc) do
  lc > 0 and cc >= lc and present?(name)
end

That purity is why the issuance rules — zero-Lesson courses, the name gate, snapshot immutability, one-per-Enrollment idempotency — are covered by fast ExUnit.Case, async: true tests with no DataCase. The cases that genuinely need infrastructure get LiveView tests instead: the public unauthenticated verify route, and the interactive Course-complete screen that collects a name and redirects. The Definition of Done asks for exactly that split, and the feature fell into it naturally.

Idempotency has a subtle edge. The first version re-evaluated the gate on every call — so after the Instructor added a Lesson, issue_if_earned saw 2-of-3 complete and reported “not earned” for a Certificate that already existed. A test caught it. The fix encodes the ADR directly: an existing Certificate is final, checked before the gate ever runs. Adding a Lesson can’t un-earn a credential, because we never ask the question again.

The name gate, and the claim that waits

A Certificate carries a name. A self-registered, magic-link Student might not have one yet (see the previous slice — Profiles are born lazily). So finishing every Lesson without a name doesn’t fail and doesn’t issue a nameless credential. It defers: the completion screen asks for a name inline, and the Certificate issues — snapshotting that name — the moment it’s saved.

The name-prompt state of the Course-complete screen, shown when the Student has no Profile name

There’s a second, redundant path for safety: saving a name on the Profile page also re-runs issuance across the Student’s Enrollments, so any course that was complete-but-unclaimed mints its Certificate right then.

Three doors to one page

The final Completion routes to a dedicated Course-complete screen — the celebratory moment, with the name prompt above standing in when a name is still missing. From there, plus a View Certificate action on completed courses in the dashboard and the Course overview, the credential is reachable from exactly the places a Student looks.

The Course-complete congratulations screen

View Certificate on the student dashboard

View Certificate on the Course overview panel

All three lead to one URL, addressed by a random, unguessable token — not the Course slug, not the email, nothing enumerable. The owner reaches it from their surfaces; a verifier reaches it by link; it’s the same page. The page just adapts its nav to who’s looking: a signed-in owner gets the app bar with a way back to their dashboard, while an anonymous verifier sees only Log in / Register — never the owner’s email or any other personal data.

The design system mostly held — except at 320px

The certificate mockup ported cleanly: tokens, the embossed paper. We dropped what we had no data for (the “6h 18m” duration) and hid the deferred actions (Download PDF, Share on LinkedIn), keeping Print — now the brown primary button it should always have been, and wired to print the paper as a single A4 landscape page (@page { size: A4 landscape }), so a Student gets a frame-ready credential, not a clipped one.

The one thing the mockup couldn’t warn us about was real data. The verify chip ends in the token — one long unbreakable string. At a 1040px canvas it’s invisible; at 320px it punched straight through the right edge of the paper. A one-line fix (overflow-wrap: anywhere on the chip), but a reminder that “follows the design system” and “survives the narrowest viewport with real content” are two separate checks.

The verify page at 320px, token wrapping cleanly inside the chip

What shipped

  • A Certificate aggregate: one per Enrollment, immutable snapshot, random verify token + human-friendly Credential ID.
  • Automatic issuance hooked off the final Completion, surfaced on a dedicated Course-complete screen (congrats / inline name-claim), with a redundant deferred-claim path through the Profile name save.
  • A public, unauthenticated /c/:token verify page, responsive to 320px and printable as a single A4 landscape page.
  • Discovery from the Course-complete screen, the dashboard, and the Course overview.
  • 24 new tests (pure-domain gate + persistence/immutability + the public route + the Course-complete screen); full suite green.

Deferred on purpose: PDF export, Share on LinkedIn, a Notification on issuance. The credential is the foundation; those are decorations on top of it.