Skip to content
Skip to Content

Docs / Concepts

Translation safety

The safety model: untrusted strings, the integrity gate, review flags, the token budget, and errors that never leak.

verbatra trusts neither the strings it translates nor what a provider sends back.

LayerGuards againstOn failure
Prompt-injection boundarya string that reads like an instructionnothing to fail: strings are only ever data
Integrity gatebroken placeholders, ICU, markup, empty or degenerate valuesthe key is withheld and retried next run
Review flagsa value that is valid but suspiciousthe key is written and queued for a person
Token budgetspend past a ceiling you setthe rest of the run is withheld, or flagged
Structured errorsa key or header leaking into a messagea stable code with a fixed message

Untrusted strings and the prompt-injection boundary

A translatable string can contain text that looks like an instruction ("ignore your instructions"), so verbatra treats every string as untrusted input. The system rules sent to an LLM provider are compile-time constants. Everything variable (the entries, your glossary and tone, the language names, a plural's target categories) travels only in the user-turn JSON payload, never in the instruction channel. The response must match a fixed schema; a response with a key that was never requested fails its whole sub-batch.

Language, script and region names

A locale code alone is not always enough: sr-Latn often comes back in Cyrillic, and pt-BR and pt-PT blur together. So an LLM provider also receives each language's English name, script and region, taken from Node.js for the locale in your config even when localeMap sends another code. They travel as data, one fixed system rule asks the model to write in the named script and variant, and they are not part of the cache fingerprint. A code Node.js cannot name, such as the private-use qaa, is sent without a name.

The integrity gate

A schema-valid translation can still be broken, so one gate decides from the candidate value itself whether to accept it; the provider's own report is never trusted. The same gate runs on every write path: a provider answer, a cache hit, a workbook import row, and an edit typed in Studio. A value shared by several keys with the same source text is gated per key against each key's own source.

CheckA candidate is refused when
Placeholdersits placeholders differ from the source's, compared as a multiset (counts matter, order does not). What counts as a placeholder is defined per format.
ICUfor next-intl and ARB, it no longer parses as an ICU message, or its plural arms do not fit the target language (below). The comparison is branch-aware, so a placeholder invented inside one plural arm is caught.
Invented tokensfor i18next, ngx-translate and YAML, it adds a {name}-shaped token the source never had. Dropping one is allowed, since single braces are literal text there.
Inline markupits HTML or XML tags differ from the source's, or are unbalanced (below).
Degeneracyit is a repetition loop, or its length ran away from the source.
Emptinessit is empty or whitespace for a non-empty source. Clearing a translation is a deliberate act: the workbook's [[CLEAR]] sentinel does it (Manual translation).

A candidate that breaks several checks is refused with one reason, the first that applies in this order: empty, icu (does not parse), placeholder, markup, icu (arms), degenerate. Each reason, and the details verbatra prints with it, is listed under Integrity refusal reasons.

What the gate guarantees: a refused value is never written. It appears on the run summary as an integrity mismatch, keeps its previous hash in the lock file, and is retried next run. A key that got no translation at all (the provider call failed, or the key was still missing after one repair request) is reported as a provider failure instead, and retries the same way. On the source side, a key whose own value is invalid ICU is set aside before translation.

What it does not guarantee: that the translation is correct. It proves structure, not meaning.

Plural arms per target language

An ICU plural has one arm per CLDR plural category of its language (examples). For next-intl and ARB, verbatra reads the target language's categories from the CLDR data built into Node.js:

  • An LLM provider is asked for exactly those arms (cardinal for plural, ordinal for selectordinal). The categories travel as data, so the injection boundary is unchanged.
  • The gate refuses a plural or selectordinal that lacks a required arm or keeps one the language does not have, on every write path, and names each wrong arm.
  • Exact-value arms such as =0 must all survive, and a translation may add more. select arms are your code's keys, so a translation keeps exactly the source's.
  • For a locale Node.js has no plural rules for, only other is required.

A value written before this check, such as a French plural without many, stays until its source changes; verbatra check --qa finds it now.

Inline markup parity

A dropped or broken inline tag (a <b> in an i18next Trans block, an escaped &lt;b&gt; in an Android <string>, an <a href> in gettext) breaks rendering, so the gate compares tag and attribute names as a multiset:

  • Refused: a dropped, invented or renamed tag; a changed set of attribute names (adding dir included); unbalanced or mis-nested markup; two tags merged into one.
  • Accepted: the same tags in another order; a translated attribute value such as title or alt; <br> written as <br/>.
  • Guarded values: a URL attribute must keep its scheme and host (/docs may become /de/docs, never https://evil.example/docs), a javascript: URL is refused, and values such as style, event handlers or a <meta content> must stay exactly as in the source.
  • Not translated: the content of script, style and similar elements must come back unchanged. Escape such a tag in prose (&lt;script&gt;) to translate the text around it.

Markup invented into a string that had none is refused too, while an ordinary bracketed word such as <Enter> is read as text. Tags the format already reports as placeholders are left to the placeholder check. Comments, raw text and malformed sources: see markup.

Review flags

A second, advisory layer flags suspicious results for a person. A flag never withholds a key: the value is written, locked, and put on the needs-review queue.

ReasonFlags a translation that
LENGTH_RATIO_OUTLIERis far shorter or longer than its source, weighted by script
MAX_LENGTH_EXCEEDEDis longer than the key's maxLength budget
EQUALS_SOURCEis identical to its source text
GLOSSARY_TERM_MISSEDmisses a glossary term's translation, or alters a do-not-translate term
GLOSSARY_FORBIDDEN_TERMuses a rendering the glossary forbids
INTEGRITY_REORDEREDkeeps every placeholder but in another order
PROVIDER_DEGRADEDcame from a batch the provider degraded, such as a dropped formality
FUZZY_CACHE_REUSEwas reused from a similar, edited source
FOREIGN_PLACEHOLDER_CHANGEDdrops or changes a placeholder-like token its format does not interpolate, such as {name} in i18next
BIDI_CONTROLS_CHANGEDleaves a direction control unclosed, or adds an override that reverses text

DeepL, Google and LibreTranslate get such a token masked, and withhold a value they cannot mask or restore (PLACEHOLDER_UNSUPPORTED); the flag covers LLMs and people.

Cache hits and shared values are flagged like fresh translations. Flagged keys appear as needsReview on the run summary, in the workbook's review columns, and on Studio's Review page, until a person approves, rejects or rewrites the value (Review decisions).

The token budget

maxTokens counts a run's tokens across every call and locale; budgetBehavior: "stop" withholds the rest of the run once the ceiling would pass, to be retried next run (rules).

What the budget guarantees: work done before the ceiling stays written and locked, and nothing is left half-written. What it does not: a request in flight can cost more than projected, the ceiling bounds one run (not a watch session), and Studio or agent retranslations are not covered.

Errors never leak

When a provider call fails, the raw SDK error (which can carry request headers or credentials) is discarded, never logged or re-thrown. verbatra raises instead a structured error with a stable code and a fixed, secret-free message. Key redaction is on Data handling.

Where you see it

Everything above is data on the run summary, per locale, and one locale's failure never stops the others.

Committed values

verbatra check --qa runs the gate and the review flags over committed values without a provider call.

Edit on GitHub

On this page