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.
| Layer | Guards against | On failure |
|---|---|---|
| Prompt-injection boundary | a string that reads like an instruction | nothing to fail: strings are only ever data |
| Integrity gate | broken placeholders, ICU, markup, empty or degenerate values | the key is withheld and retried next run |
| Review flags | a value that is valid but suspicious | the key is written and queued for a person |
| Token budget | spend past a ceiling you set | the rest of the run is withheld, or flagged |
| Structured errors | a key or header leaking into a message | a 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.
| Check | A candidate is refused when |
|---|---|
| Placeholders | its placeholders differ from the source's, compared as a multiset (counts matter, order does not). What counts as a placeholder is defined per format. |
| ICU | for 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 tokens | for 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 markup | its HTML or XML tags differ from the source's, or are unbalanced (below). |
| Degeneracy | it is a repetition loop, or its length ran away from the source. |
| Emptiness | it 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 forselectordinal). The categories travel as data, so the injection boundary is unchanged. - The gate refuses a
pluralorselectordinalthat 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
=0must all survive, and a translation may add more.selectarms are your code's keys, so a translation keeps exactly the source's. - For a locale Node.js has no plural rules for, only
otheris 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 <b> 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
dirincluded); unbalanced or mis-nested markup; two tags merged into one. - Accepted: the same tags in another order; a translated attribute value such as
titleoralt;<br>written as<br/>. - Guarded values: a URL attribute must keep its scheme and host (
/docsmay become/de/docs, neverhttps://evil.example/docs), ajavascript:URL is refused, and values such asstyle, event handlers or a<meta content>must stay exactly as in the source. - Not translated: the content of
script,styleand similar elements must come back unchanged. Escape such a tag in prose (<script>) 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.
| Reason | Flags a translation that |
|---|---|
LENGTH_ | is far shorter or longer than its source, weighted by script |
MAX_ | is longer than the key's maxLength budget |
EQUALS_ | is identical to its source text |
GLOSSARY_ | misses a glossary term's translation, or alters a do-not-translate term |
GLOSSARY_ | uses a rendering the glossary forbids |
INTEGRITY_ | keeps every placeholder but in another order |
PROVIDER_ | came from a batch the provider degraded, such as a dropped formality |
FUZZY_ | was reused from a similar, edited source |
FOREIGN_ | drops or changes a placeholder-like token its format does not interpolate, such as {name} in i18next |
BIDI_ | leaves 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_); 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.
Related
- How a run works: where each check sits in the pipeline.
- Data handling and privacy: what leaves your machine.