*How suprnova.app grew Fluent localization, why the translation process turned
out to be the best defect detector the project has ever run, and what we
learned about making AI agents check each other's work.*

---

Suprnova's manual is 104 chapters. The site that serves it - this site - is a
Suprnova app, which means the framework's localization story had to be proven
here first, at full scale, before anyone else was asked to trust it. This is
the write-up of that project: wiring Project Fluent through a Rust backend, an
Inertia frontend and a Bun SSR worker, then pointing a fleet of AI agents at
624 chapter translations and building the review machinery that let us
actually believe the output.

Some numbers up front, because the rest of the article leans on them:

- **8 locales**: English plus es, fr, de, pt-BR, pt-PT, ja and zh-Hans
- **104 chapters** translated into six languages - pt-PT ships as a delta over
  pt-BR rather than a seventh full translation
- **7,614 sections** under review tracking, every verdict pinned to a content
  hash
- **3,433 recorded verdicts** from **35 distinct reviewer identities**
- **~520 adjudicated glossary rows** across the six locale glossaries
- **281 tests** in the gate, 23 of them structural guards that hold every
  translation to its English source

## Part 1: Fluent, wired the boring way

Project Fluent's `.ftl` format won for one reason: asymmetric grammar.
A message in Fluent is a tiny program - it can branch on plural category,
carry attributes, interpolate terms - which means Japanese and German don't
have to pretend they inflect like English. The catalogs live in
`lang/<locale>/*.ftl`, and the English one is the reference every other
locale is held to by a console command:

```console
$ cargo run --bin console -- lang:check --strict
0 error(s), 0 warning(s)
```

The architectural rule that shaped everything else: **strings resolve in one
place - the client.** A server handler that needs localized copy emits a
message *id* as the prop value (`home-hero-headline`, not the sentence), and
Vue resolves it with `t()`. The server never concatenates translated
sentences, so a new string is a catalog entry, never a `Lang::get` call in a
controller.

That rule has one sharp edge: server-side rendering. The SSR worker is a Bun
process rendering concurrent requests for different readers, so a module-level
"current locale" isn't a bug, it's a data leak - reader A's page rendered in
reader B's language. The worker fetches the flattened catalog from the running
app (`/_suprnova/lang/<locale>.ftl`) and holds it in an `AsyncLocalStorage`
scope per render. The client then fetches the *same bytes* before hydrating,
so the server and the browser literally cannot disagree about what a message
says.

Detection is Session → Cookie → `Accept-Language` → `APP_LOCALE`, and the
reader's explicit choice is a `POST /locale/{locale}` that writes the session.
A small middleware rewrites the root template's hardcoded `<html lang="en">`
on the way out, because a screen reader picks its voice from that attribute
and Japanese prose read with English phonetics is not localization.

pt-PT deserves a sentence: it's a **delta catalog**. Fluent resolution walks
a parent chain (pt-PT → pt-BR → en), so European Portuguese only carries the
messages where it actually differs. The catalog is 986 bytes of docs strings
against pt-BR's full file - the honest size of the actual difference.

## Part 2: The site chrome, and the strings that hide

Localizing the UI was 348 English messages across four catalogs - and the
interesting part was the strings the extraction *missed*. A regex sweep for
`>Text<` and `attr="Text"` cannot see a bare text node spanning several lines,
or a lone label inside a multi-line component. We found them because a pt-PT
render showed a Portuguese form with an English submit button - and then we
wrote a test (`lang_usage.rs`) that reads the templates and fails on
unextracted prose, so the class of bug died rather than the instance.

Validation messages took the framework path: `ValidationMessage::keyed(...)`
resolves against the request's locale, and the one shape that *cannot* be
translated - a keyless `#[validate(message = "...")]` literal - is documented
as untranslatable by design. Making the impossible case explicit beats
discovering it in production.

## Part 3: The manual, or: the scale problem

The manual is a build artifact - Markdown in, rendered chapters out - so the
translations are too: `content/docs/<locale>/<chapter>.md`, run through the
same `docs:build` pipeline, held to the same structure. Translating it meant
624 chapter-sized documents of dense technical prose, full of code fences
that must not be touched, anchors that must keep resolving, and terminology
that must not drift between chapters.

The fleet looked like this:

- **One agent per locale, always.** The review ledger and glossary are
  read-modify-write JSON files; two agents writing one locale is a lost
  update waiting to happen. Concurrency came from breadth - six locales in
  flight at once - not from parallelism within a locale.
- **Sonnet-class models for translation, never smaller.** We tried the cheap
  tier early. It translates dead metaphors literally - "fail loudly", "hot
  path", "fire an event" all came back as if they were about volume, roads
  and combustion. Guards can't catch that; it's grammatical, fluent and
  wrong. Model choice is a correctness decision, not a cost decision.
- **Translator agents carry a packet, not the repo.** Each assignment is a
  self-contained brief: the English text, the locale's glossary, the
  conventions, the defect taxonomy. An agent that can't wander can't
  improvise.

## Part 4: The review economy

Raw translation was the easy half. The machinery that lets you *trust* 624
documents is the other half, and it became a small data model in the app
itself (`docs_review.rs`):

**Verdicts are pinned to content hashes.** A reviewer's pass is recorded
against the SHA-256 of both the English section and the translation as they
stood at review time. Edit either one and the verdict silently demotes to
*stale* - it stops counting, because it describes text that no longer exists.
This is the property that makes everything else honest: nobody can edit a
section and inherit its approval.

**Two independent passes, one veto.** A section is approved when two distinct
reviewers pass the exact bytes; a single fail outranks any number of passes.
The bar given to reviewers: *a developer who reads only this language would
be correctly informed* - not "nothing looks wrong."

**The author cannot vote on their own edits.** This rule earned itself on day
one: of the twelve repairs I shipped in one remediation round, reviewers
reading the repaired text fresh caught **four** that had stopped halfway - a
heading fixed while the body kept the old term, a verb corrected but its
reflexive pronoun dropped, two of three sites swept. The author re-reads
their intention; a fresh reader reads the text.

**Reviewers are wrong too, and the record keeps both.** Of roughly 42
findings in the final clearing round, eight were overturned - most for the
same failure, reading a glossary row as banning something it never mentions.
An overturned verdict isn't deleted and isn't flipped to a pass: it stays
exactly as written, with a recorded reason it no longer counts, and the
reviewer drops out of the quorum. A reading found to be wrong is not evidence
in either direction.

**Terminology is adjudicated, not averaged.** Every contested term ends in a
glossary row that records the ruling *and the evidence*:

```json
"key (cryptographic)": {
  "use": "キー",
  "why": "encryption.md - the chapter that owns the concept - writes it
          katakana 53 times, and キーリング was already katakana on both
          sides of the split...",
  "deviation": "Compounds stay 鍵: 公開鍵, 秘密鍵, 鍵ペア. Nobody writes 公開キー."
}
```

The `deviation` field is the important one. It's where a row says *this looks
inconsistent and is deliberate*, so the next sweep - human or agent - doesn't
"fix" a decision into a defect.

That last risk is real, because **a guard once caused our worst bug**. The
Spanish glossary banned `programador` as a rendering of *scheduler* (the
subsystem is `planificador`). The ban is a raw substring match; a sweep hit a
sentence that said "not a *programmer* error" - a person, not the subsystem -
and rewrote it into a non-sequitur about schedulers. The locale ended up with
zero occurrences of the ordinary Spanish word for a programmer, which is
exactly why nobody noticed. The fix was scoping (`allow_in`), but the lesson
is bigger: an enforcement rule that cannot read sense must be scoped by
someone who can.

## Part 5: Translation is a defect detector

The unexpected dividend: translating a manual means six careful readers who
cannot skim, reading every sentence twice, independently. Agreement between
them is signal. The process filed a standing hand-off document upstream with,
among other things:

- **39 dead anchors** in the English manual - links whose fragment names no
  heading on the target page, most authored under an older slug rule
- a changelog section announcing "eight defects" and listing seven - in a
  security summary, where the two readings are not equivalent
- bullet lists whose wrapped `+` continuation lines parse as new list items
- the same identifiers formatted both ways within single files

None of that was found by years of English-only reading. All of it was found
in the first weeks of translation.

And then there were the bugs only non-English *users* could hit. The best
one: a middleware that rewrites `<html lang>` has to rebuild the response to
edit the body - and the rebuild silently dropped every `Set-Cookie` set
inside it, including the CSRF token. The rewrite only runs when the locale
isn't English. Result: every reader whose browser advertised es, fr, de,
pt-BR, ja or zh-Hans got no CSRF cookie, and every form on the site - login,
registration, the language switcher itself - answered 419 *for exactly the
audience the translations were built for*. It survived a 278-test suite
because every test browsed in English. The regression test now visits as
seven different `Accept-Language` readers, and the fix refuses to rebuild any
response carrying a cookie.

Localization work finds these because it moves you off the paved path. Every
default - the test client's language, the developer's browser, the happy-path
session - is quietly English. The first user who isn't English-shaped walks a
code path nobody has ever watched.

## What I'd tell you to steal

1. **Ship ids, not sentences.** One resolution point (the client) means the
   server, the SSR worker and the browser can't disagree - and your
   translators work in catalogs, not in controllers.
2. **Pin review verdicts to content hashes.** It's twenty lines of code and
   it makes "approved" mean something durable. Every edit visibly re-opens
   exactly the sections it touched.
3. **One writer per ledger.** Agent concurrency by partition (locale), never
   by shared file.
4. **The author never reviews their own edit.** Not a competence question - a
   position question. Fresh eyes catch incomplete edits the author's memory
   fills in.
5. **Keep overturned verdicts.** A review system that deletes disagreement
   can't learn which of its reviewers - or its rules - produce false
   positives.
6. **Scope every mechanical ban.** A substring match will eventually meet the
   one sentence where the word means something else.
7. **Treat translation as an audit.** Budget for the defects it will find in
   your *source* text, because it will find them.

The manual is live in six languages today, the switcher swaps the whole
document server-rendered in the reader's choice, and the review ledger knows
- to the hash - which sections two independent readers have vouched for.
The remaining tail is queued, the machinery that eats it is proven, and every
section it approves stays approved until someone touches the text.

That's the part I'd defend the hardest: not that agents translated a manual,
but that we can say precisely *how much* of it has been checked, by whom,
against which bytes. Scale was never the hard part. Accountability was.
