у записи появился владелец: чужую больше не отдают

- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
av
2026-08-14 12:18:11 +03:00
parent b7d4660aef
commit 8af8ec2e54
46 changed files with 2520 additions and 101 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-14
@@ -0,0 +1,197 @@
## Context
Сессия сегодня отвечает на один вопрос — узнан ли пришедший. На вопрос «чьё он
смотрит» не отвечает никто: опрос готовности отдаёт задачу всякому вошедшему по
её идентификатору. Так записано и в спеках прямым текстом — обе строки
(`access`, преамбула; `intake`, «Опрос готовности задачи») обещают, что сужение
придёт отдельной задачей. Эта задача та самая.
Учётные записи в сервисе уже есть: их заводит вход через внешнего провайдера,
заведённый 2026-08-12. Значит владельцем записи есть кому быть.
Ограничения, из которых собрано решение:
- **Данные не переносим** — рамки задачи объявляют чистый лист. Значит шагу схемы
не нужно ни умолчание для старых записей, ни правило их раздачи.
- **Применённый шаг схемы не переписывается** (инвариант, critical). Колонка
приезжает новым шагом.
- **Колонки очереди правятся в четырёх местах** пакета хранилища (инвариант,
major), и компилятор видит два из них.
- **Записи из Telegram владельца получить не могут**: связи чата с учётной
записью не существует, её заводит `telegram-account-link` следующей задачей.
## Goals / Non-Goals
**Goals:**
- у задачи расшифровки есть владелец, назначенный при приёме и неизменяемый;
- чужая задача по её идентификатору неотличима от несуществующей;
- конвейер работает по всем записям подряд, владельцем не сужаясь;
- колонка владельца не имеет умолчания: запись не достаётся никому по недосмотру.
**Non-Goals:**
- совместный доступ, роли, передача записи другому;
- перенос записей, заведённых до этого шага;
- владелец у записи, пришедшей ботом, — его назначает следующая задача;
- список своих записей и удаление: они стоят **на** владельце, но заводятся
своими задачами.
## Decisions
### Сужение живёт в хранилище, а не в обработчике
Читающий метод репозитория задач принимает владельца и отдаёт задачу, только
если она его. Не «прочитать и сравнить в обработчике»: сравнение, стоящее в
вызывающем, повторяется в каждом новом вызывающем, и первый же забывший его
открывает чужую запись. Компилятор при смене подписи метода приводит всех
вызывающих сам.
**Отвергнуто: правило доступа коллекции хранилища.** Коллекции хранилища знают
правила вида «владелец записи равен предъявителю», и в панели они работают. Наш
опрос готовности идёт мимо коллекций — своим обработчиком и своим запросом, — и
правило коллекции на этом пути не срабатывает вовсе. Опереться на него значит
получить защиту, которой не существует, и не заметить этого: тест, ходящий
нашим API, был бы зелёным в обоих случаях.
**Отвергнуто: второй метод чтения рядом с прежним** (`GetByID` и
`GetOwnedByID`). Это второй способ делать одно и то же, и выбирается он по
внимательности вызывающего. Метод остаётся один, и владелец у него обязателен.
### Чужая запись отвечает «не найдено», а не «доступ запрещён»
Отказ по чужой записи и отказ по несуществующей — один и тот же ответ, `404` с
тем же телом. Отдельный код на чужую запись превращает опрос в перебор: по нему
считывается, какие идентификаторы заведены, а идентификатор задачи и есть то,
что мы прячем.
Различать их в журнале при этом можно и нужно — журнал читает владелец сервиса,
а не тот, кто перебирает.
### Владелец — связь на учётную запись, а не строка
Колонка заводится связью с коллекцией пользователей: хранилище само следит за
тем, чтобы владельцем стояла существующая учётная запись, а не строка, похожая
на её идентификатор.
**Целостности при удалении учётной записи связь при этом не удерживает, и
прежнее обоснование этого решения было неверным.** Проверено по исходникам
`pocketbase@v0.39.10`, `core/record_model.go`, `deleteRefRecords`: при выключенном
каскадном удалении хранилище **вынимает** идентификатор из поля связи и сохраняет
запись без проверок. То есть удаление учётной записи не уносит её задачи — но
делает их ничьими, а по правилу этой же задачи ничья запись не достаётся по API
никому. Архив человека становится недостижим молча, и восстановить владельца
нечем.
Строка с идентификатором вела бы себя здесь лучше — она пережила бы удаление, —
но платила бы тем, что владельцем становится любое значение. Что делать с этим,
решает человек на чекпоинте: развилка вынесена в открытые вопросы.
### Захват читает колонку владельца
Владелец добавляется во все четыре места правки колонок очереди, включая
`acquireColumns` и `acquiredRow`, хотя конвейер владельцем не пользуется.
Причина в инварианте: колонка, забытая в этой паре, приезжает из захвата нулевой.
Сохранение конвейера сегодня кладёт только свои поля и владельца не трогает —
значит потери нет; но снимок задачи, в котором владелец всегда пуст, это ловушка
для первого же шага, который начнёт сохранять задачу целиком. Чтение колонки
стоит одной строки в каждом из четырёх мест и снимает ловушку насовсем.
### Приём назначает владельца из предъявленной сессии
Приём по HTTP уже требует сессию и отказывает `401` без неё. Владелец берётся
оттуда же, и другого источника у него нет: параметром запроса владелец не
задаётся никогда, иначе всякий вошедший заводит запись на чужое имя.
## Risks / Trade-offs
- **Колонка обязательна в схеме → приём из Telegram перестаёт вставлять записи.**
Критерий приёмки требует обязательности, рамки запрещают назначать владельца
записям бота, и вместе это ломает основной сегодня вход. → Развилка вынесена в
открытые вопросы и на чекпоинт; ниже разобраны три способа с ценой каждого.
- **Записи бота остаются без владельца и потому невидимы в API.** Это желаемое
поведение (ответ бот отдаёт сам, в чат), но оно значит, что после
`telegram-account-link` записи придётся связать с учётной записью задним
числом — а рамки объявляют владельца неизменяемым. → Граница названа здесь и
передаётся той задаче: неизменяемость относится к записи, у которой владелец
**есть**.
- **Тест на две сессии дороже прежних.** Нужны две учётные записи в тестовой
базе и две куки. → Заводятся тем же способом, что и в тестах входа.
- **Публичный контракт меняется**: `GET /api/status/{id}` на чужую задачу
отвечает `404` там, где отдавал содержимое. → Ломка намеренная и объявлена в
предложении; форма ответа при этом не трогается.
## Migration Plan
Один новый шаг схемы, добавляющий колонку в таблицу задач. Прежние шаги не
трогаются. Данные не переносятся: рамки объявляют чистый лист, и записей,
заведённых до шага, в боевой базе на момент выкладки быть не должно — это
проверяет владелец перед выкладкой, машина такого проверить не может.
**Отката приложения нет — решение владельца от 2026-08-14.** Прежний образ поверх
новой схемы поднялся бы: колонку он не читает, и она ему не мешает. Но и не
пишет — записи, принятые по HTTP в таком окне, остались бы без владельца и после
возврата вперёд не достались бы создателю уже никогда, молча. Способ выхода из
беды поэтому другой: восстановить базу из резервной копии либо выпустить новый
релиз вперёд.
## Open Questions
**Обязательна ли колонка владельца в схеме?** Решает человек на чекпоинте.
Критерий приёмки задачи требует «обязательна и без умолчания», рамки той же
задачи запрещают назначать владельца записям бота. Три способа:
1. **Колонка не обязательна в схеме, обязательность держит приём по HTTP.**
Запись из веба без владельца завести нельзя — отказывает приём; запись бота
лежит без владельца и по API невидима никому. Цена: «обязательна» держит код,
а не схема, и прямое обращение к базе мимо приёма может завести запись без
владельца. Критерий приёмки выполняется наполовину и требует переформулировки.
**Рекомендую этот.**
2. **Колонка обязательна, записи бота получают служебную учётную запись.**
Схема держит обязательность целиком. Цена втрое больше, чем казалось:
учётная запись, за которой не стоит человек, делает сервис источником учётных
записей — а паспорт объявляет управление ими вне цели; завести её штатным
путём нельзя вовсе, потому что создание записи разрешено только контексту
обмена OIDC, и понадобится ещё один шаг схемы; после
`telegram-account-link` записи придётся переназначать, нарушая неизменяемость
владельца в первой же следующей задаче. **Способ рекомендую снять с выбора.**
3. **Колонка обязательна, связь чата с учётной записью делается здесь же.**
Критерий выполняется буквально. Цена: задача вбирает в себя следующую и
вырастает примерно вдвое — а `telegram-account-link` заведена отдельной
строкой беклога намеренно.
**Сужается ли по владельцу сам файл записи?** Нашли независимо все три прохода
ревью дизайна, и это крупнее прочего. Разграничение, спроектированное выше,
закрывает опрос состояния — то есть метаданные задачи, — а само аудио лежит в
другой коллекции, у которой владельца нет и по этому плану не появится. Правило
просмотра там пускает всякого вошедшего, и знание идентификатора файловой записи
по-прежнему равно праву скачать чужую запись. Запись задачи между тем называет
затронутой «таблицу задач **и таблицу файлов**», а модель угроз обещает владельца
у задачи и файла именно этой задачей.
Дороже всего не сама дыра, а отметка о закрытии: после мерджа паспорт и модель
угроз перепишут разграничение как сделанное, и открытый путь перестанет быть
виден. Способы:
1. **Владелец и у файла, тем же шагом схемы**, правило просмотра сужается им.
Задача растёт на колонку, правило и свой тест. Полное закрытие.
2. **Обратная ссылка с файла на задачу**, владелец выводится через неё. Дешевле в
схеме, дороже в правиле; вдобавок ссылка на файл в задаче **переставляется**
каждым шагом конвейера, и исходная копия после конвертации не связана ни с чем.
3. **Оставить как есть и записать границу честно** — строкой Non-Goals с адресом
задачи-преемника, и не переписывать паспорт с моделью угроз словом «закрыто».
Дешевле всего сегодня, но разграничение остаётся половинчатым.
**Что делать с записями удалённого пользователя?** Связь при выключенном каскаде
снимает ссылку — записи остаются, но становятся ничьими и недостижимыми по API
навсегда. Способы: запретить удаление учётной записи, пока у неё есть записи;
держать рядом со связью неизменяемый снимок идентификатора; признать потерю
ценой и записать её. Сегодня удаления пользователей в сервисе нет вовсе, так что
третий способ ничего не ломает сейчас — но он же превращает архив в то, что
теряется одной кнопкой в панели.
**Подтверждение ломки публичного контракта.** `GET /api/status/{id}` на чужую
задачу начнёт отвечать `404` там, где отдавал содержимое. Проект относит
публичный контракт HTTP API к необратимому и требует спрашивать человека всегда —
здесь и спрашивается.
@@ -0,0 +1,61 @@
## Why
Сегодня знание идентификатора задачи и есть право её читать: всякий вошедший
видит любую расшифровку по её идентификатору — свою, соседа, чью угодно. Паспорт
обещает приглашённому пользователю обратное: «каждый видит только свои записи».
Разграничение стоит первым в очереди не само по себе. На владельце записи стоят
список записей, дедупликация, удаление, учёт расхода и квота — всё, что проект
собирается делать дальше. Заводить их поверх общей кучи записей значит переделать
каждое из них потом.
## What Changes
- У записи появляется **владелец** — учётная запись, от имени которой её
завели. Назначается он один раз, при приёме, и больше не меняется.
- **Опрос готовности отдаёт только свои записи.** Чужая запись по её
идентификатору отвечает «не найдено» — тем же ответом, что и несуществующая.
Отдельного «доступ запрещён» нет намеренно: по нему перебирается список
заведённых задач.
- **Приём по HTTP заводит владельца** из предъявленной сессии. Приём без сессии
отказывал и раньше, и это не меняется.
- **Конвейер владельцем не сужается**: расшифровка идёт для всех записей подряд,
как и прежде. Владелец решает, кому запись показывать, а не кому её считать.
- **BREAKING** для содержимого базы: колонка владельца добавляется шагом схемы, и
записи, заведённые до него, владельца не получают. Данные не переносим —
проект заводится с чистого листа, так решено рамками задачи.
Записи, пришедшие ботом, владельца здесь не получают: связи чата Telegram с
учётной записью приложения ещё нет, её заводит следующая задача. Что это значит
для обязательности колонки — развилка, вынесенная в design.md и на чекпоинт.
## Capabilities
### New Capabilities
Новых нет: разграничение доступа — это поведение уже заведённой `access`, а не
новый домен.
### Modified Capabilities
- `access`: появляется владелец записи и правило «чужая запись неотличима от
несуществующей». Сегодня спека прямо говорит обратное — «разграничения записей
по владельцу здесь нет».
- `intake`: приём назначает владельца принятой записи, опрос готовности сужается
владельцем. Обе строки спеки, обещающие обратное, снимаются.
- `pipeline`: записывается то, что до сих пор было умолчанием, — выборка задачи
воркером владельцем не сужается.
- `storage`: у задачи в схеме появляется колонка владельца, и заводится она без
умолчания.
## Impact
- схема хранилища: новый шаг с колонкой владельца в таблице задач; `docs/database.md`;
- `internal/entity`, `internal/contract`: у задачи появляется владелец, чтение
сужается им;
- `internal/adapter/repo/pocketbase`: четыре места правки колонок очереди —
`applyToRecord`, `recordToJob`, `acquireColumns`, `acquiredRow`;
- `internal/service`: оба метода заведения задачи;
- `internal/controller/http`: опрос готовности и приём;
- публичный контракт `GET /api/status/{id}`: чужая запись начинает отвечать
`404` там, где прежде отдавала содержимое. Форма ответа не меняется.
@@ -0,0 +1,204 @@
# Ревью изменения `record-ownership` — финальный триаж
## Сводка
- **Размер:** среднее, пограничное с крупным. **Сложность:** незнакомое
(`docs/review.md`, «Триггеры метки»: вход через OIDC и разграничение доступа до
начала работы назвать нельзя). **Метка:** `large`. **Режим:** по графу.
- **Изменение не закоммичено**, судилось рабочее дерево (19 изменённых файлов,
5 новых).
- **Гейт зелёный**, проверено независимо от прохода `autotests`: на чистой копии
дерева `go build`, `go vet`, `go test ./... -count=1` — все пакеты `ok`.
- **Сигнал о заниженной метке:** `review-code` метку проверил, занижением не
считает. `review-basics` не запускался, поэтому второго независимого голоса
нет. Возражений против `large` не подал никто.
- **Находок на входе:** 26 (autotests 1, specs 5, code 3, adversary 6, ops 4,
architecture 7) плюс 3 наблюдения. **Осталось:** 7 в основном списке,
4 понижены в гипотезы, 3 в promote.
### План разметки задачи с исходом по каждой теме
| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| requirements | дельты + `openspec/specs/{access,intake,pipeline,storage}` | разбор | `specs` | закрыта, 5 находок |
| autotests | `CLAUDE.md` «Гейт» | — | `autotests` | закрыта, 1 находка |
| conventions | `docs/conventions/` | разбор | `code` | закрыта, 3 находки; конвенционных 0 из 4 |
| architecture | `docs/architecture.md` + `passport.md` | доказательство | `architecture` | закрыта, 7 находок |
| security | `docs/security.md` | доказательство | `adversary` | закрыта, 3 пути + 3 свойства |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | `ops` | закрыта, 4 находки |
Тем без отчёта нет. Тем без дома нет.
## Блокирует мердж
### 1. Владелец панели видит на отказе удаления чужую подсказку и по ней идёт сносить архив руками
- Файл: `internal/adapter/repo/pocketbase/owner_guard.go:42-45`
- Severity: major. Confidence: high. Действие: **инлайн**
- Оракул: прогон через собранный роутер. Удаление учётной записи с задачей даёт
`400 {"message":"Failed to delete record. Make sure that the record is not part
of a required relation reference."}` — текст стража не доезжает вовсе
(`apis/record_crud.go` и `firstApiError` подменяют неклассифицированную ошибку
своей). Починка проверена тем же прогоном: `router.NewBadRequestError` доезжает
дословно.
- Последствие: требование дельты `storage` «Отказ MUST называть причину владельцу
панели» не выполнено. Подсказка библиотеки **ведущая**: единственная
обязательная связь у задачи — `file`, и владелец панели, поверив ей, пойдёт
удалять задачи и файлы руками. Это ровно то необратимое удаление архива,
которое страж и заведён предотвращать.
- Найдено проходами: `specs`, `code`, `adversary` (одна причина, три
формулировки).
### 2. Право на архив снимается удалением учётной записи: страж считает задачи и не считает файлы, а вешается мимо сборки хранилища
- Файл: `internal/adapter/repo/pocketbase/owner_guard.go:28-49`, `main.go:208-213`
- Severity: major. Confidence: high. Действие: **развилка**
- Оракул: два прогона через роутер. **Без стража** — окружение `setupTestEnv`
ставит `BindPanelRules`, но `GuardOwnerDeletion` не ставит: удаление своей
учётной записи собственной сессией даёт `204`, у задачи и у файла владелец
снимается. **Со стражем** — учётная запись, у которой есть файлы и нет задач,
удаляется штатно, владелец файла снимается.
- Последствие: `users.DeleteRule` умолчанием библиотеки равен
`id = @request.auth.id` — вошедший сносит себя сам, и это публичная поверхность,
а не только панель. Единственная защита архива — одна строка в `main.go`, и она
покрывает одну коллекцию из двух. Потеря необратима: прежнего владельца не
остаётся нигде.
- Найдено проходами: `adversary`, `specs`, `architecture`.
### 3. Канон продолжит утверждать, что разграничения по владельцу нет
- Файл: `openspec/specs/storage/spec.md:109-111`, `docs/architecture.md:38`,
`docs/review.md:120-126`
- Severity: major. Confidence: high. Действие: **инлайн**
- Оракул: дословный текст. Действующая спека `storage` держит «Сужения по
владельцу здесь нет — его заводит отдельная задача»; дельта заводит сужение
секцией `## ADDED`, не сняв это. `docs/review.md:120` держит запись в разделе
«Типовые ложноположительные» — прямое указание будущему проходу выбросить
такую находку.
- Последствие: следующая задача вправе вернуть правило «всякий узнанный» и снова
открыть чужое аудио, а ревью эту регрессию выбросит не глядя.
- Найдено проходами: `specs` (две находки), `architecture`.
## Стоит исправить сейчас
### 4. Сценарий «Чужой файл не отдаётся» нормирует механизм, которого нет
- Файл: дельта `storage`, `internal/controller/http/ownership_test.go:180-206`
- Severity: major. Confidence: high. Действие: **инлайн**
- Оракул: прогон. Чужой просит токен файла — `200` и валидный токен: токен
выдаётся **на предъявителя**, а не на файл. Он же идёт по настоящей ссылке
`GET /api/files/files/{recordId}/{name}?token=…``404`; владелец — `200` и
содержимое.
- Последствие: спека обязывает к отказу, которого не будет никогда. Единственный
механизм, закрывающий чужое аудио, сквозной проверки не имеет: снятие правила
просмотра гейт не покраснит.
- Найдено проходом: `specs`.
### 5. Сбой хранилища приходит отправителю как «задачи нет» и не оставляет строки в журнале
- Файл: `internal/adapter/repo/pocketbase/transcript_job_repo.go:96-111`,
`internal/controller/http/transcribe.go:107-118`
- Severity: minor. Confidence: high. Действие: **инлайн**
- Оракул: прогон. Запрос недостижимой задачи — `404`, прибавка к журналу пустая.
Обработчик кладёт в `404` **любую** ошибку, включая отказ базы.
- Последствие: `design.md` обещает «различать в журнале» — не реализовано.
Отправитель на аварию хранилища получает «записи нет», владелец сервиса об
аварии не узнаёт ниоткуда.
- Найдено проходами: `code`, `ops`.
### 6. Окно отката образа заводит записи без владельца — навсегда недостижимые создателю и молча
- Файл: `internal/adapter/repo/pocketbase/migrations/202608140001_record_owner.go`,
`internal/service/transcribe.go:581-584`
- Severity: minor (ущерб крупный, наступает только при откате). Confidence: high.
Действие: **развилка**
- Оракул: прогон, воспроизводящий состояние окна отката: создатель просит
состояние — `404`, журнал пуст. `send()` для источника, отличного от Telegram,
возвращает `nil` без отправки — иных каналов доставки у записи из API нет.
- Последствие: шаг схемы применён и не откатывается вместе с образом; прежний
бинарь колонку не пишет.
### 7. Имя файла в хранилище уезжает в журнал контейнера при каждом скачивании
- Файл: `main.go:236-240`
- Severity: major. Confidence: high. Действие: **развилка**
- Оракул: `CLAUDE.md`, «Инварианты», дословно. Форма маршрута подтверждена
прогоном: скачивание идёт по `GET /api/files/files/{recordId}/{name}`, а слой
журнала пишет `URL.Path` целиком.
- Последствие: инвариант нарушен буквально, второй случай того же класса в
журнале проекта. До `critical` не поднято: названная инвариантом цена — «строка
журнала стала бы бессрочным ключом» — этой же задачей снимается. Находка **вне
дельты**.
## Гипотезы без доказательства
- **Счёт задач по `owner` идёт полным сканом, индекса нет** (`ops`). Замер: 42 мс
против 0,07 мс на 150 тыс. строк. Понижено: объёма, на котором снят замер, у
проекта нет.
- **Гонка «удаление учётной записи против приёма»** — не воспроизведена.
- **Разница во времени ответа на свою и чужую запись** — замера нет.
- **`users.UpdateRule` открыт правке своей записи** — построенного пути нет.
- **Ветка отказа `CountRecords` не покрыта** — поведение fail-closed; после
починки находки 1 покроется тем же тестом.
- **Неизменяемость владельца механизмом не держится** — панель правит `owner`
свободно, но панель в модели угроз доверена.
## Promote candidates
- **Именование колонок-связей.** `owner` — третий смысл «владельца» в проекте при
живом прецеденте `location`/`storage`. Правило именования стоит завести в
`docs/conventions/`.
- **Одно отображение записи в задачу — и в тестах тоже.** `readJob` в
`internal/service/pipeline_test.go` — второе, неполное отображение. Кандидат в
расширение инварианта либо в правило `archrules`.
- **Индекс на колонке, по которой ходит счёт внутри транзакции удаления.**
Кандидат в `docs/database.md`.
## Границы покрытия
**Что запускалось.** Метка `large`, режим по графу: `specs`, `autotests`, `code`,
`architecture`, `adversary`, `ops` — шесть именных проходов, все вернули отчёт.
**Что не запускалось и почему.** `review-basics` — план сказал, что своих тем
проекта нет и все темы разобраны именными проходами. Следствие: у сигнала о
заниженной метке остался один голос.
**Чего проходы не могли проверить в принципе.** Живой откат образа прежним
бинарём; поведение под реальным потоком; настоящая панель как интерфейс;
настоящие Telegram, SpeechKit и Object Storage.
**Что осталось на человеке** (`docs/review.md`, «Недоступно проверке»): поведение
внешних сервисов под нагрузкой, реальный профиль нагрузки, стойкость `ffmpeg` к
вредоносному входу, поведение настоящей Authelia, поведение браузера с куками.
Сознательно перестали проверять: разбор вывода настоящего `ffprobe`, работу с
настоящими внешними собеседниками.
**Сработавшие потолки.** Ни один проход не сообщил своего потолка и того, что
осталось за срезом. Контракт требует этого от каждого — **это находка о прогоне**,
а не о коде.
**Что не влезло в потолок 7** (ничего не выброшено молча):
- `RequireAuth(users)` висит на всей группе `/api`, поэтому и опрос отвечает
`403`; дельта `intake` нормирует `403` только у приёма;
- `design.md` прямым текстом отвергает правила коллекций, а реализация ими
пользуется; «Единые точки проекта» о владельце не знают;
- `ErrOwnerRequired` заведена без потребителя;
- три вопроса, объявленных решаемыми человеком, возведены в норму реализацией —
частично снято тем, что человек четыре решения на чекпоинте принял;
- `RelationField.ColumnType` даёт `TEXT DEFAULT '' NOT NULL`: «умолчания нет»
верно по замыслу, но не буквально.
**Чего в конвейере нет вовсе:**
1. **Решения проекта не сверялись**`docs/adr/` процессный документ, прогон его
не открывает. Расхождение ловит `av-dev:doc-healthcheck`.
2. **Записанные наблюдения не использовались**`docs/research/` не открывался.
3. **Поимённая сверка с руководствами по стилю Go** не задавалась ни одним
проходом.
4. **Альтернативной реализации, с которой можно сдиффить решения, нет.** Для
изменения со сложностью «незнакомое» это самый дорогой пробел прогона.
Формулировка «критичных проблем не обнаружено» не употребляется: `critical` в
отчёте нет потому, что ни одна находка не собрала оракула на этот уровень, а не
потому, что путей туда нет.
@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой записи, принятой **по HTTP**, — владельца, то
есть учётную запись, от имени которой запись принята, — и MUST отдавать данные
такой записи только её владельцу. Владелец назначается один раз, при приёме, и
MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и
передачи записи другому сервис не знает. Оговорка не случайна — назначить
владельца записи, у которой его нет, вправе задача, заводящая связь чата
Telegram с учётной записью.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой,
ни с ничьей. Правило записано со стороны **спрашивающего**, а не со стороны
записи: обязательность владельца, которую держит одна лишь подпись метода, пустую
строку пропускает, и первый же вызывающий без учётной записи получил бы ровно
множество записей без владельца, то есть все записи бота.
Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью
приложения сервис не ведёт. Такая запись MUST не доставаться по API никому —
ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает
отправителю в чат, как и прежде.
#### Scenario: Своя запись доступна
- **GIVEN** человек вошёл и принял запись
- **WHEN** он спрашивает состояние этой записи своей сессией
- **THEN** ответ несёт состояние записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится предъявитель сессии
#### Scenario: Запись из Telegram не достаётся по API
- **GIVEN** запись принята ботом
- **WHEN** её состояние спрашивает вошедший человек
- **THEN** ответ тот же, что и на неизвестный идентификатор
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены три задачи: своя, чужая и принятая ботом
- **WHEN** состояние каждой спрашивают с пустым владельцем
- **THEN** ответ на все три тот же, что и на неизвестный идентификатор
@@ -0,0 +1,136 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
сохранена и получить заведённую под неё задачу расшифровки в состоянии
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
полем `status`.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча. Появление отказа без
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
значение ради записей из Telegram, и приём по HTTP — то место, где
обязательность держится.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
стать не может.
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **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 не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Задача, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к задаче без
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
#### Scenario: Задача найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает состояние своей задачи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
#### 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` и сообщение о ненайденной задаче
@@ -0,0 +1,32 @@
## ADDED Requirements
### Requirement: Выборка воркера владельцем не сужается
Воркер SHALL брать задачи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной задачи. Задача без владельца — принятая ботом — MUST
обрабатываться наравне с прочими.
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
поставило бы в зависимость от того, кто первым завёл учётную запись.
Владелец задачи MUST переживать работу конвейера: шаг, сохраняющий свой
результат, владельца не трогает и не затирает.
#### Scenario: Задачи двух владельцев проходят одним воркером
- **GIVEN** заведены задачи двух разных владельцев в одном состоянии
- **WHEN** воркер забирает задачи этого состояния
- **THEN** ему достаются обе, в порядке заведения
#### Scenario: Задача без владельца обрабатывается
- **GIVEN** заведена задача, принятая ботом, — без владельца
- **WHEN** воркер забирает задачи её состояния
- **THEN** она достаётся ему наравне с прочими
#### Scenario: Шаг конвейера владельца не затирает
- **GIVEN** задача с владельцем прошла шаг конвейера
- **WHEN** шаг сохраняет свой результат
- **THEN** владелец задачи остаётся прежним
@@ -0,0 +1,199 @@
## ADDED Requirements
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца задачи расшифровки отдельной колонкой — связью
с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема.
Колонка приезжает **новым шагом схемы**: применённый шаг не переписывается.
Записей, заведённых до этого шага, сервис не переносит — проект заводится с
чистого листа.
#### Scenario: Колонка появляется на пустой базе
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у таблицы задач есть колонка владельца
- **AND** умолчания у неё нет
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, тем же шагом схемы, — и правило просмотра файлов MUST пускать к файлу
только его владельца. Прежнее правило пускало всякого узнанного, и знание
идентификатора файловой записи равнялось праву скачать чужое аудио.
Без этого требования разграничение закрывает метаданные задачи и оставляет
открытым содержимое — то самое, что оно и заведено прятать. Хуже самой дыры была
бы отметка о закрытии: паспорт и модель угроз называют исполнителем этой работы
именно эту задачу, и слово «закрыто» скрыло бы открытый путь.
Владелец файла MUST назначаться там же, где владелец задачи, — при приёме, из
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
для записи без владельца.
Ссылка на файл в задаче переставляется каждым шагом конвейера, поэтому владелец
файла MUST лежать своей колонкой, а не выводиться через задачу: исходная копия
после конвертации не связана с задачей ничем.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
касаясь пути, по которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним вошедшим
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
- **THEN** содержимого он не получает
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся
#### Scenario: Файл записи из Telegram не отдаётся по API
- **GIVEN** запись принята ботом, и владельца у неё нет
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном
- **THEN** содержимого он не получает
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались задачи
расшифровки **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
другую подменяет сообщением про обязательную связь — подсказкой, по которой
владелец панели пойдёт удалять записи руками.
Считаются обе коллекции с владельцем. Файл переживает свою задачу: шаг конвейера
заводит его до сохранения задачи, и потерянный захват оставляет файл с владельцем
и без ссылки.
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
запуска, а окружение проверок его не ставило вовсе.
Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
поверхность.
Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно
выглядело выполненным, а на деле хранилище при выключенном каскаде **снимает
ссылку** — задачи остаются, но становятся ничьими, а ничья задача не достаётся
по API никому. Архив человека исчезал бы молча и восстановлению не подлежал:
прежнего владельца не остаётся нигде.
Цена требования названа прямо: владелец панели упирается в отказ, а способа
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
тупик, а не недосмотр.
#### Scenario: Удаление учётной записи с записями отвергается
- **GIVEN** у учётной записи есть задачи расшифровки
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину
- **AND** задачи и их владелец остаются прежними
#### Scenario: Учётная запись с одними файлами тоже не удаляется
- **GIVEN** у учётной записи остались файлы, но задач нет
- **WHEN** её удаляют
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
#### Scenario: Учётная запись без записей удаляется
- **GIVEN** у учётной записи нет ни задач, ни файлов
- **WHEN** её удаляют
- **THEN** удаление проходит
## MODIFIED Requirements
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
коллекции. Правило MUST пускать только владельца файла: незаданное означает
«только владелец панели», и тогда файла не получит и вошедший, а прежнее «всякий
узнанный» отдавало чужое аудио тому, кто знает идентификатор записи.
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
токена: отказ наступает там, и требовать его от выдачи значит требовать
механизма, которого нет.
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
недосмотр.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **AND** забирающий предъявил сессию и взял по ней токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Без сессии файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают без сессии
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Конвейер читает файл без сессии
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
@@ -0,0 +1,130 @@
## Критерии приёмки задачи
Дословно из `tasks/items/record-ownership.md`. Файл задачи закрытие удалит —
критерии обязаны его пережить.
- Запрос чужой записи по её идентификатору возвращает «не найдено», а не
содержимое и не «доступ запрещён». Оракул — тест: две сессии, задача первой
запрашивается второй, ответ 404 и пустое тело.
*Расхождение:* «пустое тело» разошлось с нетронутым сценарием спеки, где ответ
по неизвестному идентификатору несёт сообщение о ненайденной задаче. Читается
как «то же тело, что и у неизвестного идентификатора, и без состояния и текста
расшифровки» — иначе реализация по букве критерия молча снимет действующее
требование о сообщении.
- Запись, заведённая из веба, принадлежит вошедшему. Оракул — тест приёма по
HTTP со сверкой колонки владельца.
- Выборка воркера владельцем **не** сужается: конвейер обрабатывает записи всех.
Оракул — тест: задачи двух владельцев проходят конвейер одним воркером.
- База заводится с чистого листа, колонка владельца обязательна и без умолчания.
Оракул — прогон миграций на пустой базе и попытка вставки без владельца.
**Четвёртый критерий разошёлся с рамками той же задачи** и правится решением
человека на чекпоинте: рамки запрещают назначать владельца записям из Telegram, а
обязательная колонка такую запись вставить не даст. Разобрано в `design.md`,
«Open Questions»; ниже план написан по рекомендованному способу — колонка без
умолчания, допускающая пустое значение, обязательность приёма по HTTP держит код.
## Рубрика ревью дизайна — приёмочные критерии
Порождена проходом `rubric` до чтения артефактов. Приёмка судится по одному
списку: этот блок и блок выше.
1. Чужая запись неотличима от несуществующей на всех наблюдаемых осях: тот же
код, то же тело, та же форма ответа.
2. Сужение живёт в одном месте пути чтения и обязательно к употреблению: второго
читающего входа, у которого сужение можно не позвать, нет.
3. Пустой владелец на входе чтения не совпадает ни с одной записью — своей,
чужой и ничьей.
4. Записи без владельца недостижимы сужённым путём никому, и недостижимость
выведена из правила, а не из того, что таких записей мало.
5. Владелец назначается сервером из сессии: поле владельца, пришедшее запросом,
на результат не влияет.
6. Владелец появляется в той же операции, что и запись; отказ по отсутствию
владельца не оставляет ни задачи, ни файла, и способ этого назван.
7. Неизменность владельца держит механизм, а не обещание: назван каждый путь,
которым владельца можно переписать, и что его удерживает.
8. Колонка прочитана всеми четырьмя местами правки колонок очереди плюс шагом
схемы.
9. Воркер владельцем не сужается, и это записано нормой, а не оставлено
умолчанию.
10. Судьба записи при исчезновении владельца определена.
11. Шаг схемы: одно представление «владельца нет», откат не требует переписывания
применённого шага.
12. Проверка владельца и чтение состояния берутся из одного чтения записи, а не
двумя раздельными.
13. Ответ отправителю адресуется по источнику записи, а не по владельцу: запись
без владельца получает свой ответ в чат.
14. Ни ответ, ни журнал не выдают того, что прячет разграничение; различение
чужой и несуществующей в журнале допустимо.
## 1. Схема хранилища
- [x] 1.1 Новый шаг схемы `202608140001_record_owner.go`: колонка `owner` в
таблице задач связью с коллекцией `users`, без умолчания, пустое значение
допустимо, каскадное удаление выключено. Имя колонки — `owner`, поле
сущности — `OwnerID`; имена названы здесь, потому что разойтись им есть где
— семь мест плюс подпись метода
- [x] 1.2 Тем же шагом — колонка `owner` в таблице файлов, теми же свойствами;
правило просмотра коллекции файлов сужается владельцем вместо прежнего
«всякий вошедший»
- [x] 1.3 Тем же шагом — запрет удаления учётной записи, у которой остались
задачи: отказ с причиной, а не снятие ссылки
- [x] 1.4 Прежние шаги схемы не тронуты — проверяется шагом гейта `migrations`
- [x] 1.5 `docs/database.md`: строки колонок в обеих таблицах, правило выборки по
владельцу, новое правило просмотра файлов и запрет удаления учётной записи
## 2. Сущность и контракт
- [x] 2.1 `internal/entity`: у задачи расшифровки появляется владелец
- [x] 2.2 `internal/contract`: читающий метод репозитория задач принимает
владельца; второго читающего метода не заводится
## 3. Хранилище задач
- [x] 3.1 `applyToRecord` кладёт владельца при заведении
- [x] 3.2 `recordToJob` читает владельца
- [x] 3.3 `acquireColumns` и `acquiredRow` читают колонку владельца
- [x] 3.4 `applyOwnedByPipeline` владельца **не** трогает
- [x] 3.5 Чтение задачи сужено владельцем: задача другого владельца и задача без
владельца отдают ту же ошибку, что и несуществующая
## 4. Приём и опрос
- [x] 4.1 `internal/service`: метод заведения задачи из веба принимает владельца
и отказывает при пустом; метод заведения из Telegram владельца не
назначает. Владелец кладётся и на файл, заводимый при приёме
- [x] 4.2 `internal/controller/http`: приём берёт владельца из предъявленной
сессии
- [x] 4.3 `internal/controller/http`: опрос готовности передаёт владельца в
хранилище и отвечает `404` с прежним телом на чужую и на ничью задачу
## 5. Проверки
- [x] 5.1 Тест: две сессии, задача первой запрашивается второй — `404`, тело без
состояния и текста
- [x] 5.2 Тест: приём по HTTP заводит задачу с владельцем-предъявителем
- [x] 5.3 Тест: приём по HTTP без узнанной учётной записи задачи не заводит
- [x] 5.4 Тест: задачи двух владельцев и задача без владельца проходят конвейер
одним воркером
- [x] 5.5 Тест: шаг конвейера, сохраняющий результат, владельца не затирает
- [x] 5.6 Тест: миграции на пустой базе заводят колонку без умолчания
- [x] 5.7 Тест: чтение с пустым владельцем не отдаёт ни своей, ни чужой, ни
ничьей задачи
- [x] 5.8 Тест: сессия без учётной записи пользователя получает `403` на приёме,
файла и задачи не заводится — узнан, но не запись коллекции пользователей
- [x] 5.9 Тест: вошедший просит токен чужого файла — отказ; своего — успех
- [x] 5.10 Тест: удаление учётной записи с задачами отвергается, без задач —
проходит
- [x] 5.11 `task gate` зелёный
## 6. Архивация
- [ ] 6.1 На архивации выправить `## Purpose` спеки `access`: преамбула
переживает слияние дельт дословно и сегодня утверждает, что разграничения
по владельцу нет. Валидатор преамбулу не судит — вспомнить об этом больше
некому
- [ ] 6.2 Сверить `docs/security.md`, `docs/passport.md` и **`docs/architecture.md`**
(строка «Разграничения записей по владельцу здесь нет»): все три обещают
закрытие разграничением именно этой задачей. Адрес в `architecture.md`
назван отдельно — его нашло ревью, и без него обзор архитектуры отправлял
бы следующего читателя чинить уже закрытое
+68 -5
View File
@@ -6,15 +6,17 @@
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
открытыми.
Разграничения записей по владельцу здесь **нет**: всякий вошедший видит ровно
то же, что видел прежде аноним. Его заводит отдельная задача, и до неё сессия
отвечает только на вопрос «узнан ли пришедший», а не «чьё он смотрит».
Здесь же разграничение записей по владельцу: с 2026-08-14 сессия отвечает не
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
Записи, принятые ботом, владельца не имеют вовсе и по API не достаются никому:
связи чата Telegram с учётной записью приложения сервис не ведёт, её заводит
отдельная задача.
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
белым списком, и с учётной записью приложения тот список не связан.
## Requirements
### Requirement: Вход через внешнего провайдера
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
@@ -345,3 +347,64 @@ MUST не делать. Кто допущен, определяет правил
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
- **THEN** старт кончается отказом, а отказ называет имена ключей
- **AND** значений этих ключей в отказе нет
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой записи, принятой **по HTTP**, — владельца, то
есть учётную запись, от имени которой запись принята, — и MUST отдавать данные
такой записи только её владельцу. Владелец назначается один раз, при приёме, и
MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и
передачи записи другому сервис не знает. Оговорка не случайна — назначить
владельца записи, у которой его нет, вправе задача, заводящая связь чата
Telegram с учётной записью.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой,
ни с ничьей. Правило записано со стороны **спрашивающего**, а не со стороны
записи: обязательность владельца, которую держит одна лишь подпись метода, пустую
строку пропускает, и первый же вызывающий без учётной записи получил бы ровно
множество записей без владельца, то есть все записи бота.
Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью
приложения сервис не ведёт. Такая запись MUST не доставаться по API никому —
ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает
отправителю в чат, как и прежде.
#### Scenario: Своя запись доступна
- **GIVEN** человек вошёл и принял запись
- **WHEN** он спрашивает состояние этой записи своей сессией
- **THEN** ответ несёт состояние записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится предъявитель сессии
#### Scenario: Запись из Telegram не достаётся по API
- **GIVEN** запись принята ботом
- **WHEN** её состояние спрашивает вошедший человек
- **THEN** ответ тот же, что и на неизвестный идентификатор
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены три задачи: своя, чужая и принятая ботом
- **WHEN** состояние каждой спрашивают с пустым владельцем
- **THEN** ответ на все три тот же, что и на неизвестный идентификатор
+45 -13
View File
@@ -36,8 +36,24 @@ Telegram, дописывает его сюда.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
видно было анонимно.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
значение ради записей из Telegram, и приём по HTTP — то место, где
обязательность держится.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
стать не может.
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
@@ -47,6 +63,14 @@ Telegram, дописывает его сюда.
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой задачи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни задачи не заводится
#### Scenario: Сессии нет
@@ -214,24 +238,24 @@ Telegram, дописывает его сюда.
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
пока текста нет: пустая строка на месте отсутствующего текста читается как
«расшифровка пуста».
`GET /api/status/:id` **только её владельцу**. Запрос без сессии MUST получать
код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни текста
расшифровки. Ответ владельцу MUST нести идентификатор полем `job_id`, состояние
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
Задача, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к задаче без
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
#### Scenario: Задача найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние заведённой задачи
- **WHEN** он спрашивает состояние своей задачи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
#### Scenario: Сессии нет
@@ -246,10 +270,18 @@ Telegram, дописывает его сюда.
состояние по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Чужая задача неотличима от неизвестной
- **GIVEN** задача заведена одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
идентификатору
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
- **WHEN** он спрашивает состояние своей задачи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет
+32 -1
View File
@@ -15,7 +15,6 @@ done | failed`, отмена контекста посреди шага и ос
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
## Requirements
### Requirement: Пустой прогон воркера — не отказ
@@ -325,3 +324,35 @@ MUST расти с числом её попыток до объявленног
- **GIVEN** задача принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
### Requirement: Выборка воркера владельцем не сужается
Воркер SHALL брать задачи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной задачи. Задача без владельца — принятая ботом — MUST
обрабатываться наравне с прочими.
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
поставило бы в зависимость от того, кто первым завёл учётную запись.
Владелец задачи MUST переживать работу конвейера: шаг, сохраняющий свой
результат, владельца не трогает и не затирает.
#### Scenario: Задачи двух владельцев проходят одним воркером
- **GIVEN** заведены задачи двух разных владельцев в одном состоянии
- **WHEN** воркер забирает задачи этого состояния
- **THEN** ему достаются обе, в порядке заведения
#### Scenario: Задача без владельца обрабатывается
- **GIVEN** заведена задача, принятая ботом, — без владельца
- **WHEN** воркер забирает задачи её состояния
- **THEN** она достаётся ему наравне с прочими
#### Scenario: Шаг конвейера владельца не затирает
- **GIVEN** задача с владельцем прошла шаг конвейера
- **WHEN** шаг сохраняет свой результат
- **THEN** владелец задачи остаётся прежним
+123 -4
View File
@@ -10,7 +10,6 @@
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
2026-08-11, а удаление приносит задача `delete-record`.
## Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
@@ -106,9 +105,14 @@ MUST завести свою схему и принимать записи об
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
здесь нет — его заводит отдельная задача.
коллекции. Правило MUST пускать только владельца файла: незаданное означает
«только владелец панели», и тогда файла не получит и вошедший, а прежнее «всякий
узнанный» отдавало чужое аудио тому, кто знает идентификатор записи.
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
токена: отказ наступает там, и требовать его от выдачи значит требовать
механизма, которого нет.
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
@@ -272,3 +276,118 @@ MUST завести свою схему и принимать записи об
- **WHEN** сервис запускается снова
- **THEN** приглашения завести владельца в журнале нет
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца задачи расшифровки отдельной колонкой — связью
с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема.
Колонка приезжает **новым шагом схемы**: применённый шаг не переписывается.
Записей, заведённых до этого шага, сервис не переносит — проект заводится с
чистого листа.
#### Scenario: Колонка появляется на пустой базе
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у таблицы задач есть колонка владельца
- **AND** умолчания у неё нет
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, тем же шагом схемы, — и правило просмотра файлов MUST пускать к файлу
только его владельца. Прежнее правило пускало всякого узнанного, и знание
идентификатора файловой записи равнялось праву скачать чужое аудио.
Без этого требования разграничение закрывает метаданные задачи и оставляет
открытым содержимое — то самое, что оно и заведено прятать. Хуже самой дыры была
бы отметка о закрытии: паспорт и модель угроз называют исполнителем этой работы
именно эту задачу, и слово «закрыто» скрыло бы открытый путь.
Владелец файла MUST назначаться там же, где владелец задачи, — при приёме, из
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
для записи без владельца.
Ссылка на файл в задаче переставляется каждым шагом конвейера, поэтому владелец
файла MUST лежать своей колонкой, а не выводиться через задачу: исходная копия
после конвертации не связана с задачей ничем.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
касаясь пути, по которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним вошедшим
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
- **THEN** содержимого он не получает
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся
#### Scenario: Файл записи из Telegram не отдаётся по API
- **GIVEN** запись принята ботом, и владельца у неё нет
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном
- **THEN** содержимого он не получает
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались задачи
расшифровки **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
другую подменяет сообщением про обязательную связь — подсказкой, по которой
владелец панели пойдёт удалять записи руками.
Считаются обе коллекции с владельцем. Файл переживает свою задачу: шаг конвейера
заводит его до сохранения задачи, и потерянный захват оставляет файл с владельцем
и без ссылки.
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
запуска, а окружение проверок его не ставило вовсе.
Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
поверхность.
Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно
выглядело выполненным, а на деле хранилище при выключенном каскаде **снимает
ссылку** — задачи остаются, но становятся ничьими, а ничья задача не достаётся
по API никому. Архив человека исчезал бы молча и восстановлению не подлежал:
прежнего владельца не остаётся нигде.
Цена требования названа прямо: владелец панели упирается в отказ, а способа
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
тупик, а не недосмотр.
#### Scenario: Удаление учётной записи с записями отвергается
- **GIVEN** у учётной записи есть задачи расшифровки
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину
- **AND** задачи и их владелец остаются прежними
#### Scenario: Учётная запись с одними файлами тоже не удаляется
- **GIVEN** у учётной записи остались файлы, но задач нет
- **WHEN** её удаляют
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
#### Scenario: Учётная запись без записей удаляется
- **GIVEN** у учётной записи нет ни задач, ни файлов
- **WHEN** её удаляют
- **THEN** удаление проходит