Перейти к основному содержимому

ADR 0049 — Курирование моделей по workspace

Контекст​

ADR 0042 зафиксировал проблему прямым текстом и не решил её:

Catálogo por workspace. Hoje a curadoria é global e o :workspaceId da rota é só âncora de RBAC — um owner do workspace A ativando um modelo o ativa para o B.

Это была не теория. models.is_active была одной колонкой на всю установку. Маршруты курирования уже были /workspaces/:workspaceId/models/* — но :workspaceId никогда не входил в запрос; он служил лишь для того, чтобы RolesGuard имел откуда взять действующую роль. Тот, кто кликал "активировать" на одном экране, решал за все workspaces установки, и экран не давал никакого знака об этом.

Три последствия, в порядке серьёзности:

  1. Один workspace включает дорогую модель соседу. Селектор другого начинает её предлагать, а расход появляется в бюджете того, кто ничего не решал.
  2. Выключение столь же заразно. Owner, убирающий из селектора модель, которой не доверяет, убирает её и у тех, кто от неё зависел.
  3. Не было способа узнать, кто решил. is_active — булево значение без автора и без собственной даты.

Решение​

Каталог остаётся глобальным; курирование становится по workspace.

Разделение — ответ на вопрос "чьи это данные?":

данныевладелецгде
имя, цена, окно, capabilitiesprovidermodels (глобально)
availabilityprovider, наблюдаемый синхронизациейmodels (глобально)
is_active — появляется в селекторе?workspaceworkspace_models

workspace_models — это (workspace_id, model_id) как первичный ключ, плюс is_active и curated_by.

Почему НЕ дублировать models по workspace​

Очевидная альтернатива — одна строка models на workspace — отвергнута:

  • Она создала бы N истин об одной и той же модели. Цена gpt-4o одинакова для всех; хранение её в N строках гарантирует их расхождение.
  • Она разрезала бы token_usage.model_id и model_bindings.model_id пополам: история стоимости указывает на одну строку models, и её дублирование потребовало бы переписать прошлое — ровно то, что RN-044 запрещает.
  • Синхронизация каталога стала бы записывать N раз то, что сегодня записывает один.

Отсутствие строки И ЕСТЬ выключено​

Нет третьего состояния "никогда не решалось" отдельно от "выключено". Модель, которую обнаруживает синхронизация, просто не имеет строки в workspace_models, и чтение считает её неактивной.

Это сохраняет RN-043 ("модель обнаруженная входит выключенной") без какой-либо колонки в models, которую синхронизация могла бы затоптать, — у синхронизации больше нет никакого поля курирования в её upsert. Правило перешло от "синхронизация пишет false" к "синхронизация не достигает этого решения", что сильнее.

Однако выключение — это UPDATE, а не DELETE: удаление строки удалило бы вместе с ней, кто решил и когда. Чтение трактует оба случая как неактивные; запись существует для тех, кто будет проводить аудит.

Маршрут селектора зависит от ПРОЕКТА​

GET /models стал GET /projects/:projectId/models, а не /workspaces/:workspaceId/models. Три экрана, потребляющие список (обзор, настройки и сессия), все находятся внутри проекта, и ни у одного не было workspace под рукой; RolesGuard разрешает роль из :projectId так же хорошо. Workspace берётся из проекта внутри use case — одно преобразование, в одном месте, а не разбросанное по UI.

isActive ушёл из сущности Model​

У Model больше нет isActive; кому он нужен, использует ModelComCuradoria, тип, который существует только при наличии workspace под рукой. То же на проводе: ModelResponseDto (селектор) и ModelComCuradoriaResponseDto (курирование).

Намеренно, что версия без workspace не компилируется: именно существование глобального чтения курирования порождало дефект, а тип надёжнее комментария, просящего быть осторожным.

Последствия​

  • Миграция данных перед DROP. 0034 делает декартово произведение workspaces × models WHERE is_active и только затем удаляет колонку: каждый существующий workspace получает ровно то, что видел до сих пор. curated_by остаётся пустым в этих строках — решение пришло из глобального курирования, которое никогда не регистрировало владельца, а null честнее, чем приписывать его создателю workspace.
  • Нарушение HTTP-контракта. GET /models больше не существует. Это единственный перемещённый маршрут, и он есть в CHANGELOG как несовместимое изменение.
  • Seed начал курировать. Без явного вызова активации модели существовали бы в каталоге, а селектор рождался бы пустым — и binding workspace, чуть ниже в том же seed, был бы отклонён из-за "модель отключена".
  • Области agent и session не проверяют курирование. У обеих нет якоря workspace: binding агента — по ГЛОБАЛЬНОМУ SLUG (:projectId маршрута сегодня явно игнорируется). assertModelIsBindable получает null в этих случаях и проверяет лишь доступность. Пробел старый и остаётся явным вместо заполнения угаданным workspace.

Что остаётся на потом​

  • Binding агента по проекту. Пока область agent — глобальный slug, курирование не может до неё дотянуться. Решение этого — изменение семантики binding, а не курирования, — и заслуживает собственного решения.
  • Наследование курирования. Новый workspace рождается без единой включённой модели, и сегодня кому-то нужно включать их одну за другой. Решило бы значение по умолчанию установки или копирование из первого workspace — но это продуктовая политика, а не техническое следствие этого решения.
  • Бюджет по workspace, привязанный к курированию. Включение дорогой модели остаётся решением без собственного предела; реальные пределы по-прежнему проект, сессия и task.