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

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: валюта и обменный курс, расход по области и любой экспорт.