ADR 0076 — provider снова становится измерением расходов, а сдерживание переходит к типу
- Статус: принят
- Дата: 2026-08-14
- Пересматривает: ADR 0063 (две
аудитории одного и того же расхода), который намеренно исключил
providerиз измерений - Предшествующий контекст: RN-058 (ключ, который тратит агент, принадлежит owner), RN-060 (расход ключей принадлежит owner, и видит его только он) и RN-101 (две аудитории)
Контекст
ADR 0063 оставил provider вне
измерений sumGroupedBy фразой без полутонов: «отсутствие
структурное, а не забывчивость: разбивать расход по provider — значит разбивать по
УЧЁТНЫМ ДАННЫМ, и так виду участника не дают получить эту ось
по небрежности — нет аргумента, который можно передать». Тип нёс то же предупреждение в
комментарии: «Os cinco recortes do relatório de gasto. Nenhum deles é
provider.»
Владелец продукта решил снова открыть измерение, осознавая последствие. Этот ADR существует не для того, чтобы сказать, что 0063 ошибся, — его аргумент остаётся верным, и именно поэтому повторное открытие должно прийти с записанным сдерживанием.
Чего не разделяло решение от 2026-08-09 и что потребовало использование:
- owner не может видеть в скользящем окне, через кого ушли деньги.
credential-spendотвечает по provider, но в КАЛЕНДАРНЫХ месяцах и привязан к существующим сегодня учётным данным. Вопрос «за эти 30 дней сколько ушло на OpenRouter и сколько на Anthropic» требовал прочитать два отчёта с разными окнами и сложить вручную; - человек и агент шли в одном рейтинге. Список
porAtorсмешивает обоих, различимых лишь по одному полю, а handoff дизайна просит два блока.
Решение
provider снова становится SpendDimension, а отчёт owner получает
porProvider (RN-186). Ось живёт на маршруте,
который уже требовал owner, — та же линейка RN-060, — а участник не получает
никакого поля. credential-spend остаётся нетронутым: он отвечает на счёт по месяцам, с
привязкой к существующему сегодня ключу (temCredencial), а новая ось отвечает на
расход по provider ВНУТРИ окна, рядом с моделью, проектом и актором. Это не
срез одного из другого, по тому же критерию, по которому 0063 разделил две
аудитории.
Сдерживание приватности меняет форму, а не силу: теперь сдерживает
ТИП (RN-187). У sumGroupedBy две
перегрузки, и разделяет их область:
abstract sumGroupedBy(d: SpendDimensionDoAtor, e: SpendScopeDeAtor): Promise<SpendBucket[]>;
abstract sumGroupedBy(d: SpendDimension, e: SpendScopeAmplo): Promise<SpendBucket[]>;
export type SpendDimensionDoAtor = Exclude<SpendDimension, 'provider'>;
Область, несущая actor, — вид участника и единственная область, которая у него есть, —
принимает только SpendDimensionDoAtor. sumGroupedBy('provider', escopoComAtor)
не компилируется. Две области взаимно исключают друг друга по построению
(SpendScopeAmplo объявляет actor?: undefined), поэтому правильная перегрузка
выбирается без того, чтобы кому-то нужно было указывать, какая.
Никакого if по этой комбинации ни в репозитории, ни в сценарии использования, и
отсутствие намеренно: проверка во время выполнения создавала бы впечатление,
что гарантия динамическая, когда её поддерживает компилятор, — а if — это
ровно то, что следующий рефакторинг убирает, не оставив ни одного красного теста.
Это то же рассуждение, что у RN-153/154, где разрешение «auto mode» живёт в
репозитории, а decide.ts не получил ни строки: гарантия по построению стоит
больше, чем гарантия по бдительности.
Exclude вместо второго списка, написанного вручную. Новое измерение рождается
доступным для обеих аудиторий, а его удаление из досягаемости участника становится явным
действием в этом месте — никогда не забывчивостью в другом файле. Альтернатива
(два независимых списка) имеет известный режим отказа: ограниченный список
молча устаревает.
Человек и агент становятся двумя блоками, выведенными, а не запрошенными
(RN-188). porOwner и porAgente — это разбиение
porAtor по actor_kind, сделанное в сценарии использования; porAtor остаётся целым
для тех, кто его уже потреблял. 0063 измерил, что стоимость этих запросов растёт с
размером token_usage, а не с размером запроса, — сканировать окно ещё два раза,
чтобы разделить то, что уже разделено в памяти, было бы дорого по неправильной причине.
Индекс по token_usage(created_at) входит сейчас (миграция 0044). 0063
измерил и зафиксировал: на 525 тысячах строк 55 мс и 38 мс при seq scan, 32 мс
и 19 мс с индексом. Не хватало только слота migration.
Измерение model не меняется. Два providers, обслуживающих одно имя модели,
по-прежнему в одной строке. Теперь рядом есть список по provider; скрещивание двух
измерений умножило бы строки рейтинга, не ответив на вопрос, на который два отдельных
списка уже не отвечают.
Последствия
Самая жёсткая фраза 0063 перестала действовать, и честно сказать какая. «Нет
аргумента, который можно передать» была гарантией поверхности API: ни одна сигнатура
не принимала слово provider. Сегодня одна сигнатура принимает, и гарантия зависит
от того, что вызывающий на стороне участника типизирован SpendScopeDeAtor. Так и есть —
GetMySpendUseCase объявляет тип явно и не выводит его, по этой
причине, — но разница реальна: мы перешли от «невозможно выразить» к
«невозможно скомпилировать». Второе слабее первого, и это цена,
принятая решением владельца продукта.
Цену удерживают два независимых барьера. У маршрута участника
нет параметра измерения (projectId и dias, и ничего больше), поэтому
?dimensao=provider отбрасывается Nest до того, как handler начнёт существовать. Первого
барьера уже хватало; второй существует, чтобы его по-прежнему хватало
после следующего изменения. У обоих есть тест, и тест типа — это
@ts-expect-error: если барьер упадёт, tsc отклонит строку из-за НЕИСПОЛЬЗОВАННОЙ
директивы, вместо того чтобы тест начал проверять ничто.
Кто может видеть учётные данные, не изменилось. owner на двух маршрутах, говорящих о
provider; viewer — на маршруте участника, который о нём не говорит. Классификация в
docs/security-surface.md та же — ни один маршрут не родился, ни один не сменил
роль, — и поэтому новая ось не ослабляет открытую поверхность.
«По owner» — это метка handoff, а блок — ЛЮДЕЙ. porOwner приносит
каждую строку actor_kind = 'user', а не только строки владельца workspace; метка
держится, потому что, по RN-058, все они тратят ключ owner. Кто
владелец, по-прежнему говорит поле ownerId. Если однажды появятся учётные данные на человека,
этому блоку понадобится новое имя — и то же касается всей RN-101, как
0063 уже предвидел.
actor_kind, который не является ни человеком, ни агентом, остаётся вне обоих блоков. Сегодня
это лишь system, который остаётся в porAtor и в итоге. Открыть третий блок
значило бы сказать, что у продукта есть аудитория, которой у него нет; скрыть его из итога
значило бы солгать о расходах.
Остаётся вне, объявлено: ЭКРАН. Это изменение только backend — porProvider,
porOwner и porAgente приходят к клиенту через типы apps/web/src/lib, и
ни один компонент их пока не рисует. Остаются вне, из 0063: валюта и обменный
курс, расход по области и любой экспорт.