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