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.

The public page of an Announced Course: a dark “EM BREVE” band above the hero, the title and pitch, a slate banner saying the course is not published yet, the Highlights — and a Waitlist form standing exactly where the purchase panel stands on a Published Course

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:

The Waitlist panel: eyebrow “AVISO DE LANÇAMENTO”, the promise, one e-mail field, one primary button, and a line saying the address is used for this notice and nothing else

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 panel after a failed write: the field outlined in rust and still holding beatriz@example.com, with “Não deu para concluir a inscrição agora. Tente de novo em instantes.” beneath it

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:

The confirmation state: a moss check in a circle, “VOCÊ ESTÁ NA LISTA”, and the address that was saved

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

The same panel for a repeat signup: “Você já estava na lista”, naming the address and saying it will not be added twice

The Instructor’s side is one field and one button

A Waitlist on the Course is a Brevo list id, pasted into Settings:

The Course settings form with a new Waitlist section: a “Brevo list ID” field and a hint explaining that a Course needs one before it can be announced and that nobody who signs up is stored in Forgia

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

The Studio header of a Draft Course: the title, a RASCUNHO badge, and two header actions — an outlined “Anunciar Curso” and the primary “Publicar curso”

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:

The same header after clicking Announce with no Waitlist configured: a rust toast reading “Informe uma Lista de espera (o id da lista no Brevo) na aba Configurações antes de anunciar este Curso.”

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

The header of an Announced Course: an amber ANUNCIADO badge, a ghost “Voltar para Rascunho” and the primary “Publicar curso”

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 Studio course list, one row carrying an amber ANUNCIADO badge among the RASCUNHO and PUBLICADO ones

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.

The panel refusing a malformed address: the field holding “nao-e-um-email” and the message “Isso não parece um endereço de e-mail.” — our sentence, not the browser’s bubble

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:

The same Course after Publishing: no band, no Waitlist, the full curriculum listed, and the free-enrollment panel in the aside

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.Courses still filters the grid on status == "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.