# read-api Specification ## Purpose Общие правила читающих маршрутов — то, что у каталога, точек, тренировок, записей, статистики и MCP одинаково и потому не должно решаться каждым заново. Форма конкретного ответа принадлежит capability самого маршрута; здесь — правила поверх них. Первое и главное: **публичный контракт объявляет транспорт.** Форма ответа не выводится из формы доменных типов, поэтому её смена есть правка транспортного слоя — действие, а не побочный эффект переименования поля в домене. Цена названа и она обратная: новое поле домена в ответ само не попадёт. ## Requirements ### Requirement: Публичный контракт читающего маршрута меняется только правкой транспорта Система SHALL объявлять форму ответа каждого читающего маршрута типами транспортного слоя и MUST NOT выводить её из формы доменных типов: ни один тип домена MUST NOT достигать сериализации ответа, а перевод домена в форму провода MUST быть явным перечислением полей. **Читающий маршрут** здесь — тот, что отдаёт наружу состояние витрины: каталог, точки, тренировки, записи, статистика. `/healthz` под правило не подпадает — его тело есть литеральный признак живости процесса, а не данные. **MCP собственной формы провода не объявляет.** Адаптер переводит вызовы в те же обработчики (`docs/architecture.md`, раздел «MCP»), поэтому объявленная форма у контракта одна на оба транспорта. Второе объявление развело бы их молча. Правило распространяется и на **тело отказа**: у читающего маршрута оно часть того же контракта, и клиент видит его чаще успешного ответа. Тело отказа MUST собираться объявленным типом транспорта, а не картой и не свободной строкой. **Следствие, ради которого правило и существует:** переименование поля доменного типа, разъединение встроенной в него структуры и появление в нём нового поля байты ответа не меняют — до сериализации доменный тип не доезжает. Смена публичного контракта становится правкой транспортного слоя, то есть действием, а не побочным эффектом. **Цена названа и взята сознательно, потому что она обратная.** Новое поле домена не попадает в ответ само: чтобы клиент его увидел, транспорт обязан его перечислить. Контракт перестаёт меняться случайно в обе стороны, и это дороже ровно на объём перевода. Из правила есть одно исключение, и оно ограничено **содержимым**, а не конвертом: значение, которое хранилище держит дословно, уезжает клиенту сырым JSON без разбора и переобъявления — переписывать его в тип провода значило бы нарушить инвариант «точки хранятся дословно». Конверт вокруг такого значения — метки времени, офсет, единицы, слой — объявляется типом провода и нормализуется, как того требует инвариант «форма Apple не транслируется». #### Scenario: Поле доменного типа переименовано - **GIVEN** поле доменного типа, из которого собирается ответ, переименовано, а транспортный слой не тронут - **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины - **THEN** байты ответа те же #### Scenario: Форма провода изменена намеренно - **GIVEN** транспорт переименовал поле объявленной формы - **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины - **THEN** байты ответа изменились, и изменение целиком лежит в правке транспортного слоя #### Scenario: Читающий маршрут отвечает отказом - **WHEN** читающий маршрут отвечает кодом отказа - **THEN** тело отказа собрано объявленным типом транспорта #### Scenario: Словарь значения виден клиенту строкой - **GIVEN** доменное перечисление, чьи значения клиент видит строками - **WHEN** маршрут отвечает - **THEN** строку в ответ кладёт транспорт, а домен собственной сериализации не несёт #### Scenario: Дословное содержимое проходит насквозь - **WHEN** в ответ идёт значение, сохранённое хранилищем дословно - **THEN** оно уезжает сырым JSON, а конверт вокруг него объявлен типом провода ### Requirement: Пустая коллекция чтения — список, а не отсутствие Система SHALL отдавать пустую коллекцию читающего маршрута как `[]` и MUST NOT отдавать её как `null`; отсутствующее значение MUST отдаваться как `null` и MUST NOT подменяться нулевым значением своего типа. Правило общее для всех читающих маршрутов, потому что цена у него одна: `nil`-срез сериализуется в `null`, и клиент читает «поля нет» там, где на самом деле «элементов нет»; правдоподобная нулевая дата в ответе неотличима от настоящей. Конкретные коллекции и поля каждого маршрута нормирует его собственная capability — здесь только правило, чтобы следующий маршрут не принимал его заново. Ручной перевод домена в форму провода делает это правило не теоретическим: присваивание поле в поле копирует `nil` и разыменовывает указатель ровно так, как написано, и обе ошибки молчат. #### Scenario: Коллекция ответа пуста - **WHEN** в ответ читающего маршрута идёт коллекция без единого элемента - **THEN** она уезжает как `[]` #### Scenario: Значения нет - **WHEN** поле ответа не имеет значения - **THEN** оно уезжает как `null`, а не как нулевая метка времени, пустая строка или ноль ### Requirement: Сериализация ответа чтения не экранирует содержимое Система SHALL сериализовать тела ответов читающих маршрутов **без экранирования HTML-символов**: `&`, `<` и `>` внутри значений MUST уезжать клиенту как есть и MUST NOT подменяться escape-последовательностями `&`, `<`, `>`. Правило общее для всех читающих маршрутов, а не частное для точек, потому что общим является механизм: тело собирает один помощник сериализации, и умолчание `encoding/json` экранирует эти три символа молча. На маршруте, отдающем **дословно сохранённое** содержимое, это прямо ломает обещание дословности: имя источника приходит с телефона пользовательской строкой и законно содержит `&`. Хранилище этот же капкан уже проходило и обезвредило тем же способом — кодировщик с выключенным экранированием вместо `json.Marshal`. Проверка обязана стоять на содержимом, реально несущем эти символы: набор фикстур, в котором их нет, зелен и будучи сломанным. #### Scenario: Значение несёт символ, который сериализатор склонен экранировать - **GIVEN** значение ответа читающего маршрута, содержащее `&`, `<` или `>` - **WHEN** маршрут отвечает - **THEN** эти символы присутствуют в теле ответа как есть ### Requirement: Ответ чтения непригоден для разделяемого кеша Система SHALL помечать ответы читающих маршрутов заголовком `Cache-Control: private, no-cache`. Правило общее, потому что цена у него одна на все маршруты чтения: с появлением валидатора ответ становится штатно кешируемым, а при выключенной проверке токенов — законной конфигурации для доверенной локальной сети — в запросе нет и `Authorization`. Тогда выгрузку истории здоровья вправе сохранить любой прокси на пути. Маршрут, решающий это заново, однажды решит иначе. #### Scenario: Читающий маршрут ответил - **WHEN** читающий маршрут отдаёт тело - **THEN** ответ несёт `Cache-Control: private, no-cache`