Files
healthlog/openspec/specs/catalog/spec.md
T
av 8db2ec7ff4 Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
2026-08-02 20:42:22 +03:00

665 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** предупреждение владельцу не пишется, потому что измерения не было