# catalog Specification ## Purpose Отвечает потребителю на два вопроса: «что у тебя вообще есть» — метрики, единицы, слои с границами данных и числом точек — и «какая свёртка по этой метрике осмысленна». Второй ответ **измеряется** сверкой минутного слоя с часовым, а не размечается руками: HAE рода не шлёт, и всё, что можно было бы объявить, пришлось бы угадать. Род неизвестен — свёртка не предлагается вовсе. ## Requirements ### Requirement: Каталог разрезов отдаёт наблюдаемое состояние витрины Система SHALL отдавать каталог метрик, где по каждой метрике перечислены единицы и слои с границами данных и числом точек. Каталог MUST показывать только то, что в витрине есть: досчитывать отсутствующий слой, экстраполировать границы или помнить о том, чего больше нет, он MUST NOT. **Границы слоя — это границы данных, а не обещание покрытия.** Внутри диапазона законно есть дыры: часы, за которые доставок не было, и периоды, верхние слои которых не пережили пересборку. Поэтому правило выбора слоя в Read API MUST опираться на фактические объекты запрошенного диапазона, а не считать каталожную пару границ доказательством непрерывности. Слои — часть контракта, а не деталь хранения: без каталога вопрос «в каком разрезе спрашивать» не задать. Часовой объект при этом деталью остаётся, и его число в ответ не идёт. Отсюда честность после пересборки: экспорт Apple восстанавливает только слой `sample`, а `minute` и `hour` за периоды с удалёнными доставками не воскресают. Метрика, потерявшая слой целиком, объявляет его отсутствие тем, что слоя нет в списке. Метрика с пустым именем — законное значение колонки, и каталог MUST показывать её наравне с остальными: терять на границе, которая отвечает «что у тебя вообще есть», нельзя ничего. Единицы отдаются **множеством различных значений** метрики, отсортированным и ограниченным потолком (пустые в множество не входят): на живом потоке они не менялись ни разу, но одна форма поля для обоих случаев честнее строки, которая при расхождении молча выберет одно из двух. На слой при этом приходится **ровно один** элемент списка: объекты слоя с разными единицами дают общий диапазон и общую сумму точек, а различие видно множеством единиц метрики. #### Scenario: Метрика лежит в нескольких слоях - **WHEN** у метрики есть объекты в слоях `raw`, `minute` и `hour` - **THEN** каталог перечисляет все три слоя, у каждого — границы данных и число точек #### Scenario: Слоя за период не осталось - **GIVEN** витрина пересобрана, и у метрики остались объекты только слоя `sample` - **WHEN** запрашивается каталог - **THEN** у метрики объявлен слой `sample` и не объявлены `minute` и `hour` #### Scenario: Внутри диапазона слоя есть дыра - **GIVEN** у метрики есть объекты слоя `minute` за январь и за июнь, а между ними нет ни одного - **WHEN** запрашивается каталог - **THEN** слой `minute` объявлен один раз с границами от января до июня, и каталог не утверждает, что данные есть за весь этот период #### Scenario: Единицы метрики разошлись - **GIVEN** объекты одной метрики несут разные единицы - **WHEN** запрашивается каталог - **THEN** множество единиц метрики содержит оба значения, а слой остаётся одним элементом списка с объединённым диапазоном и суммой точек #### Scenario: Метрика приехала без имени - **GIVEN** в витрине есть объекты метрики с пустым именем - **WHEN** запрашивается каталог - **THEN** метрика присутствует в ответе со своими слоями #### Scenario: Единиц у метрики стало неправдоподобно много - **GIVEN** объекты метрики несут десятки различных строк единиц - **WHEN** запрашивается каталог - **THEN** множество единиц в ответе ограничено потолком, а слой остаётся одним элементом #### Scenario: Витрина пуста - **WHEN** в витрине нет ни одного объекта - **THEN** каталог отдаёт пустой список метрик, а не отказ ### Requirement: Форма ответа каталога Система SHALL отдавать каталог по маршруту `GET /api/v1/metrics` в виде объекта с полем `metrics`. Каждая запись MUST нести поля `metric`, `units`, `aggregation` и `layers`; элемент `layers` — `layer`, `from`, `to`, `points`; объект `aggregation` — `style`, `hours`, `compared`, `agreeing`, `conflicting`, `first_hour`, `last_hour`. Все перечисленные поля MUST присутствовать всегда, в том числе со значением `null`: клиент не должен выводить смысл из наличия или отсутствия ключа. Пустой список MUST отдаваться как `[]`, а не как `null`, и отсутствие измеренного окна — как `null`, а не как нулевая метка времени: правдоподобная дата в ответе неотличима от настоящей. Семантика границ различна, поэтому имена различны: - `from`/`to` слоя — метки **первой и последней точки** слоя, включительно; - `first_hour`/`last_hour` — **ярлыки часов**, первого и последнего часа окна измерения, включительно. Порядок метрик и слоёв в ответе MUST быть детерминированным, чтобы два ответа на одинаковом состоянии витрины совпадали побайтово. Поле `style` называет род (`cumulative` / `instant` / `unknown`), а не «kind»: слово `kind` в проекте уже занято родом секции записи (`record.kind`), и два разных смысла под одним именем в одном API — вечная сноска. #### Scenario: Пустая витрина отдаётся пустым списком - **WHEN** каталог запрашивается на пустой витрине - **THEN** тело ответа — `{"metrics":[]}` #### Scenario: Род не измерен - **WHEN** у метрики нет общих часов двух слоёв - **THEN** `style` равен `unknown`, `hours` равен нулю, а `first_hour` и `last_hour` равны `null` #### Scenario: Два запроса подряд дают один ответ - **WHEN** каталог запрашивается дважды на неизменившейся витрине - **THEN** тела ответов совпадают побайтово ### Requirement: Число точки берётся из одного объявленного поля Система SHALL считать числом точки значение поля `qty`, а при его отсутствии — значение поля `Avg`, и MUST NOT выводить число из других полей. Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`, и без второго кандидата самая важная метрика потока не измерялась бы вовсе. **Ноль — значение, а не отсутствие.** Правило пустоты, принятое для сравнения полноты точек, здесь неприменимо: там ноль считается пустотой, чтобы точка без измерений не вытесняла настоящее измерение, а тут нулевой час обязан дойти до правила различимости и быть отброшенным им, а не исчезнуть раньше и молча. Значение, которое не разбирается как конечное число (строка, `null`, объект, переполнение), считается неприсланным: бесконечность, попавшая в сумму, отравляет и сумму, и среднее всего часа. Точка без числа в сумму не входит и число точек часа не увеличивает. К `Avg` система переходит только при **отсутствующем или `null`** `qty`. `qty` не того типа означает, что форма точки изменилась, и догадываться о числе не о чем: точка считается не несущей значения целиком. #### Scenario: Точка несёт только qty - **WHEN** точка имеет вид `{"qty":72.5,"date":"…"}` - **THEN** её число равно `72.5` #### Scenario: Точка несёт Min/Avg/Max без qty - **WHEN** точка имеет вид `{"Min":60,"Avg":70,"Max":80,"date":"…"}` - **THEN** её число равно значению `Avg` #### Scenario: Нулевое значение остаётся значением - **WHEN** точка имеет вид `{"qty":0,"date":"…"}` - **THEN** её число равно нулю, и точка считается несущей значение #### Scenario: Значение не разбирается как конечное число - **WHEN** точка несёт `qty` строкой или числом вне диапазона `float64` - **THEN** точка считается не несущей значения и в сумму не входит ### Requirement: Род агрегации выводится сверкой минутного и часового слоёв Система SHALL выводить род агрегации метрики (`cumulative` / `instant` / `unknown`) сравнением её часового слоя с минутным и MUST NOT определять его по имени метрики, единицам, форме точки или заголовку доставки. Час **пригоден** для сверки, когда выполнено всё: - у метрики есть объекты обоих слоёв за этот час; - час не лежит в будущем — его метка не позже текущего времени плюс запас; - единицы обоих объектов совпадают; - часовой объект несёт ровно одну точку, и она несёт значение, а её метка совпадает с началом часа; - у минутного объекта не меньше двух точек со значением; - сумма минутных значений **отличима** от их среднего. **Горизонт обязателен, и это не защита от вредителя, а условие корректности.** Час объекта берётся из метки в теле доставки, а тело не наше: одна доставка с метками в будущем занимает окно целиком и подменяет измеренный род метрики — построено и прогнано, мгновенная метрика объявлялась накопительной при нуле противоречащих часов. Запас нужен на расхождение часов телефона и сервера. Данные, помеченные будущим, MUST порождать предупреждение владельцу: это либо сбитые часы, либо чужое тело, и оба случая лечатся не кодом. **Совпадение единиц обязательно.** Мгновенная метрика, приехавшая минутным слоем в `count/min` и часовым в `count/hour`, даёт в полном часе `часовое = 60 · среднее = сумма` — то есть **уверенный ложный** `cumulative` при нуле противоречащих часов. Правило единогласия этот случай не ловит по построению: противоречия нет, есть молчание. **Часовой объект несёт ровно одну точку.** Две точки за час описывают разные интервалы, и какая из них относится к часу целиком — неизвестно; час непригоден целиком, а не «по той, у которой есть значение». Требование выравнивания часовой метки закрывает зоны с неполночасовым смещением: слой выводится по выравниванию метки в исходной зоне, а объект адресуется часом UTC, поэтому в зоне `+0530` часовая точка описывает не тот интервал, который покрывают минутные точки того же объекта. Сравнивать их нельзя, и такой час свидетельства не даёт. Требование различимости обязательно: в часе, где все значения нули, сумма равна среднему, и совпадение с любой из гипотез не значит ничего. Все три сравнения — «сходится с суммой», «сходится со средним», «сумма отличима от среднего» — MUST выполняться **одним предикатом с одним допуском**: относительным, величиной `1e-9`. Тогда час, подтверждающий обе гипотезы сразу, невыразим по построению, и исход не зависит от порядка веток. Величина названа числом, потому что от неё зависят счётчики основания в ответе: измерено, что вердикты метрик на живом корпусе одинаковы при допуске от `1e-9` до `1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49. Взято строгое значение: канонизация содержимого округляет числа до 12 значащих цифр, то есть всё, что крупнее `1e-12`, представлением не объясняется, а `1e-9` оставляет три порядка запаса и остаётся на шесть порядков строже любого содержательного расхождения (сумма и среднее при `n ≥ 2` различаются не меньше чем вдвое). Абсолютного порога у сравнения нет намеренно: около нуля относительный допуск вырождается в сторону «не сходится», то есть даёт «свидетельства нет», а не ложный род. Вердикт пригодного часа: часовое значение сходится с суммой минутных — `cumulative`, со средним — `instant`, иначе час свидетельства не даёт. Сумма минутных значений MUST считаться в порядке возрастания метки точки, чтобы вердикт не зависел от порядка точек внутри объекта. #### Scenario: Часовое значение равно сумме минутных - **GIVEN** у метрики есть минутный и часовой объекты за один час - **WHEN** часовое значение сходится с суммой минутных значений - **THEN** метрика получает род `cumulative` #### Scenario: Часовое значение равно среднему минутных - **WHEN** часовое значение сходится со средним минутных значений - **THEN** метрика получает род `instant` #### Scenario: Нулевой час свидетельством не является - **GIVEN** все минутные значения часа равны нулю, и часовое значение тоже - **WHEN** измеряется род - **THEN** этот час непригоден и в подсчёт согласных не идёт #### Scenario: Час лежит в будущем - **GIVEN** доставка принесла объекты обоих слоёв с метками позже текущего времени - **WHEN** измеряется род - **THEN** эти часы в окно не входят, род остаётся измеренным по настоящей истории, и владельцу пишется предупреждение #### Scenario: Единицы слоёв разошлись - **GIVEN** минутный объект часа несёт одни единицы, а часовой — другие - **WHEN** измеряется род - **THEN** час непригоден и свидетельства не даёт #### Scenario: Часовой объект несёт две точки - **GIVEN** у метрики за час есть часовой объект с двумя точками - **WHEN** измеряется род - **THEN** час непригоден и свидетельства не даёт #### Scenario: Минутный объект несёт одну точку - **GIVEN** минутный объект часа несёт единственную точку - **WHEN** измеряется род - **THEN** час непригоден: сумма и среднее совпадают, различить гипотезы нечем #### Scenario: Метка часовой точки не выровнена на начало часа - **GIVEN** часовая точка стоит на середине часа UTC - **WHEN** измеряется род - **THEN** час непригоден и свидетельства не даёт #### Scenario: Форма точки на исход не влияет - **WHEN** метрика приходит только с полем `qty`, без `Avg`/`Min`/`Max` - **THEN** род всё равно измеряется сверкой слоёв, а не выводится из формы ### Requirement: Род объявляется только при единогласном свидетельстве Система SHALL объявлять род метрики, только если согласных часов не меньше трёх и ни один час не дал противоположного вердикта. В остальных случаях род MUST быть `unknown`, и агрегация по такой метрике предлагаться MUST NOT. Наличие противоречащих часов MUST быть записано чекпоинтом уровня `WARN` с именем метрики и числами основания, без значений точек: род — свойство, на котором Read API строит арифметику года, и его смена не имеет права проходить молча. На живом корпусе противоречащих часов не встретилось ни разу, поэтому шума правило не создаёт. Единогласие, а не большинство: противоречащий час означает, что одна из гипотез для этой метрики ложна, и объявлять род при известном контрпримере нельзя. Порог в три часа — потому что на этом роде потом суммируют год, а один совпавший час остаётся свидетельством одного часа. Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно, может сменить объявленный род без единой новой доставки за спрошенный период. Клиент, которому это важно, различает случаи по основанию измерения — оно отдаётся вместе с родом. #### Scenario: Свидетельства противоречат - **GIVEN** у метрики есть часы с вердиктом `cumulative` и часы с вердиктом `instant` - **WHEN** измеряется род - **THEN** род равен `unknown`, число противоречащих часов отдаётся в каталоге, и пишется `WARN` с именем метрики #### Scenario: Свидетельств мало - **WHEN** согласных часов меньше трёх - **THEN** род равен `unknown` #### Scenario: Второго слоя нет вовсе - **WHEN** метрика лежит только в одном слое - **THEN** род равен `unknown`, а число часов окна равно нулю ### Requirement: Нижний слой в измерении не участвует Система SHALL измерять род только по слоям `minute` и `hour` и MUST NOT использовать в сверке слои `raw`, `sample` и `day`. Нижний слой HAE — не сэмплы, а посекундная развёртка настоящих сэмплов с инфляцией до 478×: его сумма завышена и в сверке не сходится. Слой `sample` несёт собственные интервалы сэмплов, и его сверка с часовым слоем — другая задача, вместе с импортом родного экспорта. Слой `day` — суточная сводка сна, другая схема под тем же именем, а не разрез часов. #### Scenario: Метрика есть только в нижнем слое - **WHEN** у метрики есть объекты только в слое `raw` - **THEN** род равен `unknown` #### Scenario: Нижний слой не подменяет минутный - **GIVEN** у метрики есть слои `raw` и `hour`, но нет `minute` - **WHEN** измеряется род - **THEN** сверка не выполняется и род равен `unknown` #### Scenario: Метрика лежит только в суточном слое - **WHEN** у метрики есть объекты только слоя `day` - **THEN** слой объявлен в каталоге, а род равен `unknown` ### Requirement: Каталог отдаёт основание измерения, а не только вывод Система SHALL отдавать вместе с родом четыре числа и границы окна, и клиент MUST иметь возможность отличить «свидетельств не было» от «свидетельства противоречат», не делая второго запроса. Числа определены так, что их разность осмысленна: - `hours` — сколько общих часов двух слоёв попало в окно; - `compared` — сколько из них оказалось **пригодными**; - `agreeing` — сколько пригодных часов дали **преобладающий** вердикт (при объявленном роде это он и есть); - `conflicting` — сколько дали другой. Разложение одно и то же независимо от того, объявлен род или нет: иначе `agreeing` пришлось бы толковать по-разному в двух ветках, и клиент читал бы одно поле двумя способами. Разность `compared − agreeing − conflicting` — часы, не сошедшиеся ни с одной гипотезой; разность `hours − compared` — часы, отброшенные проверкой пригодности. Без этого различения `hours` в одиночку выдавал бы «измерение шло, данные молчат» там, где ни один час не был пригоден вовсе. `first_hour` и `last_hour` — границы окна; род объявляется вместе с периодом, на котором измерен, потому что окно ограничено самыми свежими общими часами, а не всей историей. #### Scenario: Род измерен - **WHEN** метрика получила род `cumulative` - **THEN** рядом стоят число часов окна, число пригодных, число согласных, ноль противоречащих и границы окна #### Scenario: Часы были, но ни один не пригоден - **WHEN** все часы окна отброшены проверкой пригодности - **THEN** `hours` больше нуля, `compared` равен нулю, род равен `unknown` ### Requirement: Окно измерения ограничено сорока восемью часами Система SHALL измерять род по не более чем 48 самым свежим общим часам метрики и MUST NOT читать ради этого всю историю: стоимость каталога не имеет права расти вместе с журналом. Число названо в спеке, а не оставлено реализации, по той же причине, что и порог согласных часов: от него зависят счётчики основания в ответе. Измерено, что на живом корпусе окно сохраняет вердикты всех метрик, кроме редких: у `physical_effort` за всю историю набиралось пять согласных часов, а в последних сорока восьми — два, и метрика честно уходит в `unknown`. Это не издержка, а то же правило: свидетельств в свежем окне действительно мало. Окно ограничено и сверху — часами не позже текущего времени плюс запас, см. правило пригодности часа. #### Scenario: История длиннее окна - **GIVEN** у метрики общих часов больше сорока восьми - **WHEN** измеряется род - **THEN** сравниваются только сорок восемь самых свежих, и `hours` равен сорока восьми ### Requirement: Измеренный род нигде не сохраняется Система SHALL вычислять род при каждом запросе каталога и MUST NOT хранить его ни колонкой, ни кешем. Хранимое значение было бы вторым производным состоянием рядом с витриной: его пришлось бы пересчитывать после каждой свёртки, переносить или не переносить пересборкой и объяснять, на каком составе данных оно снято; устаревшее значение при этом выглядит ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина — функция журнала, и устаревать в нём нечему. #### Scenario: Новая доставка меняет род без перезапуска - **GIVEN** метрика числится `unknown`, потому что общих часов было мало - **WHEN** приезжает доставка, добавляющая согласные часы, и каталог запрашивается снова - **THEN** ответ отдаёт новый род, и перезапуск сервиса для этого не нужен ### Requirement: Каталог читается одним снимком витрины Система SHALL собирать ответ каталога из одного снимка базы: разрезы, границы и объекты окна измерения MUST читаться в одной транзакции чтения. Приём идёт непрерывно, и фоновая свёртка пишет в витрину во время запроса. Запросы вне общей транзакции дали бы смесь «разрезы до» и «род после» — ответ, внутренне противоречивый и неотличимый от обычного свежего. Число обращений к хранилищу на один запрос каталога MUST быть ограничено константой на метрику и не зависеть от размера окна: чтение объектов окна по одному даёт тысячи обращений там, где хватает двух на метрику. #### Scenario: Доставка приезжает во время сборки каталога - **GIVEN** каталог собирается, и в этот момент фоновая свёртка пишет объекты - **WHEN** ответ сформирован - **THEN** он целиком описывает одно состояние витрины #### Scenario: Размер окна не умножает число запросов - **WHEN** окно измерения увеличено - **THEN** число обращений к хранилищу на метрику не меняется ### Requirement: Каталог доступен по токену чтения Система SHALL требовать токен чтения на маршруте каталога и MUST NOT принимать на нём токен приёма. Токен MUST передаваться заголовком `Authorization` со схемой `Bearer`; значение без этой схемы токеном не считается. Пустой список токенов чтения означает выключенную проверку, и о выключенной проверке сервис предупреждает на старте — тем же способом, что о выключенной проверке приёма. Цена симметрии названа вслух: у приёма открытый контур означает мусор во входе, у чтения — выгрузку данных о здоровье, поэтому перед выкладкой наружу список обязан быть непуст. Отвечает за это отдельная задача об управлении секретами; здесь фиксируется, что предупреждение существует и адресовано владельцу. Токен чтения MUST вычищаться из сохраняемых заголовков доставки наравне с токеном приёма: заголовок с произвольным именем иначе донесёт его до базы. Контуры раздельны по архитектуре: клиент, читающий данные, писать не может, и обратное тоже неверно. #### Scenario: Запрос без токена при заданном списке - **GIVEN** список токенов чтения непуст - **WHEN** каталог запрашивается без заголовка `Authorization` - **THEN** ответ — 401, и данные не отдаются #### Scenario: Токен приёма каталога не открывает - **GIVEN** заданы разные списки токенов приёма и чтения - **WHEN** каталог запрашивается с токеном приёма - **THEN** ответ — 401 #### Scenario: Токен без схемы Bearer - **GIVEN** список токенов чтения непуст - **WHEN** каталог запрашивается с заголовком `Authorization`, где стоит голое значение токена без слова `Bearer` - **THEN** ответ — 401 #### Scenario: Проверка выключена - **GIVEN** список токенов чтения пуст - **WHEN** каталог запрашивается без заголовка `Authorization` - **THEN** каталог отдаётся #### Scenario: О выключенной проверке предупреждают на старте - **GIVEN** список токенов чтения пуст - **WHEN** сервис стартует - **THEN** в логе появляется предупреждение владельцу #### Scenario: Токен чтения не оседает в учёте доставки - **GIVEN** токен чтения послан на маршрут приёма заголовком с произвольным именем - **WHEN** доставка учтена - **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии ### 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** предупреждение владельцу не пишется, потому что измерения не было