A Student finishes a Course. Two things can now exist, and both hang off the same enrollment_id.
The first is a Certificate. It snapshots the recipient’s name, the Course title, and the Lesson count at the moment of issuance, and then it never changes again. Rename the Course, add a Lesson, edit your profile — the Certificate does not care. It is a credential, and a credential that rewrites itself is worthless. That is ADR 0011, decided back when Certificates shipped.
The second, as of this slice, is a Review. It stores a Rating, some optional text, and — deliberately — no name at all.
erDiagram
ENROLLMENT ||--o| CERTIFICATE : "issues (once)"
ENROLLMENT ||--o| REVIEW : "writes (once, editable)"
ENROLLMENT }o--|| PROFILE : "belongs to a Student with"
CERTIFICATE {
string recipient_name "FROZEN at issuance"
string course_title "FROZEN at issuance"
int lesson_count "FROZEN at issuance"
}
REVIEW {
int rating "1-5, required"
text text "public, optional"
text private_feedback "Instructor-only"
none byline "NOT STORED - read live"
}
PROFILE {
string name "the byline, resolved at render"
string avatar_url
}
Same parent, opposite instincts. The Certificate copies the Profile in and stops listening. The Review joins to the Profile on every single read.
This is not an inconsistency that slipped through. It is ADR 0015, written precisely because the next engineer to look at reviews will notice the missing reviewer_name column, assume someone forgot it, and “fix” it.
The GDPR test
The reason the two records disagree becomes obvious the moment someone asks to be erased.
Forgia scrubs a Profile rather than deleting an account, so Enrollment, Completion, and Certificate history survive. Now ask what should happen to that person’s words.
Their Certificate must not change. It attests that a specific named human completed a specific Course on a specific date. Anonymizing it retroactively would not protect them — it would destroy a credential they earned and may be relying on.
Their Review must change. It is an opinion published under a name, and if the name goes, the byline goes with it. But the Rating stays: a 5-star verdict is not personal data, it is a data point, and the Course average has no business shifting because a reviewer exercised a privacy right.
Freeze one, follow the other. There is no single rule that produces both behaviors, which is exactly why there are two ADRs.
def byline(name) do
if is_binary(name) and String.trim(name) != "",
do: String.trim(name),
else: "Anonymous student"
end
A scrubbed Profile anonymizes on the next page render. Nothing is migrated, nothing is backfilled, no job runs. The Review was never holding the name in the first place.
Earned by finishing, not by being credentialed
Who is allowed to review? “A Course I completed” — so: a Completion for every Lesson.
The tempting shortcut is to reuse the Certificate as the gate. It is right there, it means the same thing, and Certificates.issue_if_earned/1 already does the counting.
It would also have been wrong. A Certificate additionally requires a non-blank Profile name — a name-less Student who finishes the last Lesson earns nothing until they claim it. Gate the Review on the Certificate and you inherit that requirement for no reason: an anonymous Student would be blocked from having an opinion because they had not yet decided what to print on a diploma. Two different questions that happen to share a prerequisite.
So the gate is its own pure function, and a name is conspicuously not one of its inputs:
def earned?(%{lesson_count: lesson_count, completed_count: completed_count}) do
lesson_count > 0 and completed_count >= lesson_count
end
Which produces a pleasing edge case. If the Instructor adds a Lesson after you review, your Completion set no longer covers the Course. Your Review stands — it is not retracted, it still counts toward the average, it stays on the page. You simply do not gain the right to a second one. You already used yours.
The star block that isn’t a zero
Every Course now carries ★ 4.9 · 312 reviews on its catalog card and detail page — except the ones that don’t.

A Course with no Reviews yet renders no star line, no reviews count, and no Reviews section. Not 0.0 · 0 reviews. A zero is a score, and a bad one — it says people rated this and hated it, when the truth is that nobody has spoken. The absence of an opinion is not a negative opinion.
That rule lives in the context, not in a conditional smeared across three templates. aggregates_for_courses/1 returns a map, and a Course with no Reviews is simply absent from it:
def aggregates_for_courses(course_ids) do
from(r in Review,
join: e in Enrollment, on: r.enrollment_id == e.id,
where: e.course_id in ^course_ids,
group_by: e.course_id,
select: {e.course_id, avg(r.rating), count(r.id)}
)
|> Repo.all()
|> Map.new(fn {id, average, count} ->
{id, %{average: to_rounded_float(average), count: count}}
end)
end
The component takes aggregate={@aggregates[course.id]} and renders nothing when handed nil. The empty state is structural. You cannot forget it, because there is nothing to forget.
The aggregate is computed on read — no denormalized average_rating column on Course, nothing to recalculate, nothing to drift. That is only defensible if reading it is cheap, so the catalog fetches every Course’s aggregate in one grouped query. There is a test that asserts this by counting queries through :telemetry, rather than by trusting me:
{aggregates, query_count} =
count_queries(fn -> Reviews.aggregates_for_courses(course_ids) end)
assert map_size(aggregates) == 5
assert query_count == 1
That test taught me something on its first run: it reported 11 queries. Not an N+1 — a bug in the test. Telemetry handlers are global, the suite runs async: true, and I was cheerfully counting queries from every other test module racing alongside it. Ecto emits the event in the process that ran the query, so the fix is to match on the test’s own pid. The feature was innocent; the instrument was lying.
Two steps, one form
The dialog asks for the public Review first, then the private note.

The preview card underneath is not decoration. A Review is published immediately — no moderation queue, no Instructor approval, no takedowns — so the last chance to notice that you are about to say something in public, under your real name and photo, is before you press the button. The card shows exactly what the Course page will show.
Step two is the part nobody else sees.

Private Feedback is write-only in this slice. It is persisted, it is validated, and there is not a single screen in the product that reads it. That is a deliberate half-feature: the candid-note UI belongs with the public Review, in the same submission, in the same transaction, but the Instructor’s inbox for reading those notes is its own item. Shipping the write side alone is honest — the Student’s words are safely stored — as long as nobody pretends the loop is closed.
What it must never do is leak. So the public read model does not merely avoid rendering Private Feedback; it never selects it:
select: %{
rating: r.rating,
text: r.text,
reviewed_at: r.inserted_at,
name: p.name,
avatar_url: p.avatar_url
}
A field that never enters the struct cannot be accidentally interpolated into a template later. The guarantee is in the query, not in everyone’s future good judgment.
Both steps live inside one <form>, with step two hidden by a CSS class rather than unmounted. Rip an input out of the DOM and its value goes with it; step back and forth twice and the Student’s text quietly evaporates. And every input renders from the changeset — never from a separate assign — because in LiveView an input whose value comes from an assign that phx-change doesn’t update will helpfully revert whatever the user just typed.
The bug the tests could not see
Thirteen LiveView tests drove that dialog through the real tracked form, render_change then render_submit, asserting against the database. All green. The feature was, by the only evidence I had, done.
Then I opened it in a browser and pressed Tab.
Focus was still sitting on the “Rate this course” button — behind the overlay. Tab walked into the page underneath the dialog, not into the dialog. A keyboard user could open the thing and never reach a single star.
LiveViewTest renders HTML. It has no viewport, no focus, no notion of what a Tab key does. It will confirm forever that the markup is correct while the screen is unusable. The fix is a focus_wrap and a phx-mounted — but no test I could have written in ExUnit would have found it:
<.focus_wrap
id="review-dialog-focus"
class="fb-dialog"
phx-mounted={
if @rating, do: JS.focus(to: "#review-text"), else: JS.focus_first(to: "#review-form")
}
>
The same browser pass caught a second one, this time entirely self-inflicted. The stars are real radio inputs — <input type="radio">, not buttons — so that Tab reaches the group and the arrow keys pick, natively, with no JavaScript of ours. To make stars 1..N fill up with a plain ~ sibling selector, I had rendered them 5→1 and reversed the row with flex-direction: row-reverse. Visually perfect. Except the arrow keys follow the DOM, so pressing → moved the rating down.
:has() fixes it — it can style the elements before the checked one, which ~ cannot:
.fb-star-input:checked + .fb-star,
.fb-star:has(~ .fb-star-input:checked) { /* filled */ }
The DOM now runs 1→5 like the eye does, and four presses of → from star one lands on five. Both of these were caught by driving the actual thing, in an actual browser, with an actual keyboard. Neither was catchable any other way.

Submit, and the button changes its story. Re-open it and the dialog comes back pre-filled with what you wrote — because a mis-clicked star should never require a support request. Submitting again overwrites in place; the unique index on enrollment_id is what actually guarantees it, not a changeset check.
That overwrite is also where this codebase’s favorite trap lives. Ecto’s cast only writes a field whose value differs from the struct’s current value. Build the changeset from a struct you already mutated in memory and the change is invisible — the write is silently skipped, no error, no clue. So the upsert reloads from the database first, every time:
(get_by_enrollment(enrollment.id) || %Review{})
|> Review.changeset(attrs)
|> Repo.insert_or_update()
Where reviews are read

The list is adapted from the existing comment-thread component — avatar, byline, body — because the design system has no reviews list and inventing one would have meant inventing a second visual language for “a person said a thing.”
Every entry is badged Verified student, unconditionally. Not because we check something, but because there is nothing to check: a Review cannot exist without a Completion for every Lesson. The badge is a restatement of the schema.
And a Course completed long before any of this shipped is still reviewable — the dashboard’s completed rows carry a “Rate this course” link straight into the dialog. Without it, the only way to review anything would have been to finish a Course after the deploy, and every existing Student’s opinion would have been unreachable forever.

What’s still open
The Instructor cannot read a word of the Private Feedback they are being sent. That is the next item, and it is a known debt rather than a surprise.
There is no moderation: no hiding, no approval queue, no replies. An abusive Review is currently a database problem, not a product feature. That is the correct amount of machinery to build for a platform whose reviews-per-Course count is comfortably in the single digits, and the wrong amount the day it isn’t.
The dialog collapses into a bottom sheet on a narrow screen, which is more than can be said for the navbar above it:

The rest of the page still overflows at 320px — a pre-existing navbar issue, untouched here, and not worth fixing while the audience is on desktop.