Files
transcriber/openspec/changes/archive/2026-08-14-record-ownership/design.md
T
av 8af8ec2e54 у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
2026-08-14 12:18:11 +03:00

18 KiB

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