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:
$ 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:
"key (cryptographic)":
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
- 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.
- 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.
- One writer per ledger. Agent concurrency by partition (locale), never by shared file.
- 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.
- Keep overturned verdicts. A review system that deletes disagreement can't learn which of its reviewers - or its rules - produce false positives.
- Scope every mechanical ban. A substring match will eventually meet the one sentence where the word means something else.
- 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.


Comments 0