ADR 0177 — Язык ответов живёт в трёх местах — учётная запись, сессия и проект, — в открытом списке BCP-47 и никогда в users.locale
Статус
Accepted. 2026-09-28 (AT-162, эпик EP-028). Решения сопровождающего от
2026-09-28, записанные в заметках AT-168 и AT-169: предпочтение по УЧЁТНОЙ ЗАПИСИ с
переопределением по СЕССИИ; ОТКРЫТЫЙ список языков (любой BCP-47); новая учётная запись в
automatico; обнаруженный действует только ПОДТВЕРЖДЁННЫМ; общие артефакты и ходы
без автора — на языке ПРОЕКТА.
Ссылается, не редактируя, на ADR 0121 (новая таблица входит в файл своего агрегата) и ADR 0006 (системный prompt принадлежит АГЕНТУ, а не автору хода, — поэтому язык не входит в него, а входит в ориентацию на ход, которая принадлежит AT-164).
Контекст
Язык ответа агента до сих пор решала лишь модель, исходя
из системного prompt, написанного на pt-BR, и из сообщений. Сообщение, которое
открыло эпик, — разговор на португальском, на который ответили на испанском, с
DeepSeek через OpenRouter. users.locale (RN-432) существует, но это язык
ИНТЕРФЕЙСА: закрыт до pt-BR/en, потому что каждому значению нужен файл
перевода, и engine его нигде не читает.
Решения сопровождающего требуют четырёх источников, которых не было, и одного, который был, с приоритетом между ними и тремя разными областями:
- явный выбор принадлежит ЧЕЛОВЕКУ и действует во всех проектах и сессиях;
- человек может закрепить ДРУГОЙ язык в сессии, только для себя, — два пользователя в одной сессии не задевают друг друга;
- обнаруженный по сообщениям принадлежит ЧЕЛОВЕКУ, глобален и учитывается лишь после того, как он подтвердит;
- то, что принадлежит всем (общий артефакт, ход без автора), следует ПРОЕКТУ.
Решение
- Никакого enum. Три места хранят
textс КАНОНИЧЕСКИМ кодом BCP-47, проверяемым в api функциейnormalizarIdiomaBcp47(apps/api/src/domain/iam/idioma-de-resposta.ts): форма черезIntl.getCanonicalLocales, подтег языка, известный ICU процесса (Intl.DisplayNamesсfallback: 'none'), никогдаundи частного использования, не более 35 символов. Открытие языка ОТВЕТА не зависит ни от какого файла ресурсов; enum требовал бы миграции на каждый язык, а это цена, которую решение "открытый список" отвергло. - Учётная запись: две колонки в
users, аlocaleнетронут.users.response_language—NULLозначает АВТОМАТИЧЕСКИЙ, и так рождается каждая учётная запись (без значения-сигнала внутри колонки языков). И параusers.detected_language+users.detected_language_confirmed_at, с CHECK, требующим их вместе: обнаруженный существует лишь ПОДТВЕРЖДЁННЫМ. Пару записывает AT-163 (обнаружение и вопрос подтверждения); колонка рождается здесь, потому что это ступень приоритета. - Сессия: таблица
session_language_overridesв агрегате сессий, с ключом{session_id, user_id}. Конфигурация, а не событие: повторное закрепление — upsert, снятие —DELETE, а удалённая сессия уносит строки. Ключ — ПАРА, потому что переопределение принадлежит человеку; чтения "всей сессии" нет. - Проект: колонка
projects.language(реализована в AT-243, со значением по умолчанию и минимальной ролью, решёнными там, в RN-619). Это язык того, у чего нет человека-автора. - Приоритет для того, у кого есть автор (RN-618), разрешаемый по ЧЕЛОВЕКУ и никогда
по сессии: переопределение сессии > выбор учётной записи > подтверждённый обнаруженный >
users.locale. Посколькуusers.locale—NOT NULL, цепочка всегда заканчивается языком. "Точечная просьба" внутри сообщения — не ступень: её выполняет модель, благодаря формулировке ориентации. - Одна точка разрешения.
ResolverIdiomaDaRespostaUseCaseвозвращает победителя, ИСТОЧНИК и всю цепочку; это то, что показывает экран (Conta, панель сессии), и то, что вызывает AT-164, чтобы передать язык в engine.
Последствия
users.localeостаётся закрытым и по-прежнему принадлежит лишь интерфейсу: ни выбор ответов, ни обнаружение в него не пишут. Страница Conta показывает оба рядом, а маршрутPATCH /users/me/preferencesпринимает оба поля как НЕОБЯЗАТЕЛЬНЫЕ и независимые.- Список принимаемых языков — это список ICU того Node, на котором работает api. Редкий код ISO 639-3, который ICU не называет, отклоняется, объявлено, — ни один правдоподобный язык ответа под этот случай не попадает.
- В engine пока нет потребителя: ни один ход не меняет поведения с этим ADR. Транспорт (эфемерная ориентация на ход) — это AT-164; обнаружение и подтверждение — AT-163. До тех пор выбор языка в Conta записывается и показывается, но ещё не доходит до модели — экран не утверждает обратного.
- Переопределение по сессии разрешено в закрытой сессии: оно не запускает ход и не пишет в event log.