## ADDED Requirements ### Requirement: Каталог отвечает на условный запрос Система SHALL выставлять на ответе каталога заголовок `ETag` и SHALL отвечать `304 Not Modified` на запрос с `If-None-Match`, чья метка совпадает с текущей версией витрины. При совпадении снимок витрины открываться MUST NOT: смысл условного запроса в том, что самый частый запрос потребителя — повтор неизменившегося — не стоит ничего. Метка MUST строиться из **всего, от чего зависит ответ**: версии витрины и горизонта измерения. Горизонт едет вместе с часами, и метка из будущего, лежащая в витрине, въезжает в окно сама — без единого коммита. Путь построен и прогнан: та же версия витрины, `cumulative` против `unknown`. Значит версии витрины для метки НЕ ДОСТАТОЧНО, и слабая форма метки этого не лечит: смена измеренного рода — изменение семантическое, на нём Read API строит арифметику года. Горизонт входит в метку огрублённым до часа, и огрубление точное, а не приблизительное: метки объектов лежат ровно на часах, поэтому отбор по горизонту меняется ровно при переходе через час. Цена названа: один полный ответ в час на потребителя при неизменившейся витрине. Форма метки MUST оставаться слабой (`W/"…"`): она выведена из состояния, а не из байтов ответа. На исход `304` это не влияет — `If-None-Match` сравнивается слабо в любом случае. Метка MUST выставляться, только если за всё время сборки ответа в базу никто не коммитил; правило снятия версии принадлежит хранилищу и здесь не повторяется. Ответ без метки — законный исход, а не отказ: клиент просто не сможет спросить условно в следующий раз. **Метка действительна только в пределах одного ресурса, и область действия MUST входить в саму метку.** Маршрут, чей ответ есть функция параметров запроса (Read API точек), и транспорт, у которого адреса нет вовсе (MCP), обязаны подмешивать в неё канонизированную форму запроса — иначе «не изменилось» ответит на другой набор данных. Требовать этого прозой недостаточно: правило MUST быть выражено формой вызова, потому что забыть его — единственный путь всей задачи, ведущий к выдаче не тех данных. Ответ `304` MUST нести ту же метку и MUST NOT нести тела и представленческих заголовков. Клиент, не приславший `If-None-Match`, MUST получать ровно то же, что и до появления условного запроса. Ответы каталога MUST быть помечены непригодными для разделяемого кеша (`Cache-Control: private, no-cache`). До появления валидатора эвристическое кеширование посредником было маловероятным; с меткой ответ становится штатно кешируемым, а при выключенной проверке токенов (законная конфигурация) в запросе нет и `Authorization` — тогда выгрузку истории здоровья вправе сохранить любой прокси на пути. Проверка токена чтения MUST предшествовать условному запросу: `304` без токена подтверждал бы состояние витрины тому, кому она не открыта. Разбор условия MUST следовать HTTP и MUST NOT превращать кривой заголовок в отказ: - звёздочка (`*`) совпадает с любой **существующей** меткой; метки нет — условие не выполнено, и клиент со звёздочкой получает данные, а не вечный `304`; - неразбираемое значение условия не выполняет и даёт `200`, а не `400`. **Следствие названо вслух: `304` не выполняет измерения и потому не пишет предупреждений владельцу.** Предупреждения каталога (данные из будущего, противоречащий род агрегации) привязаны к сборке ответа; с условным опросом они становятся функцией смены версии витрины, а не числа запросов. Состояние при этом не исчезает: следующая доставка меняет версию, ответ собирается, и предупреждение пишется — а пока витрина стоит, повторять его на каждый опрос трёх потребителей значило бы обесценить уровень. #### Scenario: Повтор на неизменившейся витрине - **GIVEN** клиент получил каталог и запомнил его `ETag` - **WHEN** он повторяет запрос с `If-None-Match` этой метки, а витрина не менялась - **THEN** ответ — `304` без тела, с той же меткой #### Scenario: Витрина изменилась - **GIVEN** клиент получил каталог и запомнил его `ETag` - **WHEN** свёртка записала объект и клиент повторяет запрос с прежней меткой - **THEN** ответ — `200` с полным каталогом и новой меткой #### Scenario: Горизонт сдвинулся - **GIVEN** витрина не менялась - **WHEN** горизонт измерения перешёл через час - **THEN** метка отличается от прежней #### Scenario: Метка другого ресурса - **GIVEN** клиент присылает метку, выданную другим читающим маршрутом - **WHEN** совпадает версия витрины - **THEN** условие не выполнено, и ответ — `200` #### Scenario: Клиент не спрашивает условно - **WHEN** каталог запрашивается без `If-None-Match` - **THEN** ответ — `200` с полным каталогом, меткой и правилом кеширования #### Scenario: Две метки на неизменившейся витрине совпадают - **GIVEN** витрина не менялась между двумя запросами - **WHEN** каталог запрошен дважды - **THEN** метки совпадают, и тела ответов совпадают побайтово #### Scenario: Звёздочка в условии - **WHEN** каталог запрашивается с `If-None-Match: *` - **THEN** ответ — `304` с текущей меткой #### Scenario: Условие нечитаемо - **WHEN** каталог запрашивается с `If-None-Match`, который меткой не является - **THEN** ответ — `200` с полным каталогом, а не `400` и не `304` #### Scenario: Условный запрос без токена чтения - **GIVEN** список токенов чтения непуст - **WHEN** каталог запрашивается с `If-None-Match`, но без токена - **THEN** ответ — `401`, а не `304` #### Scenario: Витрина изменилась во время сборки ответа - **GIVEN** между снятием версии до и после сборки в базу был коммит - **WHEN** ответ сформирован - **THEN** он уходит с полным телом и без заголовка `ETag` #### Scenario: Версия витрины недоступна - **GIVEN** версию витрины прочитать не удалось - **WHEN** каталог запрашивается, в том числе с `If-None-Match` - **THEN** ответ — `200` с полным каталогом и без метки, а не `500` и не `304` #### Scenario: Условный ответ не собирает каталог - **GIVEN** в витрине лежат данные, помеченные будущим - **WHEN** каталог отвечает `304` по совпавшей метке - **THEN** предупреждение владельцу не пишется, потому что измерения не было