хранилище, файлы записей и очередь переведены на встроенную PocketBase

- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
av
2026-08-12 08:31:59 +03:00
parent 09cedc4e61
commit 01cc31d45f
55 changed files with 5238 additions and 1235 deletions
@@ -0,0 +1,166 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Поля с записью нет
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Имя файла в хранилище
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым
своим приёму не подконтрольно.
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла в хранилище расширение было всегда.
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
инварианту приватности, а изъятие из него кончается расширением — хвостом после
последней точки.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** имя файла в хранилище оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** имя файла в хранилище оканчивается на `.audio`
#### Scenario: Имя отправителя в хранилище не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла в хранилище не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Журнал приёма прослеживает запись
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается собственным полем журнальной строки. Имя, под которым
файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть
ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает
ссылку целиком. Норму держит capability `storage`.
#### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
#### Scenario: Имени файла в хранилище в журнале нет
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
### Requirement: Метка метрики несёт только известное расширение
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
употребить его меткой метрики: расширение приводится к нижнему регистру и
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
без расширения, и различать его от чужого хвоста метка обязана.
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
временных рядов, которым иначе распоряжается анонимный отправитель.
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
с ней.
Имя файла в хранилище это требование не трогает: там расширение остаётся тем,
каким пришло, — это уже нормировано требованием «Имя файла в хранилище».
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
`other` в метке означает «расширение не из перечня», а само оно стоит полем
журнальной строки приёма и полем формата строки конвертации.
#### Scenario: Незнакомое расширение наружу не выходит
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов
- **THEN** метка метрики принимает значение `other`
- **AND** имя файла в хранилище сохраняет пришедшее расширение
#### Scenario: Известное расширение идёт как есть
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем `sample.MP3`
- **THEN** метка метрики принимает значение `mp3`
@@ -0,0 +1,200 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, и что считается отказом шага.
Описаны пустой прогон воркера, захват задачи и срок его протухания, число
попыток и выбывание задачи из очереди, пауза перед повтором. Сознательно не
описаны переходы состояний и сама цепочка `created → converted → transcribe →
done | failed`, отмена контекста посреди шага, освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Захват задачи неделим
Захват задачи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей задачи и пометка её захваченной MUST происходить вместе, и захваченная
задача MUST возвращаться тем же шагом.
Одна и та же задача MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за одним состоянием одновременно, запись MUST достаться одному, а
второй MUST получить признак «работы в этом состоянии нет».
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
обработки невоспроизводим, а проверка, опирающаяся на «следующую» задачу,
зелена через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
одного из них теряется без следа.
Признак «работы нет» этим требованием не переопределяется — его нормирует
требование «Пустой прогон воркера — не отказ».
#### Scenario: За задачей пришли трое разом
- **GIVEN** в опрашиваемом состоянии лежит ровно одна задача
- **WHEN** три захвата этого состояния идут одновременно
- **THEN** запись получает ровно один из них
- **AND** двое остальных получают признак «работы в этом состоянии нет»
#### Scenario: Захваченная задача не выдаётся второй раз
- **GIVEN** задача захвачена и срок захвата не истёк
- **WHEN** за тем же состоянием приходит следующий захват
- **THEN** эта задача ему не выдаётся
### Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват задачи
всё ещё принадлежит ему. Запись MUST быть условна по признаку захвата, а шаг,
чей захват за время работы достался другому, MUST завершиться без записи
результата и без ответа отправителю.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет задачу, продолжая работать. Без этого условия два воркера пишут в одну
задачу по очереди, счётчик попыток сбрасывает тот, кто уже не владелец, а
отправитель получает два ответа на одну запись.
Шаг MUST записывать только те поля, которыми распоряжается сам. Задачу он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
уверен, что правка на месте.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную задачу
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
- **AND** правка владельца на месте
#### Scenario: Захват ушёл под работающим шагом
- **GIVEN** шаг работает над захваченной задачей
- **AND** за это время та же задача досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
- **AND** отправителю ничего не отправляется
### Requirement: Брошенная задача возвращается в работу
Задача, захваченная и брошенная на середине, SHALL доставаться снова по
истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший
захват MUST не мешать выдать задачу следующему.
Срок задаётся шагом конвейера и MUST быть не меньше того времени, которое этот
шаг может занять на самом длинном допустимом входе. Срок короче делает
протухание штатным событием живого шага, а не признаком беды.
Все значения времени, по которым идёт этот отбор, MUST записываться и сравниваться
в одном виде — том же, в каком хранилище пишет собственные времена записи.
Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем, обращает
условие в постоянную истину или постоянную ложь, причём молча.
#### Scenario: Захват протух
- **GIVEN** задача захвачена, а время захвата отстоит дальше срока
- **WHEN** за её состоянием приходит захват
- **THEN** задача выдаётся ему
#### Scenario: Срок сравнивается с временем, записанным хранилищем
- **GIVEN** задача захвачена, и время захвата записано в том же виде, в каком
хранилище пишет время изменения записи
- **WHEN** за её состоянием приходит захват до истечения срока
- **THEN** задача ему не выдаётся
### Requirement: Число попыток и состояние «мертва»
У задачи SHALL быть число попыток. Оно MUST расти при каждом захвате и MUST
возвращаться к нулю, когда шаг завершился без отказа. Рост при захвате, а не при
отказе, засчитывает попытку и задаче, брошенной на середине: шаг, уносящий с
собой процесс, до объявления отказа не доходит никогда, и без этого такая задача
крутилась бы вечно.
Задача, захваченная с числом попыток сверх заданного предела, MUST переводиться в
состояние «мертва» тем, кто её захватил, и MUST не отдаваться шагу в работу. Перевод
принадлежит одному месту: условие отбора, молча пропускающее задачу мимо выборки,
оставило бы её без состояния и без следа.
Мёртвая задача MUST отбираться владельцем по своему состоянию и MUST
возвращаться в работу правкой этого состояния — без запроса в консоли сервера.
Переход в «мертва» MUST сообщать отправителю о неудаче ровно так же, как
сообщает о ней отказ шага. Иначе он становится третьим исходом там, где инвариант
проекта «Принятая запись не теряется молча» допускает два: задача не пригодна к
повтору и об отказе никто не сказал.
От состояния отказа «мертва» отличается тем, чей это приговор. В `failed` задачу
переводит шаг, рассудивший об этой записи окончательно: конвертация не удалась,
распознавание вернуло ошибку. В «мертва» задача уходит без такого суждения — мы
повторяли и перестали. Ни один шаг конвейера в «мертва» не переводит сам.
Прежний признак «задача с ошибкой», исключавший задачу из выборки навсегда и
отдельный от перечня состояний, MUST не заводиться заново: два способа вывести
задачу из выборки расходятся, и молчаливо теряется тот, который забыли проверить.
#### Scenario: Задача падает на каждой попытке
- **GIVEN** шаг конвейера отказывает на каждой попытке
- **WHEN** задача проходит заданное число попыток
- **THEN** она переходит в состояние «мертва»
- **AND** следующий захват её не выдаёт
- **AND** отправитель получает сообщение о неудаче
#### Scenario: Шаг уносит процесс, не объявив отказа
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
- **WHEN** задача захватывается снова заданное число раз
- **THEN** она переходит в состояние «мертва»
#### Scenario: Прошедшая задача попыток не копит
- **GIVEN** задача прошла подряд несколько состояний без единого отказа
- **WHEN** смотрят её число попыток
- **THEN** оно не приблизилось к пределу
#### Scenario: Мёртвая задача возвращена в работу
- **GIVEN** задача в состоянии «мертва»
- **WHEN** её состояние сменили на то, с которого она отказывала
- **THEN** следующий захват выдаёт её снова
### Requirement: Пауза перед повтором нарастает
Перед повтором **отказавшей** задачи сервис SHALL выдерживать паузу, и пауза
MUST расти с числом её попыток до объявленного потолка. Задача MUST не
выдаваться захвату, пока пауза не кончилась.
Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что
внешняя операция ещё идёт, отработал без отказа: он назначает **свою** задержку
опроса, заданную числом, и попытки при этом не тратит. Пауза, выведенная из
числа попыток, на таком шаге вырождается в наименьшее своё значение и учащает
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее секунды.
#### Scenario: Отказавшая задача ждёт
- **GIVEN** задача отказала на шаге конвейера
- **WHEN** захват приходит раньше конца её паузы
- **THEN** задача ему не выдаётся
#### Scenario: Вторая пауза длиннее первой
- **GIVEN** задача отказала дважды подряд
- **WHEN** сравнивают паузу после второго отказа с паузой после первого
- **THEN** вторая длиннее
#### Scenario: Ожидание операции не учащается и не тратит попыток
- **GIVEN** внешняя операция распознавания ещё идёт
- **WHEN** шаг проверки отрабатывает подряд несколько раз
- **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число попыток задачи не растёт
@@ -0,0 +1,240 @@
## Purpose
Где живут запись, её метаданные и её файл; как файл попадает в хранилище и как
отдаётся обратно; что видит и правит владелец сервиса; с каким состоянием сервис
поднимается на чистом каталоге.
Сознательно не описаны удаление записей и файлов, срок их хранения, резервное
копирование и вход пользователей: первое и второе решены хранить бессрочно,
третье принадлежит серверу, четвёртое — задаче про вход. Первая задача, которая
тронет любое из перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи обоими входами без единого ручного
шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
решение задачи, а не следствие отказа.
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
#### Scenario: Первый запуск на пустом каталоге
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** он заводит своё хранилище и продолжает работу
- **AND** принятая следом запись доходит до состояния `done`
#### Scenario: Повторный запуск на заведённом каталоге
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежние записи
### Requirement: Файл записи живёт в хранилище
Сервис SHALL держать файл записи в хранилище, а не отдельным каталогом рядом с
ним. Файл MUST попадать туда вместе с записью, которой принадлежит, и MUST
адресоваться этой записью, а не путём на диске.
Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога
записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт
ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и
делается.
Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище,
ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в
память не помещается.
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у
поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без
предела», а величины на два-три порядка меньше нужного, и оставленные как есть
они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись
исчерпывает попытки на шаге конвертации.
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
отдают его внешней программе, — MUST получать рабочую копию **одним общим
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
оставшаяся во временном каталоге, и узнать о ней неоткуда.
#### Scenario: Принятая запись легла в хранилище
- **WHEN** запись принята любым входом
- **THEN** её файл лежит в хранилище и связан со своей записью
- **AND** отдельного каталога записей рядом с хранилищем не появляется
#### Scenario: Запись длиннее чужого умолчания принимается
- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла
- **THEN** она ложится в хранилище, а не отвергается
#### Scenario: Шаг конвейера берёт файл по записи
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** шаг конвейера берётся за эту запись
- **THEN** он получает файл по самой записи, а не по пути на диске
#### Scenario: Рабочая копия убрана после отказа шага
- **GIVEN** шагу выдана рабочая копия файла
- **WHEN** шаг завершается отказом
- **THEN** рабочей копии во временном каталоге не остаётся
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи. Отданный файл MUST совпадать с принятым по длине.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Наружу хранилище отдаёт только то, что заказано
Сервис SHALL держать закрытыми собственные разделы хранилища, которые тот
публикует тем же портом. Запрос без прав владельца MUST получать отказ на
перечисление и чтение записей коллекций, на служебные разделы хранилища —
журналы запросов, резервные копии, настройки, расписание — и на правку чего бы
то ни было.
Требование заводится потому, что порт опубликован в интернет, а вместе с
переводом наружу выходит поверхность, которой у сервиса не было. Что API сервиса
сегодня открыт всякому — известно и записано моделью угроз; новая поверхность под
это знание не подпадает и закрывается здесь.
Правило доступа, оставленное пустым, значит «только владелец панели». Именно
пустым оно MUST и оставаться: непустое правило, поставленное будущей правкой
схемы, открыло бы перечисление всех записей анонимному запросу и не нарушило бы
при этом ни одного другого требования.
#### Scenario: Аноним перечисляет записи
- **WHEN** запрос без прав владельца просит список записей коллекции задач
- **THEN** приходит отказ
#### Scenario: Аноним читает служебный раздел
- **WHEN** запрос без прав владельца просит журнал запросов или список резервных
копий хранилища
- **THEN** приходит отказ
### Requirement: Владелец видит записи в панели
Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по
своему идентификатору и правится, а её файл слушается и скачивается.
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
второго процесса.
Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка
состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка
из кода: служебные поля прошлого состояния — признак захвата, время захвата,
пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в
работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и
умирает от первого же отказа, — и не узнает об этом.
Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
перечень состояний — закрытым.
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
сервис — это записано моделью угроз проекта.
#### Scenario: Принятая запись видна владельцу
- **GIVEN** запись принята и её задача заведена
- **WHEN** владелец отбирает задачи по идентификатору принятой
- **THEN** он видит её строкой со своим состоянием
- **AND** файл этой записи скачивается из той же строки
#### Scenario: Мёртвую задачу вернули в работу правкой в панели
- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком
прежнего захвата
- **WHEN** владелец меняет её состояние на рабочее
- **THEN** признак захвата, время захвата, пауза и число попыток очищены
- **AND** ближайший захват выдаёт задачу
### Requirement: Пароль владельца от панели не лежит в конфигурации
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
отпечаток.
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
файлы разом — это самое чувствительное, что есть у сервиса.
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без
владельца не мешает принимать записи.
#### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается
- **THEN** он принимает записи обоими входами
- **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается
- **GIVEN** владелец панели заведён
- **WHEN** сервис запускается снова
- **THEN** приглашения завести владельца в журнале нет