Engineering

Translating a 104-chapter manual into six languages with a fleet of agents

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-LanguageAPP_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)": {
  "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.

Comments 0