ADR 0049 — Курирование моделей по workspace
Контекст
ADR 0042 зафиксировал проблему прямым текстом и не решил её:
Catálogo por workspace. Hoje a curadoria é global e o
:workspaceIdda 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 установки, и экран не давал никакого знака об этом.
Три последствия, в порядке серьёзности:
- Один workspace включает дорогую модель соседу. Селектор другого начинает её предлагать, а расход появляется в бюджете того, кто ничего не решал.
- Выключение столь же заразно. Owner, убирающий из селектора модель, которой не доверяет, убирает её и у тех, кто от неё зависел.
- Не было способа узнать, кто решил.
is_active— булево значение без автора и без собственной даты.
Решение
Каталог остаётся глобальным; курирование становится по workspace.
Разделение — ответ на вопрос "чьи это данные?":
| данные | владелец | где |
|---|---|---|
| имя, цена, окно, capabilities | provider | models (глобально) |
availability | provider, наблюдаемый синхронизацией | models (глобально) |
is_active — появляется в селекторе? | workspace | workspace_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.