первая встреча непокрытой секции стала наблюдаемым событием

- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат.
  Признак выводится, а не хранится — реестр был бы второй копией факта
- добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение
  только на чтение, экранированные имена и названные границы носителя
- синк документации: ADR о выводе новизны из журнала, две записи в журнал
  дефектов, два правила промоутом в конвенции, терминал оператора назван
  адресатом недоверенного входа
This commit is contained in:
av
2026-08-04 13:39:48 +03:00
parent 2130763d3c
commit bd5d17b079
27 changed files with 3125 additions and 9 deletions
+277
View File
@@ -0,0 +1,277 @@
# uncovered-sections Specification
## Purpose
Наблюдение за секциями тела Health Auto Export, которых разбор не покрывает:
первая по журналу встреча имени видна владельцу событием в логе, а перечень
накопленного отдаётся отдельной подкомандой. Разбор самих секций сюда не входит
— их формы никто не видел, и вслепую он не пишется.
## Requirements
### Requirement: Первая встреча непокрытой секции порождает событие
Свёртка SHALL писать запись уровня `WARN`, когда доставка принесла имя
непокрытой секции, которого **не было ни в одной доставке, стоящей в журнале
раньше**. Имена, признанные новыми, SHALL идти отдельным атрибутом записи —
всегда, независимо от выбранного уровня.
Уровень назван поимённо: `WARN` — «может стать проблемой, посмотри». `ERROR`
означал бы сбой, который надо разбирать, а приезд новой секции — штатное событие
внешнего мира.
Событие однократно за всю жизнь имени. Поэтому запись SHALL называть его
независимо от того, какие повторяющиеся события (перезаписи точек, запечатанный
час, расхождение слоя) случились в той же доставке: замаскировать однократное
рутинным значило бы потерять ровно то, ради чего наблюдение заведено.
Отдельной строки лога у события нет: чекпоинт свёртки остаётся единственным.
#### Scenario: Имя, которого журнал раньше не видел
- **GIVEN** ни одна прежняя доставка не содержала секции `symptoms`
- **WHEN** доставка с секцией `symptoms` свёрнута
- **THEN** запись чекпоинта свёртки имеет уровень `WARN`
- **AND** атрибут новых секций содержит ровно `symptoms`
#### Scenario: Новая секция не маскируется рутинным событием
- **GIVEN** ни одна прежняя доставка не содержала секции `ecg`
- **WHEN** доставка с секцией `ecg` одновременно перезаписывает точки уже
сохранённого часа
- **THEN** запись чекпоинта называет новую секцию, а не перезапись точек
- **AND** счётчик перезаписей остаётся в атрибутах записи
### Requirement: Повторная встреча имени события не порождает
Система SHALL считать виденным всякое имя непокрытой секции, уже встречавшееся в
доставке, стоящей в журнале раньше текущей: в атрибут новых секций оно не
попадает и уровень записи не поднимает. Перечисление непокрытых секций при этом
не меняется: атрибут `uncovered` SHALL продолжать называть их все.
Отсюда следствие для приёмки: наблюдаемый признак события — **атрибут новых
секций**, а не присутствие имени в записи вообще. Имя непокрытой секции стоит в
записи каждой доставки, которая её принесла, и так было до этого изменения.
#### Scenario: Вторая доставка с тем же именем молчит
- **GIVEN** доставка с секцией `symptoms` уже свёрнута
- **WHEN** следующая доставка приносит ту же секцию `symptoms`
- **THEN** атрибут новых секций у второй записи пуст
- **AND** уровень второй записи рутинный
- **AND** секция `symptoms` остаётся в атрибуте непокрытых секций
#### Scenario: Новое имя рядом с уже виденным
- **GIVEN** доставка с секцией `symptoms` уже свёрнута
- **WHEN** следующая доставка приносит `symptoms` и `ecg`
- **THEN** атрибут новых секций содержит ровно `ecg`
### Requirement: Новизна выводится из журнала, а не из порядка свёртки
Признак новизны SHALL определяться сравнением с доставками, стоящими в журнале
**строго раньше** текущей — по паре `(received_at, id)`, как порядок журнала
определён capability пересборки. Собственная учётная запись доставки в сравнение
не входит при любом порядке записи исхода.
Отсюда следует, что признак идемпотентен: повторная свёртка той же доставки даёт
тот же исход, а проигрывание полного журнала пересборкой воспроизводит ровно те
же события — свёртка по журналу остаётся функцией журнала, а не числа прогонов.
Хранимого реестра встреченных имён это изменение заводить MUST NOT: факт уже
лежит в учётной записи доставки, и вторая его копия расходилась бы с первой при
пересборке молча. Это решение **этого** изменения, а не запрет навсегда:
потребителю, которому понадобится история непокрытых секций, переживающая
удаление тел (ретеншен архива), придётся его пересмотреть — цена названа в
требовании о честности перечня.
Запрос новизны SHALL выполняться **вне транзакции записи** свёртки: он идёт по
растущему журналу, а транзакция записи объектов не имеет права держать блокировку
дольше, чем позволено конкурирующему приёму.
#### Scenario: Повторная свёртка той же доставки не меняет исход
- **GIVEN** доставка с новой секцией свёрнута и событие записано
- **WHEN** та же доставка свёрнута повторно
- **THEN** событие повторяется тем же составом имён
#### Scenario: Доставка, свёрнутая позже более новой, судится по журналу
- **GIVEN** доставка A стоит в журнале раньше доставки B, и обе содержат
секцию `ecg`
- **WHEN** B свёрнута первой, а A — после неё
- **THEN** A признаёт `ecg` новым, потому что раньше неё в журнале этого имени
не было
#### Scenario: Запрос новизны не удерживает блокировку записи
- **WHEN** доставка с непокрытыми секциями сворачивается
- **THEN** обращение к журналу за признаком новизны идёт вне транзакции записи
объектов
### Requirement: Отказ свёртки не глотает событие
Список непокрытых секций переживает отказ разбора, поэтому проверка новизны SHALL
идти до ветвления на успех и отказ, а новые имена SHALL попадать в запись об
отказе — она уже выше рутинного уровня. Уровень такой записи определяет отказ;
событие опознаётся атрибутом новых секций.
Это относится к **каждому** исходу, который пишет список в учётную запись, а не
к одному только отказу разбора: отказ слияния тоже записывает имена, и терять
событие ему нельзя ровно по той же причине. Отказы, случившиеся до разбора
(нечитаемое тело, паника), список очищают и потому события не теряют.
Иначе событие теряется необратимо: имя записано в учётную запись отказавшей
доставки, и следующая доставка сочтёт его виденным.
Исход, отложенный по обстоятельствам (занятость базы, отмена снаружи), — другое
дело: он учётной записи не меняет вовсе, список непокрытых секций в базу не
попадает, и доставка вернётся следующим проходом. Новые имена в такой записи
система называть MUST NOT: событие не потеряно, а повторение его на каждом
проходе занятой базы превратило бы однократный признак в дребезг.
#### Scenario: Отказ слияния при новой секции
- **GIVEN** ни одна прежняя доставка не содержала секции `ecg`
- **WHEN** разбор доставки прошёл, а слияние отказало нетранзиентно
- **THEN** запись об отказе называет новую секцию `ecg`
- **AND** учётная запись доставки сохраняет её в списке непокрытых
#### Scenario: Доставка с новой секцией и невыводимым слоем
- **GIVEN** ни одна прежняя доставка не содержала секции `cycleTracking`
- **WHEN** доставка с этой секцией не свернулась, потому что слой не вывелся
- **THEN** запись об отказе называет новую секцию `cycleTracking`
- **AND** учётная запись доставки сохраняет её в списке непокрытых
#### Scenario: Отложенная доставка события не порождает
- **WHEN** свёртка доставки отложена по занятости базы
- **THEN** запись об отложенном исходе новых секций не называет
- **AND** доставка остаётся в очереди
### Requirement: Несостоявшаяся сверка не молчит
Отказ самого запроса новизны исходом доставки система объявлять MUST NOT: правила
классификации исходов свёртки это изменение не трогает, отменённый контекст и
занятая база остаются обстоятельствами, а не свойствами доставки.
Когда доставка дошла до записи исхода по прочим правилам, а сверка не удалась,
все её непокрытые имена SHALL считаться новыми, и запись SHALL нести отдельный
признак того, что сверка не состоялась, **вместе с причиной**: занятость базы
проходит сама, а испорченное содержимое колонки не пройдёт никогда и будет
поднимать признак на каждой доставке — по одному булеву эти случаи неразличимы.
Асимметрия названа: лишняя запись стоит внимания владельца один раз, а
промолчавшее событие не восстанавливается ничем, кроме ручного запроса в базу.
#### Scenario: Сверка не удалась, разбор прошёл
- **WHEN** запрос новизны отказал, а разбор доставки прошёл
- **THEN** свёртка доходит до конца и записывает исход
- **AND** все непокрытые имена доставки объявлены новыми
- **AND** запись несёт признак несостоявшейся сверки и причину
### Requirement: Перечень непокрытых секций отдаётся одной командой
Система SHALL отдавать перечень непокрытых секций, накопленных журналом,
отдельной подкомандой — без ручного SQL по рабочей базе. Строка перечня SHALL
называть имя секции, число доставок с ним, первую и последнюю встречу (метку
журнала и идентификатор доставки); идентификатор нужен, чтобы достать тело из
архива.
В перечень SHALL входить доставки **всех** статусов разбора: список непокрытых
секций сохраняется и при отказе, и молчать о таком имени значило бы терять как
раз подозрительное.
Порядок строк SHALL быть детерминированным — по имени секции.
Вывод SHALL иметь объявленный предел числа строк, а остаток называться числом:
предел разбора в 32 имени действует на **одну доставку**, а различных имён
журнал способен накопить сколько угодно.
База SHALL открываться только на чтение: команда диагностическая, и запуск её при
живом сервисе не должен ни мигрировать схему, ни писать. Отказ открытия (базы
нет, версия схемы не та) SHALL давать ненулевой код возврата и внятное
сообщение — молчаливый пустой перечень неотличим от «ничего не приезжало».
#### Scenario: Перечень на базе с непокрытыми секциями
- **GIVEN** журнал содержит доставки с секциями `symptoms` и `ecg`
- **WHEN** оператор запускает подкоманду перечня
- **THEN** вывод содержит обе секции с числом доставок и границами встреч
- **AND** порядок строк детерминирован
#### Scenario: Перечень на базе без непокрытых секций
- **WHEN** ни одна доставка непокрытых секций не приносила
- **THEN** команда завершается нулевым кодом и говорит, что перечень пуст
#### Scenario: Базы по указанному пути нет
- **WHEN** оператор запускает подкоманду с конфигом, указывающим на
несуществующую базу
- **THEN** команда завершается ненулевым кодом и называет причину
#### Scenario: Имён больше предела вывода
- **WHEN** различных имён в журнале больше объявленного предела
- **THEN** вывод содержит предел строк и называет число оставшихся имён
### Requirement: Перечень честен относительно своего носителя
Система SHALL называть границы перечня в спеке и в выводе команды, а не обещать
«всё, что поток когда-либо приносил»: перечень и признак новизны производны от
`delivery.uncovered_sections` — колонки, которая обрезается разбором и
обнуляется пересборкой.
Границы SHALL называться и в **выводе команды**, а не только в спеке: пустой
перечень без них читается как «поток ничего не приносил» — обещание, которого
носитель не даёт.
- Имя, вытесненное границей списка (не больше 32 имён на доставку), в колонку не
попадает вовсе — ни события, ни строки перечня оно не даст; наблюдаемым
остаётся счётчик отброшенных имён, который уже поднимает уровень записи.
- Пересборка обнуляет производные от разбора поля и заполняет их заново только по
сохранившимся телам: доставка, тело которой удалено ретеншеном, свой список
теряет, поэтому «те же события» пересборка воспроизводит **при полном архиве**.
- Имя, секцию которого разбор научился покрывать, уходит из колонки при
пересвёртке — перечень отвечает о текущем состоянии покрытия, а не об истории.
- Поэтому пересвёртка ранее частично разобранных доставок (правило «покрыли
секцию — пересверните») может дать событие о новизне повторно: журнал тот же, а
колонка заполняется заново.
#### Scenario: Пустой перечень не говорит за весь поток
- **WHEN** в учётных записях журнала непокрытых секций нет
- **THEN** вывод команды говорит именно это, а не «журнал такого не приносил»
- **AND** называет границы носителя
#### Scenario: Учётная запись с неразбираемым списком не роняет ответ
- **GIVEN** в колонке одной доставки лежит значение, не разбираемое как JSON
- **WHEN** выполняется сверка новизны или собирается перечень
- **THEN** ответ строится по остальным записям, а не отказывает целиком
#### Scenario: Имя вытеснено границей списка
- **GIVEN** тело доставки содержит 32 незнакомых ключа перед секцией `ecg`
- **WHEN** доставка свёрнута
- **THEN** события о новизне `ecg` нет, потому что имя в учётную запись не попало
- **AND** запись несёт счётчик отброшенных имён и уровень `WARN`
### Requirement: Вывод перечня не доверяет содержимому тела
Печатаемое имя секции SHALL быть экранировано, чтобы управляющие
последовательности из тела не влияли на терминал оператора: имя приходит
верхнеуровневым ключом чужого тела, длина его ограничена разбором, содержимое —
ничем.
Вывод SHALL оставаться свободным от значений точек, имён устройств и любых
других данных о здоровье: имя секции — структурный ключ, а не измерение.
#### Scenario: Имя секции с управляющими символами
- **GIVEN** учётная запись доставки содержит имя секции с управляющим символом
- **WHEN** оператор запускает подкоманду перечня
- **THEN** символ выводится экранированной последовательностью, а не сырым
байтом