## Context

Приём и свёртка сегодня — одна операция. `ingest.Accept` пишет тело в архив,
вставляет строку `delivery` и **тут же** зовёт `fold.Fold` на контексте,
отвязанном от запроса, но синхронно; обработчик отвечает только после этого.
Стоимость свёртки измерена: 1001 объект — 815 мс, 4001 — 3.07 с, 16001 —
11.07 с. Переход на одну транзакцию на доставку снял около 0.7 мс на объект
(прогон живого архива ускорился с 64 до 52 секунд), но порядок величины
остался.

Что уже есть и на что опираемся:

- `fold.Fold(ctx, deliveryID)` — свёртка **одной** доставки по идентификатору,
  тело читается из архива. Идемпотентна: победитель координаты — функция
  множества кандидатов, а не порядка.
- `internal/replay` — проигрывание журнала целиком: состав из архива, порядок
  `(received_at, id)`, классификация исходов, отчёт. Появился задачей
  `reindex-iz-arhiva`.
- `store.ParsePending` — «этим разбором тело ещё не смотрели». Статус
  консервативный: ретеншен его не трогает никогда. Миграция `00005` перевела в
  него все доставки, и подобрать их сегодня может только `healthlog reindex`.
- `store.LastDerivedLayer(automationID, before, beforeID)` — наследование слоя
  строго от **предшествующей** доставки: слой обязан быть функцией префикса
  журнала.
- `store.inTx` — пять попыток с нарастающей паузой при занятости базы,
  `_txlock=immediate`, одна транзакция на доставку.

Ограничения окружения: один процесс, SQLite, файлы; «без очередей и внешних
зависимостей» — принцип архитектуры. Телефон шлёт молча каждые пять минут и
доставку не переприсылает. `stop_grace_period` контейнера — 30 секунд.

## Goals / Non-Goals

**Goals:**

- Время ответа на приём перестаёт зависеть от ширины доставки.
- Свёртка идёт в порядке журнала и при конкурентных доставках тоже.
- Несвёрнутое переживает падение и рестарт процесса, а не только штатную
  остановку.
- Подбор `pending` и пересборка — один код, а не два похожих.
- Отставание воркера видно **до** того, как станет отставанием на сутки, — в
  том числе когда воркер не двигается вовсе.

**Non-Goals:**

- **Параллельная свёртка.** Слой — функция префикса журнала, запись объекта —
  read-modify-write. Воркер один, и это требование, а не упрощение.
- **Дедупликация доставок, ретеншен архива, `/stats`.** Свои задачи беклога.
- **Гарантия «доставка свёрнута к моменту ответа».** Она снимается сознательно
  — в этом вся задача; взамен даётся «доставка сохранена и учтена к моменту
  ответа», а несвёрнутое видно в `parse_status`.
- **Абсолютный порядок журнала при конкурентных приёмах.** Достижимого предела
  — «все видимые воркеру неразобранные доставки сворачиваются в порядке
  `(received_at, id)`» — достаточно; см. риски.
- **Возврат `failed` в очередь.** Доставка, отказавшая по собственному
  содержимому, остаётся `failed` и возвращается только пересборкой. Это
  названная граница, см. решение 4б.

## Decisions

### 1. Очередью служит таблица `delivery`, а не список идентификаторов в памяти

Формулировка задачи говорила «очередь идентификаторов доставок» и отдельно
оговаривала поведение при переполнении. Реализуется это **очередью в базе**:
доставка ждёт свёртки в собственном статусе `pending`, а канал между приёмом и
воркером несёт не идентификаторы, а один бит «есть работа» (буфер 1,
неблокирующая отправка).

Prior art здесь однозначен и стар — это **transactional outbox** и его частный
случай «база как очередь заданий»
([AWS Prescriptive Guidance](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html),
[Three Dots Labs, durable execution на Go и SQLite](https://threedots.tech/post/sqlite-durable-execution/)).
Суть шаблона ровно наша: состояние задания пишется в ту же базу той же
транзакцией, что и факт события, а фоновый процесс выбирает необработанные
строки. Всё, что живёт только в памяти, теряется при падении — а у нас падение
означает молчаливую потерю свёртки для доставки, которую телефон не перешлёт.

Что это даёт сверх памяти, по пунктам исходной задачи:

- **Переполнения нет.** «Очередь переполнена — доставка остаётся `pending`, это
  не отказ» выполняется по построению: доставка `pending` всегда, пока не
  свёрнута. Сигнал теряться может и должен — он ничего не несёт.
- **Подбор `pending` при старте — не отдельный код.** Это обычный проход
  воркера: старт просто будит его первым сигналом. Второй путь подбора не
  появляется, потому что путь один.
- **Падение и `SIGKILL` не теряют очередь.** Транзакция свёртки откатывается,
  статус остаётся `pending`, следующий старт подберёт.

Форма сигнала — канал ёмкостью 1 с неблокирующей отправкой — не изобретение:
это форма `os/signal.Notify` («Package signal will not block sending to c… a
buffer of size 1 is sufficient») и `time.Ticker` («will drop ticks to make up
for slow receivers»), и она же названа в стайлгайде Uber (*Channel Size is One
or None*). `sync.Cond` здесь непригоден механически: `Wait()` не кладётся в
`select` с `ctx.Done()`.

Отвергнуто: **канал идентификаторов в памяти** (буферизованный, с политикой
переполнения). Причина — он вводит второе, недолговечное представление того же
факта: доставка одновременно «в очереди» и «pending в базе», и эти два
представления расходятся при каждом падении. Плюс политика переполнения
(«оставить pending») всё равно требует подбора из базы, то есть кода из
варианта выше — только теперь его два.

Отвергнуто: **опрос базы по таймеру ВМЕСТО сигнала**. Он добавляет задержку в
полпериода на каждую доставку без всякой пользы: сигнал — одна строка. Но тик
**в дополнение** к сигналу берётся, и по другой причине — см. решение 5.

### 2. Порядок — тот же `(received_at, id)`, курсором внутри прохода

Порядок журнала определён capability пересборки (`openspec/specs/reindex/`), и
здесь он не переопределяется, а используется: воркер обрабатывает доставки в
том же порядке и по той же причине. Повторять обоснование в двух спеках нельзя —
правило поехало бы в одной и осталось в другой.

Проход воркера выбирает неразобранные доставки запросом
`WHERE parse_status = 'pending' AND (received_at, id) > (?, ?)
ORDER BY received_at, id LIMIT n`, курсор внутри прохода строго возрастает.

Форма сравнения — **row-value**, а не развёрнутая через `OR`, и это проверено
планом запроса на воспроизведённой схеме:

```
(received_at,id) > (?,?)                          → SEARCH … COVERING INDEX delivery_pending
received_at > ? OR (received_at = ? AND id > ?)   → SCAN  … COVERING INDEX delivery_pending
```

Прецедент в проекте уже есть — `store.LastDerivedLayer`. Нулевой курсор —
`(time.Time{}, "")`, то есть `0001-01-01T00:00:00Z`: один текст запроса без
ветки «первая страница».

Строго возрастающий курсор нужен не ради страниц, а ради **завершимости**:
доставка, у которой не удалось записать даже исход разбора, остаётся `pending` —
и проход без курсора выбирал бы её вечно. С курсором проход конечен всегда.

Доставка, приехавшая во время прохода с меньшим `received_at`, курсором
пропускается — и подбирается следующим проходом, который её же сигнал и
запустит.

Отвергнуто: множество «уже пробованных в этом проходе» вместо курсора.
Эквивалентно по эффекту, но растёт по памяти вместе с задолженностью — а
задолженность после миграции `00005` это весь архив.

### 3. Общий с пересборкой код — классификатор исхода одной доставки

`replay.Run` сегодня несёт в себе цикл, который для каждой доставки зовёт
`fold.Fold` и разбирает исход по классам: `ErrLayerUnknown` — штатный отказ
(слой не выведен), `ErrMalformed` — непонятое содержимое, прочее — настоящая
поломка; счётчики частичного разбора и несравнимых наборов читаются **только**
у успешной свёртки, иначе `Partial` молча занижается, а по нему принимается
решение о судьбе тела.

Это и есть та половина, которую задача требует не дублировать. Она выносится в
`replay.Player.Play(ctx, deliveryID) (Outcome, error)` — исход **одной**
доставки значением, — и её зовут оба: `replay.Run` в своём цикле и воркер в
своём. Накопление — `(*Outcome).Add(other)`; `replay.Report` встраивает
`Outcome`, чтобы у пересборки не появилось второго набора имён для тех же
исходов.

**`Player` не смотрит на контекст.** Он классифицирует только ошибку, которую
вернула свёртка; решение «нас остановили» принимает цикл, каждый по своему
контексту. Иначе один и тот же `ctx.Err() != nil` означал бы у двух вызывающих
противоположное: у пересборки в свёртку уходит тот же отменяемый контекст
(«нас остановили»), у воркера — отвязанный от остановки, с собственным дедлайном
(«доставка не уложилась в две минуты»). Воркер, унаследовавший чужую ветку,
принял бы свой дедлайн за остановку и бросил проход молча.

Возврат значением, а не накопление по указателю: так устроены `fold.Fold`,
`store.MergePoints` и `replay.Run`, аккумулирующего out-параметра в проекте нет
ни одного. Плюс правило «счётчики только у успеха» становится утверждением о
результате одного вызова, а не вычитанием двух состояний — а именно на этом
правиле уже один раз занижался `Partial`.

Целиком общим цикл быть не может, и это названная граница: у пересборки состав
берётся из **архива** (тело без учётной записи — тоже событие) и пишется в
пустую базу, у воркера состав берётся из **учёта** (`pending`) и пишется в
рабочую. Общее у них — порядок, точка входа в свёртку и классификация исхода;
именно они и разошлись бы молча.

Отвергнуто: **звать `replay.Run` из воркера**. Он требует пустой базы
назначения и проигрывает весь журнал с нуля — под живым приёмом это не
операция подбора, а пересборка.

Отвергнуто: **воркер в `internal/ingest`**. Тогда порядок журнала знали бы два
пакета, и правку правила пришлось бы вносить в оба. `internal/replay` уже
объявлен местом, где живут «состав, порядок, отчёт»; фоновое проигрывание
хвоста — тот же предмет, только непрерывный.

### 4. Остановка формулируется инвариантом, а не обещанием досчитать

Свёртка идёт на контексте `context.WithoutCancel` от контекста воркера плюс
собственный дедлайн — ровно так, как сегодня это делает `ingest.Accept`.
Механизм не новый, он переезжает. Отмена контекста воркера проверяется
**между** доставками.

Обещать «текущая доставка досворачивается» нельзя: `foldTimeout` — две минуты, а
весь бюджет остановки — тридцать секунд, и `srv.Shutdown` тратит его первым.
Обещание, которое система не всегда исполняет, — это флакующий приёмочный тест и
неверное представление у следующего читателя. Поэтому требование формулируется
**инвариантом**: после остановки не существует доставки, которая числится
разобранной, а записана частично; несвёрнутое остаётся `pending`.

Порядок остановки: `srv.Shutdown` (перестаём принимать) → отмена контекста
воркера → ожидание его выхода в остатке того же бюджета. Обратный порядок
оставил бы доставки, принятые после остановки воркера, никого не разбудившими.

Механизм ожидания — `done chan struct{}`, закрываемый воркером в `defer`, и
`select` с бюджетом: `sync.WaitGroup.Wait()` бюджета не принимает.

Два следствия, которые надо назвать вслух, иначе они дадут ложные `ERROR`:

- **`Shutdown` возвращает `context.DeadlineExceeded` штатно** — так
  задокументировано в stdlib. Сегодня `runServe` возвращает любую его ошибку
  наверх, а `main` печатает `fatal startup` и выходит с кодом 1. После того как
  бюджет ответа приёма вырос (решение 7), исчерпание бюджета остановки во время
  загрузки станет обычным делом, и штатная остановка докладывалась бы как
  провал старта. Контекстная ошибка `Shutdown` — `WARN`, а не отказ команды.
- **База не закрывается, пока воркер не вышел.** `defer st.Close()` при не
  уложившемся в бюджет воркере закрыл бы базу под живой транзакцией свёртки, и
  в лог ушли бы `ERROR` по доставке, с которой всё в порядке. Не уложились —
  оставляем закрытие процессу, а факт называем `WARN`.

### 4б. Отмена и занятость базы оставляют доставку в очереди, всё прочее — нет

Сегодня `fold.fail` пишет `parse_status = failed` на **любой** ошибке. Пока
свёртка шла синхронно, это было терпимо. С воркером — нет: `failed` из очереди
выбывает навсегда, а вернуть его может только `healthlog reindex`, то есть
операция с остановкой сервиса и ручной подменой базы. Занятость базы после пяти
попыток `inTx` (порядка 200 мс на широкой доставке) стирала бы доставку с полки
молча — притом что сама эта задача делает конкуренцию за базу штатной.

Правило: **исход разбора отражает доставку, а не обстоятельства.**

- Отказ окружения — отмена контекста и занятость базы — статус **не меняет**:
  доставка остаётся `pending` и подбирается следующим проходом или тиком.
- Всё остальное (`ErrMalformed`, `ErrLayerUnknown`, нечитаемое тело, тело сверх
  предела, исчерпанный дедлайн свёртки) — `failed`, как и сейчас: это свойства
  самой доставки, и повторять их бесполезно.

Занятость распознаётся сентинелом `store.ErrBusy` — `inTx` уже отличает
`SQLITE_BUSY`/`SQLITE_BUSY_SNAPSHOT` по коду, осталось назвать исход доменной
ошибкой у источника, как того требуют конвенции.

Отвергнуто: **счётчик попыток с переводом в `failed` после N**. Он нужен
очередям заданий общего назначения, где задание может быть ядовитым. У нас
ядовитость уже отсечена по классу: содержимое даёт `failed` с первого раза, а в
`pending` остаются только те два случая, которые проходят сами. Колонка и
политика «сколько попыток достаточно» были бы изобретением без наблюдения.

### 5. Проход будит не только сигнал: тик — страховка и площадка для метки

К сигналу добавляется тик (порядка минуты) в том же `select`. Он не альтернатива
сигналу (см. решение 1), он закрывает два случая, которые сигнал закрыть не
может:

- **Доставка, оставшаяся `pending` по решению 4б**, ждала бы следующей доставки,
  чтобы её кто-то разбудил. Ночью телефон молчит часами.
- **Отставание невидимо ровно тогда, когда оно опасно.** Если метка задержки
  вычисляется внутри прохода, а прохода нет, «работа есть, прогресса нет»
  неотличимо от здорового пустого потока.

Наблюдаемость — две метки, и обе берутся из строк, которые проход и так
выбрал:

- `WARN` «доставка ждала свёртки дольше пяти минут» с `delivery_id` и
  величиной ожидания. Порог — период быстрого прохода синхронизации: если
  доставка ждала дольше, чем интервал между доставками, очередь растёт, а не
  рассасывается. Считается от `received_at` до **начала** свёртки.
- `INFO` один раз при старте: сколько доставок числится неразобранными. Это
  размер задолженности и ответ на вопрос «что сервис будет делать первые минуты
  после рестарта».

**Первый проход задержку не считает.** После миграции `00005` неразобранными
числятся все доставки архива, и метка сработала бы сотней строк подряд, ничего
не сообщив: они ждали не воркера, а его появления. Задолженность при старте
называется одним `INFO`, метка включается после первого прохода.

**Отказ прохода воркер переживает.** Отказ `SELECT` (занятая база, отказ диска)
— это `ERROR` и выход из прохода, а не из цикла: воркер, умерший от временного
отказа базы, остановил бы свёртку до конца жизни процесса, а приём продолжал бы
отвечать `200`.

Числа — текущая длина `pending`, возраст самой старой неразобранной доставки —
это `/stats`, и они уезжают строкой в задачу `stats-endpoint`. Здесь их
нет намеренно: отдельного механизма счётчиков в проекте пока не существует.

### 6. Частичный индекс по неразобранным доставкам

Запрос прохода спрашивается чаще, чем раз в минуту, а `delivery` растёт на
~300 строк в сутки (100 тысяч в год). Без индекса это скан таблицы с сортировкой
на каждый проход.

Индекс — **частичный**: `(received_at, id) WHERE parse_status = 'pending'`. В
установившемся режиме в нём ноль–одна строка, потому что свёрнутая доставка из
него выпадает; полный индекс по `parse_status` хранил бы все сто тысяч ради
выборки из одной. План запроса проверен (см. решение 2): индекс покрывающий, и
счёт задолженности по нему тоже не сканирует таблицу.

### 7. Длинный бюджет ответа даётся маршруту приёма, а не всему серверу

`WriteTimeout` у Go ставится в `readRequest`, до вызова обработчика, и потому
покрывает **и чтение тела**: при `read_timeout = 5m` и `write_timeout = 30s`
загрузка дольше 30 секунд обрывается, а `read_timeout` при этом обещает пять
минут. Премисса проверена по исходнику (`net/http/server.go`, постановка
write-дедлайна `defer`-ом внутри `readRequest`), симптом описан
[здесь](https://adam-p.ca/blog/2022/01/golang-http-server-timeouts/) и
[здесь](https://blog.cloudflare.com/exposing-go-on-the-internet/). Для 64 МиБ по
мобильной сети это не теоретический случай, и после выноса свёртки это
**единственный** оставшийся источник того же молчаливого обрыва.

Лечится это не подъёмом глобального умолчания, а дедлайном на том маршруте,
которому длинный бюджет нужен: обработчик приёма перед чтением тела ставит
`http.NewResponseController(w).SetWriteDeadline(now + read_timeout +
write_timeout)`. Тогда `/healthz` и будущий Read API сохраняют тридцатисекундную
защиту от застрявшей записи, конфиг не меняется вовсе, и не появляется пары
таймаутов, из которых один молча отменяет другой.

Механика проверена: `middleware.WrapResponseWriter` из chi реализует
`Unwrap() http.ResponseWriter`, поэтому `ResponseController` до соединения
добирается. Транспорт, не поддерживающий дедлайнов, отвечает
`http.ErrNotSupported` — это `DEBUG` и продолжение работы, а не отказ приёма.

Отвергнуто: **поднять умолчание `write_timeout` до `read_timeout`**. Три
возражения. Оно снимает защиту от застрявшей записи со **всех** маршрутов, ради
одного. Оно кладёт требование о глобальном параметре сервера в capability
приёма, где читатель Read API его не найдёт. И оно порождает вопрос
«сравниваются умолчания или эффективные значения», на который два реализатора
ответят по-разному.

Отвергнуто: **не трогать вовсе**. Так и было бы, будь это вместо выноса
свёртки; вместе с ним это доведение до конца — иначе `read_timeout` остаётся
обещанием, которого сервер не исполняет.

### 8. У цикла воркера есть синхронный шов, и тесты идут через него

`Worker.Pass(ctx) (Outcome, error)` — один проход, синхронный, без каналов;
`Run(ctx)` — тонкий `select` поверх него. Тесты зовут `Pass` напрямую и ничего
не ждут по часам; на `Run` остаётся один тест — «отмена завершает цикл», и он
синхронизируется возвратом `Run`, а не сном.

Без такого шва проверки «все свёрнуты», «проход конечен», «метка не сработала
на первом проходе» пишутся опросом базы с таймаутом, то есть сном в разной
форме, и мигают на загруженной машине. Гейт при этом перестаёт быть
детерминированным, а на нём стоит весь конвейер ревью.

Остальные швы — те, что есть:

- Порядок при конкурентных доставках проверяется наблюдаемым следствием
  порядка — **наследованием слоя**: доставка без плотных метрик обязана
  получить слой предшествующей ей по `(received_at, id)`.
- Отмена не оставляет половинчатого состояния — доставка остаётся `pending`, а
  не `parsed` с половиной объектов.
- `task verify:archive` остаётся оракулом сходимости: пересборка проигрывает
  журнал сама и воркера не касается.

### 9. Учёт доставки переживает обрыв соединения

`Accept` всё равно переписывается, и заодно чинится сузившийся до одного шага
риск: `store.CreateDelivery` идёт на контексте запроса, а тот отменяется при
обрыве связи клиентом. Тело к этому моменту уже в архиве (`arch.Write`
контекста не берёт), и отказ на вставке оставляет тело сиротой — восстановимо
только пересборкой с подменой базы. Раньше вероятность обрыва размазывалась по
следующей за вставкой свёртке; теперь вставка — последний шаг перед `200`.

Поэтому учёт ведётся на `context.WithoutCancel` с коротким собственным
дедлайном — тем же приёмом и по той же причине, по какой это делает
`fold.finish`: отмена снаружи не должна превращаться в свойство доставки.
Проверка формы тела остаётся на исходном контексте — там отменяемость уместна.

## Risks / Trade-offs

- **Абсолютный порядок при конкурентных приёмах недостижим** → две доставки,
  принимаемые одновременно, могут закоммитить строки в порядке, обратном их
  `received_at`; если воркер успел свернуть позднюю до того, как ранняя стала
  видимой, наследование слоя разойдётся с тем, что даст пересборка. Смягчение:
  окно сузилось (воркер один и берёт минимум из видимых, а не сворачивает в
  порядке завершения обработчиков), исход остаётся детерминированно чинимым
  (`healthlog reindex`), и сам эффект касается только доставок **без плотных
  метрик**. Абсолютную гарантию дало бы удержание порядка на приёме, то есть
  сериализация приёма — цена, которую задача платить не собиралась.
- **Ответ `200` больше не означает «разобрано»** → это объявленная смена
  контракта, и в `docs/architecture.md` она фиксируется как контракт, а не как
  деталь реализации воркера. Клиент HAE о разборе и не спрашивал; владелец
  видит исход в `parse_status` и в логе. Читатель, делающий `POST` → чтение,
  получает гонку — сегодня такой читатель один, тесты, и они переписаны на
  синхронный `Pass`.
- **`failed` из очереди не возвращается** → доставка, отказавшая по
  содержимому, ждёт пересборки. Это осознанная граница: обратное означало бы
  бесконечный повтор заведомо безнадёжного. Названа в спеке.
- **Задолженность после рестарта разбирается не мгновенно** → 116 тел живого
  архива это порядка минуты работы воркера; всё это время витрина неполна.
  Названо `INFO`-строкой при старте. `/healthz` этого не отражает — он статичен;
  отражать будет `/stats`, задача `stats-endpoint`.
- **Второй процесс на той же базе даёт двух воркеров** → «одна горутина» —
  свойство процесса, а не файла базы. Порчи витрины ждать не приходится
  (`_txlock=immediate` и повтор транзакции сериализуют слияние), но наследование
  слоя перестаёт быть функцией префикса. Механизма против этого не вводим:
  запуск второго `serve` на той же базе не входит ни в один сценарий проекта, а
  блокировка файла — отдельная задача с собственной ценой. Названо, чтобы не
  было открытием.
- **Свёртка теперь конкурирует с приёмом за базу** → она и раньше шла на
  отвязанном контексте, то есть параллельно следующему запросу; новое здесь
  только то, что параллельность стала штатной. `busy_timeout`,
  `_txlock=immediate` и повтор транзакции уже есть, а исчерпание повторов теперь
  не стирает доставку с полки (решение 4б). Наблюдение за этим — задача
  `merge-cost-wide-delivery`.
- **Тик даёт проход раз в минуту при пустой очереди** → это один запрос по
  покрывающему частичному индексу, в котором ноль строк. Цена измеримо нулевая,
  а без него состояние «работа есть, прогресса нет» невидимо.

## Migration Plan

Миграция схемы одна — `00006`, частичный индекс по неразобранным доставкам.
Данных она не трогает; `Down` снимает индекс.

Порядок выкладки обычный: `task build` → `task restart`. Первый старт нового
бинаря напечатает `INFO` с размером задолженности и разберёт её проходами
воркера — то есть заодно подберёт доставки, которые числятся `pending` после
миграции `00005`.

Откат — предыдущий бинарь: он свернёт всё синхронно, как раньше;
неразобранное к тому моменту останется `pending` до следующего `reindex`. Индекс
старому бинарю не мешает.

## Open Questions

- Метка задержки считается от `received_at`, который хранится с секундной
  точностью; для порога в пять минут этого достаточно, но если порог когда-то
  опустится до секунд, точности не хватит.
- Каждая будущая миграция, переводящая строки в `pending` (спека хранения этого
  прямо требует от задач, покрывающих новую секцию), теперь автоматически
  запускает пересвёртку под живым приёмом. Для `00005` это желаемое поведение;
  для миграции размером в годовой архив вопрос о темпе встанет заново.
