The client pointed at one string. The Student dashboard opened with Bem-vindo de volta, %{name}.welcome back, masculine — and their courses are taken by a great many women, and by trans and gender-fluid people.

English gave us no warning, because English has nothing to warn about. The msgid is Welcome back, %{name}. and it is neutral in the only sense English can be: it has no grammatical gender to get wrong. Portuguese does. Every adjective, every participle, every article agrees with something, and when that something is the person reading the screen, the translator has to pick. Somebody picked masculine 25 times without ever making a decision about it.

The Student dashboard greeting: Que bom te ver de novo, Beatriz

The obvious fixes are both bad

The first instinct is the split form: bem-vindo(a). It is what Brazilian officialdom has done for decades, and it is worse than it looks. It enumerates two options, which means it is a list — and a list that names two things is a list that excludes the third. It also reads as bureaucratic, and it makes screen readers say “bem-vindo parêntese a parêntese”.

The second instinct is a neologism — bem-vinde, bem-vind@, bem-vindx. These solve the grammar honestly. They also fail for a general Brazilian audience today: @ and x are unpronounceable, and -e reads as a typo to most people who have not already opted into it. That is a fact about the audience rather than about the platform, and ADR 0026 records it as the thing to revisit when the fact changes.

What is left is the option that requires actual writing: rewrite around the agreement. Do not find a neutral way to say “you are welcome” — say something with no adjective in it.

Two moves, and both of them come from the domain

The rewrite turns out to be mechanical once you name what it does. There are two moves, and the second one is the interesting one.

Say what the reader did, not what the reader is. A state needs an adjective; an action does not. Você foi convidado is a condition, and conditions inflect. Você recebeu um convite is a thing that happened, and verbs in the past tense do not care who you are.

Count the thing, not the people. This is where the fix stopped being a copy exercise and started agreeing with the codebase. The Studio said Total inscritos and %{count} alunos inscritos — counts of people, and people have gender. But Enrollment has been a term in CONTEXT.md since long before this item, and an Enrollment is an abstraction with nothing to inflect. Total de inscrições. %{count} inscrições. The Portuguese moved toward the ubiquitous language, not away from it, and the grammar problem disappeared rather than being worked around.

flowchart TD
    A["Portuguese string
with a gendered ending"] --> B{"What does
it agree with?"} B -->|"a thing"| C["Leave it alone
Este curso está sendo preparado
Seu certificado está pronto"] B -->|"the Instructor,
a known person"| C B -->|"the reader"| D{"Is it a state
or a count?"} D -->|"a state"| E["Restate as an action
Você foi convidado

Você recebeu um convite"] D -->|"a count of people"| F["Count the domain object
%{count} alunos inscritos

%{count} inscrições"] E --> G["No adjective left
to agree with anyone"] F --> G style C fill:#e8e4d9,stroke:#8a8578 style G fill:#dce8dc,stroke:#5f7a5f

The left branch matters as much as the right one. Este curso ainda está sendo preparado has a masculine ending and is correctpreparado agrees with curso. So does every certificado in the catalogue, and so does Seu certificado está pronto. The target was never “masculine endings”; it was agreement with a human referent. Getting that distinction wrong would have produced a diff full of grammatical errors in the name of inclusion.

The emails had stopped halfway

The confirmation email was the clearest evidence that a previous pass had been made by someone applying instinct rather than a rule. It opens with Boas-vindas — already neutral, already deliberate. Then it ends:

…confirme o email abaixo para ativá-la, e você estará conectado e pronto para aprender.

Two masculine adjectives in the last six words of a string whose first word had been fixed. Whoever repaired the greeting had repaired the greeting.

The confirmation email: Boas-vindas, Beatriz! and a tail that ends on an action

There were eight such strings across five emails, and one small mercy in the code: the HTML and plain-text bodies share a msgid. user_notifier.ex and the .html.heex appear in the same .po block, so each fix landed on both.

The invitation email: Você recebeu um convite para Hint Ladder Demo

--no-fuzzy is the wrong guard, and it is why this item was small

This repo pins --no-fuzzy on both gettext aliases, and there is a post about why: mix gettext.merge will copy a translation across from any msgid within a Jaro distance of 0.8, Jaro compares characters rather than meaning, and Elixir’s compiler — unlike GNU msgfmtships the guess. That is how a login button briefly wore the translation of an email subject.

It is tempting to reach for that guard here. It does not apply. The fuzzy trap fires on a rewritten msgid, and this item rewrote none — every English string was already neutral, so there was nothing to change on the source side. The whole diff is msgstr:

Wrote priv/gettext/pt/LC_MESSAGES/default.po
  (0 new messages, 0 removed, 669 unchanged, 0 reworded (fuzzy), 0 marked as obsolete)

That line is the acceptance criterion. 0 new and 0 removed prove no msgid was disturbed; 0 reworded (fuzzy) proves nothing was guessed. And it is the reason an item touching 56 strings across two dozen screens was a small item: none of it was code.

The cost of --no-fuzzy runs the other way, and it is worth stating because it is the thing that will bite next: with fuzzy off, rewording a msgid loses its translation instead of inheriting one. Any future copy change to the English is a retranslation, on purpose.

Half of the rule is now a test

CONTEXT.md and ADR 0026 both said, in as many words, that nothing in the build could check this. That was true and it was also the part of the situation most likely to decay — a rule that lives only in a document is a rule that holds until the next person is in a hurry.

It turns out to be half checkable. Inclusive prose is a judgement call and no test will ever make it. But the forms that carry the failure are a closed set — bem-vindo, aluno, conectado, convidado, inscrito, (a) — and a denylist over every msgstr catches them the day they are typed instead of the day somebody reads the screen in production. Forgia.PtCopyTest does that, in the shape the repo already had for GettextCatalogueTest: a test that judges the catalogue rather than the application, because nothing in the application can tell that a translation excluded somebody.

@gendered ~r/bem-vind[oa]|\balun[oa]s?\b|\bconectad[oa]s?\b|\bconvidad[oa]s?\b|\binscrit[oa]s?\b|\(a\)/i

# The one string that trips the denylist and is already inclusive:
# its participle agrees with `Pessoas`, not with the reader.
@already_inclusive [
  "People with an account are enrolled right away; everyone else gets an invitation."
]

That allowlist is one entry long and carries its reason inline, which is the only way an allowlist stays honest. And the test earns its keep the way any guard does — by being watched fail. Putting Bem-vindo de volta back produced exactly the failure it should, naming the string.

Both documents were corrected. They now say what is checked and what still is not: a newly invented gendered phrasing sails straight through, and for anything past the closed set the rule holds only because CONTEXT.md gets read first.

What the Instructor sees

The decision that widened the item most was going after all 30 uses of aluno, not just the three a Student can see. CONTEXT.md says to prefer estudante without qualifying by audience — it is epicene, so only its article inflects — and the Instructor’s help text is read by a person too.

But the deciding argument was enforceability. A denylist can only be global. Scoped to three strings it would have needed an allowlist of 27 exceptions, and an allowlist of 27 is a list nobody maintains.

The Studio’s enrolment panel: INSCRIÇÕES ATUAIS, Adicionar estudantes, and the empty state reading Você ainda não adicionou ninguém

The bulk-add preview was the densest patch of the problem — every row described the person with a participle. será inscrito, já inscrito, será convidado, já convidado. Four strings, four genders assigned to four people the Instructor is in the middle of adding. Restated as what happens rather than to whom:

The bulk-add preview: three rows reading já tem inscrição, envia convite, and convite já enviado

One badge was a genuine improvement disguised as a translation fix. The Review byline said Aluno verificadoverified student. What a Review is actually verified by is the Completion: CONTEXT.md has said since the Reviews item that a Review may only be written once every Lesson has one. Conclusão verificada is neutral and it is also more accurate than what it replaced.

Another was constrained in a way only the catalogue reveals. EnrolledInscrito, masculine — is one msgid serving two surfaces: a stat label sitting under a number in the Studio, and a badge beside a check icon on the Student’s course page. The replacement had to read as both. Inscrição ativa does, and there is no version of this fix where the two surfaces get different words without touching a msgid.

The course page for an enrolled Student: a check icon beside INSCRIÇÃO ATIVA, above Seu progresso

Two things the browser found that no test would have

The item was verified by driving the real app, and both surprises came from there rather than from mix test.

The first was a false bug. The bulk-add preview classified an invited email as will enroll instead of already invited, which looked like the exact regression a rename can cause. It wasn’t: the dev database already held a confirmed account for that address from some earlier session, and preview_status/4 correctly prioritizes an account over an Invitation. The seed was wrong, not the code. CLAUDE.md warns in writing that the dev DB is not a stable fixture, and this is what that warning looks like from the inside.

The second was the string lengths. Total inscritosTotal de inscrições is five characters wider in a stat label under a number, and Manter a sessão ativa replaced Continuar conectado on a button. Nothing wrapped. The login screen actually reads better than before, because the replacement happens to pair with the button below it — both now talk about the session rather than about you.

The magic-link confirm page: Manter a sessão ativa above Entrar só nesta sessão

What this cost

Some strings got slightly longer. A few are marginally less idiomatic than the masculine default — Você recebeu um convite para X is three words longer than Você foi convidado para X, and no rewriting makes that back.

And writing new Portuguese copy is now a deliberate pass rather than a direct translation. The denylist will catch the six familiar endings. It will not catch the seventh one nobody has typed yet.