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

- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (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
@@ -0,0 +1,52 @@
# Новизна имени секции выводится из журнала, а не хранится реестром
- **Дата:** 2026-08-04
- **Источник:** openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md
## Решение
Признак «имя непокрытой секции встречено впервые» **не хранится**: он считается
запросом к журналу — «встречалось ли имя в доставках, стоящих строго раньше этой
по паре `(received_at, id)`». Реестр-таблица по образцу `category_value`
очевидный ответ на тот же вопрос, уже применённый в этом проекте, — отвергнут.
## Почему
Цитата из источника:
> Форма ответа взята у `category_value` — «когда имя встретилось впервые по
> журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`.
> Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт
> считанные разы за жизнь имени.
И там же, о цене реестра:
> Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой
> пересборке, плюс миграция и новая единица хранения витрины (а значит и
> отпечатка). Ноль новых сведений: имя выводимо из журнала.
Третья рассмотренная форма — множество виденных имён в памяти процесса —
отвергнута по инварианту «хранилище есть свёртка по журналу»: состояние стало бы
функцией жизни процесса, и живой приём разошёлся бы с пересборкой в том, что
считает первой встречей.
## Чем платим
Ценой названы три вещи, и все они следствия выбранного носителя:
- **проход по журналу** на каждой доставке с непокрытыми секциями. Измерено на
синтетическом журнале годового объёма: у секции, приезжающей давно, ранний
выход даёт десятки микросекунд, у появившейся только что — около 52 мс на
доставку, пока её не покроет отдельная задача;
- **границы носителя наследуются целиком**: имя, вытесненное границей списка в
32 имени, события не даёт вовсе; пересборка заполняет колонку заново и только
по сохранившимся телам; покрытая разбором секция уходит из перечня;
- **история не переживает удаления тел.** Ретеншен, срезающий архив, унесёт с
собой и записи о непокрытых секциях за те же периоды.
## Когда пересматривать
Последнее и есть условие пересмотра, названное заранее: **задаче ретеншена
архива реестр понадобится** — именно затем, чтобы история пережила удаление тел,
и тогда это уже другая цена, а не вторая копия факта. Запрет реестра в спеке
`uncovered-sections` — решение этого изменения, а не запрет навсегда.
+4
View File
@@ -33,6 +33,10 @@
| Дата | Запись | Статус |
| --- | --- | --- |
- [ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala](ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md)
— признак «секция встречена впервые» выводится запросом к журналу; реестр по
образцу `category_value` отвергнут как вторая копия факта, с названным
условием пересмотра — ретеншен архива.
- [ADR-2026-08-04-tie-break-po-poryadku-zhurnala](ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)
— тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило
становится явной функцией порядка журнала; хранимая метка провенанса
+35 -1
View File
@@ -226,7 +226,7 @@ capability**, и здесь стоит ссылка, а не пересказ т
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md) |
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md), [`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md) |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
@@ -355,6 +355,40 @@ capability**, и здесь стоит ссылка, а не пересказ т
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
**Первая встреча имени — другое дело**
([`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md)). Момент, когда поток принёс секцию,
которой раньше не было, фиксировался колонкой, но не наблюдался ничем: увидеть
его мог только тот, кто догадается заглянуть в базу. Теперь свёртка спрашивает
журнал, встречалось ли имя в доставках **строго раньше** этой (пара
`(received_at, id)`, запросом вне транзакции записи), и первая встреча даёт
`WARN` с именами отдельным атрибутом `uncovered_new`. Повторные молчат. Признак
выводится, а не хранится: реестр был бы второй копией факта, обязанной сходиться
с колонкой при каждой пересборке. Отсюда же идемпотентность — проигрывание
полного журнала повторяет ровно те же события.
Событие переживает **отказ** свёртки: список непокрытых секций переживает его
(доставка с невыводимым слоем всё равно пишет имена), и смолчать значило бы
потерять событие навсегда — следующая доставка сочла бы имя виденным. А
отложенный по обстоятельствам исход событий не даёт: учётной записи он не
меняет, доставка вернётся следующим проходом.
Перечень накопленного отдаёт `healthlog uncovered` — имя, число доставок,
первая и последняя встреча, чтением только на чтение и с экранированием имён
(ключ приходит из чужого тела). Границы у перечня три, и они названы, а не
замолчаны: имя, вытесненное границей списка в 32 имени, в колонку не попадает
вовсе; пересборка обнуляет колонку и заполняет её заново только по сохранившимся
телам; а имя, секцию которого разбор научился покрывать, уходит из колонки при
пересвёртке — то есть перечень отвечает о текущем состоянии покрытия, а не об
истории.
Цена сверки измерена на синтетическом журнале годового объёма; числа и метод
живут в одном месте — `design.md` изменения `aktivnaya-proverka-novyh-sekcij`,
решение 3, — и здесь не дублируются. Правило из замера: ранний выход есть только
у секции, приезжающей давно (строки просматриваются от старых к новым); у только
что появившейся секции проход идёт почти по всему журналу на каждой доставке,
пока её не покроет отдельная задача. Имён больше одного спрашиваются одним
запросом — тридцать два запроса подряд стоили секунду с лишним на доставку.
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
+9
View File
@@ -71,3 +71,12 @@
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
структуры обновляем схему в [database.md](../database.md) тем же изменением —
это проверяет `task gate`.
- **Значение, читаемое табличной функцией SQLite (`json_each` и родня), проходит
проверку ВНУТРИ её аргумента, а не условием в `WHERE`.** Функция получает
значение строки раньше, чем применится фильтр, и порядок этот SQLite не
обещает: неразбираемое значение роняет **весь** запрос, а не пропускает
строку. Условие в `WHERE` работает, пока планировщик проталкивает его вниз, и
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
состоялась» на каждой доставке), и перечень целиком.
+11
View File
@@ -35,6 +35,17 @@
посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное
расхождение). Три случая одного класса за три дня: записи 2026-08-02,
2026-08-03 и 2026-08-04 в [review.md](../review.md).
Метода мало — **синтетический корпус обязан содержать измеряемый случай в той
форме, в какой он бывает в жизни**. Сверка новизны секции мерялась на журнале,
где новое имя стояло во всех доставках, то есть его первая встреча лежала в
начале — ранний выход давал 31 мкс. В жизни секцию включают сегодня, первая
встреча оказывается в хвосте, и та же операция стоит 52 мс: три порядка
разницы, а на числе стояло решение «индекс не нужен» (запись 2026-08-04).
И **число живёт в одном месте.** Один и тот же замер, записанный в
комментарий кода и в `architecture.md`, разошёлся внутри одного изменения.
Дом числа — `design.md` изменения; остальные формулируют правило и ссылаются.
- **Оракул сходимости называет свою посылку рядом с собой, и прогон её
печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями:
живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой
+6 -1
View File
@@ -1852,7 +1852,12 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
Меняется ли что-то на глубине часов и суток — покажет более длинный ряд
доставок.
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`.
`heartRateNotifications`, `cycleTracking`, `medications`. Разбор покрывает
ровно остальные три (`metrics`, `workouts`, `stateOfMind``decodeCovered` в
`internal/hae`), сверено поимённо 2026-08-04. Момент их появления больше не
требует догадки: первая встреча имени даёт `WARN` в логе свёртки, а перечень
накопленного отдаёт `healthlog uncovered`. Разбор самой секции пишется, когда
её будет на чём проверить, — вслепую он не пишется.
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
+53
View File
@@ -499,3 +499,56 @@
- **Что осталось незакрытым:** гейт после интеграции обязан звать `BASE`
вершиной **до** слияния. Сейчас это знание живёт только в этой записи —
ни `Taskfile.yml`, ни скилл батча его не несут.
## 2026-08-04 — событие о новой секции терялось на отказе слияния [пойман]
- **Где:** `internal/fold/fold.go`, ветвь отказа `store.Merge` в change
`2026-08-04-aktivnaya-proverka-novyh-sekcij`
- **Симптом:** доставка, принёсшая имя секции впервые, при нетранзиентном отказе
слияния писала имя в `delivery.uncovered_sections`, но запись об отказе его не
называла. Следующая доставка считала имя виденным — событие, однократное за
всю жизнь имени, пропадало **навсегда**, то есть ровно то, ради чего задача и
делалась.
- **Причина:** ветвей записи исхода в свёртке четыре, а дизайн рассмотрел одну.
Признак новизны считался до ветвления и корректно доезжал до `residueOf`
(отказ разбора), но ветвь отказа слияния собирала остаток **вручную** и поле
новизны в него не клала. Дельта-спека говорила «до ветвления на успех и
отказ», подразумевая один отказ.
- **Чем воспроизведён:** свёртка доставки с новой секцией при снесённой таблице
`bucket` — запись `ERROR` без `uncovered_new`, а `SectionsSeenBefore` на
следующей доставке уже отвечает «виденное». Тест закреплён:
`TestFoldОтказСлиянияНазываетНовуюСекцию`.
- **Чем пойман:** тремя проходами независимо (`specs`, `code`, `adversary`),
причём двое написали падающий тест. Дешёвый `code`-проход нашёл его наравне с
дорогими — признак того, что дефект был в форме «ветвь собрана руками рядом с
ветвью, собранной функцией», а такое видно чтением.
- **Что изменено:** новизна передаётся и в эту ветвь; дельта-спека переписана в
терминах «каждый исход, который пишет список в учётную запись», и отдельно
названы исходы, которые список очищают (нечитаемое тело, паника) и потому
события не теряют.
## 2026-08-04 — замер стоимости снят на корпусе, где измеряемого случая не бывает [пойман]
- **Где:** `design.md` того же change, решение 3; утверждение «в режиме
постоянного приезда секции сверка стоит 18 мкс на доставку»
- **Симптом:** на числе стояло решение «частичный индекс не нужен». Число
описывало **не тот** режим.
- **Причина:** синтетический журнал наполнялся так, что новая секция была во
**всех** доставках, то есть её первая встреча лежала в самом начале журнала —
и `LIMIT 1` выходил рано. В жизни секцию включают на телефоне сегодня: первая
встреча оказывается в хвосте, и проход идёт почти по всему журналу на каждой
доставке. Разница — три порядка (31 мкс против 52 мс).
- **Чем воспроизведён:** `tmp/seenmeasure` с хвостовым именем: голова 31 мкс,
хвост 52 мс, отсутствующее имя 50 мс.
- **Чем пойман:** `adversary` — он не поверил числу и построил корпус, в котором
измеряемый случай выглядит как в жизни. Это третий случай за три дня, когда
оценка оказалась функцией того, **как устроен корпус**, а не того, что
измеряют (записи 2026-08-02, 2026-08-04 про `verify:archive`).
- **Что изменено:** замер перемерян тремя случаями (голова, хвост, отсутствие),
числа сведены в одно место (`design.md`), код и `architecture.md` формулируют
правило и ссылаются на источник. Развилка «принять цену или завести индекс»
вынесена владельцу.
- **Что осталось незакрытым:** правило «число замера обязано нести метод и
описывать тот случай, ради которого снято» действует только для тестов
(`conventions/testing.md`). На `design.md` оно теперь распространено записью
ниже, но механизировать его нечем.
+7
View File
@@ -44,6 +44,13 @@ disabled`, `read auth disabled`), но стартовать не отказыв
`export.xml`, который выбирает человек, но формируется он устройством и по
объёму (3,6 млн записей) глазами не проверяется.
**Новый адресат недоверенного входа — терминал оператора.** Подкоманда
`healthlog uncovered` печатает имена секций, а имя это верхнеуровневый ключ
чужого тела: длина у него ограничена разбором (64 байта, не больше 32 имён),
содержимое — ничем. Печатается оно экранированным (`%q`), иначе управляющая
последовательность из тела подделала бы строки вывода. Тот же вход попадает
структурным атрибутом в лог свёртки, где его экранирует кодировщик `slog`.
Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у
сервиса нет.