Internationalisation (i18n)
OpenRegister ships first-class internationalisation across the object model, the REST API, and the rendered HTTP responses. This document describes the two pillars introduced by hydra ADR-025:
- Source-of-truth — declaring which language is canonical for a
translatable property, projecting that into the sidecar, and flipping
derived translations to
outdatedwhenever the source value changes. - API language negotiation — explicit per-request language overrides
via query parameters and the write-side
X-Translation-Target-Languageheader.
See also: openspec/specs/register-i18n/spec.md,
openspec/changes/i18n-source-of-truth/,
openspec/changes/i18n-api-language-negotiation/.
Schema declaration
Translatable properties carry translatable: true. They may also declare
a canonical source language via the sourceLanguage modifier:
{
"type": "object",
"properties": {
"title": {
"type": "string",
"translatable": true,
"sourceLanguage": "nl"
},
"categoryCode": {
"type": "string"
}
}
}
sourceLanguageis OPTIONAL. If omitted, OR usesRegister.defaultLanguage(the first element ofRegister.languages, falling back to'nl').sourceLanguageMUST NOT appear on properties withouttranslatable: true; the validator rejects this with a 400.- The value MUST match basic BCP-47 syntax (
/^[a-z]{2,3}(-[a-zA-Z0-9]{2,8})*$/).
Object-level source override
Individual objects may override the schema's source-language for a single
property by writing to _translationMeta.<property>.sourceLanguage:
{
"id": "obj-uuid",
"title": {"nl": "Welkom", "en": "Welcome"},
"_translationMeta": {
"title": {
"sourceLanguage": "en"
}
}
}
Override precedence (highest first):
- object body
_translationMeta.<property>.sourceLanguage - schema property
sourceLanguage Register.defaultLanguage'nl'(hardcoded final fallback)
Translation projection + status
The openregister_translations sidecar carries one row per
(object_uuid, property, language) tuple and now also tracks
source_language. The projection service writes that column on every
upsert; the Translation entity exposes it via
getSourceLanguage() and embeds it (alongside an isSource flag) in
jsonSerialize().
When the canonical source-language value for a property changes,
SaveObject invokes
TranslationStatusService::markDerivedTranslationsOutdated($uuid, $property, $sourceLanguage),
which flips every other-language row for that property from
approved / human_reviewed / machine_translated to outdated.
Already-outdated and already-draft rows are not re-flipped.
To back-fill source_language on data created before the migration ran:
docker exec nextcloud php occ openregister:translations:backfill-source-language
# optional flags
docker exec nextcloud php occ openregister:translations:backfill-source-language \
--batch-size=500 --dry-run
The command is idempotent: re-running it once every row has a non-empty
source_language reports 0 rows updated.
Request-side language negotiation
The middleware honours, in priority order:
| Priority | Source | Notes |
|---|---|---|
| 1 | ?_lang=<bcp47> | Canonical per-request override |
| 2 | ?language=<bcp47> | Alias for ?_lang= |
| 3 | Accept-Language: <RFC 9110> | Existing browser-default fallback |
| 4 | Register.defaultLanguage | Resolved against the register's languages list |
| 5 | 'nl' | Hardcoded final fallback |
Invalid BCP-47 tags at any step are logged and fall through; no 400
is ever emitted for a malformed language parameter.
LanguageService::getRequestedLanguageSource() returns
'query' | 'header' | 'default', useful for telemetry and the
X-Source-Language response setter.
Write-side X-Translation-Target-Language
Translators can edit a single language without sending the full language-keyed body by adding the header on POST/PUT/PATCH:
PATCH /api/objects/r/s/uuid HTTP/1.1
Content-Type: application/json
X-Translation-Target-Language: en
{"title": "Welcome"}
The handler wraps the scalar value under the target language, persisting
title.en = "Welcome" without touching title.nl. Sending a full
language-keyed body PLUS the header is a conflict and returns
400 Bad Request with body:
{
"error": {
"code": "TRANSLATION_TARGET_CONFLICT",
"property": "title",
"targetLanguage": "en"
}
}
Translation metadata envelope
When ?_translationMeta=true is on the request, every translatable
property in the response gets a sibling entry under
_meta.languageMeta.<property>:
{
"id": "obj-uuid",
"title": "Welcome",
"_meta": {
"languageMeta": {
"title": {
"served": "en",
"sourceLanguage": "nl",
"isSource": false,
"status": "approved"
}
}
}
}
Use cases:
- A translation UI rendering "Original (Dutch)" vs "Translation (out of date)" badges.
- A reviewer dashboard summarising freshness ratios.
The envelope is OFF by default (no payload bloat for clients that don't need it).
Response headers
| Header | When | Example |
|---|---|---|
Content-Language | Always | en |
X-Content-Language-Fallback | Fallback chain used | true |
X-Source-Language | Single-object content | nl |
X-Source-Language reflects the dominant source language across the
object's translatable properties (or the only source language when there
is exactly one).
Translation search filters
GET /api/translations/search accepts the standard
?query=, ?language=, ?status=, ?objectUuid= filters plus the new:
?sourceLanguage=<bcp47>— restrict to rows whose canonical source language matches the given tag.?isOutOfDate=true— restrict to rows inoutdatedstatus.?compareToSource=true— return bothvalueandsourceValueside-by-side per matched row.
Client snippets
curl
# Read in English; declare the explicit override even though the browser sends Dutch.
curl -H "Accept-Language: nl" \
"https://or.example/api/objects/r/s/uuid?_lang=en"
# Edit only the German translation of `title` on a single object.
curl -X PATCH \
-H "Content-Type: application/json" \
-H "X-Translation-Target-Language: de" \
-d '{"title": "Willkommen"}' \
"https://or.example/api/objects/r/s/uuid"
axios
// Read.
await axios.get(`/api/objects/${r}/${s}/${id}`, { params: { _lang: 'en' } });
// Write the French translation.
await axios.patch(
`/api/objects/${r}/${s}/${id}`,
{ title: 'Bienvenue' },
{ headers: { 'X-Translation-Target-Language': 'fr' } }
);
Postman
Add _lang to the Params tab for reads. For writes, add a header
X-Translation-Target-Language: <bcp47> and send a scalar body.
Hydra references
- ADR-025 (parent decision) —
hydra/openspec/architecture/adr-025-i18n-source-of-truth.md. openspec/changes/i18n-source-of-truth/— source-of-truth half.openspec/changes/i18n-api-language-negotiation/— API negotiation half.