первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат. Признак выводится, а не хранится — реестр был бы второй копией факта - добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение только на чтение, экранированные имена и названные границы носителя - синк документации: ADR о выводе новизны из журнала, две записи в журнал дефектов, два правила промоутом в конвенции, терминал оператора назван адресатом недоверенного входа
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-04
|
||||
@@ -0,0 +1,248 @@
|
||||
## Context
|
||||
|
||||
Разбор перечисляет верхнеуровневые ключи `data`, которых он не покрывает, и
|
||||
свёртка пишет их в `delivery.uncovered_sections` JSON-массивом (change
|
||||
`2026-08-01-nerazobrannye-sekcii-dostavki`). Покрыты три секции — `metrics`,
|
||||
`workouts`, `stateOfMind`; не виденными живьём остаются `symptoms`, `ecg`,
|
||||
`heartRateNotifications`, `cycleTracking`, `medications`.
|
||||
|
||||
Колонка отвечает на вопрос «что останется потерянным, если тело удалить». На
|
||||
вопрос «а когда именно поток принёс что-то новое» она отвечает только тому, кто
|
||||
догадается спросить: события нет, а лог свёртки печатает `uncovered` атрибутом
|
||||
на уровне `INFO`, где оно неотличимо от рутины — поток идёт раз в пять минут.
|
||||
|
||||
Ограничения, в которых живёт решение:
|
||||
|
||||
- **Хранилище — свёртка по журналу** (`critical`). Пересборка обязана дать то же
|
||||
состояние; всё, что наблюдаемо, обязано быть функцией журнала, а не порядка
|
||||
прогонов.
|
||||
- **Поток не останавливается** — наблюдательный механизм не имеет права ронять
|
||||
свёртку и не имеет права держать блокировку базы: конкурирующий приём при
|
||||
исчерпанном `busy_timeout` отвечает `500` по доставке, тело которой уже на
|
||||
диске.
|
||||
- Схема не трогается: колонка уже есть, задача её и использует.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Первая по журналу встреча имени непокрытой секции видна владельцу без запроса
|
||||
в базу; повторные встречи молчат.
|
||||
- Событие переживает отказ свёртки — иначе оно теряется навсегда.
|
||||
- Перечень накопленного достаётся одной командой.
|
||||
- Пересборка полного журнала воспроизводит ровно те же события.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Разбор новых секций. Формы никто не видел; вслепую разбор не пишется.
|
||||
- Хранение реестра секций, HTTP-эндпоинт, уведомление наружу. Первое —
|
||||
вторая копия факта, второе и третье — отдельные цели (`Read API`,
|
||||
`Наблюдаемость`).
|
||||
- История непокрытых секций, переживающая удаление тел архива. Это предмет
|
||||
ретеншена, и цена решения названа ниже.
|
||||
|
||||
## Три формы решения и компромисс каждой
|
||||
|
||||
Рассматривались три носителя признака «имя встречено впервые»; выбрана первая.
|
||||
|
||||
1. **Вывод из журнала запросом (выбрано).** Носителя нет вовсе — признак
|
||||
считается по существующей колонке. Компромисс: платим запросом по журналу на
|
||||
каждую доставку с непокрытыми секциями (см. решение 3) и наследуем все
|
||||
границы колонки — обрезку списка и обнуление пересборкой (решение 7).
|
||||
2. **Реестр-таблица по образцу `category_value`.** Строка на имя с провенансом
|
||||
первой встречи; запрос новизны становится точечным по первичному ключу.
|
||||
Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой
|
||||
пересборке, плюс миграция и новая единица хранения витрины (а значит и
|
||||
отпечатка). Ноль новых сведений: имя выводимо из журнала. Отвергнуто —
|
||||
но не навсегда: ретеншену, срезающему тела, реестр понадобится именно затем,
|
||||
чтобы история пережила удаление, и тогда это уже другая цена.
|
||||
3. **Множество виденных имён в памяти процесса**, наполняемое при старте.
|
||||
Запрос один на запуск, дальше — проверка по map. Компромисс: состояние
|
||||
становится функцией жизни процесса, а не журнала; наполнение при старте — тот
|
||||
же полный проход, только раньше; пересборка и живой приём расходятся в том,
|
||||
что считают первой встречей. Отвергнуто по инварианту «свёртка по журналу».
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Новизна выводится из журнала, а не хранится реестром
|
||||
|
||||
Форма ответа взята у `category_value` — «когда имя встретилось впервые по
|
||||
журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`.
|
||||
Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт
|
||||
считанные разы за жизнь имени.
|
||||
|
||||
Новизна считается запросом: «какие из этих имён встречались в доставках, стоящих
|
||||
в журнале **строго раньше** текущей». Пусто — имя новое.
|
||||
|
||||
Прочтение колонки — `json_each` по `delivery`; колонка заведена массивом ровно с
|
||||
этим расчётом («читается из SQLite через `json_each`», миграция `00005`).
|
||||
|
||||
### 2. Сравнение парой `(received_at, id)`, а не по идентификатору
|
||||
|
||||
Строгое сравнение пары решает три вещи разом:
|
||||
|
||||
- **самоисключение** — своя же строка (её пишет `finish` после) в счёт не идёт
|
||||
при любом порядке записи;
|
||||
- **идемпотентность** — повторная свёртка той же доставки даёт тот же исход,
|
||||
поэтому пересборка не выдумывает и не глотает события;
|
||||
- **порядок журнала, а не порядок прогона.** `received_at` хранится с секундной
|
||||
точностью, а строка учёта становится видимой воркеру только после записи тела:
|
||||
при конкурентном приёме доставка может стать видимой после более новой. Пока
|
||||
окно существует, сравнение по журналу делает исход от него независимым; когда
|
||||
окно закроют на самом приёме, сравнение всё равно останется — на нём стоят
|
||||
самоисключение и идемпотентность.
|
||||
|
||||
Та же лексикографическая пара уже стоит в `mergeCategories` и по той же причине;
|
||||
здесь она в `WHERE`, а не в `ON CONFLICT`.
|
||||
|
||||
Следствие названо вслух: если две доставки стали видимы не в журнальном порядке,
|
||||
имя может дать событие дважды — сначала на более новой, потом на более старой.
|
||||
Это не дефект, а честное «первой в журнале была вот эта»; потери здесь нет, а
|
||||
дублирование ограничено одной парой.
|
||||
|
||||
### 3. Цена запроса измерена, а форма выбрана по замеру
|
||||
|
||||
Индекса по `uncovered_sections` в схеме нет, и это изменение его не заводит.
|
||||
Значит запрос — **полный проход по `delivery`** с фильтром по непустому списку,
|
||||
а не «индексный».
|
||||
|
||||
**Это единственное место, где живут числа замера.** Комментарий кода и
|
||||
`docs/architecture.md` формулируют правило и ссылаются сюда; дублировать цифры
|
||||
запрещено — они уже разошлись однажды внутри одного изменения.
|
||||
|
||||
Метод: синтетический журнал в `./tmp` (`tmp/seenmeasure`, прогон повторяем),
|
||||
105 тысяч доставок — годовой объём при 288 в сутки; 20 прогонов на случай;
|
||||
машина ничем другим не занята.
|
||||
|
||||
| случай | по имени отдельно | одним запросом (в коде) |
|
||||
|---|---|---|
|
||||
| одно имя, первая встреча в начале журнала | 31 мкс | 31 мкс |
|
||||
| одно имя, первая встреча в хвосте | — | 52 мс |
|
||||
| одно имя, в журнале не встречалось | 50 мс | 50 мс |
|
||||
| 32 имени | 1.36 с | 65 мс |
|
||||
|
||||
Читается это так, и первое было названо неверно в первой редакции дизайна:
|
||||
|
||||
- **ранний выход есть только у давно приезжающей секции.** Строки
|
||||
просматриваются от старых к новым, поэтому `LIMIT 1` выходит рано, лишь когда
|
||||
первая встреча имени лежит в начале журнала;
|
||||
- **у секции, появившейся только что, раннему выходу не на чем сработать** — её
|
||||
первая встреча в хвосте, и проход идёт почти по всему журналу. Это и есть
|
||||
заявленный сценарий задачи: 52 мс на каждой доставке, 288 раз в сутки — около
|
||||
15 секунд чтения в сутки, пока секцию не покроет отдельная задача. Позиция
|
||||
первой встречи зафиксирована навсегда, поэтому цена сама не рассосётся;
|
||||
- **32 имени по одному стоили секунду с лишним на доставку**, и тело, выбившее
|
||||
границу списка, приезжает раз в пять минут — это режим, а не случай. Поэтому
|
||||
имён больше одного спрашиваются одним запросом; частому случаю (одно имя
|
||||
давней секции) это ничего не стоит.
|
||||
|
||||
Что это стоит в жизни: **пока непокрытых секций нет** (сегодняшний режим — все
|
||||
три приезжающие секции покрыты) запрос не берётся вовсе, он берётся только при
|
||||
непустом списке.
|
||||
|
||||
**Развилка, вынесенная владельцу** (записана в докладе задачи): принять ли эти
|
||||
52 мс на доставку или завести частичный индекс по непустому списку. Индекс — это
|
||||
миграция и правка `docs/database.md`, то есть выход за рамку «схема не
|
||||
трогается», поэтому в этом изменении он не делается. Остаток доведён с принятой
|
||||
ценой, названной здесь числом.
|
||||
|
||||
### 4. Запрос идёт вне транзакции записи
|
||||
|
||||
Прецедент проекта прямой: канонизация внутри транзакции держала блокировку
|
||||
5.019 с и дала 768 МиБ пика. Проход по журналу внутри транзакции записи объектов
|
||||
повторил бы ровно этот класс — с той разницей, что отказ конкурирующего приёма
|
||||
необратим. Сверка выполняется отдельным чтением до записи.
|
||||
|
||||
### 5. Событие живёт в едином логирующем чекпоинте свёртки
|
||||
|
||||
Своей строки лога у события нет: конвенция проекта — один логирующий чекпоинт на
|
||||
доменной границе. Имена новых секций идут **атрибутом всегда**, а уровень
|
||||
поднимается веткой `switch`.
|
||||
|
||||
Нормируется **поведение**, а не позиция ветви: запись обязана называть новую
|
||||
секцию, какие бы повторяющиеся события ни случились в той же доставке. В коде это
|
||||
достигается тем, что ветвь стоит первой, и это остаётся решением кода, а не
|
||||
требованием спеки — иначе следующая задача, добавляющая свою ветвь, получит
|
||||
арбитра в чужой capability.
|
||||
|
||||
Уровень назван поимённо — `WARN`. «Выше `INFO`» зеленело бы и на `ERROR`, а
|
||||
`ERROR` у владельца означает сбой, который надо разбирать.
|
||||
|
||||
### 6. Путь отказа проверяется, отложенный — нет
|
||||
|
||||
Список непокрытых секций переживает отказ разбора (`residueOf`): доставка, у
|
||||
которой не вывелся слой, всё равно записывает имена. Значит проверка новизны идёт
|
||||
**до** ветвления на успех и отказ, а новые имена доезжают до `fail` и печатаются
|
||||
его строкой.
|
||||
|
||||
Отложенный исход (занятость базы, отмена) — отдельный случай: он не пишет учётной
|
||||
записи вовсе, доставка возвращается следующим проходом. Событие там не
|
||||
печатается: оно не потеряно, а повторение на каждом проходе занятой базы
|
||||
превратило бы однократный признак в дребезг.
|
||||
|
||||
### 7. Границы носителя названы, а не замолчаны
|
||||
|
||||
Признак и перечень производны от колонки, у которой три слепые зоны, и все три
|
||||
идут в спеку:
|
||||
|
||||
- **обрезка списка**: имя, стоящее в теле после 32 незнакомых ключей, в колонку
|
||||
не попадает — события не будет. Наблюдаемым остаётся счётчик отброшенных имён,
|
||||
который уже поднимает уровень записи («тело на HAE не похоже вовсе»).
|
||||
Объявлять при обрезке новыми **видимые** имена рассматривалось и отвергнуто:
|
||||
невидимого это не возвращает, а сигнал об обрезке уже есть и уже громкий;
|
||||
- **пересборка** обнуляет производные от разбора поля и заполняет их заново
|
||||
только по сохранившимся телам. Ретеншен, срезающий тела, стирает историю
|
||||
непокрытых секций вместе с ними — поэтому «те же события» пересборка
|
||||
воспроизводит при **полном** архиве. Это же и есть будущая цена решения 1;
|
||||
- **покрытие секции**: пересвёртка убирает имя из колонки, и перечень отвечает о
|
||||
текущем состоянии покрытия, а не об истории. Она же может дать событие
|
||||
повторно — журнал тот же, а колонка заполняется заново.
|
||||
|
||||
### 8. Перечень отдаёт подкоманда `healthlog uncovered`
|
||||
|
||||
Той же формы, что `reindex` и `healthcheck`: конфиг флагом, человекочитаемый
|
||||
вывод в stdout, ненулевой код на отказ. HTTP-маршрут отвергнут — Read API ещё
|
||||
нет, а инструмент нужен на той же машине, где лежит база.
|
||||
|
||||
Имя `uncovered`, а не `sections`: команда перечисляет только непокрытое, а
|
||||
широкое имя заняло бы место под будущий вопрос «какие секции покрыты» (его
|
||||
сегодня закрывает сверка глазами, критерий К4). Словарь при этом закреплён:
|
||||
`uncovered` — состояние покрытия (колонка, атрибут, capability
|
||||
`uncovered-sections`), `new` — событие первой встречи (атрибут новых секций).
|
||||
|
||||
База открывается **только на чтение** (`store.OpenForRead`): команда
|
||||
диагностическая, и запуск её при живом сервисе не должен ни мигрировать схему,
|
||||
ни писать. Отказ открытия говорит причину и даёт ненулевой код — пустой перечень
|
||||
от молчаливого отказа неотличим.
|
||||
|
||||
Строки — имя, число доставок, первая и последняя встреча (метка журнала и
|
||||
идентификатор доставки; по идентификатору достают тело из архива). Вывод имеет
|
||||
объявленный предел строк с остатком числом: предел разбора в 32 имени действует
|
||||
на одну доставку, а различных имён журнал накопит сколько угодно — достаточно
|
||||
версии HAE, кладущей в ключ переменную часть.
|
||||
|
||||
Имя печатается **экранированным** (`%q`): оно приходит верхнеуровневым ключом
|
||||
чужого тела, обрезано по длине на разборе, но по содержимому не ограничено ничем
|
||||
— сырая печать в терминал впустила бы туда управляющие последовательности.
|
||||
|
||||
Данных о здоровье в выводе нет: имя секции — структурный ключ, а не измерение.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Полный проход по журналу на каждой доставке с непокрытыми секциями**
|
||||
(решение 3) → измерено на синтетическом годовом журнале; цена принята и
|
||||
названа числом там же, развилка про индекс вынесена владельцу. Замер
|
||||
повторяем (`tmp/seenmeasure`).
|
||||
- **Долгий читатель против чекпоинта WAL**: команда перечня делает проход по
|
||||
`delivery` вторым процессом, а удерживаемый читатель останавливает продвижение
|
||||
чекпоинта (измерено раньше: журнал 51 МБ при лимите 8 МиБ) → запрос
|
||||
одиночный и короткий, снимок не удерживается дольше вывода; команда
|
||||
диагностическая и запускается руками.
|
||||
- **Слепые зоны носителя** (решение 7) → названы в спеке требованием «перечень
|
||||
честен относительно своего носителя»; молчание о них было бы хуже самих зон.
|
||||
- **Дублирование события при несовпадении видимости с журналом** (решение 2) →
|
||||
ограничено парой строк, потери нет; альтернатива сделала бы событие функцией
|
||||
очереди и разошлась бы с пересборкой.
|
||||
- **Лишние `WARN` при отказе сверки** (спека «Несостоявшаяся сверка не молчит»)
|
||||
→ ограничены доставками с непокрытыми секциями и сопровождаются атрибутом о
|
||||
причине.
|
||||
@@ -0,0 +1,68 @@
|
||||
## Why
|
||||
|
||||
Разбор уже перечисляет секции тела, которых он не покрывает, и пишет их в
|
||||
`delivery.uncovered_sections`. Момент, ради которого колонка заводилась —
|
||||
«поток принёс секцию, которой раньше не было», — **фиксируется, но ничем не
|
||||
наблюдается**: узнать о нём можно только запросом в базу руками, а догадаться
|
||||
заглянуть в колонку некому.
|
||||
|
||||
Пользователь дописывает на телефоне оставшиеся метрики (`symptoms`, `ecg`,
|
||||
`heartRateNotifications`, `cycleTracking`, `medications`, вес), и данные
|
||||
появятся сами. Пропущенный момент стоит дорого не сразу: тело живёт в архиве до
|
||||
следующего проверенного экспорта, и разбор новой секции, написанный через
|
||||
квартал, писать будет уже не по чему.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Первая по журналу встреча имени непокрытой секции даёт запись уровня `WARN` в
|
||||
логирующем чекпоинте свёртки, с именами отдельным атрибутом; повторные встречи
|
||||
того же имени события не дают.
|
||||
- Тот же признак переживает **отказ** свёртки: доставка, у которой не вывелся
|
||||
слой, всё равно записывает список непокрытых секций, поэтому промолчать о
|
||||
новом имени здесь значило бы потерять событие навсегда — следующая доставка
|
||||
сочтёт имя уже виденным. Отложенный по обстоятельствам исход события не даёт:
|
||||
учётной записи он не меняет, доставка вернётся следующим проходом.
|
||||
- Новизна **выводится из журнала**, а не хранится: строгое сравнение пары
|
||||
`(received_at, id)` с прежними доставками, запросом вне транзакции записи.
|
||||
Схема не трогается, отдельного реестра не заводится, состояние остаётся
|
||||
свёрткой по журналу — пересборка полного архива повторяет те же события.
|
||||
- Появляется подкоманда `healthlog uncovered`: перечень непокрытых секций,
|
||||
накопленных журналом, — имя, число доставок, первая и последняя встреча.
|
||||
Без ручного SQL по рабочей базе, чтением только на чтение, с объявленным
|
||||
пределом вывода и экранированием имён.
|
||||
- Границы носителя названы спекой, а не замолчаны: обрезка списка на 32 имени,
|
||||
обнуление производных полей пересборкой и уход имени из колонки после того,
|
||||
как секцию покрыли.
|
||||
|
||||
Разбор самих новых секций сюда **не входит**: их формы никто не видел, и вслепую
|
||||
разбор не пишется. Каждая приехавшая секция станет отдельной задачей — тогда,
|
||||
когда её будет на чём проверить.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `uncovered-sections`: наблюдение за секциями, которых разбор не покрывает —
|
||||
событие первой по журналу встречи имени и перечень накопленного.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `storage`: перечень оснований, повышающих уровень записи чекпоинта свёртки,
|
||||
дополнен первой встречей имени непокрытой секции. Перечень нормирован там и
|
||||
читается как исчерпывающий — без правки следующий автор снял бы новую ветвь как
|
||||
незаказанную.
|
||||
|
||||
Capability `parsing` не меняется: разбор непокрытых секций остаётся как есть,
|
||||
новое поведение читает его результат.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/store` — запрос «какие из этих имён встречались строго раньше в
|
||||
журнале» и запрос перечня для команды; обе — чтение по существующей колонке
|
||||
через `json_each`, вне транзакции записи.
|
||||
- `internal/fold` — признак новизны в `Stats`, ветвь эскалации в едином
|
||||
логирующем чекпоинте и в пути отказа.
|
||||
- `cmd/healthlog` — подкоманда `uncovered`.
|
||||
- `docs/architecture.md`, `docs/research/apple-health.md` — раздел про
|
||||
неразобранные секции и сверка перечня не виденных живьём секций с множеством
|
||||
покрытых имён разбора.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Триаж ревью: aktivnaya-proverka-novyh-sekcij
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Профиль:** deep, режим по графу. **Гейт:** зелёный (exit 0 дважды,
|
||||
покрытие диффа 96%, 2 непокрытые строки — унаследованный пробел `main.go`).
|
||||
- **Сверка состава с профилем:** расхождений нет. Deep — 7–8 проходов
|
||||
(`docs/review.md`, итог по конвейеру); запущено 7, восьмой (`reimpl`) —
|
||||
по триггеру, и триггер не сработал (см. границы покрытия).
|
||||
- **Проходы поимённо:**
|
||||
- `gate` — отработал, 2 находки, зелёный;
|
||||
- `specs` — отработал, 5 находок + наблюдения вне спеки;
|
||||
- `code` — отработал, 3 находки;
|
||||
- `adversary` — отработал, 4 находки с прогнанными оракулами + 3 свойства
|
||||
без построенного пути;
|
||||
- `ops` — отработал, 2 находки, обязательные вопросы отвечены;
|
||||
- `architecture` — отработал, 2 находки + 1 правка «дешевле до мерджа»;
|
||||
- `reimpl` — **не запускался**: триггер «новое правило слияния, идентичности
|
||||
или разбора» не сработал — изменение вводит наблюдение поверх существующей
|
||||
логики;
|
||||
- `triage` — этот отчёт.
|
||||
- **Счёт:** на входе 19 именованных находок, после дедупликации по причине —
|
||||
13. В отчёте: 2 блокируют, 4 сейчас, 6 в гипотезах, 2 promote, 1 подтверждённая
|
||||
не влезла в потолок (названа в границах покрытия).
|
||||
- Находка №1 найдена тремя проходами независимо — это подняло её приоритет;
|
||||
подтверждением служит прогнанный оракул, а не согласие проходов.
|
||||
- Совпадений с «Типовыми ложноположительными» из `docs/review.md` нет; отсев
|
||||
шёл по проектному списку.
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### Отказ слияния навсегда съедает событие о новой секции — владелец о ней не узнает никогда
|
||||
|
||||
- Файл: `internal/fold/fold.go:214-222`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: `tmp/adv/lost_event_test.go`, перепрогнан триажем — FAIL
|
||||
воспроизведён: запись об отказе (`delivery fold failed`) не несёт
|
||||
`uncovered_new`, при этом `fail` пишет `residue.uncovered` в колонку
|
||||
(`fold.go:587`), и следующая доставка считает `ecg` виденным. Путь достижим:
|
||||
`context.DeadlineExceeded` не транзиентен, тело ~63 МиБ держит блокировку
|
||||
5.019 с (замер adversary).
|
||||
- Последствие: единственная возможность события «поток начал приносить новую
|
||||
секцию» — первая доставка с этим именем. Если её свёртка упала на слиянии,
|
||||
событие теряется молча и не повторяется: имя уже записано как виденное.
|
||||
Прямое нарушение требования дельта-спеки «Отказ свёртки не глотает событие»
|
||||
и класс «молчание» — самый дорогой после порчи данных.
|
||||
- Предложение: в ветви отказа слияния передавать `novelty` в остаток:
|
||||
`s.fail(ctx, deliveryID, err, parseResidue{uncovered: parsed.Uncovered,
|
||||
skipped: skippedEntities(parsed), novelty: novelty})`. Тест из
|
||||
`tmp/adv/lost_event_test.go` перенести в постоянные.
|
||||
- Найдено проходом: specs, code, adversary (дедуплицировано; оракул один)
|
||||
- Действие: инлайн
|
||||
|
||||
### Вывод команды обещает «журнал не приносил», хотя носитель перечня обрезается и обнуляется — оператор поверит пустоте, которой нельзя верить
|
||||
|
||||
- Файл: `cmd/healthlog/uncovered.go:71-95`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: дословный пункт дельта-спеки —
|
||||
`openspec/changes/aktivnaya-proverka-novyh-sekcij/specs/uncovered-sections/spec.md:198-203`:
|
||||
«Система SHALL называть границы перечня в спеке **и в выводе команды**, а не
|
||||
обещать „всё, что поток когда-либо приносил"». Вывод границ не называет, а
|
||||
пустая ветвь печатает ровно запрещённое обещание: «Непокрытых секций журнал
|
||||
не приносил.»
|
||||
- Последствие: перечень производен от колонки, которую обрезает разбор
|
||||
(32 имени на доставку) и обнуляет пересборка (тела, удалённые ретеншеном,
|
||||
список теряют). Пустой вывод после ретеншена или вытеснения читается как
|
||||
«поток ничего не приносил» — оператор примет решение по неполному ответу,
|
||||
считая его полным.
|
||||
- Предложение: переписать пустую ветвь («в учётных записях журнала непокрытых
|
||||
имён нет») и добавить в вывод строку границ носителя (обрезка разбором,
|
||||
обнуление пересборкой). Закрепить сквозным тестом `runUncovered` на непустой
|
||||
базе — он же закрывает критерий приёмки К3 и находку specs о его отсутствии.
|
||||
- Найдено проходом: specs
|
||||
- Действие: инлайн
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### Одна битая строка `uncovered_sections` роняет и сверку (вечный WARN-дребезг), и команду перечня целиком, не называя виновника
|
||||
|
||||
- Файл: `internal/store/uncovered.go:37,92-98,167-186`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: тест триажа `tmp/triage/jsoneach_test.go` — воспроизведено:
|
||||
`SQL logic error: malformed JSON (1)` на весь запрос; с
|
||||
`AND json_valid(uncovered_sections)` битая строка пропускается, живая
|
||||
считается. Достижимо не штатным путём (`json.Marshal` битого не пишет):
|
||||
ручная правка, будущая миграция мимо кода, восстановление БД. Схемной защиты
|
||||
`CHECK(json_valid(...))` в миграции 00005 нет.
|
||||
- Последствие: `json_each` над невалидным JSON — ошибка всего запроса, а guard
|
||||
`notEmptyList` проверяет только `'[]'` и `''`. Итог: сверка новизны вечно
|
||||
`unknown=true` (постоянный WARN, неотличимый от занятости базы), а
|
||||
`healthlog uncovered` отказывает целиком и не говорит, какая строка битая.
|
||||
- Предложение: добавить `AND json_valid(uncovered_sections)` в `notEmptyList`
|
||||
(действует на оба запроса) — проверено, чинит.
|
||||
- Найдено проходом: ops
|
||||
- Действие: инлайн
|
||||
|
||||
### Обоснование «индекс не нужен» стоит на замере не того случая: в заявленном сценарии скан почти полный на каждой доставке, а `LIMIT` не ограничивает работу
|
||||
|
||||
- Файл: `internal/store/uncovered.go:49-64,177-186`; `docs/architecture.md:383-387`
|
||||
- Severity: minor (факт high, операционная угроза low)
|
||||
- Confidence: high
|
||||
- Оракул: `tmp/adv/seen_cost_test.go` — журнал 105 000: имя с первой встречей в
|
||||
начале — 31 мкс, в хвосте — 13.9 мс, новое — 14.1 мс. Ранний выход `LIMIT 1`
|
||||
окупается только для имени из **начала** журнала; в заявленном сценарии
|
||||
(секция начала приезжать недавно) первая встреча — в хвосте, и цена платится
|
||||
на каждой доставке, а не «один раз за жизнь имени». Плюс `EXPLAIN QUERY PLAN`
|
||||
для `UncoveredSections` (ops): `SCAN d`, `USE TEMP B-TREE FOR GROUP BY` —
|
||||
`--limit 5` стоит столько же, сколько `--limit 200`.
|
||||
- Последствие: сегодня терпимо (~14 мс × 288 доставок ≈ 4 с чтения в сутки),
|
||||
но записанное рассуждение, на котором держится решение «индекса нет»,
|
||||
неверно, и следующая задача обопрётся на него как на факт. Вдобавок числа
|
||||
замера разошлись между двумя местами: `uncovered.go:53-58` — 28 мкс / 45 мс;
|
||||
`architecture.md:383-386` — 18 мкс / 46 мс / 57 мс (находка gate и code).
|
||||
- Предложение (развилка, варианты):
|
||||
- (а) принять цену и переписать обоснование в `uncovered.go` и
|
||||
`architecture.md` честно (худший случай — почти полный скан на доставку),
|
||||
заодно свести разошедшиеся числа и оставить их в одном месте со ссылкой из
|
||||
другого — ноль кода, минуты работы;
|
||||
- (б) завести индекс или таблицу первых встреч имён — миграция плюс правка
|
||||
`docs/database.md`; окупится только если различных имён станет много;
|
||||
- (в) ничего не менять — отвергается: обоснование записано неверно.
|
||||
- Найдено проходом: adversary, ops (дедуплицировано: одна причина — оценка
|
||||
стоимости снята на нерепрезентативном случае); расхождение чисел — gate, code
|
||||
- Действие: развилка
|
||||
|
||||
### Несостоявшаяся сверка не называет причину: `uncovered_seen_unknown=true` без ошибки, разбираться не по чему
|
||||
|
||||
- Файл: `internal/fold/fold.go:514-522`; `internal/store/uncovered.go:111`
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: положение конвенции `docs/conventions/logging.md` (проход code);
|
||||
чтение кода — `novelty()` отбрасывает `err` из `SectionsSeenBefore` молча,
|
||||
из-за чего `clipCoord` в тексте ошибки `sectionSeenOnce` — мёртвый
|
||||
предохранитель: ошибка никуда не доезжает.
|
||||
- Последствие: оператор видит «сверка не состоялась, новыми объявлены все
|
||||
имена», но не видит, почему — занятость, отмена, битая строка (см. находку
|
||||
про `json_valid`) неразличимы. Молчание причины при говорящем признаке.
|
||||
- Предложение: при `unknown` доводить причину до записи — либо логировать в
|
||||
`novelty()`, либо нести ошибку в `sectionNovelty` до атрибутов чекпоинта /
|
||||
записи об отказе.
|
||||
- Найдено проходом: specs, code, adversary (дедуплицировано)
|
||||
- Действие: инлайн
|
||||
|
||||
### Сообщение чекпоинта вводит третий термин мимо словаря — операторский контракт (grep, алерты) затвердеет с неверным именем
|
||||
|
||||
- Файл: `internal/fold/fold.go` (msg `"delivery folded, unseen section name"`)
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: словарь изменения — `uncovered` (состояние) и `new` (событие);
|
||||
«unseen» не существует ни в спеке, ни в атрибутах (атрибут называется
|
||||
`uncovered_new` — виден в прогоне `tmp/adv/dupclip_test.go`).
|
||||
- Последствие: после мерджа сообщение — операторский контракт; grep и алерты
|
||||
завяжутся на «unseen», и переименование станет дороже с каждой неделей.
|
||||
Сейчас — одна строка и один тест.
|
||||
- Предложение: переименовать msg в термины словаря, например
|
||||
`"delivery folded, uncovered section new"`.
|
||||
- Найдено проходом: architecture
|
||||
- Действие: инлайн
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- **Вторая сериализация журнального ключа** (`received_at || ' ' || id` +
|
||||
`splitJournalKey`, `internal/store/uncovered.go:180-181,216-226`) —
|
||||
корректность лексикографического сравнения держится на незакреплённой
|
||||
фиксированной ширине `FormatTime`. Оракула (теста, ломающего ширину) не
|
||||
построено; понижено до гипотезы. Дешёвая страховка — тест-шпилька на ширину
|
||||
формата. (architecture, minor/medium)
|
||||
- **Два SQL-пути в `SectionsSeenBefore`** (одно имя / батч,
|
||||
`internal/store/uncovered.go:84-87`) — экономия быстрого пути ≈ 13 с чтения
|
||||
в сутки; вопрос «что опытный человек удалил бы». Последствие — только цена
|
||||
сопровождения двух запросов; ущерб не построен. Если владелец возьмёт
|
||||
вариант (б) развилки про индекс — вопрос снимется сам. (architecture,
|
||||
minor/low)
|
||||
- **Сценарий «Отложенная доставка события не порождает» не закреплён тестом** —
|
||||
держится на порядке двух блоков в `fail` (`fold.go:546-565`); перестановка
|
||||
при рефакторинге даст дребезг события на каждом проходе занятой базы.
|
||||
(specs, minor)
|
||||
- **Ветвь новизны при переменной части в ключе навсегда занимает `msg`** — если
|
||||
HAE начнёт класть в ключ переменную часть, каждая доставка будет «с новой
|
||||
секцией» и чекпоинт станет постоянным WARN. Путь не построен (наблюдённые
|
||||
ключи стабильны). (adversary, свойство без пути)
|
||||
- **Два снимка в `UncoveredSections` (count и перечень) могут разойтись на
|
||||
единицу** — два запроса без общей транзакции; окно — конкурентная запись
|
||||
между ними. Команда диагностическая, ущерб — косметика остатка. (adversary,
|
||||
свойство без пути)
|
||||
- **Поведение вне спеки, исход не меняющее:** флаг `-limit` не заказан спекой;
|
||||
`context.Background()` вместо `signal.NotifyContext` (у соседнего `reindex` —
|
||||
второе). Оставлено на решение оркестратора без severity. (specs)
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **`CHECK (json_valid(...))` для JSON-колонок в будущих миграциях** — схемная
|
||||
защита, которой не хватило в 00005; правило для `docs/conventions/` или
|
||||
чек миграций в гейте. (из находки ops про битую строку)
|
||||
- **«Число замера живёт в одном месте, остальные ссылаются»** — числа одного
|
||||
замера разошлись между `uncovered.go` и `architecture.md` уже до мерджа;
|
||||
класс тот же, что у записи 2026-08-04 в `docs/review.md` (числа, на которых
|
||||
стоит нормативный текст, читаются как факт и не перепроверяются). (gate, code)
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Прогон:** профиль deep, режим по графу. База диффа — рабочее дерево
|
||||
(HEAD == master). Гейт зелёный, exit 0 дважды; diff-coverage 96%.
|
||||
|
||||
**Запускалось:** gate, specs, code, adversary, ops, architecture, triage.
|
||||
Оракулы adversary прогнаны в `./tmp/adv`, оракул триажа — `./tmp/triage`.
|
||||
`task verify:archive` прогнан оркестратором — зелёный, 90 с.
|
||||
|
||||
**Не запускалось:**
|
||||
|
||||
- `reimpl` — триггер `docs/review.md` («новое правило слияния, идентичности или
|
||||
разбора») не сработал: изменение наблюдает поверх существующей логики, правил
|
||||
не двигает. Если считать сверку новизны «правилом наблюдения» — это
|
||||
расширительное чтение триггера; решение не запускать принял оркестратор, и
|
||||
класс «независимая реализация нашла бы другую форму» не покрыт.
|
||||
- `task verify:busy` — не гонялся. Именно он проверил бы поведение новой сверки
|
||||
при удерживаемой блокировке (ветвь `uncovered_seen_unknown` — ровно про
|
||||
занятую базу). Изменение правил разбора/слияния не двигает, поэтому
|
||||
обязательный порог CLAUDE.md формально не задет, но ветвь unknown проверена
|
||||
только юнит-тестами, не прогоном.
|
||||
|
||||
**Не влезло в потолок (подтверждено, но ниже линии):**
|
||||
|
||||
- Два разных ключа, совпадающих в первых 64 байтах, дают дубль после
|
||||
`clipSection`: одна доставка показана в перечне как две
|
||||
(`internal/store/uncovered.go:177-186`, `count(*)` считает строки
|
||||
`json_each`, а не доставки). Оракул: `tmp/adv/dupclip_test.go`, перепрогнан —
|
||||
FAIL воспроизведён. Вероятность мизерная (нужны имена длиннее 64 байт с общим
|
||||
префиксом), последствие — неверное число в диагностической команде. Чинится
|
||||
`count(DISTINCT d.received_at || ' ' || d.id)` либо дедупликацией имён после
|
||||
обрезки на разборе.
|
||||
- Непокрытые строки диспетчера подкоманд `cmd/healthlog/main.go:34-35` —
|
||||
унаследованный пробел (весь `main()` 0% и до изменения). (gate)
|
||||
|
||||
**Что запущенные проходы не могли проверить по построению:**
|
||||
|
||||
- `gate` — только механизируемое; «в Go так не пишут» не проверяет никто (см.
|
||||
ниже).
|
||||
- `specs` — судит против записанной спеки; сами границы спеки: отказ слияния
|
||||
как исход в дельте не описан (находка №1 стоит на общем требовании «не
|
||||
глотает событие»); приоритет ветви новизны над событиями потери содержания
|
||||
(`EntitiesHeld`, `PointsErased`) — выбор кода, не спеки; `Deliveries` считает
|
||||
пары (доставка, элемент массива) — семантика зафиксирована кодом.
|
||||
- `adversary` — три свойства названы без построенного пути (перечислены в
|
||||
гипотезах); не построенный путь не означает недостижимый.
|
||||
- `ops` — истории инцидентов у нового кода нет; поведение под реальным потоком
|
||||
не наблюдалось. Сигналов о молчании потока нет — существующий пробел, не
|
||||
этого диффа.
|
||||
- `architecture` — судит форму, не поведение; шов `loggedFold`/`breakSeenQuery`
|
||||
рассмотрен и находкой не признан.
|
||||
- `triage` — ничего нового не находит по построению; пропуск любого прохода —
|
||||
пропуск триажа тоже.
|
||||
|
||||
**Целиком на человеке** (`docs/review.md`, «Недоступно проверке» — два списка,
|
||||
намеренно раздельных):
|
||||
|
||||
*Не проверит ни один проход:* реальный профиль нагрузки (телефон шлёт молча и
|
||||
непрерывно); поведение HAE за пределами наблюдённого; полнота словаря переводов
|
||||
после обновления iOS; секции, которых поток ещё не приносил (`symptoms`, `ecg`,
|
||||
`heartRateNotifications`, `cycleTracking`, `medications`) — для этого изменения
|
||||
это особенно прямо: команда `uncovered` написана ровно про них, и судить можно
|
||||
только форму кода, не встречу с реальностью. Плюс общее: история инцидентов,
|
||||
поведение внешних систем в их версиях, завязка потребителей на текущее
|
||||
поведение, вопрос «а нужна ли эта функциональность вообще».
|
||||
|
||||
*Перестали проверять сознательно:* `verify:archive`/`verify:busy` вне гейта
|
||||
(в этом прогоне archive прогнан, busy — нет, см. выше); класс «в Go так не
|
||||
пишут» — не покрыт вовсе после упразднения `idiom` (запись 2026-08-02); класс
|
||||
«чего нет в зрелой реализации такого узла» — вне профиля `design`.
|
||||
|
||||
**Документы проекта:** всё нужное было на месте — инварианты `CLAUDE.md`
|
||||
(severity брались из них, не выводились), `docs/review.md` с журналом, типовыми
|
||||
ложноположительными и обоими списками «Недоступно проверке». Ни один проход не
|
||||
заявил недостающего документа; отдельных строк деградации нет.
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Значения точек не попадают в логи
|
||||
|
||||
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
|
||||
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
|
||||
|
||||
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
|
||||
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
|
||||
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
|
||||
идентификатор и род сущности, метка времени, идентификатор доставки — они
|
||||
описывают, что случилось, а не что измерено.
|
||||
|
||||
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
|
||||
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
|
||||
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
|
||||
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
|
||||
построчный разбор логов. То же относится к идентификатору сущности: он приходит
|
||||
из чужого тела и ограничен по длине при разборе.
|
||||
|
||||
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
|
||||
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
|
||||
Повышает уровень другое, и оснований два:
|
||||
|
||||
- срабатывание границ списка: тело с сотнями секций или с именем длиннее предела
|
||||
на HAE не похоже вовсе;
|
||||
- **первая по журналу встреча имени непокрытой секции** — событие однократное за
|
||||
всю жизнь имени, и правила его живут в capability наблюдения за непокрытыми
|
||||
секциями. Здесь оно названо, чтобы перечень оснований оставался полным: иначе
|
||||
следующий читатель снимет ветвь как незаказанную.
|
||||
|
||||
#### Scenario: Разбор доставки логируется без значений
|
||||
|
||||
- **WHEN** доставка разобрана
|
||||
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
|
||||
идентификатор доставки
|
||||
- **AND** не содержит ни значений точек, ни имён устройств
|
||||
|
||||
#### Scenario: Удержанная обеднённая версия логируется координатами
|
||||
|
||||
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
|
||||
- **THEN** запись `WARN` содержит идентификатор и род сущности
|
||||
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
|
||||
|
||||
#### Scenario: Непокрытые секции названы именами ключей
|
||||
|
||||
- **WHEN** доставка содержит непокрытую секцию
|
||||
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
|
||||
- **AND** не содержит ничего из содержимого этих секций
|
||||
- **AND** уровень записи из-за одной лишь частичности не повышается
|
||||
|
||||
#### Scenario: Границы списка сработали
|
||||
|
||||
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
|
||||
- **THEN** запись лога имеет уровень `WARN`
|
||||
- **AND** содержит число отброшенных имён
|
||||
|
||||
#### Scenario: Имя непокрытой секции встречено впервые
|
||||
|
||||
- **WHEN** доставка принесла имя непокрытой секции, которого не было ни в одной
|
||||
доставке раньше неё в журнале
|
||||
- **THEN** запись лога имеет уровень `WARN`
|
||||
- **AND** содержит имя отдельным атрибутом новых секций
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
## ADDED 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** символ выводится экранированной последовательностью, а не сырым
|
||||
байтом
|
||||
@@ -0,0 +1,138 @@
|
||||
## 1. Хранилище: два запроса по существующей колонке
|
||||
|
||||
- [x] 1.1 `store.SectionsSeenBefore(ctx, names, before DeliveryRef) (map[string]struct{}, error)` —
|
||||
какие из имён встречались в доставках, стоящих в журнале строго раньше
|
||||
`before`; сравнение парой `(received_at, id)`, чтение через `json_each`,
|
||||
`EXISTS` на имя (ранний выход для виденного), вне транзакции записи
|
||||
- [x] 1.2 `store.UncoveredSections(ctx, limit)` — перечень: имя, число доставок,
|
||||
первая и последняя встреча (метка и идентификатор доставки), порядок по
|
||||
имени, предел строк с числом остатка; доставки всех статусов разбора
|
||||
- [x] 1.3 Тесты хранилища: имя из более ранней доставки виденное, из более
|
||||
поздней — нет, своя же строка в счёт не идёт, доставки одной секунды
|
||||
разводятся идентификатором; перечень сходится с независимо посчитанным
|
||||
ожиданием; отказавшая доставка в перечень входит; предел вывода срабатывает
|
||||
- [x] 1.4 Замер стоимости на **синтетическом** журнале в `./tmp` (~105 тысяч
|
||||
доставок, годовой объём): время запроса для виденного и для нового имени;
|
||||
число печатается, метод замера записывается рядом с результатом
|
||||
|
||||
## 2. Свёртка: признак новизны и эскалация
|
||||
|
||||
- [x] 2.1 Проверка новизны сразу после разбора — до ветвления на успех и отказ,
|
||||
**вне транзакции записи**; берётся только при непустом списке непокрытых
|
||||
- [x] 2.2 Отказ запроса не роняет свёртку и исходом доставки не становится: все
|
||||
имена считаются новыми, признак несостоявшейся сверки идёт в запись
|
||||
- [x] 2.3 Новые имена в `Stats` и в атрибутах чекпоинта; ветвь `WARN` ставится
|
||||
первой в `switch` (решение кода — спека нормирует поведение, а не позицию)
|
||||
- [x] 2.4 Новые имена доезжают до пути отказа (`parseResidue` → `fail`) и
|
||||
печатаются его записью; отложенный исход их не называет
|
||||
- [x] 2.5 Тесты свёртки: первая встреча даёт `WARN` с атрибутом новых секций,
|
||||
вторая доставка молчит, новое имя рядом с виденным, новая секция не
|
||||
маскируется перезаписью точек, отказ слоя называет новое имя, отложенный
|
||||
исход не называет, отказ сверки объявляет всё новым и не роняет свёртку
|
||||
|
||||
## 3. Команда перечня
|
||||
|
||||
- [x] 3.1 Подкоманда `healthlog uncovered --config <path>`: `store.OpenForRead`,
|
||||
человекочитаемый вывод в stdout, ненулевой код и причина на отказ открытия
|
||||
- [x] 3.2 Имя печатается экранированным (`%q`); пустой перечень — отдельная
|
||||
строка и нулевой код; предел строк с числом остатка
|
||||
- [x] 3.3 Регистрация подкоманды в `main.go` и в шапке пакета
|
||||
- [x] 3.4 Тесты команды: перечень на базе с секциями, пустой перечень, имя с
|
||||
управляющим символом, базы по пути нет, имён больше предела
|
||||
|
||||
## 4. Документация
|
||||
|
||||
- [x] 4.1 `docs/architecture.md` — раздел «Неразобранные секции доставки»
|
||||
дополнен событием, командой и границами носителя
|
||||
- [x] 4.2 `docs/research/apple-health.md` — перечень не виденных живьём секций
|
||||
сверен с множеством покрытых имён в `internal/hae` поимённо
|
||||
- [x] 4.3 `README.md` / `CLAUDE.md` — команда названа там, где перечислены
|
||||
прочие подкоманды (если перечислены)
|
||||
|
||||
## 5. Приёмка
|
||||
|
||||
- [x] 5.1 `task gate` зелёный
|
||||
- [x] 5.2 `task verify:archive` — повторный прогон живого архива даёт то же
|
||||
состояние (правило наблюдения затрагивает путь свёртки)
|
||||
- [x] 5.3 Поведенческая верификация: сервис поднят, подсунута доставка с
|
||||
выдуманной секцией — ровно одна запись с атрибутом новых секций; вторая
|
||||
доставка молчит; `healthlog uncovered` на живой базе
|
||||
|
||||
## 6. Отработка ревью кода (профиль `deep`)
|
||||
|
||||
- [x] 6.1 Отказ **слияния** доносит новизну до записи об отказе: он пишет имя в
|
||||
учёт так же, как отказ разбора, и терял событие навсегда (найдено тремя
|
||||
проходами с оракулом). Тест `TestFoldОтказСлиянияНазываетНовуюСекцию`
|
||||
- [x] 6.2 Вывод команды называет границы носителя в **обоих** исходах; пустая
|
||||
ветвь больше не говорит за весь поток
|
||||
- [x] 6.3 Учётная запись с неразбираемым списком не роняет ни сверку, ни
|
||||
перечень: проверка стоит внутри аргумента `json_each`, а не условием в
|
||||
`WHERE` (порядок вычисления SQLite не обещает)
|
||||
- [x] 6.4 Причина несостоявшейся сверки идёт в запись рядом с признаком
|
||||
- [x] 6.5 Сообщение чекпоинта приведено к словарю: `delivery folded, new
|
||||
uncovered section`
|
||||
- [x] 6.6 Число доставок считается `count(DISTINCT d.id)`: обрезка имён по 64
|
||||
байтам давала дубли внутри одной доставки
|
||||
- [x] 6.7 Замер перемерян и исправлен: ранний выход есть только у давно
|
||||
приезжающей секции; у только что появившейся — почти полный проход.
|
||||
Числа живут в одном месте (`design.md`), код и `architecture.md` ссылаются
|
||||
- [x] 6.8 Сквозной тест команды: перечень сходится с посчитанным по **телам**
|
||||
архива независимо от кода команды (оракул К3)
|
||||
|
||||
## Критерии приёмки задачи
|
||||
|
||||
Из `docs/tasks/items/unseen-sections-check.md`; оракулы К1–К3 уточнены после
|
||||
ревью предложения — наблюдаемый признак события есть **атрибут новых секций**, а
|
||||
не присутствие имени в записи (имя непокрытой секции стоит в записи каждой
|
||||
доставки, которая её принесла, и так было до этого изменения).
|
||||
|
||||
- [x] К1 имя секции, которого разбор раньше не встречал, порождает событие
|
||||
уровня выше рутины — оракул: тест на доставке с выдуманной секцией, ровно
|
||||
одна запись уровня `WARN` с этим именем **в атрибуте новых секций**
|
||||
- [x] К2 то же имя во второй доставке события больше не порождает — оракул: тот
|
||||
же тест на двух доставках подряд, у второй атрибут новых секций пуст
|
||||
- [x] К3 список всего, что поток когда-либо приносил и разбор не покрыл,
|
||||
достаётся одной командой, без ручного SQL — оракул: перечень собран из
|
||||
**тел** `internal/hae/testdata` независимо от кода команды и сходится со
|
||||
строками её вывода (имя, число доставок, границы); дешёвой добавкой —
|
||||
сверка с `SELECT DISTINCT` по `delivery.uncovered_sections` на живой базе.
|
||||
**Живая база проверку не пропустила по своей причине:** её схема (версия 8)
|
||||
отстала от бинаря (10) — контейнер не пересобирался, — и `OpenForRead`
|
||||
строго отказал, как и заказано. Сверка сделана на поднятом сервисе с
|
||||
отдельной базой; на живой базе непокрытых секций сегодня нет вовсе
|
||||
(`SELECT DISTINCT` даёт пустое множество), так что сверять было бы нечего
|
||||
- [x] К4 перечень не виденных живьём секций в `docs/research/apple-health.md`
|
||||
сходится с множеством покрытых имён в `internal/hae` — оракул: глазами,
|
||||
сверка двух списков поимённо
|
||||
- [x] К5 повторный прогон живого архива даёт то же состояние — оракул:
|
||||
`task verify:archive`
|
||||
|
||||
## Приёмочные критерии из рубрики ревью (профиль `design`)
|
||||
|
||||
Рубрика прохода `review-rubric`, порождённая до чтения предложения. Пункты,
|
||||
которые предложение не закрывало, отработаны правкой спеки и дизайна; здесь они
|
||||
остаются проверяемыми свойствами.
|
||||
|
||||
- [x] Р1 признак «впервые» есть функция префикса журнала: перестановка порядка
|
||||
свёртки не переносит событие с доставки на доставку
|
||||
- [x] Р2 узел не читает состояние, которое сам же пишет в этом шаге:
|
||||
собственная строка исключена сравнением, а не порядком записи
|
||||
- [x] Р3 идемпотентность и отсутствие второй копии факта: повторная свёртка даёт
|
||||
тот же состав имён, хранимого реестра нет
|
||||
- [x] Р4 наблюдательный механизм не роняет поток: отказ сверки не меняет
|
||||
`parse_status` и не превращает `pending` в `failed`
|
||||
- [x] Р5 запрос новизны выполняется вне транзакции записи; в транзакции записи
|
||||
обращений к `delivery` с `json_each` нет
|
||||
- [x] Р6 стоимость названа числом с методом замера (задача 1.4), а не посылкой
|
||||
«список почти всегда пуст»
|
||||
- [x] Р7 событие однократно на имя: сто доставок подряд с одним новым именем
|
||||
дают ровно одну запись повышенного уровня; уровень назван поимённо (`WARN`)
|
||||
- [x] Р8 признак и перечень честны относительно носителя: обрезка списка,
|
||||
обнуление пересборкой и уход имени после покрытия названы в спеке
|
||||
- [x] Р9 имя секции — недоверенный вход: экранировано на выводе, ограничено по
|
||||
длине разбором, данных о здоровье в выводе нет
|
||||
- [x] Р10 CLI читает и только читает: без миграций и записи, расхождение версии
|
||||
схемы — явный отказ
|
||||
- [x] Р11 вывод детерминирован, ограничен пределом и отличает пустоту от отказа
|
||||
- [x] Р12 оракулы не пришпилены к числу, производному от размера корпуса, и не
|
||||
тавтологичны (К3 перестроен от тел архива)
|
||||
Reference in New Issue
Block a user