ADR 0063 — Две аудитории для одного и того же расхода: счёт owner и потребление участника
- Статус: принят
- Дата: 2026-08-09
- Предшествующий контекст: ADR 0042
(замороженная цена в
token_usage, которая делает любой отчёт воспроизводимым), RN-058 (ключ, который тратит агент, принадлежит owner) и RN-060 (расход ключей принадлежит owner, и видит его только он)
Контекст
Запрос был о вкладке сводки расходов, в духе экрана activity OpenRouter, но говорящей о providers, owner и агентах этого продукта. Решение о том, кто что видит, пришло вместе с ним и принадлежит пользователю: owner видит всё workspace; участник видит только собственное потребление.
Данные никогда не были проблемой. В token_usage всё есть в столбцах с Фазы 9 —
provider, модель, актор, токены, стоимость, источник binding, задержка и цена,
давшая стоимость, замороженная в момент вызова по
ADR 0042. Не хватало
агрегаций: существовало пять, все по агенту или по provider×месяц, и ни одной
по модели, по проекту внутри workspace, по сессии или по человеку.
Трудность в другом, и это столкновение двух правил, которые у продукта уже были:
- по RN-058 ключ LLM, который тратит любой агент, принадлежит owner workspace. Участник, запускающий агента, тратит учётные данные другого человека;
- по RN-060 отчёт об этом расходе принадлежит owner и только ему, с
@RequireRole('owner')на маршруте. Неmaintainer: счёт владельца — не дело того, кто ведёт проект.
Вместе они делают «сколько потратил Я» буквально запросом доли счёта другого человека. Существовало два лёгких выхода, и оба неправильны:
- открыть отчёт по учётным данным участнику, отфильтровав его строки. Это отменило бы RN-060 через чёрный ход — ответ по-прежнему говорил бы о provider и ключе, а это ровно то, что правило оставляет владельцу, и участнику хватило бы сложить строки, чтобы восстановить счёт;
- ничего не показывать участнику. Тоже неправильно, и по практической причине:
его потребление существует, зарегистрировано с его именем в
token_usage, и единственным человеком, которому нельзя его видеть, оказался бы именно он.
Решение
Две аудитории получают разные отчёты, потому что задают разные вопросы. Ни один не является срезом другого.
RN-060 по-прежнему управляет отчётом по УЧЁТНЫМ ДАННЫМ. GET /workspaces/:id/credential-spend, GetCredentialSpendUseCase и
CredentialSpendSection остаются как есть: сгруппированы по provider, который является
единицей ключа, требуют owner и отвечают на вопрос счёта —
«сколько ушло с моего ключа OpenRouter в этом месяце». Новая вкладка повторно использует его
целиком, а не переписывает.
Вид участника — по АКТОРУ, в токенах и оценочной стоимости, и не
разбивается по provider или учётным данным. GET /projects/:id/spend/me возвращает
потребление вызывающего, по сессии и по дню, внутри проекта. Актор берётся из
аутентифицированного токена, и сценарий использования не предоставляет способа спросить о
другом: нет параметра, куда можно написать id другого человека. «Участник не видит
строки другого актора» — это свойство сигнатуры, а не проверка, которую кто-то может
забыть вызвать.
Агент не входит в счёт участника. token_usage фиксирует, кто потратил,
а не кто приказал потратить; приписать агента тому, кто его запустил, значило бы выдумать
данные, которых в таблице нет. То, что тратят агенты, появляется в отчёте
owner, которому принадлежит ключ.
Оси provider в новой агрегации не существует. Пять новых измерений
(model, project, actor, session, day) живут в одном методе
репозитория, sumGroupedBy(dimensao, escopo), и provider не является одним из них.
Отсутствие структурное, а не забывчивость: разбивать расход по provider — значит
разбивать по учётным данным, и так виду участника не дают получить
эту ось по небрежности — нет аргумента, который можно передать. По той же причине два
providers, обслуживающих одно и то же имя модели, попадают в одну строку в измерении
model: их разделение вновь ввело бы ось учётных данных под другим именем.
Owner видит обе вещи — разбивку workspace по модели, проекту, актору и дню, и счёт по учётным данным прямо под ней, — потому что он единственный, кто может видеть обе. Экран никогда не вызывает маршрут owner без роли: намеренно запрашивать 403 — это шум в журнале безопасности.
Без библиотеки графиков. Это две формы, по одной серии каждая: столбцы по дням
(дискретная величина — линия подразумевала бы непрерывный расход между двумя днями, которого
нет) и горизонтальные столбцы рейтинга. <rect> и <span> во встроенном SVG и
CSS покрывают это, а зависимость для графиков здесь была бы весом runtime ради
геометрии, умещающейся в десять строк. Дневная серия приходит из api плотной: день без
расхода входит с нулём, иначе три вызова за три недели становятся тремя слипшимися
столбцами, неотличимыми от трёх дней подряд использования.
Последствия
Вопрос участника остаётся объявленно неполным, и это правильная цена. Он видит собственный чат и не видит агентов, которых запускал. Пока ключ принадлежит owner (RN-058), любое число «агента», показанное участнику, было бы расходом другого человека с его именем сверху. Если однажды у продукта появятся учётные данные на человека, это решение придётся пересмотреть — и это будет новый ADR, а не редактирование этого.
Это два маршрута, а не один с ветвлением по роли. Единый endpoint, который
менял бы форму в зависимости от вызывающего, имел бы два контракта под одним именем, а
тест поверхности (docs/security-surface.md) классифицировал бы один маршрут с
самой разрешительной ролью — теряя именно то различие, ради сохранения которого существует этот ADR.
Измерение model смешивает providers. Тот, кто хочет знать, через кого модель
была обслужена, здесь этого не узнает, намеренно. upstream_provider остаётся в
таблице для тех, кому нужно расследовать конкретный случай.
Ни migration, ни индекса — пока. У token_usage только PK, и
оба запроса делают seq scan. Измерено на 525 тысячах строк в изолированной тестовой
базе: отчёт workspace выходит за 55 мс, а отчёт участника — за 38 мс.
С индексом по token_usage(created_at) те же планы становятся bitmap heap
scan и опускаются до 32 мс и 19 мс. Выигрыш реален, и индекс дёшев, но
слот migration этой волны принадлежит другой фазе; он войдёт позже, с
уже выполненным и зафиксированным здесь измерением. Расчёт меняет объём: оба запроса читают
всё окно, и стоимость растёт с размером token_usage, а не с размером
запроса.
Окно скользящее и имеет потолок (180 дней, по умолчанию 30). Отчёт без
потолка был бы приглашением сканировать всю таблицу через query string, а урок
429, превращавшегося в белый экран (RN-088/RN-090), слишком свеж, чтобы его игнорировать.
Стоимость, показываемая участнику, оценочная, и экран это говорит. Она берётся из цены, замороженной в момент вызова (ADR 0042), — лучшее существующее число, — но счёт, приходящий owner, выставляет provider, и мы никогда не обещали, что они совпадут до цента.
Остаётся вне, объявлено: валюта и обменный курс (backlog, а конвертация по выдуманному курсу была бы хуже честного доллара), расход по области (отсечён со времён ADR 0038 — реальные потолки остаются на проекте, сессии и задаче) и любой экспорт. Вкладка читает: в ней нет глагола записи, и именно отсутствие глагола делает эту границу проверяемой.