---
title: "Error codes"
description: "Every error, result and notice code verbatra reports: what it means, which commands raise it, the exit code, and the next step."
type: "reference"
---
# Error codes

Every error, result and notice code verbatra reports: what it means, which commands raise it, the exit code, and the next step.

Every failure verbatra reports carries a stable code: in the `verbatra: error [CODE] message` line on stderr, as the `code` or `causeCode` of a `--json` envelope, as the `error.code` of a failed locale, or as a notice on the run summary. Branch on the code, never on the message. Each code has its own anchor, `/docs/error-codes#` followed by the code in lower case, such as [`#config_not_found`](#config_not_found).

The **Next step** of an error code is the hint verbatra prints after the error line as `next: ...` and puts in the envelope's `hint` field. It is quoted here exactly as verbatra prints it, in English, and the SDK returns the same text from [`errorHint`](https://verbatra.kreitz-webdev.de/docs/sdk/errors.md#errorhint). The exit codes themselves and the envelope shape are on [Exit codes and JSON output](https://verbatra.kreitz-webdev.de/docs/cli/output.md); symptom-first help is on [Troubleshooting](https://verbatra.kreitz-webdev.de/docs/troubleshooting.md).

Two codes belong to two families: `CONFIG_INVALID` (the SDK, and `init` in the CLI) and `NETWORK_POLICY_VIOLATION` (the SDK and the providers). The lower-case anchor leads to the SDK entry; the other entry carries a prefixed anchor.

## Code index [#code-index]

Every error code with its family and the exit codes it can end a CLI run with; its family's section says which applies when.

| Code | Family | Exit code |
| --- | --- | --- |
| [`AGENT_FILE_INVALID`](#agent_file_invalid) | CLI | `2` |
| [`CLI_ERROR`](#cli_error) | CLI | `2` |
| [`CONFIG_EXISTS`](#config_exists) | CLI | `2` |
| [`CONFIG_INVALID`](#cli-config_invalid) | CLI | `2` |
| [`FORMAT_AMBIGUOUS`](#format_ambiguous) | CLI | `2` |
| [`INIT_UNWRITABLE`](#init_unwritable) | CLI | `2` |
| [`INVALID_CONCURRENCY`](#invalid_concurrency) | CLI | `2` |
| [`INVALID_DEBOUNCE`](#invalid_debounce) | CLI | `2` |
| [`INVALID_DIRECTION`](#invalid_direction) | CLI | `2` |
| [`INVALID_FORMAT`](#invalid_format) | CLI | `2` |
| [`INVALID_LOCALE`](#invalid_locale) | CLI | `2` |
| [`INVALID_LOCALES`](#invalid_locales) | CLI | `2` |
| [`INVALID_LOCK_TIMEOUT`](#invalid_lock_timeout) | CLI | `2` |
| [`INVALID_MAX_TOKENS`](#invalid_max_tokens) | CLI | `2` |
| [`INVALID_OPTION`](#invalid_option) | CLI | `2` |
| [`INVALID_OUT`](#invalid_out) | CLI | `2` |
| [`INVALID_PORT`](#invalid_port) | CLI | `2` |
| [`INVALID_PROVIDER`](#invalid_provider) | CLI | `2` |
| [`INVALID_QA_OPTION`](#invalid_qa_option) | CLI | `2` |
| [`INVALID_SEVERITY`](#invalid_severity) | CLI | `2` |
| [`LAYOUT_AMBIGUOUS`](#layout_ambiguous) | CLI | `2` |
| [`MISSING_OPTIONS`](#missing_options) | CLI | `2` |
| [`REDACTION_UNSUPPORTED`](#redaction_unsupported) | CLI | `2` |
| [`USAGE_ERROR`](#usage_error) | CLI | `2` |
| [`CONFIG_NOT_FOUND`](#config_not_found) | SDK | `2` or `1` |
| [`CONFIG_INVALID`](#config_invalid) | SDK | `2` or `1` |
| [`UNKNOWN_FORMAT`](#unknown_format) | SDK | `2` or `1` |
| [`UNKNOWN_LOCALE`](#unknown_locale) | SDK | `2` |
| [`UNKNOWN_KEY`](#unknown_key) | SDK | none |
| [`KEY_PROTECTED`](#key_protected) | SDK | none |
| [`KEY_PINNED`](#key_pinned) | SDK | none |
| [`PROVIDER_CONSTRUCTION_FAILED`](#provider_construction_failed) | SDK | `2` |
| [`MACHINE_TRANSLATION_DISABLED`](#machine_translation_disabled) | SDK | none |
| [`NETWORK_POLICY_VIOLATION`](#network_policy_violation) | SDK | `2` |
| [`SOURCE_UNREADABLE`](#source_unreadable) | SDK | `2` or `1` |
| [`SOURCE_INVALID`](#source_invalid) | SDK | `2` or `1` |
| [`LOCK_FILE_INVALID`](#lock_file_invalid) | SDK | `2` |
| [`PROVENANCE_FILE_INVALID`](#provenance_file_invalid) | SDK | `2` |
| [`PROVENANCE_FILE_UNWRITABLE`](#provenance_file_unwritable) | SDK | none |
| [`REVIEW_VALUE_CHANGED`](#review_value_changed) | SDK | none |
| [`REVIEW_SOURCE_CHANGED`](#review_source_changed) | SDK | none |
| [`REVIEW_REJECT_UNSUPPORTED`](#review_reject_unsupported) | SDK | none |
| [`REVIEW_RESTORE_FAILED`](#review_restore_failed) | SDK | none |
| [`REVIEWER_INVALID`](#reviewer_invalid) | SDK | `2` |
| [`LOCK_CONTENDED`](#lock_contended) | SDK | `1` |
| [`GLOSSARY_NOT_FILE_BACKED`](#glossary_not_file_backed) | SDK | none |
| [`GLOSSARY_UNWRITABLE`](#glossary_unwritable) | SDK | none |
| [`LOCALE_LAYOUT_INVALID`](#locale_layout_invalid) | SDK | `2` |
| [`LOCALE_PATH_COLLISION`](#locale_path_collision) | SDK | `2` |
| [`CONCURRENCY_INVALID`](#concurrency_invalid) | SDK | none |
| [`CONCURRENCY_BUDGET_CONFLICT`](#concurrency_budget_conflict) | SDK | `2` |
| [`MAX_TOKENS_INVALID`](#max_tokens_invalid) | SDK | none |
| [`LOCK_TIMEOUT_INVALID`](#lock_timeout_invalid) | SDK | none |
| [`PAGE_CURSOR_INVALID`](#page_cursor_invalid) | SDK | none |
| [`PAGE_LIMIT_INVALID`](#page_limit_invalid) | SDK | none |
| [`TARGET_UNWRITABLE`](#target_unwritable) | SDK | `1` or `2` |
| [`PSEUDO_OUTPUT_CONFLICT`](#pseudo_output_conflict) | SDK | `2` |
| [`SOURCE_UNWRITABLE`](#source_unwritable) | SDK | `2` |
| [`EXTRACT_NOT_CONFIGURED`](#extract_not_configured) | SDK | `2` |
| [`EXTRACT_FS_UNSUPPORTED`](#extract_fs_unsupported) | SDK | none |
| [`TYPES_OUTPUT_CONFLICT`](#types_output_conflict) | SDK | `2` |
| [`TYPES_UNWRITABLE`](#types_unwritable) | SDK | `2` |
| [`TMX_OUTPUT_CONFLICT`](#tmx_output_conflict) | SDK | `2` |
| [`TMX_UNWRITABLE`](#tmx_unwritable) | SDK | `2` |
| [`MEMORY_UNWRITABLE`](#memory_unwritable) | SDK | `2` |
| [`EXPORT_OUTPUT_CONFLICT`](#export_output_conflict) | SDK | `2` |
| [`EXPORT_UNWRITABLE`](#export_unwritable) | SDK | `2` |
| [`LOCALE_STATE_NOT_CARRIED_OVER`](#locale_state_not_carried_over) | SDK | `1` |
| [`LOCALE_UNSUPPORTED_BY_PROVIDER`](#locale_unsupported_by_provider) | SDK | `2` |
| [`NOT_A_LOCALE_FILE`](#not_a_locale_file) | SDK | `2` |
| [`SENSITIVE_CONTENT_WITHHELD`](#sensitive_content_withheld) | SDK | none |
| [`RUN_CANCELLED`](#run_cancelled) | SDK | none |
| [`LOCALE_FAILED`](#locale_failed) | SDK | `1` |
| [`MISSING_API_KEY`](#missing_api_key) | Provider | `2` |
| [`INVALID_REQUEST`](#invalid_request) | Provider | `1` |
| [`INVALID_RESPONSE`](#invalid_response) | Provider | `1` |
| [`OUTPUT_TRUNCATED`](#output_truncated) | Provider | `1` |
| [`PROVIDER_REFUSED`](#provider_refused) | Provider | `1` |
| [`PROVIDER_BLOCKED`](#provider_blocked) | Provider | `1` |
| [`RATE_LIMITED`](#rate_limited) | Provider | `1` |
| [`TIMEOUT`](#timeout) | Provider | `1` |
| [`AUTH_FAILED`](#auth_failed) | Provider | `1` |
| [`PROVIDER_UNAVAILABLE`](#provider_unavailable) | Provider | `1` |
| [`NETWORK_POLICY_VIOLATION`](#provider-network_policy_violation) | Provider | `1` |
| [`PROVIDER_ERROR`](#provider_error) | Provider | `1` |
| [`INVALID_JSON`](#invalid_json) | Format adapter | `1` or `2` |
| [`INVALID_YAML`](#invalid_yaml) | Format adapter | `1` or `2` |
| [`INVALID_XML`](#invalid_xml) | Format adapter | `1` or `2` |
| [`INVALID_STRUCTURE`](#invalid_structure) | Format adapter | `1` or `2` |
| [`MAX_DEPTH_EXCEEDED`](#max_depth_exceeded) | Format adapter | `1` or `2` |
| [`INPUT_TOO_LARGE`](#input_too_large) | Format adapter | `1` or `2` |
| [`MIXED_STRUCTURE`](#mixed_structure) | Format adapter | `1` or `2` |
| [`ADAPTER_FAILED`](#adapter_failed) | Format adapter | none |
| [`DUPLICATE_FORMAT`](#duplicate_format) | Format adapter | none |
| [`INVALID_FORMAT_ID`](#invalid_format_id) | Format adapter | none |

## CLI error codes [#cli-error-codes]

The CLI raises these codes itself. The same list ships as `CLI_ERROR_CODES` (with the type `CliErrorCode`) from `@verbatra/cli`. Every one of them exits `2`.

### AGENT_FILE_INVALID [#agent_file_invalid]

`init --agent` found a client config (`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json` or `.gemini/settings.json`) that is not plain JSON, not an object, or holds a servers value that is not an object, a `.codex/config.toml` it cannot scan safely or that sets the verbatra server inline, with dotted keys, as an array of tables or twice, a symbolic link on a path it would write, or an unpaired or repeated verbatra marker in `AGENTS.md` or `CLAUDE.md`; nothing was written.

- **Raised by**: `init --agent`
- **Next step**: Repair the MCP config or the verbatra markers in the file the message names, or replace the symbolic link it names with a plain file, then run `verbatra init --agent` again.

### CLI_ERROR [#cli_error]

A failure carried no code of its own (the fallback).

- **Raised by**: any command

### CONFIG_EXISTS [#config_exists]

`init` would overwrite a different `verbatra.config.ts` without `--force`, or another verbatra config file in the directory would be read first; reported ahead of any missing or ambiguous answer, and at a terminal before the first prompt.

- **Raised by**: `init`
- **Next step**: Edit the existing config, or pass --force to replace verbatra.config.ts; remove any other config file the message names first.

### CONFIG_INVALID [#cli-config_invalid]

`init` could not scaffold a valid config from the answers it was given (the SDK raises the same code for a config that fails to load).

- **Raised by**: `init`
- **Next step**: Correct the init answers the message names, then run `verbatra init` again.

### FORMAT_AMBIGUOUS [#format_ambiguous]

`init` found several formats that read the locale files; `candidates` lists them.

- **Raised by**: `init`
- **Next step**: Pass --format with one of the candidates.

### INIT_UNWRITABLE [#init_unwritable]

`init` could not write a file into its directory, for example because it is read-only, or a file it writes is a symbolic link that does not resolve inside the project; the message names the file, the cause, and the files already written.

- **Raised by**: `init`
- **Next step**: Make the directory writable, or replace the symbolic link the message names with a plain file, then run `verbatra init` again.

### INVALID_CONCURRENCY [#invalid_concurrency]

`--concurrency` is not a whole number from 1 to 100.

- **Raised by**: `translate`, `watch`
- **Next step**: Pass --concurrency as a whole number from 1 to 100.

### INVALID_DEBOUNCE [#invalid_debounce]

`watch --debounce` is not a whole number of milliseconds from 1 to 60000.

- **Raised by**: `watch`
- **Next step**: Pass --debounce as a whole number of milliseconds from 1 to 60000.

### INVALID_DIRECTION [#invalid_direction]

`tmx` got a direction other than `import` or `export`, or `tmx export` got an import-only flag (`--dry-run`, `--overwrite`).

- **Raised by**: `tmx`
- **Next step**: Run `verbatra tmx import` or `verbatra tmx export`, with only the flags that direction takes.

### INVALID_FORMAT [#invalid_format]

`--format` on `export` or `import` names no supported handoff format, or `--format` on `init` names no supported locale file format.

- **Raised by**: `export`, `import`, `init`
- **Next step**: Pass a supported --format value; the command's --help lists them.

### INVALID_LOCALE [#invalid_locale]

`pseudo --locale` is not a language tag made of letters, digits, and hyphens, or a locale answered at an interactive `init` prompt is not a BCP 47 code (the `--source` and `--targets` flags fail with `CONFIG_INVALID` instead).

- **Raised by**: `pseudo`, `init`
- **Next step**: Give the locale as a language tag of letters, digits and hyphens, such as pt-BR or en-XA.

### INVALID_LOCALES [#invalid_locales]

`--locales` was given but lists no locale, or the target locales answered at an interactive `init` prompt include the source locale.

- **Raised by**: `translate`, `watch`, `check`, `diff`, `export`, `tmx`, `report`, `init`
- **Next step**: Pass --locales as a comma-separated list of configured target locales, or omit it.

### INVALID_LOCK_TIMEOUT [#invalid_lock_timeout]

`--lock-timeout` is not a whole number of seconds from 1 to 3600.

- **Raised by**: `translate`, `watch`, `import`
- **Next step**: Pass --lock-timeout as a whole number of seconds from 1 to 3600.

### INVALID_MAX_TOKENS [#invalid_max_tokens]

`translate --max-tokens` is not a whole number of at least 1.

- **Raised by**: `translate`
- **Next step**: Pass --max-tokens as a whole number of at least 1.

### INVALID_OPTION [#invalid_option]

An `init` flag does not fit the provider or is malformed: `--base-url` without `openai-compatible` or `libretranslate`, not a URL, or carrying credentials, a query string, or a fragment; `--api-key-env-var` with a provider other than `openai-compatible`, or not a variable name; `--model` for a provider that takes no model (`deepl`, `google-translate`, `libretranslate`, `none`); any flag given an empty value; `--targets` naming no locale; `--path` without `{locale}`; `--client` without `--agent`, naming no client, or naming one init cannot wire. Or `--cwd` names no existing directory (any command, before it runs), `doctor --data-flow` comes with `--literals`, `--locales` or `--live`, `doctor --literals` comes with `--locales` or `--live`, `check --file` names no file or comes with `--locales`, `--consistency`, `--require-reviewed` or `--sensitive`, or `pseudo --mode` names a mode other than `accented` or `bidi`.

- **Raised by**: every command that takes `--cwd`, plus `init`, `doctor`, `check`, `pseudo`
- **Next step**: Correct or remove the flag the message names.

### INVALID_OUT [#invalid_out]

`--out` on `types` or `pseudo` was given but names no path.

- **Raised by**: `types`, `pseudo`
- **Next step**: Pass --out with a file path, or omit it.

### INVALID_PORT [#invalid_port]

`studio --port` is not an integer from 1 to 65535.

- **Raised by**: `studio`
- **Next step**: Pass --port as a whole number from 1 to 65535.

### INVALID_PROVIDER [#invalid_provider]

`init --provider` names no known provider.

- **Raised by**: `init`
- **Next step**: Pass --provider with a supported provider id; `verbatra init --help` lists them.

### INVALID_QA_OPTION [#invalid_qa_option]

`check` got `--severity` or `--strict` without `--qa` or `--file`, or `--strict` together with `--severity error`.

- **Raised by**: `check`
- **Next step**: Add --qa or --file, and do not combine --strict with --severity error.

### INVALID_SEVERITY [#invalid_severity]

`check --severity` is neither `error` nor `warning`.

- **Raised by**: `check`
- **Next step**: Pass --severity error or --severity warning.

### LAYOUT_AMBIGUOUS [#layout_ambiguous]

`init` found several file patterns that fit equally well; `candidates` lists them.

- **Raised by**: `init`
- **Next step**: Pass --path with one of the candidates.

### MISSING_OPTIONS [#missing_options]

`init` could not prompt (no terminal, or `--json`) and a value was neither passed, detected, nor defaulted by `--yes`; the message names every missing flag.

- **Raised by**: `init`
- **Next step**: Pass the flags the message names, or --yes where the message offers it.

### REDACTION_UNSUPPORTED [#redaction_unsupported]

`mcp --redact-values` loaded an older `@verbatra/mcp` that does not confirm it redacts values; the server was stopped before serving anything.

- **Raised by**: `mcp`
- **Next step**: Upgrade @verbatra/mcp to the version that ships with this CLI, then start `verbatra mcp --redact-values` again.

### USAGE_ERROR [#usage_error]

The argument parser rejected the command line (an unknown option, a missing argument), or `mcp` received options it cannot accept; its `hint` names the command's `--help`.

- **Raised by**: any command
- **Next step**: Run the command with --help to see the options and arguments it accepts.

## SDK error codes [#sdk-error-codes]

Whole-run failures throw an `SdkError` with one of these codes; [Errors](https://verbatra.kreitz-webdev.de/docs/sdk/errors.md) describes the class and which codes carry a `cause`. In the CLI a thrown code ends the run with `2`, while a code recorded on one failed locale exits `1`. Where the index gives `2` or `1`, `doctor` reports the code as a failed check and exits `1`. Codes marked with no CLI command reach you through the SDK, Studio or the MCP server.

### CONFIG_NOT_FOUND [#config_not_found]

No config was found by search, or an explicit `configPath` does not exist (thrown by `loadConfig`).

- **Raised by**: every command that loads the config
- **Next step**: Run `verbatra init` to create a config, or pass the path of an existing config file.

### CONFIG_INVALID [#config_invalid]

A config was found but is unparseable or fails validation, or its glossary file could not be resolved; `translate` (a dry run too, unless the provider is `none`) and `retranslateEntry` also throw it when `VERBATRA_NETWORK_POLICY` or `VERBATRA_NETWORK_ALLOWED_HOSTS` holds an invalid value, before any provider is built.

- **Raised by**: every command that loads the config
- **Next step**: Fix the config field the message names, then run `verbatra doctor` to confirm the setup.

### UNKNOWN_FORMAT [#unknown_format]

No adapter is registered for the configured format; thrown before any file is read.

- **Raised by**: every command that reads locale files; Studio and the MCP server
- **Next step**: Set `format` in the config to a supported format, or register an adapter for this one.

### UNKNOWN_LOCALE [#unknown_locale]

A requested locale is not among the configured target locales.

- **Raised by**: `translate`, `watch`, `check`, `diff`, `export`, `tmx`, `report`; Studio and the MCP server
- **Next step**: Use a locale from the config's `targetLocales`, or add it there first.

### UNKNOWN_KEY [#unknown_key]

A requested key is not in the source resource (`keyValue`, `keyIntegrity`, `editEntry`, `retranslateEntry`, `approveEntry`, `rejectEntry`).

- **Raised by**: Studio and the MCP server
- **Next step**: Use a key that exists in the source locale file, or add it there first.

### KEY_PROTECTED [#key_protected]

`retranslateEntry` refused to replace a value a person wrote, imported, or changed outside verbatra, because neither `includeHuman` nor `humanEdits: "overwrite"` allowed it.

- **Raised by**: Studio and the MCP server
- **Next step**: Keep the human-written value, or opt in to replacing it: include human edits for this call, or set `humanEdits: "overwrite"` in the config.

### KEY_PINNED [#key_pinned]

The key matches the config's `pinnedKeys`, so a machine write was refused (`retranslateEntry`, and `editEntry` with `actor: "agent"`).

- **Raised by**: Studio and the MCP server
- **Next step**: Edit the value by hand, or remove the key from `pinnedKeys` in the config to let verbatra write it.

### PROVIDER_CONSTRUCTION_FAILED [#provider_construction_failed]

The provider could not be built; wraps the provider's own error, including a missing API key. For a missing key the hint names the variable instead of the next step below, such as `Set GEMINI_API_KEY in the environment, or, with the CLI, in a .env file in the project directory.` The SDK reads no `.env` file.

- **Raised by**: `translate`, `watch`; Studio and the MCP server
- **Next step**: Check the config's `provider` block and its API key environment variable, then run `verbatra doctor`.

### MACHINE_TRANSLATION_DISABLED [#machine_translation_disabled]

The config sets `provider: { id: "none" }`, so a provider-spending action was refused before any provider was built (`retranslateEntry`, `assertMachineTranslationEnabled`); `translate` and `watch` never throw it.

- **Raised by**: Studio and the MCP server
- **Next step**: Translate the key by hand, or configure a machine provider in place of `provider: { id: "none" }`.

### NETWORK_POLICY_VIOLATION [#network_policy_violation]

The effective [network policy](https://verbatra.kreitz-webdev.de/docs/network-policy.md) does not permit the provider's endpoint or its proxy; thrown by a non-dry-run `translate`, by `watch` at startup, and by `retranslateEntry`, before the provider is built or any API key read.

- **Raised by**: `translate`, `watch`; Studio and the MCP server
- **Next step**: Point the provider at a host the network policy permits, or add its host to `network.allowedHosts` or VERBATRA_NETWORK_ALLOWED_HOSTS.

### SOURCE_UNREADABLE [#source_unreadable]

The source locale file does not exist, or, for `import` and `tmx import`, the file or directory to import does not.

- **Raised by**: every command that reads locale files; Studio and the MCP server
- **Next step**: Create the source locale file, or fix `files.pattern` and `sourceLocale` in the config so they point at it.
- **Next step for `import`**: Pass the path of the filled handoff file, or of the directory it was exported into, relative to the working directory or `--cwd`.
- **Next step for `tmx import`**: Pass the path of an existing TMX file, relative to the working directory or `--cwd`.

### SOURCE_INVALID [#source_invalid]

The source locale file could not be read or parsed; wraps the adapter's read error. For `import` and `tmx import`, the file to import is too large or not a readable handoff or TMX document.

- **Raised by**: every command that reads locale files; Studio and the MCP server
- **Next step**: Fix the file the message names so it parses under the configured format.
- **Next step for `import`**: Fix the handoff file the message names, or pass its format with `--format` when its extension names none.
- **Next step for `tmx import`**: Fix the TMX file the message names, or export it again from the tool that wrote it.

### LOCK_FILE_INVALID [#lock_file_invalid]

The lock file is present but corrupt, oversized, or at an unsupported version.

- **Raised by**: `translate`, `import`, `check`, `diff`, `export`, `report`; Studio and the MCP server
- **Next step**: Restore verbatra.lock.json from version control, then try again.

### PROVENANCE_FILE_INVALID [#provenance_file_invalid]

The provenance file is present but corrupt, oversized, or structurally wrong; thrown by a write (`translate`, `watch`, `importWorkbook`, `editEntry`, `retranslateEntry`, `approveEntry`, `rejectEntry`) before it changes anything, and by `loadProvenance`, never by a report; one from a newer verbatra is left untouched instead, and `translate`, `watch`, and `importWorkbook` report the notice `PROVENANCE_VERSION_UNRECOGNIZED`.

- **Raised by**: `translate`, `watch`, `import`; Studio and the MCP server
- **Next step**: Restore verbatra.provenance.json from version control, then try again.

### PROVENANCE_FILE_UNWRITABLE [#provenance_file_unwritable]

A review decision could not be saved because the provenance file is from a newer verbatra or would grow past the size verbatra reads back (`approveEntry`, `rejectEntry`, `approveLocale`); nothing is written.

- **Raised by**: Studio and the MCP server
- **Next step**: Upgrade verbatra if a newer version wrote verbatra.provenance.json, then record the decision again.

### REVIEW_VALUE_CHANGED [#review_value_changed]

The key has no value, or its value is not the `expectedValue` the reviewer saw, or `expectedValue` is not a string (`approveEntry`, `rejectEntry`).

- **Raised by**: Studio and the MCP server
- **Next step**: Reload the current value, review it again, and resend the decision.

### REVIEW_SOURCE_CHANGED [#review_source_changed]

The source changed since the value was written, so it cannot be approved as it stands (`approveEntry`).

- **Raised by**: Studio and the MCP server
- **Next step**: Edit or retranslate the key first, then review its new value.

### REVIEW_REJECT_UNSUPPORTED [#review_reject_unsupported]

The format keeps a key's value when the file is written without it, as XLIFF does, or the locale file is too large to keep a copy to restore, so `rejectEntry` left the file as it was.

- **Raised by**: Studio and the MCP server
- **Next step**: Edit the value instead of rejecting it.

### REVIEW_RESTORE_FAILED [#review_restore_failed]

`rejectEntry` failed after it rewrote the locale file, and restoring the locale file or the provenance file failed too; the message names the original failure (also the `cause`) and the files to restore from version control.

- **Raised by**: Studio and the MCP server
- **Next step**: Restore the files the message names from version control.

### REVIEWER_INVALID [#reviewer_invalid]

The `reviewer` is empty, longer than 64 characters, or contains a control character (`approveEntry`, `rejectEntry`, `approveLocale`, `importWorkbook`).

- **Raised by**: `import --reviewer`; Studio and the MCP server
- **Next step**: Pass a reviewer name of 1 to 64 characters with no control characters.

### LOCK_CONTENDED [#lock_contended]

A locale's write lock, or the glossary lock `updateGlossaryTerm` takes, could not be acquired before its timeout, or another process took it over before a write it protects, in which case nothing was written; the message names the lock file's path.

- **Raised by**: `translate`, `watch`, `import`; Studio and the MCP server
- **Next step**: Wait for the other verbatra process to finish and try again, or remove the lock file the message names if no process holds it.

### GLOSSARY_NOT_FILE_BACKED [#glossary_not_file_backed]

The config's glossary is inline or absent, so there is no glossary file to read or rewrite (`readGlossaryFile`, `updateGlossaryTerm`). With no glossary at all, the hint says instead: Create a glossary file, such as glossary.json holding `{}`, and set the config's `glossary` to its path, such as `"glossary.json"`.

- **Raised by**: Studio and the MCP server
- **Next step**: Move the glossary into its own file and set the config's `glossary` to that file's path.

### GLOSSARY_UNWRITABLE [#glossary_unwritable]

The glossary file could not be written, or the glossary write lock could not be created, for instance in a read-only project, or released after a saved edit (`updateGlossaryTerm`); the file-system error is the `cause`.

- **Raised by**: Studio and the MCP server
- **Next step**: Make the glossary file and its directory writable, then try again.

### LOCALE_LAYOUT_INVALID [#locale_layout_invalid]

`files.pattern` and `files.localeStyle` cannot be combined, or the style has no valid path spelling for a configured locale.

- **Raised by**: every command that reads locale files; Studio and the MCP server
- **Next step**: Fix `files.pattern` and `files.localeStyle` in the config so every locale maps to a valid path.

### LOCALE_PATH_COLLISION [#locale_path_collision]

Two configured locales resolve to the same absolute file path.

- **Raised by**: every command that reads locale files; Studio and the MCP server
- **Next step**: Change `files.pattern` or `files.localeStyle` in the config so each locale resolves to its own file.

### CONCURRENCY_INVALID [#concurrency_invalid]

`concurrency` was set but is not an integer of at least 1; raised before any locale runs.

- **Raised by**: the SDK only
- **Next step**: Pass a concurrency that is a whole number of at least 1.

### CONCURRENCY_BUDGET_CONFLICT [#concurrency_budget_conflict]

A live run set `concurrency` above 1 while a token budget applies, configured or per-run; raised before any provider call (a dry run is exempt).

- **Raised by**: `translate`, `watch`
- **Next step**: Run with a concurrency of 1 while a token budget applies, or remove the budget.

### MAX_TOKENS_INVALID [#max_tokens_invalid]

`translate`'s per-run `maxTokens` was set but is not a whole number of at least 1; raised before anything is read or spent.

- **Raised by**: the SDK only
- **Next step**: Pass a token budget that is a whole number of at least 1.

### LOCK_TIMEOUT_INVALID [#lock_timeout_invalid]

`lockAcquireTimeoutMs` was set but is not a whole number of milliseconds of at least 0 (`translate`, `watch` at startup, `importWorkbook`, `retranslateEntry`, `retranslateEntries`, `approveEntry`, `rejectEntry`, `approveEntries`, `rejectEntries`); raised before anything is read or locked.

- **Raised by**: the SDK only
- **Next step**: Pass a lock timeout that is a whole number of at least 0.

### PAGE_CURSOR_INVALID [#page_cursor_invalid]

The `cursor` passed to `localeValuesPage` or `provenanceReportPage` is malformed, longer than `PAGE_CURSOR_MAX_LENGTH` (512), was made under other filters, or points at a key that moved or is gone because the files changed between pages.

- **Raised by**: the SDK; Studio's `verbatra_locale_values` agent tool (the MCP server's `locale.values` and `report.provenance` report it as invalid input for `cursor`)
- **Next step**: Call again without a cursor to start from the first page.

### PAGE_LIMIT_INVALID [#page_limit_invalid]

The `limit` passed to `localeValuesPage` or `provenanceReportPage` is not a whole number from 1 to `PAGE_LIMIT_CAP` (1000).

- **Raised by**: the SDK only
- **Next step**: Pass a page limit that is a whole number from 1 to 1000.

### TARGET_UNWRITABLE [#target_unwritable]

A target locale file could not be written (its directory is not writable, does not exist, is read-only, or is out of space); the message names the target file and the file-system code, never the internal temporary file the atomic write uses.

- **Raised by**: `translate`, `import`, `pseudo`; Studio and the MCP server
- **Next step**: Make the target locale file and its directory writable, then try again.

### PSEUDO_OUTPUT_CONFLICT [#pseudo_output_conflict]

`pseudolocalize` was asked for a pseudolocale that names a configured locale, or for an output path that would write onto a configured locale file or outside `cwd`, also after symbolic links are resolved; refused before anything is read or written.

- **Raised by**: `pseudo`
- **Next step**: Choose a pseudolocale and an output path inside the project that name no configured locale.

### SOURCE_UNWRITABLE [#source_unwritable]

The source locale file could not be written (`extract` only); `xliff` and `apple-xcstrings` reach it when no catalog exists yet, because neither is created from nothing.

- **Raised by**: `extract`
- **Next step**: Make the source locale file writable; for xliff and apple-xcstrings, create the catalog first.

### EXTRACT_NOT_CONFIGURED [#extract_not_configured]

`extract` was called with a config that carries no `extract` block.

- **Raised by**: `extract`
- **Next step**: Add an `extract` block to the config naming the framework and source roots.

### EXTRACT_FS_UNSUPPORTED [#extract_fs_unsupported]

`extract` was given a `deps.fs` that implements no `readDirectory`, so no source file can be discovered.

- **Raised by**: the SDK only
- **Next step**: Pass a file system that implements `readDirectory`, or use the default one.

### TYPES_OUTPUT_CONFLICT [#types_output_conflict]

`generateTypes` refused its output path: one that names no file (or ends in a path separator), is absolute, climbs out of `cwd` or names `cwd` itself, or does not end in `.ts`, `.mts` or `.cts`, and one naming a configured locale file, the lock file, the provenance file, the translation-memory cache, a file verbatra searches for its configuration, the config file the run loaded, or the glossary file the config names, also after symbolic links are resolved; a generating run also refuses to replace an existing file that does not begin with verbatra's header line.

- **Raised by**: `types`
- **Next step**: Choose a relative `.ts`, `.mts` or `.cts` output path inside the project that names no file verbatra manages.

### TYPES_UNWRITABLE [#types_unwritable]

The declaration file `generateTypes` produces could not be written (its directory is not writable, does not exist, or the disk is out of space); the message names the file and the file-system code.

- **Raised by**: `types`
- **Next step**: Make the output file and its directory writable, then try again.

### TMX_OUTPUT_CONFLICT [#tmx_output_conflict]

`exportTmx` refused its output path: one that names no file (or ends in a path separator) or resolves outside `cwd` or to `cwd` itself, and one naming a file verbatra manages, as listed under [`TYPES_OUTPUT_CONFLICT`](#types_output_conflict), also after symbolic links are resolved; refused before the memory is read.

- **Raised by**: `tmx export`
- **Next step**: Choose an output path inside the project that names no locale file or other file verbatra manages.

### TMX_UNWRITABLE [#tmx_unwritable]

The TMX file `exportTmx` produces could not be written (its directory is not writable, a directory already sits at that path, or the disk is out of space); the message names the file and the file-system code.

- **Raised by**: `tmx export`
- **Next step**: Make the output file and its directory writable, then try again.

### MEMORY_UNWRITABLE [#memory_unwritable]

The translation-memory cache `verbatra.cache.json` could not be written by `importTmx` (its directory is not writable, a directory already sits at that path, or the disk is out of space); the message names the file and the file-system code.

- **Raised by**: `tmx import`
- **Next step**: Make verbatra.cache.json and its directory writable, then try again.

### EXPORT_OUTPUT_CONFLICT [#export_output_conflict]

`exportWorkbook` refused its output path: one that resolves outside `cwd`, and one that, or a file the export would write into it, names a file verbatra manages, as listed under [`TYPES_OUTPUT_CONFLICT`](#types_output_conflict), also after symbolic links are resolved; an `xlsx` path that names no file or names `cwd` itself is refused too; refused before anything is read.

- **Raised by**: `export`
- **Next step**: Choose an output path inside the project that names no locale file or other file verbatra manages.

### EXPORT_UNWRITABLE [#export_unwritable]

The handoff `exportWorkbook` produces could not be written (its directory is not writable, a directory or file is in the way, or the disk is out of space); the message names the file and the file-system code, and the file-system error is the `cause`.

- **Raised by**: `export`
- **Next step**: Make the output file and its directory writable, then try again.

### LOCALE_STATE_NOT_CARRIED_OVER [#locale_state_not_carried_over]

Thrown by `editEntry`, `retranslateEntry`, `approveEntry`, and `rejectEntry`, which write nothing then, and otherwise recorded on the `error` of a locale `translate`, `watch`, or `importWorkbook` did not run because its state under a respelled code such as `pt_BR` could not be moved to it in the lock file or the provenance file; the next run tries again ([A respelled locale code](https://verbatra.kreitz-webdev.de/docs/the-lock-file.md#a-respelled-locale-code)).

- **Raised by**: `translate`, `watch`, `import`; Studio and the MCP server
- **Next step**: Wait for other verbatra processes to finish, make verbatra.lock.json and verbatra.provenance.json writable, then try again.

### LOCALE_UNSUPPORTED_BY_PROVIDER [#locale_unsupported_by_provider]

The configured DeepL or Google Cloud Translation provider does not list the source locale or a selected target locale in the language table verbatra ships; thrown by `translate` (a dry run and an estimate too), `watch` at startup, and `retranslateEntry`, before any provider is built, any key read, or any locale started; a locale mapped explicitly in `localeMap` is trusted instead ([Language support](https://verbatra.kreitz-webdev.de/docs/language-support.md#checking-support-before-a-run)).

- **Raised by**: `translate`, `watch`; Studio and the MCP server
- **Next step**: Remove the unsupported locale from `targetLocales`, map it to a supported code in `provider.options.localeMap`, or choose a provider that supports it.

### NOT_A_LOCALE_FILE [#not_a_locale_file]

`checkFile` was given a path that is not the file of any configured locale, or no file exists there.

- **Raised by**: `check --file`
- **Next step**: Pass the path of an existing locale file that `files.pattern` maps to a configured locale.

### SENSITIVE_CONTENT_WITHHELD [#sensitive_content_withheld]

`retranslateEntry` did not send the key, or dropped its answer, because `sensitiveData.mode` is `block` or `redact` and the key holds content a detector or pattern matched that could not be sent or did not come back intact ([Sensitive content guard](https://verbatra.kreitz-webdev.de/docs/data-handling.md#sensitive-content-guard)). `translate` and `watch` list such keys in `sensitiveWithheld` instead.

- **Raised by**: Studio and the MCP server
- **Next step**: Remove the content from the key, list it in `sensitiveData.allow`, or turn the detector off in `sensitiveData.detectors`.

### RUN_CANCELLED [#run_cancelled]

The `signal` passed to `translate` or `retranslateEntry` aborted. `retranslateEntry` throws it when the abort comes before the provider answers, having written nothing. `translate` records it on each locale the cancellation kept from starting and marks the summary [`cancelled`](https://verbatra.kreitz-webdev.de/docs/sdk/run-summary.md). An entry point that takes a write lock, such as `editEntry`, `approveEntry` or `updateGlossaryTerm`, also throws it when `releaseHeldLocks` is running as it goes to take the lock.

- **Raised by**: the SDK only
- **Next step**: Run the operation again to finish the work the cancellation left pending.

### LOCALE_FAILED [#locale_failed]

Never thrown: the fallback `code` recorded on a failed locale's `error`.

- **Raised by**: `translate`, `watch`, `import`
- **Next step**: Fix the cause the locale's message names, then try again.

## Provider error codes [#provider-error-codes]

A provider failure raises a `ProviderError` with one of these codes ([Structured errors](https://verbatra.kreitz-webdev.de/docs/providers.md#structured-errors)). Inside `translate` and `watch` only its own request fails: its keys are withheld, the locale gets a `SUB_BATCH_FAILED` notice naming the code, and the keys are picked up again on the next run. A single-key retranslation from Studio or the MCP server re-throws the `ProviderError`. A failed request exits `1`, and its locale ends `partial` or `failed`.

### MISSING_API_KEY [#missing_api_key]

The required environment variable is unset or empty. The message names the variable, never a value, and in the CLI it arrives as the `causeCode` of `PROVIDER_CONSTRUCTION_FAILED`, exiting `2`. For LibreTranslate, whose key is optional, it means the server demanded a key and `LIBRETRANSLATE_API_KEY` is not set.

- **Next step**: Set the API key environment variable your provider reads, then run `verbatra doctor`.

### INVALID_REQUEST [#invalid_request]

The request failed validation before any network call: a malformed field, a code DeepL does not accept (a `localeMap` entry giving a regional source code, or a target that resolves to a bare `en` or `pt`), or a code that is not well-formed BCP-47 for Google Cloud Translation. For LibreTranslate it also covers a language your server has no model for and a request over its `--char-limit` or `--batch-limit`. Retrying will not help.

- **Next step**: Check the provider options in the config, such as the model name.

### INVALID_RESPONSE [#invalid_response]

The output was malformed, incomplete, or failed reconciliation against the request.

- **Next step**: Try again; if it keeps failing, configure a different model.

### OUTPUT_TRUNCATED [#output_truncated]

The model hit its output-token limit; see [Output-token limits](https://verbatra.kreitz-webdev.de/docs/providers.md#output-token-limits).

- **Next step**: Translate fewer keys at a time, or configure a model with a larger output limit.

### PROVIDER_REFUSED [#provider_refused]

The model declined to answer.

- **Next step**: Review the source text the provider refused, or translate it by hand.

### PROVIDER_BLOCKED [#provider_blocked]

The request or response was safety-blocked or filtered.

- **Next step**: Review the source text the provider blocked, or translate it by hand.

### RATE_LIMITED [#rate_limited]

HTTP 429 or an SDK rate-limit error. For Google Cloud Translation, this also covers an HTTP 403 whose body names a quota reason (a daily or per-minute limit). Google reuses 403 for both quota and credential failures, so raise your project's Cloud Translation quota. LibreTranslate reports an HTTP 403 whose error text says "too many" as this code too, because its server answers a banned client with 403.

- **Next step**: Wait a moment and try again, or lower the concurrency.

### TIMEOUT [#timeout]

A network or request timeout; [`requestTimeoutMs`](https://verbatra.kreitz-webdev.de/docs/providers.md#request-timeout) raises the bound.

- **Next step**: Try again, or raise the provider's `requestTimeoutMs` in the config.

### AUTH_FAILED [#auth_failed]

HTTP 401 or 403: the key is invalid, revoked, or lacks permission. From the Anthropic, OpenAI, Gemini, OpenCode and `openai-compatible` providers the message names the status, and on a 403 adds that the server refused the request under its policy or the key's permissions. For Google Cloud Translation, check that `GOOGLE_TRANSLATE_API_KEY` is a valid, unrestricted key for a project with the Cloud Translation API enabled. LibreTranslate also reports an HTTP 400 whose error text mentions an API key as this code when a key is configured. Retrying will not help.

- **Next step**: Check that the provider's API key is valid and allowed to use the configured model.

### PROVIDER_UNAVAILABLE [#provider_unavailable]

HTTP 5xx: the provider failed on its own side, and the request itself was never at fault. Where a provider [retries](https://verbatra.kreitz-webdev.de/docs/providers.md#retries), a transient 5xx is absorbed, so one that reaches this code is a sustained outage. Google Cloud Translation and LibreTranslate do not retry, so a single 5xx surfaces here at once.

- **Next step**: Try again later, or check the configured endpoint.

### NETWORK_POLICY_VIOLATION [#provider-network_policy_violation]

The [network policy](https://verbatra.kreitz-webdev.de/docs/network-policy.md) refused the request right before it was sent: the host name resolved to an address the policy does not permit, or a response redirected to a refused host. The message names only the host. Retrying will not help.

- **Next step**: Point the provider at a host the network policy permits, or add its host to `network.allowedHosts` or VERBATRA_NETWORK_ALLOWED_HOSTS.

### PROVIDER_ERROR [#provider_error]

Anything unclassifiable. From the Anthropic, OpenAI, Gemini, OpenCode and `openai-compatible` providers, an HTTP failure names its status, such as `HTTP 404`, and the server's error type when it is a short token of letters, digits and underscores, such as `MissingSessionID`; the server's own message, the response body and the URL path never appear. For an `openai-compatible` `baseUrl`, a 404 or 405 adds a check that `baseUrl` is the API root, and a 400 or 422 a check of the model name and of Chat Completions support with strict JSON schema output; retrying will not help a 4xx status. When the failure is a recognizable transport problem, the message names it (connection refused, host name not resolved, connection closed, host unreachable, TLS certificate not verified) and says what to check; otherwise it falls back to a static, secret-free message. When a provider package could not be loaded, the message names the package and says to reinstall `@verbatra/sdk`.

- **Next step**: Act on the cause or HTTP status the message names, then check the provider's `baseUrl` (if it takes one), `model` and the rest of the `provider` block in the config.

## Format adapter error codes [#adapter-error-codes]

A format adapter that cannot handle a file raises an `AdapterError` with one of these codes. On the source locale file it arrives wrapped in `SOURCE_INVALID`, with the adapter's code as the `causeCode`; on a target file it becomes the `error.code` of that locale. Every command that reads locale files, Studio and the MCP server can raise one; the CLI exits `2` when the source file is at fault and `1` when a target file fails its locale in `translate` or `import`. `check --file` reports a file that does not parse as a `syntax` finding instead. The set is closed: a [custom adapter](https://verbatra.kreitz-webdev.de/docs/custom-format-adapters.md) raises one of these codes rather than its own.

### INVALID_JSON [#invalid_json]

A JSON-based locale file is not valid JSON. The message ends with the line and column when the parser reports them.

- **Next step**: Fix the syntax error the message names in that file.

### INVALID_YAML [#invalid_yaml]

A YAML locale file is not valid YAML. The message ends with the line and column when the parser reports them.

- **Next step**: Fix the syntax error the message names in that file.

### INVALID_XML [#invalid_xml]

An XML-based locale file, such as XLIFF or Android `strings.xml`, is not well-formed XML.

- **Next step**: Fix the syntax error the message names in that file.

### INVALID_STRUCTURE [#invalid_structure]

The file parses but does not have the shape the configured format expects, for example a dotted key and a nested key path that resolve to the same key, or a YAML mapping key that is a map or a sequence.

- **Next step**: Reshape the file the message names to match the configured format.

### MAX_DEPTH_EXCEEDED [#max_depth_exceeded]

The file nests deeper than verbatra reads.

- **Next step**: Reduce the nesting depth of the file the message names.

### INPUT_TOO_LARGE [#input_too_large]

The file is larger than verbatra reads.

- **Next step**: Split the file the message names into smaller files.

### MIXED_STRUCTURE [#mixed_structure]

The file mixes flat and nested keys where the format forbids it.

- **Next step**: Use either flat or nested keys throughout the file the message names.

### ADAPTER_FAILED [#adapter_failed]

A `custom:` adapter threw an unexpected error. The error names the adapter's format, and the adapter's own error is its `cause`.

- **Raised by**: the SDK, with a `custom:` adapter (the CLI loads no plugin)
- **Next step**: Report the failure to the maintainer of the custom adapter the message names.

### DUPLICATE_FORMAT [#duplicate_format]

A second adapter was registered for a format identifier the registry already holds.

- **Raised by**: `AdapterRegistry.register` in the SDK
- **Next step**: Register only one adapter for each format identifier.

### INVALID_FORMAT_ID [#invalid_format_id]

An adapter was registered under a malformed `custom:` identifier.

- **Raised by**: `AdapterRegistry.register` in the SDK
- **Next step**: Name the custom adapter `custom:` followed by a valid name.

## Review gate codes [#check-result-codes]

`check --require-reviewed` reports why it failed as `result.review.code` ([Require reviewed translations](https://verbatra.kreitz-webdev.de/docs/cli/check.md#require-reviewed-translations)).

### REVIEW_REQUIRED [#review_required]

At least one machine-written translation (origin `machine`, `memory`, `fuzzy` or `agent`) is not approved in the committed `verbatra.provenance.json`. The envelope stays `ok: true`, and `result.locales[].review.unreviewed` names the keys.

- **Raised by**: `check --require-reviewed`
- **Exit code**: `1`
- **Next step**: Approve or correct the listed keys in [Studio's Review queue](https://verbatra.kreitz-webdev.de/docs/review-in-studio.md) or with the MCP tools `review.approve` and `review.reject`, then commit `verbatra.provenance.json`.

### REVIEW_STATE_UNREADABLE [#review_state_unreadable]

`verbatra.provenance.json` is corrupt or from a newer verbatra, so no review state can be read and the gate fails closed.

- **Raised by**: `check --require-reviewed`
- **Exit code**: `1`
- **Next step**: Restore `verbatra.provenance.json` from version control, or upgrade verbatra if a newer version wrote it.

## Integrity refusal reasons [#integrity-refusal-reasons]

The [integrity gate](https://verbatra.kreitz-webdev.de/docs/translation-safety.md#the-integrity-gate) refuses a candidate value with exactly one of these reasons, the first that applies in the order that page gives. The reason appears in a locale's `integrityRefusals` after `translate`, `watch` and `import` (`integrity-withheld` in the human output), in the result of `editEntry` and of `retranslateEntry` (`accepted: false` with the `reason`), and as an error of `check --qa`, often with `details` naming what differs. A refusal exits `1` from `check --qa`, and from `translate` and `import` when the locale ends partial or failed. The set ships as the `INTEGRITY_GATE_REASONS` tuple.

### placeholder [#placeholder]

The candidate's placeholders differ from its 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). For i18next, ngx-translate and YAML, a `{name}`-shaped token the candidate adds and the source never had is refused too, although single braces are literal text there. The details name each dropped placeholder with `-` and each added one with `+`, such as `-{{name}}` or `+{{nom}}`.

- **Next step**: Put the source's placeholders back into the translation, or let the next run translate it again.

### markup [#markup]

The candidate's inline HTML or XML markup differs from its source's, or is not well formed. The details name each tag behind the refusal, prefixed with `-` for one the source had and the candidate dropped or `+` for one the candidate invented.

- **Next step**: Keep the source's tags, attribute names and protected attribute values in the translation.

#### How markup is compared [#how-markup-is-compared]

Tags are compared as a multiset of tag names plus attribute names:

- Refused: a dropped tag, an invented tag, a renamed tag, a changed set of attribute names (adding a
  `dir` attribute for a right-to-left target included), markup that comes back unbalanced or
  mis-nested, such as `<b>a<i>b</b></i>`, and a tag that comes back nested inside another of the
  same name when the source had them as siblings, which is how two links silently become one.
- Accepted: the same tags in a different order, a changed attribute value such as a translated
  `title` or a localized `href` (within the limits on attribute values described below), and either
  spelling of a void element, so `<br>` and `<br/>` are one tag.
- Counted, not just noticed: two adjacent pairs merged into one (`<b>a</b> <b>b</b>` becoming
  `<b>a b</b>`) is refused.
- Compared exactly, so a case change is a finding. The HTML void elements (`<br>`, `<img>`, `<hr>`
  and the rest) need no closing tag in any letter case, so `<BR>` is recognised as one just like
  `<br>`.

A source with no markup at all is still protected against markup invented into it. The candidate is
refused for any tag pair, void element or self-closing tag it adds, for a closing tag with no
opening tag (`Hallo</div>`), and for an unclosed opening tag that carries anything beyond its name,
such as `<Enter onfocus=...>` or `<x-key onmouseover=...>`.

An unclosed `<Word>` with nothing but its name, where the name is only letters, digits, hyphens and
underscores, is a bracketed word, such as `<Enter>` in `Druecke <Enter>`. Bracketed words are set
aside on both sides before the source is judged, so a source that carries one is still compared: a
candidate that drops a `<b>` pair beside `<Enter>`, or invents a `<script>` pair beside it, is
refused. Once the source carries tags, bracketed words are counted like tags. Where it carries none,
a bracketed word the candidate adds is read as prose and accepted, unless its name, in any letter
case, is a standard HTML element (`Hallo <script>`), is `image`, which a parser turns into an
`<img>`, or contains a hyphen, which makes it a custom element (`Hallo <my-widget>`).

That name test is deliberately cautious, so an ordinary word that happens to be an element name,
such as `<Del>`, `<Option>`, `<Select>`, `<Input>` or `<Menu>`, is refused when a translation adds
it to a source that does not have it. The workaround is to keep the bracketed word in your source
string too: a translation that carries the same bracketed words as its source is accepted.

Comments, CDATA sections, declarations and processing instructions are not tags, and they are read
the way an HTML parser reads them. `<!-->` and `<!--->` are complete empty comments, a comment ends
at the first `-->` or `--!>`, and a comment with no end hides the rest of the value. `<![CDATA[`,
`<!DOCTYPE html>`, any other `<!...`, `<?...` and a `</` followed by anything but a letter, such as
`</1 x>`, all end at the first `>`, while an empty `</>` is dropped, as a parser drops it, and a
numeric closing tag such as `</0>` is compared as a tag. Each such construct is compared as a
multiset of its text with runs of whitespace collapsed, so one the candidate adds, drops or rewrites
is refused, including a comment whose text was translated. Tags after a construct's end are compared
as usual, so `Hallo <![CDATA[><img src=x onerror=...>]]>` is refused for both the construct and the
`<img>`.

The content of a raw text element is text too. After an opening `script`, `style`, `title`,
`textarea`, `xmp`, `iframe`, `noscript`, `noembed` or `noframes` tag, everything up to its closing
tag, in any letter case and followed by whitespace, `/` or `>`, is text, and everything after
`<plaintext>` is text to the end of the value. A tag written inside such an element is not compared,
and a quoted attribute value cannot reach across the closing tag to hide what follows:
`Füge ein <script><b x="</script><img src=x onerror=...>` is refused for the `<img>`. Where a parser
can read that content two ways, the candidate is refused unless it is the source unchanged, with a
detail such as `+<noscript>...</noscript>`: a `script` whose content opens `<!--`, a `noscript`
whose content holds a `<`, since whether scripting is enabled decides how it is read, and any raw
text element whose content holds a `<` after an `svg`, `math` or `select` tag, where it may not be
raw text at all. For the same reason, a CDATA section or an `image` tag in a value where the source
or the candidate opens `svg`, `math` or `select` is refused unless the candidate is the source
unchanged.

The content of a `script`, `style`, `iframe`, `noembed`, `noframes`, `noscript`, `xmp` or
`plaintext` element runs or styles the page instead of being shown, so it is never translated: each
such element in the candidate must carry exactly the content of the element in the same position
among those of its name in the source, or the candidate is refused with a detail such as
`+<script> content`. `title` and `textarea` content is shown as text and may be translated. This
makes prose that literally contains one of those tags untranslatable, because in
`Add a <script> tag` everything after `<script>` is its content: `Füge ein <script> Tag` is refused.
Escape the tag in the source instead, as `Add a &lt;script&gt; tag`.

The check is deliberately quiet, but it reads tags the way an HTML parser does. A tag starts at `<`
or `</` followed by a letter or, for a numeric rich-text tag such as `<0>`, by digits. Its name runs
to whitespace, `/` or `>`, and a quoted attribute value may contain `>`. So `5 < 10`, `x <= y` and
an escaped `&lt;b&gt;` stay ordinary text, while `<support@example.com>` and `<abbr title="a > b">`
are tags, and a `<` that never reaches its `>` renders nothing and is not a tag. A candidate that
leaves a tag unfinished that way is still refused when its source finishes every tag, with a detail
naming the unfinished start, such as `+<a`. A source whose own tags are malformed is compared all
the same. After setting aside the tags the format reports as placeholders and any bracketed words, a
source counts as malformed when it has an opening tag that is never closed (void elements excepted),
a closing tag with no opening tag, or tags closed in the wrong order. Prose can trip this by
accident: in `a<b and c>d`, the run `<b and c>` is an opening `b` tag with the attributes `and` and
`c` that is never closed, so the source counts as malformed. For such a source only the nesting
check is skipped: its tags are compared as a multiset like any other, so a translation that keeps
`a<b and c>d` exactly is accepted, one that adds an `<img>` beside it is refused, and so is one that
translates the words inside the run (`a<b und c>d`), because a parser reads `und` as a new
attribute. No source is too large to compare either. A candidate is refused only once it carries
more than twice as many tags and constructs as its source, or more than 256 for a smaller source,
with the detail `+more than N inline tags` naming that limit.

Attribute values are free with these exceptions. The value of a URL attribute (`href`, `src`,
`action`, `formaction`, `xlink:href`, `poster`, `data`, `background`, `ping`, `cite`, `longdesc`,
`srcset`, `manifest`, `codebase`, `archive`, and the SVG animation attributes `to`, `from`, `values`
and `by`) is read after decoding numeric character references and `&colon;`, `&Tab;`, `&NewLine;`,
`&sol;` and `&bsol;` and ignoring tabs, line breaks and surrounding control characters. A
`javascript:`, `vbscript:` or `data:` scheme in it is refused unless the source carries that exact
value on the same tag and attribute, with a detail such as `+<a href="javascript:...">`. Beyond
that, every URL in the value, including each URL of a `srcset`, `ping`, `archive` or `values` list,
must keep a scheme and authority (host and port, or the host of a scheme-relative `//host` URL) that
a source value of the same tag and attribute has, and a relative URL must stay relative. So `/docs`
may become `/de/docs` and `https://verbatra.dev/en` may become `https://verbatra.dev/de`, but
`/docs` may not become `https://evil.example/docs` or `//evil.example/docs`, and a localized
absolute link has to keep the same host; the detail names the new origin, such as
`+<a href="https://evil.example...">`, or `+<a href="./...">` for an absolute link made relative.
The path, query and fragment may change freely. A changed URL without an explicit `http`, `https`,
`ws`, `wss`, `ftp` or `file` scheme is also refused when a backslash (`\` or `%5C`) sits in its
leading slashes or its host, because a page on another scheme reads `//verbatra.dev\@evil.example/`
as a link to evil.example.

Some attribute values must come back exactly as the source has them, compared after decoding
character references, with a detail such as `+<meta content="...">`: `http-equiv` and `content` on
`meta`; `type`, `nomodule`, `integrity` and `crossorigin` on `script`; `rel`, `as`, `integrity`,
`crossorigin` and `type` on `link`; `target` on `base`; `attributeName` and `attributeType` on
`set`, `animate`, `animateTransform` and `animateMotion`; `sandbox`, `allow` and `allowfullscreen`
on `iframe`; `method`, `enctype` and `target` on `form`; and `style`, `srcdoc` and every event
handler such as `onclick` on any element. A translatable attribute such as `title` or `alt` stays
free.

Two format-shaped limits follow from comparing whole values. For a format that packs several plural
arms into one value separated by a pipe (Vue I18n), a translation that adds the arms its language
needs repeats the source's markup and is refused. The same happens for an ICU-style plural
(`{count, plural, one {...} other {...}}`) in a format that stores it as plain text rather than
parsing it as ICU (i18next, ngx-translate and YAML): a translation with a different number of arms
than the source repeats or drops the markup inside them and is refused. In both cases the previous
translation stays.

The check also stands down per tag. A tag the format already reports as a placeholder (an XLIFF
inline element, a next-intl or ARB ICU rich-text tag) is left to the placeholder check, together
with as many closing tags as it has openings, and is never reported twice. An opening tag of that
kind that the candidate leaves unclosed is still refused, named by the closing tag it is missing,
such as `-</g>`. So is one whose nesting with the other tags no longer matches the source, such as a
`<b>` that comes back crossing the `<g>` it sat inside, and one the source carries only as text,
inside a comment or a raw text element, because moving it out would create the element. Every other
tag in the same value is still compared, including a surplus closing tag such as a second `</g>`.

### icu [#icu]

For next-intl and ARB, the candidate no longer parses as an ICU message (a `plural` without its `other` arm included), or its `plural` or `selectordinal` arms do not fit the target language's CLDR categories, or its `select` arms differ from the source's. The details name each wrong arm. [Plural arms per target language](https://verbatra.kreitz-webdev.de/docs/translation-safety.md#plural-arms-per-target-language) explains the rule.

- **Next step**: Fix the message so it parses and carries exactly the arms the target language needs.

### degenerate [#degenerate]

The candidate is structurally degenerate: a repetition loop, or a length that ran away from its source, which is how a model that collapsed into repeating a fragment looks even when every placeholder survived.

- **Next step**: Let the next run translate the key again, or write the translation by hand.

### empty [#empty]

The candidate is empty or only whitespace although its source is not. Clearing a translation is a deliberate act: the workbook's `[[CLEAR]]` sentinel does it.

- **Next step**: Write a translation, or clear the value on purpose with `[[CLEAR]]` in a workbook import.

## Review reasons [#review-reason-codes]

A review flag marks an accepted translation as worth a second look; it never withholds a key, and a `check --qa` warning fails the job only under `--strict`. The set ships as the `REVIEW_REASON_CODES` tuple; [Translation safety](https://verbatra.kreitz-webdev.de/docs/translation-safety.md#review-flags) explains the review layer.

### LENGTH_RATIO_OUTLIER [#length_ratio_outlier]

The translation is far shorter or longer than its source.

It fires below 0.35 times or above 3.0 times the trimmed source length; very short sources are skipped. Length is counted in grapheme clusters, and each character is weighted by its script: a Chinese character counts 3.5, a Japanese kana 1.5 and a Korean syllable 2.

- **Raised by**: `translate`, `watch` (on `needsReview`); `retranslateEntry` (in `reviewReasons`); `export` (in the review column); `check --qa` (as a warning)
- **Exit code**: none from `translate`; `1` from `check --qa --strict`
- **Next step**: Read the translation against its source, then keep or correct it.

### MAX_LENGTH_EXCEEDED [#max_length_exceeded]

The translation is longer than the `maxLength` budget configured for its key.

Length is counted in grapheme clusters on the value exactly as written. The budget is inclusive, so a value of exactly the budget is not flagged, and a key without a budget is never measured.

- **Raised by**: `translate`, `watch` (on `needsReview`); `retranslateEntry` (in `reviewReasons`); `export` (in the review column); `check --qa` (as a warning)
- **Exit code**: none from `translate`; `1` from `check --qa --strict`
- **Next step**: Shorten the translation, or raise the key's `maxLength` budget.

### EQUALS_SOURCE [#equals_source]

The translation equals its source verbatim although the target locale differs.

It fires only when the source has translatable text: a source made only of `doNotTranslate` terms, or of terms whose translation is the term itself, is not flagged.

- **Raised by**: `translate`, `watch` (on `needsReview`); `retranslateEntry` (in `reviewReasons`); `export` (in the review column); `check --qa` (as a warning)
- **Exit code**: none from `translate`; `1` from `check --qa --strict`
- **Next step**: Translate the value, or keep it if the text really stays the same in that language.

### GLOSSARY_TERM_MISSED [#glossary_term_missed]

A glossary term in the source is missing its translation for the target locale, or a `doNotTranslate` term is missing or altered.

Each locale is checked against its own translation of the term from a per-locale glossary. Matching ignores case unless the term is `caseSensitive`, and folds case by each locale's rules (Turkish `İ` included). The source side needs a whole word, so `AI` is not found inside `Airport`; the target side only needs the term somewhere in the value, because a translated term fuses with the text around it. A term in a script without word separators (Han, kana, Thai and similar) is matched by containment.

- **Raised by**: `translate`, `watch` (on `needsReview`); `retranslateEntry` (in `reviewReasons`); `export` (in the review column); `check --qa` (as a warning)
- **Exit code**: none from `translate`; `1` from `check --qa --strict`
- **Next step**: Use the glossary's translation of the term, or keep the term unchanged.

### GLOSSARY_FORBIDDEN_TERM [#glossary_forbidden_term]

The translation uses a rendering the glossary's `forbidden` list rules out for the target locale.

It is matched as a whole word, and by containment for scripts without word separators, so an inflected or compounded form is caught only when you list it too. A rendering the source itself contains is not flagged, since the translation may quote it.

- **Raised by**: `translate`, `watch` (on `needsReview`); `retranslateEntry` (in `reviewReasons`); `export` (in the review column); `check --qa` (as a warning)
- **Exit code**: none from `translate`; `1` from `check --qa --strict`
- **Next step**: Replace the forbidden rendering with the approved one.

### INTEGRITY_REORDERED [#integrity_reordered]

The placeholders matched exactly but appear in a different order than in the source.

- **Raised by**: `translate`, `watch` (on `needsReview`); `retranslateEntry` (in `reviewReasons`); `export` (in the review column); `check --qa` (as a warning)
- **Exit code**: none from `translate`; `1` from `check --qa --strict`
- **Next step**: Check that the new order reads correctly in the target language.

### PROVIDER_DEGRADED [#provider_degraded]

The provider degraded the batch this key came from, such as a formality downgrade or an ignored glossary.

It fires when the batch carried a `FORMALITY_DOWNGRADED` or `GLOSSARY_IGNORED` notice, which DeepL, Google Cloud Translation and LibreTranslate report. `PLACEHOLDER_UNSUPPORTED` does not raise it, because those entries are withheld rather than degraded.

- **Raised by**: `translate`, `watch` (on `needsReview`)
- **Exit code**: none
- **Next step**: Check the tone and the glossary terms of the value.

### FUZZY_CACHE_REUSE [#fuzzy_cache_reuse]

The value was reused from the cache for an earlier, near-identical source that has since been edited. It only occurs with `fuzzyCache` enabled.

- **Raised by**: `translate`, `watch` (on `needsReview`)
- **Exit code**: none
- **Next step**: Check that the reused value still matches the edited source.

### FOREIGN_PLACEHOLDER_CHANGED [#foreign_placeholder_changed]

The translation dropped or changed a token shaped like a placeholder that the project's format does not interpolate, such as `{name}` in an i18next value or `%{count}` in a YAML value.

It fires when such a token occurs in the source more often than, verbatim, in the translation. The format's own placeholders are left to the integrity gate, a token the translation only adds is not flagged, and an ICU argument counts once, by its head such as `{n, plural,`, so translated arms never raise it. Tokens inside ICU arms, a printf token glued to a letter or digit, percent-encoded bytes such as `%2F` and anything inside a URL are never checked, and neither are formats from third-party adapters. Which syntaxes each format protects is listed under [Formats](https://verbatra.kreitz-webdev.de/docs/formats.md).

- **Raised by**: `translate`, `watch` (on `needsReview`); `retranslateEntry` (in `reviewReasons`); `export` (in the review column); `check --qa` (as a warning, naming each missing token in `details`, such as `-{name}`)
- **Exit code**: none from `translate`; `1` from `check --qa --strict`
- **Next step**: Restore the token in the translation if your library interpolates it.

### BIDI_CONTROLS_CHANGED [#bidi_controls_changed]

The translation holds invisible direction controls that can reorder the text around it: an embedding, override or isolate it never closes, or an override the source does not have.

It fires when the translation's embeddings (U+202A, U+202B), overrides (U+202D, U+202E) and isolates (U+2066 to U+2068) do not pair up with their closers (U+202C, U+2069) within each paragraph while the source's do, or when it holds more left-to-right or right-to-left overrides than the source. An unclosed control leaks its direction into the text your app puts after the value. Directional marks such as U+200F never raise it, and neither does a translation that drops balanced controls the source has.

- **Raised by**: `translate`, `watch` (on `needsReview`); `retranslateEntry` (in `reviewReasons`); `export` (in the review column); `check --qa` (as a warning)
- **Exit code**: none from `translate`; `1` from `check --qa --strict`
- **Next step**: Close or remove the direction controls in the translation.

## Run notices [#sdk-notice-codes]

A notice is data on a locale's `notices`, never thrown. It reports a condition the run handled, and it changes the exit code only where the entry says so. [RunSummary](https://verbatra.kreitz-webdev.de/docs/sdk/run-summary.md) shows where notices sit on the summary.

### PLURAL_CATEGORIES_INCOMPLETE [#plural_categories_incomplete]

A plural in a target locale lacks a CLDR plural category its language uses, such as a Polish plural with only `one` and `other`, and verbatra could not generate the rest.

- **Raised by**: `translate`, `watch`; `check` lists it with the plurals, and `doctor` under Plural completeness
- **Exit code**: none on its own; `1` from `check --qa --strict`
- **Next step**: Add the missing plural forms by hand.

### SUB_BATCH_FAILED [#sub_batch_failed]

A provider request failed. The notice names the provider's error code, or `PROVIDER_CALL_FAILED` when the failure was not a structured provider error, and the request's keys are listed under `providerFailures`. Its `causeCode` carries the provider's code when there is one, and the CLI prints that code's next step after the run.

- **Raised by**: `translate`, `watch`
- **Exit code**: `1`: the locale ends `partial` or `failed`
- **Next step**: Look up the code it names under [Provider error codes](#provider-error-codes). The keys are translated again on the next run.

### BLANK_ROW_BASELINE_RETAINED [#blank_row_baseline_retained]

An imported row was left blank for a key whose source changed since the row's baseline was recorded, so the prior baseline was kept and the drift is still reported.

- **Raised by**: `import`
- **Exit code**: none on its own
- **Next step**: Translate the key in a fresh export, or run `verbatra translate`.

### HANDOFF_REVIEWS_RECORDED [#handoff_reviews_recorded]

An XLIFF handoff marked keys reviewed or final, and they were recorded as approved in `verbatra.provenance.json`.

- **Raised by**: `import`
- **Exit code**: none
- **Next step**: Nothing to fix; commit `verbatra.provenance.json`.

### BUDGET_TOKENS_EXCEEDED [#budget_tokens_exceeded]

The run reached its token budget: `maxTokens` in the config, or `--max-tokens` for one run. Under `"warn"` the run continues; under `"stop"` the keys not yet attempted are withheld and listed under `budgetWithheld`.

- **Raised by**: `translate`, `watch`
- **Exit code**: none under `budgetBehavior: "warn"`; `1` under `"stop"` once keys are withheld
- **Next step**: Raise the budget, or run again: withheld keys are translated on the next run.

### CACHE_VERSION_UNRECOGNIZED [#cache_version_unrecognized]

`verbatra.cache.json` was written by a newer verbatra, so it was left untouched and the run used no cache.

- **Raised by**: `translate`, `watch`
- **Exit code**: none
- **Next step**: Upgrade verbatra, or delete the file to rebuild it.

### PROVENANCE_VERSION_UNRECOGNIZED [#provenance_version_unrecognized]

`verbatra.provenance.json` was written by a newer verbatra, so it was left untouched and the run recorded no provenance for the values it wrote.

- **Raised by**: `translate`, `watch`, `import`
- **Exit code**: none
- **Next step**: Upgrade verbatra to keep the record current.

### PROVENANCE_FILE_TOO_LARGE [#provenance_file_too_large]

Recording this locale would have grown `verbatra.provenance.json` past the size verbatra reads back, so the file was kept as it was and the values the locale wrote have no record.

- **Raised by**: `translate`, `watch`, `import`
- **Exit code**: none
- **Next step**: The values stay written; only their record is missing. Check why the file grew so large before the next run.

### LOCALE_STATE_CARRIED_OVER [#locale_state_carried_over]

State recorded under an underscore spelling such as `pt_BR` was moved to the configured `pt-BR`. See [A respelled locale code](https://verbatra.kreitz-webdev.de/docs/the-lock-file.md#a-respelled-locale-code).

- **Raised by**: `translate`, `watch`, `import`
- **Exit code**: none
- **Next step**: Nothing to fix; commit the lock and provenance files.

### LOCALE_STATE_CARRY_OVER_SKIPPED [#locale_state_carry_over_skipped]

That move was left for the next run, because the lock-file guard stayed contended or could not be read or created, or a file could not be written. Unless only the translation memory stayed behind, the locale did not run and failed with `LOCALE_STATE_NOT_CARRIED_OVER`.

- **Raised by**: `translate`, `watch`, `import`
- **Exit code**: `1` when the locale failed with `LOCALE_STATE_NOT_CARRIED_OVER`
- **Next step**: Wait for other verbatra processes to finish, then run again.

### LOCALE_UNVERIFIED_BY_PROVIDER [#locale_unverified_by_provider]

The code sent is not on the provider's language table: only its base language is, or it comes from `localeMap`.

- **Raised by**: `translate` (a dry run too), `watch`; `doctor --locales` reports it
- **Exit code**: none
- **Next step**: Run `verbatra doctor --locales` to see the code sent, and map the locale in `provider.options.localeMap` if it is wrong.

### LOCALE_NOT_WELL_TESTED [#locale_not_well_tested]

An LLM provider translates into a language outside the list of well-tested languages.

- **Raised by**: `translate` (a dry run too), `watch`; `doctor --locales` reports it
- **Exit code**: none
- **Next step**: Have a person review the translations for this locale.

### GLOSSARY_UNSUPPORTED_BY_PROVIDER [#glossary_unsupported_by_provider]

The provider cannot apply a glossary for this locale.

- **Raised by**: `translate` (a dry run too), `watch`; `doctor --locales` reports it
- **Exit code**: none
- **Next step**: Check the glossary terms in review, or choose a provider that applies a glossary for the locale.

### FORMALITY_UNSUPPORTED_BY_PROVIDER [#formality_unsupported_by_provider]

The provider cannot apply the configured tone for this locale.

- **Raised by**: `translate` (a dry run too), `watch`; `doctor --locales` reports it
- **Exit code**: none
- **Next step**: Check the tone in review, or choose a provider that applies formality for the locale.

### SOURCE_FOREIGN_PLACEHOLDERS [#source_foreign_placeholders]

Source values this locale is about to translate hold a token shaped like a placeholder that the project's format does not interpolate. The message gives the number of such values and names the first keys.

DeepL, Google Cloud Translation and LibreTranslate get such a token masked like a placeholder, and withhold a value they cannot mask or restore ([`PLACEHOLDER_UNSUPPORTED`](#placeholder_unsupported)). An LLM provider or a person must keep it unchanged; a translation that drops or changes it is flagged [`FOREIGN_PLACEHOLDER_CHANGED`](#foreign_placeholder_changed). The notice is raised per locale, only when that locale has such a key to translate, and never for formats from third-party adapters.

- **Raised by**: `translate` (a dry run too), `watch`
- **Exit code**: none
- **Next step**: Review the translations of the named keys, or switch the syntax if your library does not interpolate these tokens.

### SOURCE_VALUE_EMPTY [#source_value_empty]

Source keys whose value is empty or whitespace only, such as a key [`extract`](https://verbatra.kreitz-webdev.de/docs/cli/extract.md) added without a default, are never missing, stale or up to date, whatever the target or the lock file holds; in a `gettext-po` source catalog that is an empty `msgstr`. Nothing is sent, written or counted for them, and the target keeps whatever value it holds. The message gives the number of keys and names the first ones.

- **Raised by**: `translate` (a dry run too), `watch`
- **Exit code**: none
- **Next step**: Write the source text of the named keys, then run `verbatra translate` again.

### SENSITIVE_CONTENT_SENT [#sensitive_content_sent]

Under `sensitiveData.mode: "warn"`, keys or glossary terms this locale sent hold content a detector or pattern matched. They were sent unchanged. The message names the detectors and the first keys, never the matched text unless it is part of a key name.

- **Raised by**: `translate` (a dry run too), `watch`
- **Exit code**: none
- **Next step**: Remove the content, allow it in `sensitiveData.allow`, or switch to `redact` or `block`.

### SENSITIVE_CONTENT_REDACTED [#sensitive_content_redacted]

Under `"redact"`, the matches in these keys' values were replaced by tokens before sending and restored in the translation. The message names the first keys in full, so a match inside a key name appears in it.

- **Raised by**: `translate` (a dry run too), `watch`
- **Exit code**: none
- **Next step**: None needed.

### SENSITIVE_CONTENT_WITHHELD [#notice-sensitive_content_withheld]

Under `"block"` or `"redact"`, keys were not sent because of a match and are listed in `sensitiveWithheld`, or glossary terms with a match were left out of the request. Under `"redact"` a key is withheld when the match is in its key name or overlaps a placeholder, or a token did not come back exactly once. The message names the first keys in full, so a match inside a key name appears in it.

- **Raised by**: `translate` (a dry run too), `watch`
- **Exit code**: `1`, since a withheld key leaves the locale `partial` or `failed`; `0` when only glossary terms were left out, since the locale still succeeds
- **Next step**: Remove the content, or allow it in `sensitiveData.allow`; the keys are retried on the next run.

### RUN_CANCELLED [#notice-run_cancelled]

The run's `signal` aborted while this locale was running. Keys not yet translated were not sent, or their request was abandoned: they stay pending for the next run, and the locale is `partial`, or `failed` when other keys were withheld and none landed. Translations that arrived before the abort were written and recorded. The message gives the number of pending keys.

- **Raised by**: the SDK only
- **Exit code**: none: no CLI command raises it
- **Next step**: Run `translate` again to send the pending keys.

## Provider notices [#provider-notice-codes]

A provider notice reports a graceful degradation on a successful request, as data on the locale's `notices`. Every key of a degraded batch also carries the `PROVIDER_DEGRADED` review reason.

### FORMALITY_DOWNGRADED [#formality_downgraded]

A requested `formal` or `informal` tone was not applied, as on DeepL's free tier or with Google Cloud Translation Basic.

- **Raised by**: `translate`, `watch`
- **Exit code**: none
- **Next step**: Check the tone in review, or use a provider or plan that supports formality.

### GLOSSARY_IGNORED [#glossary_ignored]

A glossary with required translations or terms to keep was not applied: DeepL applies only a native glossary id, and Google Cloud Translation Basic supports no glossary.

- **Raised by**: `translate`, `watch`
- **Exit code**: none
- **Next step**: Check the glossary terms in review, or use an LLM provider for glossary-bound text.

### PLACEHOLDER_UNSUPPORTED [#placeholder_unsupported]

At least one value was left untranslated by DeepL, Google Cloud Translation or LibreTranslate. A value is withheld when a placeholder is not literal in it, or when its text outside the placeholders holds `<`, `>`, `{` or `}`: ICU arguments such as plural, select or number, markup beside a placeholder, or a comparison sign (LibreTranslate keeps markup, so only braces count there). Google also withholds a value with a line break, tab or double space. A result is dropped when a marker comes back lost, duplicated, rewritten or bare, or with an unknown entity or a stray tag. A value with markup and no placeholders is sent unmasked. A token of another placeholder syntax than the format's counts as a placeholder here.

- **Raised by**: `translate`, `watch`
- **Exit code**: `1`: the keys are listed under `providerFailures`, and the locale ends `partial` or `failed`
- **Next step**: Translate those values by hand, or use an LLM provider for them.
