---
title: "Translation safety"
description: "The safety model: untrusted strings, the integrity gate, review flags, the token budget, and errors that never leak."
type: "concept"
---
# 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 [#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 [#language-script-and-region-names]

<AvailableFrom version="0.12.0" />

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](https://verbatra.kreitz-webdev.de/docs/the-cache.md) fingerprint. A code Node.js cannot name, such as
the private-use `qaa`, is sent without a name.

## The integrity gate [#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](https://verbatra.kreitz-webdev.de/docs/formats.md). |
| 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](https://verbatra.kreitz-webdev.de/docs/manual-translation.md)). |

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](https://verbatra.kreitz-webdev.de/docs/error-codes.md#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](https://verbatra.kreitz-webdev.de/docs/the-lock-file.md), 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 [#plural-arms-per-target-language]

<AvailableFrom version="0.12.0" />

An ICU plural has one arm per CLDR plural category of its language
([examples](https://verbatra.kreitz-webdev.de/docs/language-support.md#plurals)). 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`](https://verbatra.kreitz-webdev.de/docs/cli/check.md#quality-check) finds it now.

### Inline markup parity [#inline-markup-parity]

<AvailableFrom version="0.11.0" />

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`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#markup).

## Review flags [#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_RATIO_OUTLIER`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#length_ratio_outlier) | is far shorter or longer than its source, weighted by script |
| [`MAX_LENGTH_EXCEEDED`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#max_length_exceeded) | is longer than the key's [`maxLength`](https://verbatra.kreitz-webdev.de/docs/config-file.md#maxlength) budget <AvailableFrom version="0.11.0" /> |
| [`EQUALS_SOURCE`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#equals_source) | is identical to its source text |
| [`GLOSSARY_TERM_MISSED`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#glossary_term_missed) | misses a glossary term's translation, or alters a do-not-translate term |
| [`GLOSSARY_FORBIDDEN_TERM`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#glossary_forbidden_term) | uses a rendering the glossary forbids <AvailableFrom version="0.12.0" /> |
| [`INTEGRITY_REORDERED`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#integrity_reordered) | keeps every placeholder but in another order |
| [`PROVIDER_DEGRADED`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#provider_degraded) | came from a batch the provider degraded, such as a dropped formality |
| [`FUZZY_CACHE_REUSE`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#fuzzy_cache_reuse) | was [reused from a similar, edited source](https://verbatra.kreitz-webdev.de/docs/the-cache.md#fuzzy-reuse) <AvailableFrom version="0.11.0" /> |
| [`FOREIGN_PLACEHOLDER_CHANGED`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#foreign_placeholder_changed) | drops or changes a placeholder-like token its [format](https://verbatra.kreitz-webdev.de/docs/formats.md) does not interpolate, such as `{name}` in i18next <AvailableFrom version="0.12.0" /> |
| [`BIDI_CONTROLS_CHANGED`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#bidi_controls_changed) | leaves a direction control unclosed, or adds an override that reverses text <AvailableFrom version="0.12.0" /> |

DeepL, Google and LibreTranslate get such a token masked, and withhold a value they cannot mask
or restore ([`PLACEHOLDER_UNSUPPORTED`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#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](https://verbatra.kreitz-webdev.de/docs/review-in-studio.md), until a person approves, rejects or rewrites the
value ([Review decisions](https://verbatra.kreitz-webdev.de/docs/the-lock-file.md#review-decisions)).

## The token budget [#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](https://verbatra.kreitz-webdev.de/docs/config-file.md#maxtokens-and-budgetbehavior)).

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 [#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](https://verbatra.kreitz-webdev.de/docs/error-codes.md#provider-error-codes) and a fixed, secret-free message. Key redaction is on
[Data handling](https://verbatra.kreitz-webdev.de/docs/data-handling.md#api-keys).

## Where you see it [#where-you-see-it]

Everything above is data on the [run summary](https://verbatra.kreitz-webdev.de/docs/sdk/run-summary.md), per locale, and one locale's
failure never stops the others.

### Committed values [#committed-values]

<AvailableFrom version="0.12.0" />

[`verbatra check --qa`](https://verbatra.kreitz-webdev.de/docs/cli/check.md#quality-check) runs the gate and the review flags over
committed values without a provider call.

## Related [#related]

- [How a run works](https://verbatra.kreitz-webdev.de/docs/how-it-works.md): where each check sits in the pipeline.
- [Data handling and privacy](https://verbatra.kreitz-webdev.de/docs/data-handling.md): what leaves your machine.
