хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
@@ -0,0 +1,255 @@
## MODIFIED Requirements
### Requirement: Захват задачи неделим
Захват записи воркером SHALL быть одним неделимым запросом к базе: выбор
подходящей записи и пометка её захваченной MUST происходить вместе, одним
оператором с возвратом.
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
сохранение писало бы этот ноль поверх сохранённого значения.
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
признаком занятости. Условие записи результата сверяет именно это значение:
захват, перевыданный другому — по протуханию срока или после того, как человек
снял признак остановки подкомандой оснастки, — обязан обращать запись первого в
отказ.
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
два шага записали бы в одну запись по очереди, испортив её результат.
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
получить признак «работы сейчас нет».
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
при самом захвате.
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
одного из них теряется без следа.
Признак «работы нет» этим требованием не переопределяется — его нормирует
требование «Пустой прогон воркера — не отказ».
#### Scenario: За работой пришли трое разом
- **GIVEN** к работе пригодна ровно одна запись
- **WHEN** три захвата идут одновременно
- **THEN** запись получает ровно один из них
- **AND** двое остальных получают признак «работы сейчас нет»
#### Scenario: Захваченная запись не выдаётся второй раз
- **GIVEN** запись захвачена и срок захвата не истёк
- **WHEN** приходит следующий захват
- **THEN** эта запись ему не выдаётся
#### Scenario: Захват отдаёт идентификатор и свой признак
- **GIVEN** к работе пригодна запись
- **WHEN** воркер её захватывает
- **THEN** захват возвращает идентификатор записи и признак этого захвата
- **AND** колонки записи шаг читает отдельным чтением
#### Scenario: Признак перевыданного захвата отличается от прежнего
- **GIVEN** запись захвачена, и признак первого захвата известен
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
- **THEN** признак нового захвата отличается от признака первого
### Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата**
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
результата.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет запись, продолжая работать. Снять захват может и человек, вернувший
остановленную запись в работу. Без условия по уникальному признаку два воркера
пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не
владелец.
Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений
наружу шаг не делает. Требование от этого не ослабло — порча записи двумя
пишущими остаётся его предметом целиком.
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил за это время: молча, без строки в журнале и
без отказа тому, кто правил. Владелец записи, заголовок, краткое описание и темы
конвейер MUST не трогать.
Возвращает остановленную запись в работу сегодня владелец сервиса подкомандой
оснастки — своего экрана для этого у сервиса нет. Норму это не меняет: она
написана про поля, а не про того, чьей рукой правка сделана, и переживёт
появление экранов.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную запись
- **AND** владелец за это время изменил поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
- **AND** правка владельца на месте
#### Scenario: Захват ушёл под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** за это время та же запись досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
#### Scenario: Человек снял остановку под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** человек за это время снял с неё признак остановки, освободив захват
- **AND** запись досталась другому воркеру
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
### Requirement: Остановка записи — признак, а не рубеж
Сервис SHALL останавливать запись отдельным признаком с причиной и MUST не
стирать при этом достигнутый рубеж. Признак MUST нести время остановки, причину
и машинный текст отказа.
Снятие признака SHALL возвращать запись в работу **с того рубежа, где она
стояла**, и MUST сбрасывать **всё, чем прошлый прогон её удерживал**:
- признак остановки — время остановки, причину и машинный текст отказа;
- признак захвата и срок его протухания;
- число отказов;
- паузу перед повтором;
- время входа в рубеж.
Перечень назван целиком и в одном месте, потому что забытое поле не даёт ни
отказа, ни строки в журнале. Оставленный признак захвата держит запись занятой до
протухания срока и отдаёт её потом чужому шагу — тому, чей результат условен по
прежнему значению. Оставленное время входа в рубеж останавливает запись снова
первым же захватом, если остановленной она простояла дольше предела, и перезапуск
не работает вовсе. Оставленные отказы и пауза откладывают первый же прогон на
накопленный срок.
Возврат в работу MUST идти **через домен**: тот, кто его делает, называет запись,
а поля выше сбрасывает домен одним действием. Правка колонок мимо домена
повторяет перечень вторым местом, и второе место расходится с первым молча.
Возврат в работу MUST писать событие журнала записи с происхождением «человек».
Иначе запись, побывавшая остановленной и вернувшаяся в работу, неотличима в
журнале от записи, которую конвейер вёл без остановок, а происхождение события
перестаёт различать что-либо: другого писателя, кроме конвейера, у журнала не
остаётся.
Инструментом возврата сегодня служит подкоманда набора инструментов
разработчика: панели у сервиса нет, а экраны владельца приносят отдельные задачи.
Норма написана про поля и про домен, а не про инструмент, и появление экрана её
не трогает.
Прежние состояния отказа и смерти MUST не заводиться заново: обе причины
восстанавливаются одинаково — снятием признака, — и различие между ними
перестаёт быть структурным, оставаясь причиной остановки. Состояние, называющее
отказ, стирает достигнутый рубеж, и продолжение с места остановки становится
невозможным.
**Способ вывести запись из выборки MUST быть один — этот признак.** Второго
признака, исключающего запись из работы помимо рубежа и паузы, MUST не
заводиться: два способа расходятся, и молчаливо теряется тот, который забыли
проверить. Условие отбора MUST не выводить запись из выборки молча — запись,
переставшая браться в работу, обязана нести признак остановки с причиной.
Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному
месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её
без следа.
Остановленная запись MUST не выдаваться захвату.
#### Scenario: Остановленная запись продолжает с места остановки
- **GIVEN** шаг остановил запись на рубеже приведения
- **WHEN** признак остановки снимают
- **THEN** следующим идёт отправка на распознавание, а не повторное приведение
#### Scenario: Остановленная запись не выдаётся захвату
- **GIVEN** у записи стоит признак остановки
- **WHEN** за её рубежом приходит захват
- **THEN** запись ему не выдаётся
#### Scenario: Снятие признака сбрасывает всех сторожей
- **GIVEN** запись остановлена с накопленными отказами и паузой
- **AND** остановленной она простояла дольше предела времени в рубеже
- **WHEN** признак остановки снимают
- **THEN** число отказов, пауза и время входа в рубеж сброшены
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
#### Scenario: Возврат в работу освобождает захват
- **GIVEN** запись остановлена, и признак прошлого захвата на ней стоит
- **WHEN** признак остановки снимают
- **THEN** признака захвата и срока его протухания на записи нет
- **AND** ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока
#### Scenario: Возврат в работу виден в журнале записи
- **GIVEN** запись остановлена
- **WHEN** её возвращают в работу
- **THEN** в журнале событий записи есть событие с происхождением «человек»
### Requirement: Конвейер ответа отправителю не шлёт
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
своей записи владелец узнаёт **карточкой записи**; адрес карточки и содержимое
ответа нормирует capability `archive`.
Владелец сервиса узнаёт исход журналом и журналом событий записи. Панели, где он
видел бы то же строкой таблицы, у сервиса нет — она ушла вместе со встроенным
хранилищем, и второго канала наблюдения это не отняло: журнал событий пишется
по-прежнему, а читается запросом к базе, пока экрана нет.
Держатель нормы сменился вместе с убранным опросом готовности: прежде исход
отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше
нет. Обязанность при этом не изменилась — изменилось только то, каким адресом
она исполняется.
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
потерянную ветку.
Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой
записи — там остановка видна признаком и причиной — и журналом владельца, где у
неё стоит причина. Обязанность при этом сменила направление: прежде об отказе
сообщали, теперь отказ доступен спросившему. Отправитель, который не
спрашивает, об остановке не узнаёт.
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме —
норму держит capability `storage`.
#### Scenario: Готовый текст отправителю не уходит
- **GIVEN** запись дошла до конечного рубежа
- **WHEN** шаг конвейера её завершает
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
- **AND** текст достаётся отдельным адресом текста записи
#### Scenario: Остановка видна карточкой, а не сообщением
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** владелец записи спрашивает её карточку
- **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину
- **AND** в журнале владельца сервиса есть запись об остановке с причиной