удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
@@ -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` зелёный целиком.
|
||||
Reference in New Issue
Block a user