The oldest problem in selling a course is that you finish it, publish it, and then start looking for someone to sell it to. The fix is to invert that: put the pitch up while the Course is still being written, collect addresses from the people who want it, and open on day one with an audience already assembled.
That is what Announced is. A third value on courses.status, between Draft and Published, meaning: the Course does not exist yet, but its page does.

Everything on that page is deliberate, including the two things that are missing.
What the page does not show
No curriculum. An Announced Course usually has some Chapters and some half-written Lessons, and showing them would promise titles that may never ship. The refinement was blunt about this: highlights and the intro video are sales assets made on purpose, a half-built curriculum is a leak. So the section is not rendered at all — not collapsed, not greyed out.
No price, no Buy, no Enroll. There is nothing to sell yet. The purchase panel is not disabled on this page; it is absent from the DOM. A disabled Buy button is a different promise (“you will be able to buy this, here, shortly”) and we are not making it.
What stands in the purchase panel’s place is the one thing a visitor can do:

Nothing about that visitor is stored here
The Waitlist is a Brevo list. That is the whole of it — no waitlist_signups table, no count in the Studio, no log line carrying the address.
This was the load-bearing decision of the item, and it is not a shortcut. The people who sign up never created an account, so a local row would be personal data about a stranger, and it would need its own deletion path to be defensible. Brevo’s list already ships unsubscribe and erasure. A second copy would also drift: someone who unsubscribes in Brevo would still be told here that they are on the list, and be refused a re-signup.
So Forgia asks Brevo two questions and keeps neither answer:
flowchart TD
V["visitor types an address"] --> T{"Throttle
10 per IP / 10 min"}
T -->|"deny"| TM["“try again in a few minutes”"]
T -->|"allow"| R["GET /v3/contacts/{email}"]
R -->|"200 · listIds contains ours"| A["“you were already on it”"]
R -->|"200 · not on ours"| W["POST /v3/contacts
updateEnabled: true"]
R -->|"404 · unknown contact"| W
R -->|"timeout · 401 · 500"| W
W -->|"2xx"| S["“you're on the list”"]
W -->|"anything else"| E["“we couldn't add you just now”
address kept · retry offered"]
style S fill:#dce8dc,stroke:#5f7a5f
style A fill:#dce8dc,stroke:#5f7a5f
style E fill:#fbe6e1,stroke:#b14333
style TM fill:#f2e3c2,stroke:#b5912a
Note the asymmetry between the two calls, because it is the point.
The read is allowed to fail. A 404, a timeout, a 500 — anything at all — is treated as “not on the list” and the signup proceeds. The read exists for exactly one reason: so a returning visitor is told the truth instead of thanked twice. It is not load-bearing for correctness, because POST /v3/contacts with updateEnabled: true is idempotent and a Brevo list is a set. An unknown answer must never cost someone their place.
The write is not. There is no queue and no local store to replay from, so a write that fails is a lead that never existed. It is reported and never swallowed:

The address stays in the field. Retrying costs a click, not a retype.
Both answers, when they come, replace the form rather than sitting above it:

And the returning visitor gets the other sentence, which is the entire reason the read exists:

The Instructor’s side is one field and one button
A Waitlist on the Course is a Brevo list id, pasted into Settings:

And Announce sits next to Publish in the header, offered only while the Course is Draft:

The two preconditions — a Waitlist, and public visibility — live on the changeset, not on whether the button is rendered. Hiding a disabled button is the version of this that leaves the Instructor guessing:

Announced, the header grows a way back and keeps the way forward:

The badge is the quiet part of widening an enum. Both Studio badges were binary if/else, and a binary badge renders an Announced Course as “Draft” without breaking a single test — the ADR named it as the failure mode to watch for in review, which is the only place it would have been caught.

The rule that looked like it fell out of status
Here is the part worth the post.
A Course’s Slug is derived from its title and regenerates on every rename while the Course is Draft. It freezes on Publish, because by then the URL is out in the world. The code said so plainly:
defp maybe_put_slug(changeset, %{status: "published"}), do: changeset
Adding a third status makes the correction look obvious, and the ADR written during refinement said as much: the freeze becomes status != "draft" — correct rather than merely convenient, because the pre-launch is precisely when a link is being shared and therefore precisely when it must stop moving.
It is a one-line change and it is wrong.
status is not a latch. It is exactly the field that moves back. An Instructor who announces a Course, shares the link in a newsletter, then decides it is not ready and clicks Voltar para Rascunho has a Course whose status reads draft again — and the very next rename regenerates the Slug and breaks every URL already circulating. Which is the one thing the rule exists to prevent.
flowchart LR
D["draft
slug regenerates
on every rename"] -->|"Announce"| A["announced
slug frozen
link is being shared"]
A -->|"Publish"| P["published
slug frozen"]
D -->|"Publish"| P
A -->|"Back to Draft"| D2["draft again
status says 'draft'
— but the link is already out"]
D2 -.->|"status != draft ⇒ THAW
next rename breaks every shared URL"| X["❌"]
D2 -->|"slug_frozen ⇒ still frozen"| OK["✓"]
style A fill:#f2e3c2,stroke:#b5912a
style P fill:#dce8dc,stroke:#5f7a5f
style X fill:#fbe6e1,stroke:#b14333
style OK fill:#dce8dc,stroke:#5f7a5f
So the freeze got a latch of its own — courses.slug_frozen, a boolean set by whatever takes a Course out of Draft and never unset:
defp maybe_put_slug(changeset, %{slug_frozen: true}), do: changeset
defp maybe_put_slug(changeset, %{status: status}) when status in ["announced", "published"],
do: changeset
The status clause stays alongside it so rows written before the column existed still read correctly, and the migration backfills every Course that is not Draft today.
The generalisable bit: a rule about “has this ever happened” cannot be stored in a field that describes “what is true now”, however neatly the current value happens to imply it most of the time. The tell is that the state is reachable from two directions. ADR 0027 has been amended to say so rather than left to read as if the one-liner had worked.
Notably, the test for this is four lines and would have been written either way — announce, unannounce, rename, assert the slug. It was the writing of it that caught the thaw, not a review of the diff.
Two things only a browser could tell us
The suite went green on 1,666 tests before either of these was known. Both belong to the house collection of bugs ExUnit structurally cannot see, and both are now in CLAUDE.md.
A form with type="email" needs novalidate. The browser’s own constraint validation refuses to submit a malformed address, so join_waitlist never runs, the panel’s message never appears, and the visitor is judged by a rule we do not control and given an untranslatable bubble. render_submit performs no constraint validation, so the test for “a malformed address is refused” passed the whole time — against a server that was never reached in a real browser. The type stays, because it picks the right mobile keyboard; the form now carries novalidate.

Nothing may live inside a phx-disable-with button. This one is nastier. The design called for an envelope icon on the submit button. It rendered correctly, survived phx-change, and then vanished the first time anyone submitted.
// phoenix_live_view/view.js:1482
el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.textContent);
textContent. Not innerHTML. LiveView stores the button’s text, swaps in “Adicionando…”, and writes the text back — so every child element inside the button is destroyed on the first press, permanently, because the template’s version is never re-rendered into it. An inline SVG comes back stripped of its viewBox and width and, unconstrained in a flex row, stretches to fill the button; measured, it went from 16px to 150px tall and took the button from 46px to 180px. Swapping it for a Heroicon <span> made it disappear outright instead.
The server render is correct in both cases and LiveViewTest never patches the DOM, so nothing in the suite could have moved. What found it was a Playwright script reading getBoundingClientRect() after a submit.
The resolution was to pick one. The disable is worth more than the glyph here — two synchronous Brevo round trips sit inside that submit, so the button has to say it is working and must not be pressed twice — so the button is label-only. The icon would have to live outside it, and the design’s mockup has no disabled state to reconcile.
What Publishing does
Publishing an Announced Course is the ordinary transition. The band goes, the banner goes, the Waitlist form goes, the curriculum comes back, and the URL that was shared during the pre-launch is the URL of the real page:

Telling the Waitlist that the day has come is done by hand, in Brevo. Forgia’s job ended when the address reached the list, and there is no second system here pretending otherwise.
Deliberately left out
Three things were split out rather than smuggled in, and each is its own Card:
- Announced Courses in the catalog. The page is reachable by direct link only;
Forgia.Coursesstill filters the grid onstatus == "published". Letting them in is a card of its own because the open questions are real — do they sort above or below Published ones, does the language filter apply, is a catalog of only-Announced Courses a state worth designing. - A platform-wide newsletter opt-in alongside the Course’s own Waitlist, whose list id belongs in Server Settings rather than on every Course.
- Hiding unpublished Course pages. A Draft Course’s page is as reachable today as it was yesterday; this item only changed what an Announced one renders. That door is still open, and the Card that closes it now carries a table saying exactly which combinations of status and visibility a stranger may reach.
One thing is open and not carded, because it is not yet a defect: a Brevo outage is invisible to the Instructor until somebody complains. The fix is the local store that was deliberately rejected above, so it needs evidence before it gets a Card.