ADR 0177 — The response language lives in three places — account, session and project —, as an open BCP-47 list and never in users.locale
Status
Accepted. 2026-09-28 (AT-162, epic EP-028). Maintainer decisions of
2026-09-28, written in the notes of AT-168 and AT-169: preference per ACCOUNT
with an override per SESSION; an OPEN list of languages (any BCP-47); new
accounts in automatico; the detected one only counts when CONFIRMED; shared
artifacts and turns without an author in the PROJECT's language.
References, without editing, ADR 0121 (a new table goes into its aggregate's file) and ADR 0006 (the system prompt belongs to the AGENT, not to the turn's author — that is why the language does not go into it, but into a per-turn guidance, which is AT-164's).
Context
An agent's response language, until now, was decided only by the model, from a
system prompt written in pt-BR and from the messages. The report that opened
the epic was a conversation in Portuguese answered in Spanish, with DeepSeek via
OpenRouter. users.locale (RN-432) exists, but it is the INTERFACE language:
closed to pt-BR/en because each value needs a translation file, and the
engine does not read it anywhere.
The maintainer's decisions call for four sources that did not exist and one that did, with a precedence among them, and three different scopes:
- the explicit choice belongs to the PERSON and applies in every project and session;
- the person can pin ANOTHER language in one session, only for themselves — two users in the same session do not affect each other;
- the one detected from messages belongs to the PERSON, global, and only counts after they confirm;
- what belongs to everyone (shared artifact, turn without an author) follows the PROJECT.
Decision
- No enum. The three places store
textwith a CANONICAL BCP-47 code, validated in the api bynormalizarIdiomaBcp47(apps/api/src/domain/iam/idioma-de-resposta.ts): form viaIntl.getCanonicalLocales, a language subtag known to the process's ICU (Intl.DisplayNameswithfallback: 'none'), neverundnor private use, at most 35 characters. Opening the RESPONSE language does not depend on any resource file; an enum would require a migration per language, which is the cost the "open list" decision refused. - Account: two columns in
users, andlocaleuntouched.users.response_language—NULLis AUTOMATIC, and that is how every account is born (no sentinel value inside a language column). And the pairusers.detected_language+users.detected_language_confirmed_at, with a CHECK requiring both together: the detected one only exists CONFIRMED. Who writes the pair is AT-163 (detection and the confirmation question); the column is born here because it is a step of the precedence. - Session: table
session_language_overrides, in the sessions aggregate, keyed by{session_id, user_id}. Configuration and not an event: pinning again is an upsert, releasing is aDELETE, and a deleted session takes its rows with it. The key is the PAIR because the override is the person's; there is no "whole session" read. - Project: column
projects.language(implemented in AT-243, with the default and minimum role decided there, in RN-619). It is the language of whatever has no human author. - The precedence for those with an author (RN-618), resolved per PERSON and
never per session: session override > account choice > confirmed detected >
users.locale. Sinceusers.localeisNOT NULL, the chain always ends in a language. The "one-off request" inside a message is not a step: the model honours it, through the wording of the guidance. - One resolution point.
ResolverIdiomaDaRespostaUseCasereturns the winner, the ORIGIN and the whole chain; it is what the screen shows (the Account, the session bar) and what AT-164 calls to send the language to the engine.
Consequences
users.localestays closed and stays interface-only: neither the response choice nor detection write to it. The Account page shows both side by side, and thePATCH /users/me/preferencesroute accepts both fields as OPTIONAL and independent.- The list of accepted languages is that of the ICU of the Node running the api. A rare ISO 639-3 code that ICU does not name is refused, declared — no plausible response language is in that case.
- There is no consumer in the engine yet: no turn changes behaviour with this ADR. The transport (the ephemeral per-turn guidance) is AT-164; detection and confirmation, AT-163. Until then, choosing a language in the Account is stored and shown, and does not yet reach the model — the screen does not claim otherwise.
- The per-session override is allowed in a closed session: it does not trigger a turn nor write to the event log.