## 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 к необратимому и требует спрашивать человека всегда —
здесь и спрашивается.
