удалён вход Telegram, владелец записи стал обязателен в схеме

- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
av
2026-08-15 07:24:35 +03:00
parent 97ceb7bb69
commit 8f7c3a057a
74 changed files with 2453 additions and 2733 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-14
@@ -0,0 +1,225 @@
## Context
Сервис принимает записи двумя входами. Приложение и внешняя программа приходят по
HTTP с сессией, и у их записей есть владелец; бот приходит от чата, и у его
записей владельца нет — связи чата с учётной записью сервис не ведёт. Отсюда
оговорка про бота в каждом решении о владельце: в спеке приёма, в спеке доступа,
в схеме хранилища и в отборе записей.
Вход Telegram при этом несёт собственный вес: клиент, транспорт обновлений,
разбиение длинного текста на сообщения, заглушка отправителя, сборка входа при
старте с разбором «недоступность или ошибка настройки», список допущенных людей,
доставка ответа из конвейера и правило о недоставленном ответе.
Ограничение, которое правит решением: применённый шаг схемы не переписывается —
это инвариант проекта, и он **critical**. Колонки `tg_chat_id`,
`tg_reply_message_id` и значение `telegram` перечня `source` заведены шагами
`202608110001` и `202608140002`, оба применены. В боевой базе по этим колонкам
лежат записи живых людей.
## Goals / Non-Goals
**Goals:**
- Убрать вход Telegram из кода, настроек и зависимостей целиком.
- Сделать владельца записи безусловным: завести запись без владельца сервису
нечем.
- Сделать владельца обязательным **в схеме**, а не только в приёме: ни одной
записи не потерять и ни одного применённого шага схемы не переписать.
- Оставить возврат входа дешёвым: удалённое лежит в истории git одним изменением,
и возвращается оно вместе со связью чата и учётной записи.
**Non-Goals:**
- Замена доставки ответа. Уведомлений взамен чата не заводим — на это стоит
задача `ntfy-delivery`.
- Чистка хранилища от колонок и значений бота. Ни нового шага схемы, ни правки
применённых.
- Переделка приёма по HTTP, конвейера, распознавания и панели.
- Связь чата Telegram с учётной записью. Она нужна возврату входа, и заводит её
своя задача.
## Decisions
### Схема теряет только необязательность владельца
Колонки чата и ответного сообщения остаются, значение `telegram` в перечне
источников остаётся, применённые шаги не переписываются. Новый шаг схемы у
изменения один, и делает он ровно одно: колонка владельца у аудиозаписи и у файла
перестаёт принимать пустое значение.
Шаг безопасен потому, что записей без владельца в боевой базе нет — это сказал
владелец сервиса, отвечая на прямой вопрос, и на этом ответе решение и стоит.
Будь такие записи, шаг пришлось бы либо отменить, либо оплатить необратимой
правкой чужих данных: назначить им владельца выдумкой или удалить.
**Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
оракулом: хранилище держит обязательность связи проверкой записи при сохранении,
а не ограничением таблицы. Смена признака на базе с ничьей записью проходит
зелёным и такую запись оставляет — то есть прогон шага на копии боевой базы
чистую базу от грязной не отличает. Заставить шаг считать строки самому владелец
решил не делать (решение от 2026-08-14): безопасность держится ручной проверкой,
и она названа первым шагом плана перехода.
Цена промаха, если ничью запись всё же проглядят: она становится незакрываемой.
Захват идёт сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение
отказывает — включая то, которым ставится признак остановки. Запись повторяется
неограниченно, не оставляя следа ни в метрике, ни в журнале событий.
Колонка темы словаря обязательной была уже — разное правило у трёх колонок одного
смысла читалось бы как недосмотр, и теперь их правило одно.
Рассмотрено и отвергнуто:
- **Новый шаг, убирающий колонки бота.** Он законен — запрет стоит на правке
применённого шага, а не на новом, — но необратим по данным: колонки заполнены у
записей живых людей, и восстановить их после удаления неоткуда. Возврат входа
завёл бы их заново пустыми.
- **Сужение перечня `source` до `unknown` и `api`.** Строки со значением
`telegram` в базе есть, и после сужения перечня они перестают проходить
проверку схемы: панель откажется их сохранять, а поведение записи при чтении
зависит от библиотеки. Цена — молчаливая порча уже принятых записей.
Поля адресата уходят при этом из **модели** — колонки остаются в схеме, а
аудиозапись их больше не несёт. Это безопасно ровно потому, что колонки адресата
пишет только заведение записи: снимок шага конвейера кладётся отображением, где
их нет вовсе, и потому значение уже принятой записи он не затирает. Правила
`internal/archrules` такую пару держат: колонка, которую никто не пишет, законна,
незаконна колонка, которую пишут, но не читают.
Следствие: константа `entity.SourceTelegram` остаётся в модели. На неё ссылается
применённый шаг `202608140002`, и убрать её значило бы переписать применённый
шаг. В модели она получает комментарий об историческом значении: новых записей с
ним не появляется.
### Владелец записи перестаёт быть необязательным и в модели
Поле владельца в аудиозаписи становится обычной строкой вместо ссылки, которой
позволено отсутствовать. Модель здесь повторяет схему, а не расходится с ней:
значения «владельца нет» больше не существует ни на одном уровне.
Рассмотрено и отвергнуто:
- **Оставить ссылку, которой позволено отсутствовать.** Она заставляет каждого
читателя решать, что делать с отсутствием, хотя отсутствия больше не бывает.
Два способа выразить одно значение расходятся молча.
- **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
уходила в конвейер, стоила денег на распознавание и не доставалась потом
никому. Решение владельца от 2026-08-14 — обязательность держит схема.
Правило «пустой владелец не совпадает ни с одной записью» при этом остаётся и не
становится избыточным: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем. Снять его, сославшись на схему, значит
открыть выборку первому же вызывающему без учётной записи.
### Отправителя о готовности и об остановке узнаёт опрос, и другого канала нет
Доставка из конвейера убирается вместе с договором об отправителе сообщений.
Правило о недоставленном ответе уходит: недоставке взяться неоткуда.
Рассмотрено и отвергнуто:
- **Оставить заглушку отправителя.** Договор без единой реализации, кроме
пустой, — это мёртвый шов, который читается как незаконченная работа и
переживает не одну задачу.
- **Завести уведомление взамен сразу.** Это другой предмет со своей внешней
зависимостью; задача на него уже стоит в беклоге, и слепить их значит собрать
два изменения в одно.
### Метка убранного входа не выставляется вовсе
Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
месте.
### Незнакомые ключи настроек по-прежнему не судятся
Секция `[telegram]` и ключ `server.users_while_list` убираются из структуры
настроек и из образца. Имя ключа написано с опечаткой — `while` вместо `white`, —
и это записано «Расхождением» в `docs/conventions/config.md`; удаление ключа
закрывает и его. Файл, где их забыли, сервис поднимет молча: разбор настроек
незнакомые ключи не проверяет.
Рассмотрено и отвергнуто: **завести отказ старта по незнакомому ключу**. Это
новое поведение настроек, полезное само по себе, но чужое этой задаче: оно
касается всех ключей, а не убранных, и разбирается своей задачей.
## Risks / Trade-offs
- **У сервиса не остаётся входа, которым человек может воспользоваться.** Это
главный риск изменения, и он не смягчается ничем внутри задачи. Приложения нет,
своего токена у программы нет, значит после выкладки положить запись можно
единственным способом: собранным руками запросом с сессией, снятой из браузера
после входа. Срок этого состояния задаётся чужими задачами — экраном приложения
и личными токенами, — и внутри этой задачи не назначается. Прецедент в журнале
ревью: 2026-08-12 поверхность закрыли так, что войти не мог никто, и спасением
тогда был именно бот.
- **Боевой файл настроек переживёт выкладку с мёртвой секцией** → сервис
поднимется без бота, и это ровно то, чего мы хотим; секцию убирает человек при
выкладке, и об этом сказано в плане перехода.
- **Записи, застрявшие в конвейере на минуту выкладки, дойдут до текста, и ответа
в чат по ним не уйдёт** → отправитель в Telegram ответа не получит вовсе.
Смягчения нет и быть не может: чат — это и есть убираемый вход. Расшифровка не
теряется, владелец сервиса видит её в панели.
- **Ничья запись, если она всё же найдётся, шагом не ловится** → она остаётся в
базе и становится незакрываемой: сохранить её нечем, остановить тоже, а следа
не остаётся ни в метрике, ни в журнале событий. Смягчение одно и ручное —
проверка запросом первым шагом плана перехода. Заставить шаг считать строки
самому владелец решил не делать.
- **Возврат входа стоит восстановления кода** → удаление уезжает одним
изменением, и его архив вместе с историей git даёт точку возврата. Связь чата с
учётной записью всё равно потребует нового кода: без неё возвращать вход значит
возвращать записи без владельца.
- **Правила, потерявшие предмет, останутся в памятке и в модели угроз** →
инварианты про белый список бота и про ответ отправителю убираются синком
документации тем же изменением, а не следующей задачей.
## Migration Plan
1. Проверить боевую базу запросом: `SELECT count(*) FROM audio_records WHERE
owner = ''` и то же по `files`. Оба должны отдать ноль. Прогон самого шага на
копии этого не показывает — он проходит зелёным и на грязной базе.
2. Собрать образ (`task image`) — сборка образа в гейт не входит, и человек
обязан прогнать её сам.
3. Выложить (`inv pl -- transcriber` из `pet-project-server`) — запускает человек.
4. Убрать из боевого файла настроек секцию `[telegram]` и ключ
`server.users_while_list`. Порядок с шагом 3 безразличен: оставленные ключи
сервис пропускает молча, и для подъёма этот шаг не нужен.
5. Ключ доступа к боту остаётся в хранилище выкладки под возврат входа — решение
владельца от 2026-08-14. Ротация не делается, бот у Telegram остаётся
зарегистрированным. Цена решения названа прямо: отправитель голосового не
получит ни ответа, ни отказа и не отличит выведенный вход от сломанного
сервиса. Оповещать людей из списка допущенных владелец не стал.
6. Откат — прежний образ. Он потребует вернуть настройки: сервис прошлой версии
без ключа `telegram.enabled` не поднимается. Схему откат вернёт своим
обратным шагом — колонка владельца снова примет пустое значение, — и на
записях это не сказывается: пустых среди них нет.
### Формы решения, между которыми выбирали
Форма выбрана не единственной рассмотренной, и остальные две названы здесь, а не
подразумеваются:
- **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
следующая задача. Размен здесь честный — стоимость сопровождения против
стоимости отката, — и выбран он в пользу первой.
- **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
адресатом — чатом владельца строкой настроек; связь чата с учётной записью для
этого не нужна. Смягчает главный риск: человек хотя бы узнаёт исход. Отвергнуто
потому, что заводит понятие «канал уведомления владельца», которое тут же
переделает задача `ntfy-delivery`, и держит ради этого клиент, договор и
зависимость — плату за временный мост.
## Open Questions
- Критерии приёмки постановка не назвала: они предложены в `tasks.md` и
подтверждаются человеком на чекпоинте.
- Судьба задач беклога `telegram-account-link` и
`bot-api-only-through-bot-client`: обе теряют предмет до возвращения входа.
Решает владелец; это изменение их не трогает.
@@ -0,0 +1,80 @@
## Why
Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
записи заводятся, правило «каждая запись принадлежит человеку» действует
наполовину: половину записей оно накрывает, а другую половину обходит, и каждое
решение о владельце приходится писать с оговоркой про бота.
Основной вход сегодня — приложение, и работа идёт над ним. Второй вход при этом
стоит денег на каждой задаче: его нельзя не учитывать в приёме, в доставке
ответа, в отборе записей и в настройках. Убираем его, чтобы модель стала одной, и
убираем **временно**: бот вернётся, когда появится связь чата с учётной записью.
## What Changes
- **BREAKING**: вход Telegram убирается целиком. Бот не поднимается, записи от
него не принимаются, ответ в чат не уходит.
- **BREAKING по смыслу, а не по разбору**: настройки входа Telegram и список
допущенных к боту людей уходят из конфигурации. Файл, где их забыли, сервис
примет молча — незнакомые ключи разбор настроек не судит, — но значить они
перестают что бы то ни было. Секцию убирает человек при выкладке.
- У записи появляется владелец на правах обязательного, и держит это хранилище:
колонка владельца у записи и у файла перестаёт принимать пустое значение. Завести
ничью запись нельзя ничем — ни приёмом, ни конвейером, ни рукой в панели.
- Из конвейера уходит доставка ответа отправителю: единственный оставшийся вход
узнаёт исход опросом готовности и из панели владельца. Вместе с доставкой
уходят правило про недоставленный ответ и обязанность остановки сообщать
отправителю.
- Наблюдатель видит один поднятый вход вместо двух.
- Записи с источником Telegram, если они есть, остаются нетронутыми: колонки чата
и ответного сообщения из схемы не убираются, и ни одна запись не удаляется.
Записей **без владельца** в базе при этом нет — так ответил владелец сервиса, и
на этом стоит обязательность колонки; прогон нового шага схемы на копии боевой
базы проверяет это до выкладки.
## Capabilities
### New Capabilities
Новых нет: изменение убирает поведение, а не заводит.
### Modified Capabilities
- `intake`: уходит требование о признаке включения входа Telegram; требование о
видимых наблюдателю входах говорит об одном входе.
- `pipeline`: уходят требования о недоставленном ответе и об обязанности всякой
остановки сообщать отправителю — адресата у сообщения не осталось. Взамен
появляется требование, что конвейер наружу не обращается вовсе. Из выборки
воркера и из требования о держателе захвата уходят оговорки про записи без
владельца и про ответ отправителю.
- `access`: запись без владельца перестаёт быть возможной. Правило «чужую запись
не отдают» не меняется; остаётся и правило «пустой владелец не совпадает ни с
одной записью» — оно запрещает спрашивать ничьим именем, а не заводить ничью
запись.
- `storage`: колонка владельца у аудиозаписи и у файла перестаёт принимать пустое
значение — это новый шаг схемы. Приведённая копия файла получает владельца
своей записи. Из требований о подъёме на чистом каталоге и о пароле владельца
уходит «обоими входами».
## Impact
- **Код**: удаляются клиент бота, отправитель сообщений, разбиение длинного
текста, заглушка отправителя, транспорт бота, сборка входа при старте и договор
об отправителе сообщений. Из службы расшифровки уходят приём записи от бота и
отправка текста в чат.
- **Настройки**: секция `[telegram]` целиком и ключ `server.users_while_list`
(имя ключа в коде написано с опечаткой, и она здесь воспроизведена намеренно —
искать в боевом файле надо именно его).
- **Зависимости**: `go-telegram-bot-api/telegram-bot-api/v5` уходит из манифеста.
- **Хранилище**: один новый шаг схемы, и он делает колонку владельца обязательной
у аудиозаписи и у файла. Применённые шаги не переписываются, колонки чата и
ответного сообщения остаются на месте вместе с записями, которые их заполнили.
- **Наблюдение**: метка `telegram` у признака поднятого входа больше не
выставляется.
- **Документы**: паспорт теряет потребителя и два типовых сценария; инвариант о
белом списке бота уходит из памятки; модель угроз теряет вход.
- **Задачи беклога**: `telegram-account-link` и `bot-api-only-through-bot-client`
теряют предмет до возвращения бота. Судьбу их решает владелец — это изменение
их не закрывает.
@@ -0,0 +1,192 @@
# Ревью remove-telegram-intake — триаж
## Сводка
- **Режим:** по графу. **Метка:** `large` (разметка `review-scope`). 50 файлов, −2394 строки, семь
удалённых файлов кода, новый шаг схемы; необратимый элемент — шаг схемы, переписаны инварианты
`CLAUDE.md`.
- **База диффа:** `origin/master` отстала на два закрытых изменения; сверка шла против `HEAD` плюс
незакоммиченное рабочее дерево.
- **Гейт:** зелёный целиком (`task gate` → 0).
- **Сигнал о заниженной метке:** `review-code` возражений не подал. `review-basics` не запускался —
второго независимого голоса о метке нет.
- **На вход:** 22 находки (specs 5, code 8, architecture 3, ops 2, adversary 4, autotests 0) плюс
4 замечания. После дедупликации по причине — 11 различных причин, одна выброшена как ошибочная.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
| --- | --- | --- | --- | --- |
| requirements | `openspec/specs/{intake,pipeline,access,storage}` + дельты | разбор | specs | закрыта, 5 находок |
| autotests | `CLAUDE.md` «Гейт»/«Команды» | — | autotests | закрыта, 0 находок + замечание о дрейфе `go-linters.md` |
| conventions + техника | `docs/conventions/` | разбор | code | закрыта, 7 находок + 1 ошибочная |
| architecture | `docs/architecture.md` (+ `passport.md`) | доказательство | architecture | закрыта, 3 находки |
| security | `docs/security.md` | доказательство | adversary | закрыта, 4 находки + 3 свойства без пути |
| operations | `docs/architecture.md` «Эксплуатация» (+ `database.md`) | доказательство | ops | закрыта, 2 находки |
Тем без отчёта нет. Тем без дома нет. `basics` не запускался — своих тем сверх ядра план ему не дал.
## Блокирует мердж
### Ничья запись переживёт новый шаг схемы и станет незакрываемой
- Файл: `internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go:20-30`;
`openspec/changes/remove-telegram-intake/design.md`, «Migration Plan», п. 1
- Severity: major. Confidence: high
- Оракул (переснят триажем, `go test ./internal/adapter/repo/pocketbase/ -run TestTriageProbeOwnerRequiredOverOwnerlessRow`):
```
PRE-STEP: ничья запись заведена, owner=""
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
ПОСЛЕ ШАГА: строка на месте, owner=""
FindAndAcquire: выдана запись
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
```
- Последствие: `Required` у поля связи PocketBase — проверка при сохранении записи, а не ограничение
таблицы. Шаг проходит зелёным на базе с ничьей записью, и запись остаётся. Дальше она выдаётся
воркеру (захват идёт сырым запросом мимо валидации), любое сохранение падает, `halt()` перехватывает
отказ до строк, растящих `WorkerJobCounter` и пишущих `record_events`. Запись не останавливается,
берётся снова по истечении срока захвата и повторяется неограниченно: ни метрики, ни журнала
событий, только строка в логе контейнера. Предвыкладочная проверка, на которой стоит безопасность
шага, не отличает чистую базу от грязной.
- Найдено проходами: specs, code, ops (три прохода, одна причина).
- **Действие: развилка.** Три варианта: (1) шаг сам считает ничьи строки и отказывается;
(2) шаг остаётся, утверждение «падает на живой базе» уходит из комментария и плана, а в порядок
выкладки добавляется ручная проверка запросом; (3) шаг приводит данные — необратимо, решение
владельца обязательно.
### Второй ответ провайдера, приехавший пустым, стирает сохранённую расшифровку
- Файл: `internal/service/transcribe.go:716-733` (`poll` → `storeOutcome`),
`internal/adapter/repo/pocketbase/text_repo.go:40-53`
- Severity: critical. Confidence: high
- Оракул: падающий тест, переснят триажем — `expected: "Личный разговор." actual: ""`.
- Последствие: поток gRPC SpeechKit, закрывшийся на первом `Recv`, отказом не считается — `outcome`
пуст, `err == nil`. `Texts.Put` кладёт пустое поверх сохранённого безусловно, рубеж двигается,
запись доходит до `done` без текста. Сырой ответ уцелеет (`Finish` пишет вложение под условием
`len(raw) > 0`), но `ReadRaw`/`Parse` в боевом коде не зовёт никто.
- **Регрессией этого изменения не является:** `poll`, `storeOutcome` и `text_repo.go` диффом не
тронуты. Изменение сняло последний видимый признак вырожденного ответа — заглушку «на записи нет
текста», — но она уходила только в Telegram.
- Найдено проходом: adversary.
- **Действие: развилка.** (1) `storeOutcome` не пишет пустое поверх непустого; (2) вырожденный ответ
считается отказом шага; (3) задача, мердж как есть.
## Стоит исправить сейчас
### Пустая расшифровка перестала замечаться, а два документа обещают, что она замечена
- Файл: `internal/service/transcribe.go` (`finish`), `docs/conventions/logging.md:62`,
`docs/architecture.md:142`
- Severity: major. Confidence: high
- Оракул: `logging.md:62` называет «пустой текст распознавания» поимённым примером уровня `WARN`;
`architecture.md:142` утверждал «запись завершается заглушкой „на записи нет текста"». Заглушку
изменение убрало вместе с доставкой, замены не было.
- **Действие: инлайн. Исправлено:** `poll` пишет `WARN` с идентификатором записи при пустом
результате; строка `architecture.md:142` переписана на фактическое поведение; заведён оракул
`TestEmptyRecognitionIsNamedInJournal`.
### Приёмка переписанного инварианта «остановленная запись несёт причину» не может упасть
- Файл: `internal/service/pipeline_test.go`
- Severity: minor. Confidence: high
- Оракул: `Halt()` ставит `HaltedAt` и `HaltReason` одним движением, `IsHalted()` читает `HaltedAt` —
значит `require.NotNil(HaltReason)` следует из `require.True(IsHalted())`. Независимый сигнал
(`sender.sent()`) убран вместе с доставкой.
- **Действие: инлайн. Исправлено:** вторым утверждением взята строка журнала событий — отдельное
сохранение, способное упасть само по себе; добавлена третья причина остановки.
### Удаление входа не доведено: подавление линтера, конвенции, фикстуры и комментарии
- Файл: `.golangci.yml:158`; `docs/conventions/go-linters.md:81,92,155`; `internal/contract/error.go`;
`internal/config/config_test.go`; `internal/service/transcribe.go`; `internal/contract/repository.go`;
`internal/metrics/format_label.go`; `internal/service/ownership_test.go`;
`openspec/specs/pipeline/spec.md:104`
- Severity: minor. Confidence: high
- Последствие: подавление `errcheck` по мёртвому символу — заряженная мина: вход убран временно, метод
`send` вернётся под тем же именем и молча окажется без проверки отказов. Остальное — документы и
комментарии, обещающие ответ отправителю, включая нормативный доклад `contract/repository.go`.
- Найдено проходами: specs, code, architecture, autotests (одна причина, четыре прохода).
- **Действие: инлайн. Исправлено целиком:** снято подавление и строки про `send`; `controller/tg`
убран из таблицы архправил; удалён `ErrDeliveryChannelDown`; переписаны комментарии; фикстуры
переведены с мёртвой секции; требование «Захват задачи неделим» внесено в дельту.
### Анонимный запрос кладёт до мегабайта своего текста в журнал контейнера одной строкой
- Файл: `main.go:184-202`
- Severity: major. Confidence: high
- Оракул: живой прогон — 900000 байт в пути → 404, журнал вырос с 1048 до 901194 байт.
- Последствие: длину строки журнала задаёт неузнанный посетитель; разбор инцидента по журналу
становится невозможен. Тот же класс назван недопустимым в `internal/controller/http/auth.go:277-284`.
- **Дефект пред-существующий:** `main.go` этим изменением правится только в части подъёма входов.
- **Действие: развилка.** Чинить здесь или заводить задачей.
## Гипотезы без доказательства
- **`transcribed` — лишний рубеж** (architecture): готовая расшифровка платит отдельный захват и
попадает под сторож застревания за проход, переставляющий одну колонку. Оракула, что это случалось,
нет. Вопрос владельцу — место ему в задаче.
- **Отказ приёма по пустому владельцу приходит новому вызывающему как 500** (specs): путь сегодня
недостижим, транспорт отвергает раньше.
- **`http.status_code=0` у всякого отвергнутого запроса** (adversary, `main.go:194-199`).
Пред-существующее.
- **Хвост имени отправителя уезжает в журнал внутри поля `error`** (adversary): инвариант приватности
не нарушен, нарушена форма изъятия. Пред-существующее.
- **Три свойства без построенного пути** (adversary): длительность из метаданных ничем не ограничена
(переполнение `int`); непустой, но более короткий ответ провайдера затирает и вложение;
`POST /api/collections/users/request-verification` открыт анониму.
**Выброшено как ошибочное:** находка `code` о `gofmt` на `pipeline_test.go` — `gofmt -l .` пуст,
проверено дважды.
**Выброшено как вкусовщина:** переименование `CreateJobFromApi`.
**Проектные ложноположительные:** строка «Запись без владельца не достаётся никому» в `docs/review.md`
отменена дважды, последний раз этой же задачей — находка о ничьей записи ей подтверждается, а не
отсеивается.
## Promote candidates
- **В `docs/conventions/logging.md`:** длину поля журнала не задаёт вызывающий — всё, что приходит
извне, уезжает в журнал усечённым до объявленного предела. Правила нет, случай второй.
- **Отклонённый кандидат:** страж существования символов в `exclude-functions` — заводить нельзя,
`CLAUDE.md` «Проверок над проверками не заводить» запрещает стражей предмета у правил.
## Границы покрытия
**Что запускалось.** Шесть проходов на метке `large`, режим по графу: specs, autotests, code,
architecture, adversary, ops. `basics` не запускался — план не дал ему тем сверх ядра. Корректор метки
(`review-code`) отработал, возражений не подал; второго корректора в прогоне не было.
**Потолки проходов.** Ни один проход не сообщил свой потолок и что осталось за срезом, хотя контракт
обязывает. Неизвестно, показал ли `code` все находки или только верхние.
**Что не влезло в потолок триажа** (названо, а не выброшено):
- откат образа после вычистки секции `[telegram]` из боевого конфига роняет старт прежнего бинаря
(`ops`, оракул — прогон исторического бинаря `9a964f2`);
- ряд метрики `intake_up{telegram}` исчезает, а не обнуляется — цена не названа в документах владельца;
- `down202608140003` не покрыт (0.0%) — так у всех четырёх шагов `down*`, и они операционно
недостижимы: cobra-команды PocketBase не подключены;
- частичное покрытие двух требований дельты: «Поднятые входы видны наблюдателю» (оракул только живой)
и «Приведённая копия получает владельца записи» (косвенно).
**Что осталось целиком на человеке** — из `docs/review.md`, «Недоступно проверке»: поведение внешних
сервисов под нагрузкой и на границах, реальный профиль нагрузки, стойкость `ffmpeg` к вредоносному
входу, поведение настоящей Authelia, поведение браузера с куками. Сознательно перестали проверять:
разбор вывода настоящего `ffprobe`; работа с настоящими SpeechKit и Object Storage; вход через живого
провайдера OIDC.
**Четыре строки, которые в конвейере не закрывает никто:**
1. Решения проекта не сверялись — `docs/adr/` процессный, расхождение ловит `av-dev:doc-healthcheck`.
2. Записанные наблюдения (`docs/research/`) не использовались; всякое число снято на этом прогоне.
3. Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.
4. Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.
**Чем работал триаж.** Две пробы на копии дерева в скретчпаде, базы — временные каталоги. Боевых
данных, боевых ключей и выкладки не касался.
Формулировки «критичных проблем не обнаружено» в отчёте нет: одна `critical` подтверждена падающим
тестом и живёт в сервисе прямо сейчас.
@@ -0,0 +1,57 @@
## MODIFIED Requirements
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
- **GIVEN** человек вошёл и принял запись
- **WHEN** он спрашивает состояние этой записи своей сессией
- **THEN** ответ несёт состояние записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится предъявитель сессии
#### Scenario: Ничью запись завести нечем
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** хранилище её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** состояние каждой спрашивают с пустым владельцем
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
@@ -0,0 +1,245 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
идентификатор записи полем `job_id` и её рубеж полем `status`.
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
вовсе.
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
молча. Меняются значения поля рубежа, а не его имя.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
стать не может.
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `uploaded`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
отсутствующего текста читается как «расшифровка пуста».
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
у одной и той же записи от того, успел ли отработать необязательный шаг, а
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
применяться: она делает ответ функцией порядка записи, а не состояния записи.
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений
`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть.
Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ
перестал быть состоянием.
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
нести признак остановки отдельным полем `halted` со значением истины. Машинный
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о
неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки
здесь несёт всю обязанность целиком.
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
кодам ответа перебирается список заведённых записей.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом.
#### Scenario: Запись найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
- **AND** значение `status` принадлежит перечню рубежей конвейера
#### Scenario: Запись остановлена
- **GIVEN** запись остановлена признаком на рубеже приведения
- **WHEN** владелец спрашивает её рубеж
- **THEN** поле `status` несёт рубеж приведения
- **AND** поле `halted` несёт истину
- **AND** машинного текста отказа в ответе нет
#### Scenario: Сессии нет
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
рубеж по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Чужая запись неотличима от неизвестной
- **GIVEN** запись заведена одним вошедшим
- **WHEN** её рубеж спрашивает другой вошедший
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
идентификатору
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Записи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Оговорка про второй вход из требования ушла вместе с ним: имя, данное
отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и
сценарии судят именно его.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой и MUST выставлять метку только тому входу, который у сервиса есть.
Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля
читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная
единица рядом с ним — как исправность того, чего нет.
Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался
один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у
единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до
входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не
виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден
неудачей чтения самих метрик.
Различать поднятый и неподнятый вход признак MUST снова, как только входов у
сервиса станет больше одного.
#### Scenario: В метриках только оставшийся вход
- **GIVEN** сервис поднялся
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы
- **AND** метки убранного входа Telegram в метриках нет вовсе
## REMOVED Requirements
### Requirement: Признак включения решает, поднимается ли вход Telegram
**Reason**: Вход Telegram убран из сервиса целиком, и решать о его подъёме стало
нечего. Настройки входа — признак включения, ключ доступа и срок ожидания
обновлений — уходят из конфигурации вместе с ним.
**Migration**: Секцию `[telegram]` и ключ `server.users_while_list` — имя в коде
именно такое, с опечаткой, и в боевом файле искать надо его — из файла настроек
убрать руками. Оставленные ключи сервис пропускает молча — незнакомые
ключи разбор настроек не судит, — и потому файл, забытый как есть, поднимет
сервис без бота и без единого слова о том, что секция больше ничего не значит.
Записи с источником Telegram, если они в базе есть, остаются нетронутыми и
достаются своему владельцу; ответ в чат по ним не уходит. Возврат входа заводится
новым изменением вместе со связью чата и учётной записи.
@@ -0,0 +1,258 @@
## ADDED Requirements
### Requirement: Конвейер ответа отправителю не шлёт
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес
опроса и содержимое ответа нормирует capability `intake`.
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
потерянную ветку.
Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом
готовности — там остановка видна признаком — и журналом владельца, где у неё
стоит причина. Обязанность при этом сменила направление: прежде об отказе
сообщали, теперь отказ доступен спросившему. Отправитель, который не
спрашивает, об остановке не узнаёт.
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме
хранилища — норму держит capability `storage`.
#### Scenario: Готовый текст отправителю не уходит
- **GIVEN** запись дошла до конечного рубежа
- **WHEN** шаг конвейера её завершает
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
- **AND** текст достаётся опросом готовности
#### Scenario: Остановка видна опросом, а не сообщением
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** владелец записи спрашивает её рубеж
- **THEN** ответ несёт достигнутый рубеж и признак остановки
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
## 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
возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост
при захвате, а не при отказе, засчитывает попытку и записи, брошенной на
середине: шаг, уносящий с собой процесс, до объявления отказа не доходит
никогда.
**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по
собственной остановке сервиса, MUST возвращать число отказов назад и MUST не
выносить записи приговора: запись не виновата в том, что нас перезапустили, и
несколько выкладок подряд иначе останавливают здоровую многочасовую запись с
приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до
объявления исхода, отказ тратит.
Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Остановка эта видна отправителю опросом готовности наравне с прочими —
норму держит capability `intake`.
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
запись.
#### Scenario: Запись отказывает на каждой попытке
- **GIVEN** шаг конвейера отказывает на каждой попытке
- **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт
- **AND** опрос готовности отдаёт владельцу записи признак остановки
#### Scenario: Шаг уносит процесс, не объявив отказа
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
- **WHEN** запись захватывается снова заданное число раз
- **THEN** у неё появляется признак остановки
#### Scenario: Остановка сервиса отказа не тратит
- **GIVEN** шаг работает над записью
- **WHEN** сервис останавливают, и шаг прерывается отменой
- **THEN** число отказов записи прежнее
- **AND** признака остановки у записи не появляется
#### Scenario: Прошедшая запись отказов не копит
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
- **WHEN** смотрят её число отказов
- **THEN** оно не приблизилось к пределу
### Requirement: Выборка воркера владельцем не сужается
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной записи.
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
владельцем поставило бы записи одних людей в зависимость от того, кто первым
завёл учётную запись.
Оговорка про записи без владельца из требования ушла: заводить их стало нечем —
колонка владельца пустого значения не принимает, и норму держит capability
`storage`.
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
результат, владельца не трогает и не затирает.
#### Scenario: Записи двух владельцев проходят одним воркером
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
- **WHEN** воркер забирает работу
- **THEN** ему достаются обе, в порядке заведения
#### Scenario: Шаг конвейера владельца не затирает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** шаг сохраняет свой результат
- **THEN** владелец записи остаётся прежним
## REMOVED Requirements
### Requirement: Недоставленный ответ не роняет шаг
**Reason**: Доставка ответа отправителю убрана вместе с входом Telegram, и
недоставке взяться неоткуда: обращения наружу шаг больше не делает. Обе прежние
причины недоставки — неподнятый вход отправителя и неназванный адресат записи —
описывали именно этот вход.
**Migration**: Счётчик недоставленных ответов и записи журнала о недоставке
уходят вместе с требованием; наблюдателю, построившему на них отбор, ждать от
них значений больше нечего. Исход записи виден опросом готовности и журналом
событий записи.
### Requirement: Всякая остановка сообщает отправителю
**Reason**: Обязанность сообщить требовала адресата, а адресатом был чат
Telegram. С убранным входом сообщать стало нечем и некуда, и обязанность
переходит к опросу готовности — её держит требование «Конвейер ответа
отправителю не шлёт» вместе с capability `intake`.
**Migration**: Отправитель узнаёт об остановке признаком в ответе опроса
готовности. Владелец сервиса видит остановку записью журнала и полем причины у
самой записи — как и прежде.
@@ -0,0 +1,181 @@
## ADDED Requirements
### Requirement: Пустой результат не кладётся поверх сохранённого
Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и
структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и
считаться сделанной работой, а не отказом.
Требование стоит на повторном опросе одной и той же операции распознавания.
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
человек снял признак остановки в панели. Провайдер при этом вправе ответить
пустым потоком, отказом это не считается, и безусловная замена стирала бы
расшифровку живого человека — без следа и без возврата, потому что сервис
объявлен архивом и удаления по требованию не знает.
Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух
хранителей одного результата читается как недосмотр, и один из них молча теряет
то, ради чего второй заведён.
Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст,
больше одного, и правило, записанное у одного из них, у остальных читалось бы
как снятое.
#### Scenario: Пустой второй ответ не стирает расшифровку
- **GIVEN** расшифровка записи сохранена
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
- **THEN** сохранённая расшифровка остаётся прежней
- **AND** шаг завершается без отказа
## MODIFIED Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
единого ручного шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
решение задачи, а не следствие отказа.
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
#### Scenario: Первый запуск на пустом каталоге
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** он заводит своё хранилище и продолжает работу
- **AND** принятая следом запись доходит до состояния `done`
#### Scenario: Повторный запуск на заведённом каталоге
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежние записи
### Requirement: Пароль владельца от панели не лежит в конфигурации
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
отпечаток.
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
файлы разом — это самое чувствительное, что есть у сервиса.
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца
приёму не мешает.
#### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается
- **THEN** он принимает записи
- **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается
- **GIVEN** владелец панели заведён
- **WHEN** сервис запускается снова
- **THEN** приглашения завести владельца в журнале нет
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за
записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным
входом заводить ничью запись стало некому, и обязательность переезжает из одного
лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница
не косметическая: пока обязательность жила в приёме, ничью запись заводили руками
в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась
потом никому.
Владелец MUST не назначаться и не меняться конвейером.
#### Scenario: Колонка появляется на пустой базе
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у аудиозаписи есть колонка владельца
- **AND** умолчания у неё нет
- **AND** пустого значения она не принимает
#### Scenario: Запись без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
конвейером или руками в панели
- **THEN** хранилище её не сохраняет
#### Scenario: Конвейер владельца не назначает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** смотрят её владельца
- **THEN** он прежний
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
значение оставалось у файлов, заведённых конвейером для записи без владельца;
таких записей больше не заводится, и разное правило у записи и у её файла
читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
остановится признаком на первом же приведении.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
касаясь пути, по которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним вошедшим
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
- **THEN** содержимого он не получает
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся
#### Scenario: Файл без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** хранилище его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
- **GIVEN** запись с владельцем дошла до приведения
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
@@ -0,0 +1,131 @@
## 1. Модель записи и отображение в хранилище
- [x] 1.1 Убрать из `entity.AudioRecord` поля адресата ответа `TgChatId` и
`TgReplyMessageId`
- [x] 1.2 Оставить константу `entity.SourceTelegram` и объявить её комментарием
историческим значением: на неё ссылается применённый шаг схемы `202608140002`,
переписывать который запрещено инвариантом проекта
- [x] 1.3 Сделать владельца записи обычной строкой вместо ссылки, допускающей
отсутствие, и провести это через `applyToRecord` и `recordToAudioRecord`
- [x] 1.4 Убрать колонки адресата из `record_mapping.go` в обоих направлениях;
заведёнными в схеме они при этом остаются
- [x] 1.5 Завести **новый** шаг схемы: колонка владельца у аудиозаписи и у файла
перестаёт принимать пустое значение. Откат шага возвращает необязательность
- [x] 1.6 Убедиться, что ни один **применённый** шаг схемы не изменён: `task
migrations` отвечает нулём
- [x] 1.7 Проверить, что конвейер заводит приведённую копию файла с владельцем
записи: без этого первый же шаг приведения упрётся в обязательность колонки
## 2. Служба расшифровки
- [x] 2.1 Убрать метод приёма записи от бота `CreateJobFromTelegram`
- [x] 2.2 Убрать из службы отправителя сообщений: поле, параметр конструктора,
отправку текста, сообщение о неудаче и запись о недоставке
- [x] 2.3 Убрать метрику недоставленных ответов вместе с её причинами
- [x] 2.4 Убрать человеческие тексты отказа, которые уходили отправителю: их
единственным читателем была отправка. Шаг, доводящий запись до конечного
рубежа, остаётся — он двигает рубеж, — и обращений наружу не делает ни одного
- [x] 2.5 Убрать из `internal/archrules` транспорт `internal/controller/tg` из
перечня транспортов: правило требует существования названных пакетов, и без
этой правки гейт краснеет удалением каталога
## 3. Вход и сборка сервиса
- [x] 3.1 Удалить пакет `internal/adapter/telegram` целиком
- [x] 3.2 Удалить транспорт `internal/controller/tg` целиком
- [x] 3.3 Удалить `telegram_build.go` и `telegram_build_test.go`
- [x] 3.4 Убрать из `main.go` сборку входа, запуск транспорта в отдельной
горутине и остановку бота при завершении
- [x] 3.5 Убрать из `internal/contract` договор об отправителе сообщений
- [x] 3.6 Убрать выставление метки `telegram` у признака поднятого входа; метка
`http` остаётся
## 4. Настройки и зависимости
- [x] 4.1 Убрать `TelegramConfig`, её умолчания и проверку обязательности ключа
`telegram.enabled`
- [x] 4.2 Убрать ключ `server.users_while_list` из структуры настроек и
умолчаний. Имя написано с опечаткой — `while` вместо `white`, — и она стоит
«Расхождением» в `docs/conventions/config.md`: удаление ключа закрывает и его
- [x] 4.3 Убрать секцию `[telegram]` из `config.example.toml` вместе с
пояснениями. Ключа списка допущенных в образце нет — это второе записанное
«Расхождение», и оно закрывается тем же удалением
- [x] 4.4 Прогнать `go mod tidy` и убедиться, что `go-telegram-bot-api` ушёл из
`go.mod` и `go.sum`
## 5. Проверки
- [x] 5.1 Поправить тесты, опирающиеся на убранный вход: приём, владение,
конвейер, метрики, недоставка, завершение работы
- [x] 5.2 Оставить проверку того, что запись без владельца не заводится: приём
без учётной записи отвечает `403` и не заводит ни файла, ни записи
- [x] 5.3 Оставить проверку того, что запись без владельца не достаётся опросом:
ответ тот же, что и на неизвестный идентификатор
- [x] 5.4 `task gate` зелёный целиком
- [x] 5.5 Поведенческая проверка на живом сервисе: подъём с файлом настроек без
секции `[telegram]`, приём записи по HTTP, опрос готовности, признак поднятого
входа в метриках
## 6. Документы канона
- [x] 6.1 Паспорт: убрать потребителя «Пользователь Telegram» и сценарии 3 и 4;
поправить строку об основном входе и сценарий 6 — сообщения о неудаче
отправитель больше не получает, а видит исход опросом. Убранный вход записать
событием с датой, как записаны прочие сдвиги границы
- [x] 6.2 `CLAUDE.md`: убрать инвариант «Бот отвечает только тем, кто в белом
списке» (**critical**) целиком; переписать инвариант «Остановленная запись
сообщает отправителю, какой бы ни была причина» (**major**) в терминах опроса
готовности; переписать инвариант «Принятая запись не теряется молча»
(**major**) — он требует сообщить пользователю и называет состояние `failed`,
которого нет с прошлой задачи; снять «и без ответа отправителю» из инварианта о
держателе захвата;
поправить раздел «Что это» (входов больше не два), «Стек» (зависимость ушла) и
запрет «Боевым токеном бота не запускаться» вместе с рецептом локального
подъёма через `telegram.enabled = false`
- [x] 6.3 `docs/architecture.md`: поправить обзор capability поимённо — буллеты
`intake` (признак включения входа), `pipeline` (недоставленный ответ), `access`
(запись из Telegram без владельца), строку «поведение прочих узлов, включая
приём из Telegram, живёт только в коде», принцип «бот, HTTP-сервер и воркеры в
одном бинарнике» и строку «через него идут оба входа» в «Единых точках
проекта»; `docs/security.md` —
убрать вход из периметра; `docs/database.md` — сказать про колонки, оставшиеся
без кода; `docs/conventions/` — снять ключи бота и закрыть оба «Расхождения»
про `users_while_list`
- [x] 6.4 `docs/review.md`: снять род узла «транспорт `internal/controller/tg`» и
«клиент внешнего сервиса `adapter/telegram`» из типовых узлов, ложноположительное
про запись без владельца, вопрос «через него идут оба входа», триггер метки
«трогает оба входа сразу» и рецепт живого прогона через `telegram.enabled = false`
- [x] 6.5 `README.md`: убрать вход из описания сервиса и из настроек
- [x] 6.6 Проза актуальных спек, которую дельты не правят: разделы Purpose у
`intake`, `pipeline` и `access` — дельты правят только требования, и проза
иначе уедет в архив с обещаниями про бота
## Критерии приёмки
Постановка пришла текстом и критериев не назвала. Ниже — **предложенные**;
приняты они после ответа человека на чекпоинте.
1. Ни одного упоминания входа Telegram нигде в дереве, кроме мест, где оно
законно. Оракул: `grep -ri telegram` по всему репозиторию; законны ровно
четыре места — применённые шаги схемы и константа источника, архив изменений
`openspec/changes/archive/`, записи решений `docs/adr/` и историческая часть
журнала ревью. Всё прочее — находка. Оракул нарочно шире кода: прошлые
удаления теряли документы именно потому, что их сверяли грепом по `internal/`.
2. Применённые шаги схемы не переписаны, колонки `tg_chat_id`,
`tg_reply_message_id` и значение `telegram` перечня источников на месте.
Изменение добавляет ровно один новый шаг — обязательность владельца. Оракул:
`task migrations` отвечает нулём, `git diff` по каталогу шагов показывает
только добавленный файл.
3. Запись без владельца завести нечем ни одним путём. Оракулы: тест приёма, где
сессия не даёт учётной записи, — ответ `403`, ни файла, ни аудиозаписи не
заведено; тест хранилища — сохранение записи и файла с пустым владельцем
отклоняется схемой.
4. Отправитель узнаёт об остановке опросом готовности. Оракул: тест опроса на
остановленной записи — поле рубежа несёт достигнутый рубеж, поле остановки
несёт истину, машинного текста отказа в ответе нет.
5. Сервис поднимается с файлом настроек без секции `[telegram]` и принимает
запись по HTTP. Оракул: живой запуск по разделу команд `CLAUDE.md`, `POST
/api/audio` отвечает `201`, `GET /api/status/:id` отвечает `200`.
6. Признак поднятого входа несёт метку `http` и не несёт метки `telegram`.
Оракул: чтение адреса метрик у поднятого сервиса.
7. `task gate` зелёный целиком.
+23 -30
View File
@@ -10,12 +10,10 @@ OIDC, чем предъявляется сессия, что её прекращ
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
Записи, принятые ботом, владельца не имеют вовсе и по API не достаются никому:
связи чата Telegram с учётной записью приложения сервис не ведёт, её заводит
отдельная задача.
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
белым списком, и с учётной записью приложения тот список не связан.
Записи без владельца у сервиса не бывает: колонка владельца пустого значения не
принимает, и норму эту держит capability `storage`. Прежде такие записи заводил
вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход
убран вместе с этим исключением.
## Requirements
### Requirement: Вход через внешнего провайдера
@@ -350,13 +348,14 @@ MUST не делать. Кто допущен, определяет правил
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой записи, принятой **по HTTP**, — владельца, то
есть учётную запись, от имени которой запись принята, — и MUST отдавать данные
такой записи только её владельцу. Владелец назначается один раз, при приёме, и
MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и
передачи записи другому сервис не знает. Оговорка не случайна — назначить
владельца записи, у которой его нет, вправе задача, заводящая связь чата
Telegram с учётной записью.
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
@@ -368,16 +367,12 @@ Telegram с учётной записью.
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой,
ни с ничьей. Правило записано со стороны **спрашивающего**, а не со стороны
записи: обязательность владельца, которую держит одна лишь подпись метода, пустую
строку пропускает, и первый же вызывающий без учётной записи получил бы ровно
множество записей без владельца, то есть все записи бота.
Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью
приложения сервис не ведёт. Такая запись MUST не доставаться по API никому —
ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает
отправителю в чат, как и прежде.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
@@ -396,15 +391,13 @@ capability `intake`: там живёт адрес опроса, и держат
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится предъявитель сессии
#### Scenario: Запись из Telegram не достаётся по API
#### Scenario: Ничью запись завести нечем
- **GIVEN** запись принята ботом
- **WHEN** её состояние спрашивает вошедший человек
- **THEN** ответ тот же, что и на неизвестный идентификатор
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** хранилище её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены три задачи: своя, чужая и принятая ботом
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** состояние каждой спрашивают с пустым владельцем
- **THEN** ответ на все три тот же, что и на неизвестный идентификатор
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
+33 -128
View File
@@ -6,12 +6,9 @@
записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём по существу описан пока **только для HTTP** — того, что нормируют
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки.
Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его
возвращение заводит их заново, вместе со связью чата и учётной записи.
## Requirements
### Requirement: Приём записи по HTTP
@@ -40,10 +37,12 @@ Telegram, дописывает его сюда.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
значение ради записей из Telegram, и приём по HTTP — то место, где
обязательность держится.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
@@ -154,11 +153,9 @@ Telegram, дописывает его сюда.
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
Оговорка про второй вход из требования ушла вместе с ним: имя, данное
отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и
сценарии судят именно его.
#### Scenario: Имя записи не видно в журнале принятой записи
@@ -265,15 +262,15 @@ Telegram, дописывает его сюда.
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
нести признак остановки отдельным полем `halted` со значением истины. Машинный
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла
запись, — это нормирует capability `pipeline`.
а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о
неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки
здесь несёт всю обязанность целиком.
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
кодам ответа перебирается список заведённых записей.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
идентификатор, — кодом `404` и тем же телом.
#### Scenario: Запись найдена
@@ -325,117 +322,25 @@ Telegram, дописывает его сюда.
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
вход и неподнятый.
метрикой и MUST выставлять метку только тому входу, который у сервиса есть.
Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля
читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная
единица рядом с ним — как исправность того, чего нет.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
единственный канал наблюдения, который у владельца автоматизирован.
Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался
один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у
единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до
входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не
виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден
неудачей чтения самих метрик.
#### Scenario: Вход Telegram не поднят
Различать поднятый и неподнятый вход признак MUST снова, как только входов у
сервиса станет больше одного.
- **GIVEN** сервис поднялся без Telegram
#### Scenario: В метриках только оставшийся вход
- **GIVEN** сервис поднялся
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю
- **AND** признак поднятости входа HTTP равен единице
### Requirement: Признак включения решает, поднимается ли вход Telegram
Намерение владельца SHALL объявляться отдельным признаком включения входа
Telegram, а ключ доступа MUST означать только доступ. При выключенном входе
сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе.
При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя
незаполненного ключа и MUST не нести его значения.
Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где
признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав
недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён
затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок
тихой — либо бот молча пропадает, либо файл без признака молча работает.
Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO`
при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем;
предупреждение остаётся за тем, чего владелец не выбирал.
При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и
MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и
конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в
журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без
значения ключа.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы
неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Вход выключен
- **GIVEN** в настройках сервиса вход Telegram выключен
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что
вход выключен настройкой
#### Scenario: Вход выключен, а ключ доступа задан
- **GIVEN** в настройках сервиса вход Telegram выключен
- **AND** ключ доступа при этом заполнен
- **WHEN** сервис запускается
- **THEN** он поднимается без Telegram, и бот не заводится
- **AND** к Telegram не уходит ни одного обращения
#### Scenario: Вход включён, а ключа доступа нет
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** ключ доступа пуст
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** сообщение об отказе называет имя незаполненного ключа
#### Scenario: Признака включения в настройках нет
- **GIVEN** в настройках сервиса нет признака включения входа Telegram
- **AND** ключ доступа заполнен и Telegram признаёт по нему бота
- **WHEN** сервис запускается
- **THEN** старт кончается отказом настройки
- **AND** сообщение об отказе называет недостающий ключ
#### Scenario: Вход включён и ключ годен
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** стоит ключ, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения ключа
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram отвечает отказом на этот ключ
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения ключа
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы
- **AND** метки убранного входа Telegram в метриках нет вовсе
+57 -127
View File
@@ -3,14 +3,14 @@
## Purpose
Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер,
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
когда доставить его некуда.
когда работы нет, и что считается отказом шага.
Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба
сторожа — число отказов и время в рубеже, — откладывание работы отдельно от
перехода, неделимость захвата и срок его протухания, условие записи результата
держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой,
журнал событий записи и недоставка ответа при неподнятом входе.
журнал событий записи и молчание конвейера наружу: обращений к отправителю он не
делает вовсе, и свой исход тот узнаёт опросом готовности.
Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы
считаются приговором записи, а какие поводом к повтору**. Второе — не пробел
@@ -101,7 +101,7 @@
захват, перевыданный другому — по протуханию срока или после того, как человек
снял признак остановки в панели, — обязан обращать запись первого в отказ.
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
два шага записали бы в одну запись и оба ответили бы отправителю.
два шага записали бы в одну запись по очереди, испортив её результат.
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
@@ -155,14 +155,18 @@
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата**
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
результата и без ответа отправителю.
результата.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет запись, продолжая работать. Снять захват может и человек, вернувший
остановленную запись в работу. Без условия по уникальному признаку два воркера
пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не
владелец, а отправитель получает два ответа на одну запись.
пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не
владелец.
Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений
наружу шаг не делает. Требование от этого не ослабло — порча записи двумя
пишущими остаётся его предметом целиком.
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
@@ -185,7 +189,6 @@
- **AND** за это время та же запись досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
- **AND** отправителю ничего не отправляется
#### Scenario: Человек снял остановку под работающим шагом
@@ -263,89 +266,18 @@ MUST расти с числом её отказов до объявленног
- **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число отказов записи не растёт
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить запись до достигнутого рубежа, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
записи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
запись заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у записи не назван** — источником значится Telegram, а чата в записи нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у записи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ
засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы
про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не
появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и
**признака остановки не получает**, а причина недоставки живёт в записи журнала,
а не в рубеже записи.
То же MUST относиться к недоставке сообщения об **остановке**: остановка уже
сохранена, и недоставка её MUST не отменять.
Идентификатор записи в этой строке обязателен: без него владелец видит, что
ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя
в эту запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
#### Scenario: Вход отправителя не поднят
- **GIVEN** запись принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака
остановки не получает
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
записи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у записи не назван
- **GIVEN** у записи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** запись остаётся на достигнутом рубеже
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
записи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Не доехало сообщение об остановке
- **GIVEN** запись остановлена признаком
- **AND** вход отправителя не поднят
- **WHEN** шаг доходит до ответа отправителю
- **THEN** признак остановки у записи остаётся
- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** запись принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
### Requirement: Выборка воркера владельцем не сужается
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной записи. Запись без владельца — принятая ботом — MUST
обрабатываться наравне с прочими.
при выборе очередной записи.
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
поставило бы в зависимость от того, кто первым завёл учётную запись.
владельцем поставило бы записи одних людей в зависимость от того, кто первым
завёл учётную запись.
Оговорка про записи без владельца из требования ушла: заводить их стало нечем —
колонка владельца пустого значения не принимает, и норму держит capability
`storage`.
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
результат, владельца не трогает и не затирает.
@@ -356,12 +288,6 @@ MUST расти с числом её отказов до объявленног
- **WHEN** воркер забирает работу
- **THEN** ему достаются обе, в порядке заведения
#### Scenario: Запись без владельца обрабатывается
- **GIVEN** заведена запись, принятая ботом, — без владельца
- **WHEN** воркер забирает работу
- **THEN** она достаётся ему наравне с прочими
#### Scenario: Шаг конвейера владельца не затирает
- **GIVEN** запись с владельцем прошла шаг конвейера
@@ -463,38 +389,6 @@ MUST расти с числом её отказов до объявленног
- **THEN** число отказов, пауза и время входа в рубеж сброшены
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
### Requirement: Всякая остановка сообщает отправителю
Остановка записи по любой причине SHALL сообщать отправителю о неудаче ровно
так же, как сообщает о ней отказ шага, и MUST быть видна владельцу сервиса
записью в журнале.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
инвариант допускает два исхода — запись пригодна к повтору либо об отказе
сказано, — а остановленная запись захвату не выдаётся, значит первый исход
исключён.
Причин остановки больше одной, и обязанность общая для всех: исчерпанные
отказы, застревание в рубеже, приговор шага. Обязанность, записанная у одной
причины, у остальных читалась бы как снятая.
Ответ уходит **после** того, как признак остановки сохранён, и недоставка этого
ответа MUST не отменять остановку: её нормирует требование «Недоставленный ответ
не роняет шаг».
#### Scenario: Остановка по отказам сообщает отправителю
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
#### Scenario: Остановка по времени сообщает отправителю
- **GIVEN** запись остановлена по пределу времени в рубеже
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
- **AND** в журнале владельца есть запись об остановке с причиной
### Requirement: Время в рубеже ограничено
У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при
@@ -674,8 +568,8 @@ MUST не быть привязаны к отдельному шагу: кажд
Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Об этой остановке отправителю сообщается наравне с прочими — норму
держит требование «Всякая остановка сообщает отправителю».
работу. Остановка эта видна отправителю опросом готовности наравне с прочими —
норму держит capability `intake`.
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
@@ -689,7 +583,7 @@ MUST не быть привязаны к отдельному шагу: кажд
- **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт
- **AND** отправитель получает сообщение о неудаче
- **AND** опрос готовности отдаёт владельцу записи признак остановки
#### Scenario: Шаг уносит процесс, не объявив отказа
@@ -710,3 +604,39 @@ MUST не быть привязаны к отдельному шагу: кажд
- **WHEN** смотрят её число отказов
- **THEN** оно не приблизилось к пределу
### Requirement: Конвейер ответа отправителю не шлёт
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес
опроса и содержимое ответа нормирует capability `intake`.
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
потерянную ветку.
Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом
готовности — там остановка видна признаком — и журналом владельца, где у неё
стоит причина. Обязанность при этом сменила направление: прежде об отказе
сообщали, теперь отказ доступен спросившему. Отправитель, который не
спрашивает, об остановке не узнаёт.
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме
хранилища — норму держит capability `storage`.
#### Scenario: Готовый текст отправителю не уходит
- **GIVEN** запись дошла до конечного рубежа
- **WHEN** шаг конвейера её завершает
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
- **AND** текст достаётся опросом готовности
#### Scenario: Остановка видна опросом, а не сообщением
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** владелец записи спрашивает её рубеж
- **THEN** ответ несёт достигнутый рубеж и признак остановки
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
+68 -16
View File
@@ -17,8 +17,8 @@
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи обоими входами без единого ручного
шага до первого запуска.
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
единого ручного шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
@@ -267,14 +267,14 @@ MUST завести свою схему и принимать записи об
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без
владельца не мешает принимать записи.
Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца
приёму не мешает.
#### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается
- **THEN** он принимает записи обоими входами
- **THEN** он принимает записи
- **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается
@@ -289,10 +289,13 @@ MUST завести свою схему и принимать записи об
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема.
Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за
записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным
входом заводить ничью запись стало некому, и обязательность переезжает из одного
лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница
не косметическая: пока обязательность жила в приёме, ничью запись заводили руками
в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась
потом никому.
Владелец MUST не назначаться и не меняться конвейером.
@@ -301,6 +304,14 @@ capability `intake`, а не схема.
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у аудиозаписи есть колонка владельца
- **AND** умолчания у неё нет
- **AND** пустого значения она не принимает
#### Scenario: Запись без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
конвейером или руками в панели
- **THEN** хранилище её не сохраняет
#### Scenario: Конвейер владельца не назначает
@@ -313,9 +324,16 @@ capability `intake`, а не схема.
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
для записи без владельца.
Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
значение оставалось у файлов, заведённых конвейером для записи без владельца;
таких записей больше не заводится, и разное правило у записи и у её файла
читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
остановится признаком на первом же приведении.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
@@ -340,12 +358,18 @@ capability `intake`, а не схема.
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся
#### Scenario: Файл записи из Telegram не отдаётся по API
#### Scenario: Файл без владельца не сохраняется
- **GIVEN** запись принята ботом, и владельца у неё нет
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном
- **THEN** содержимого он не получает
- **GIVEN** сервис поднят
- **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** хранилище его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
- **GIVEN** запись с владельцем дошла до приведения
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались
@@ -551,3 +575,31 @@ MUST быть помечено защищённым.
- **WHEN** записи назначают шестую тему
- **THEN** назначение не проходит
### Requirement: Пустой результат не кладётся поверх сохранённого
Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и
структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и
считаться сделанной работой, а не отказом.
Требование стоит на повторном опросе одной и той же операции распознавания.
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
человек снял признак остановки в панели. Провайдер при этом вправе ответить
пустым потоком, отказом это не считается, и безусловная замена стирала бы
расшифровку живого человека — без следа и без возврата, потому что сервис
объявлен архивом и удаления по требованию не знает.
Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух
хранителей одного результата читается как недосмотр, и один из них молча теряет
то, ради чего второй заведён.
Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст,
больше одного, и правило, записанное у одного из них, у остальных читалось бы
как снятое.
#### Scenario: Пустой второй ответ не стирает расшифровку
- **GIVEN** расшифровка записи сохранена
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
- **THEN** сохранённая расшифровка остаётся прежней
- **AND** шаг завершается без отказа