ADR 0110 — Бюджет по области АДДИТИВЕН, рядом с проектом/сессией — не каскад и не новая таблица
- Статус: Принят
- Дата: 2026-08-23
- Контекст: закрывает пункт «бюджет по области» из отсечения ADR 0038, отслеживаемый с ФАЗЫ 8 и упомянутый последним в Последствиях ADR 0053 («
budget per area... стал на шаг ближе: недостающая таблица теперь существует») - Расширяет (не редактируя): ADR 0053 (
agent_areas/max_parallel), ADR 0063 (бюджеты проекта/сессии,CheckBudgetGateUseCase/RecordLlmUsageUseCase), ADR 0064 (ДРУГОЙ механизм, с которым этот не следует путать)
Контекст
У agent_areas (dev/qa/infra, на проект) есть max_parallel с
ФАЗЫ 14d — потолок, который lead может тратить, не спрашивая, а выше него пользователь
даёт разрешение. Расход токенов, в свою очередь, имел только два потолка: проект и
сессия (таблица budgets, CheckBudgetGateUseCase блокирует перед
вызовом, RecordLlmUsageUseCase увеличивает после него). Ничто не ограничивало
то, что могла сжечь одна ОБЛАСТЬ, — область dev с пятью агентами параллельно
и без индивидуального потолка могла потратить столько, сколько позволял лимит сессии,
так же, как область с одним агентом, без возможности увидеть или ограничить разницу
по области.
Решение
1. Аддитивно, а не каскадом — механизм, отличный от ADR 0064
Это констатация, ради записи которой существует этот ADR, потому что два
механизма разделяют одно слово словаря («область») и больше ничего.
Каскад binding модели из ADR 0064
(session > agent > area > project > workspace) выбирает ОДНОГО победителя —
самая специфичная область со значением перекрывает всё ниже неё. Бюджет так
не работает и никогда не работал: бюджеты проекта и сессии уже
были независимыми проверками, каждая из которых могла блокировать сама
(CheckBudgetGateUseCase.execute запускает обе параллельно и возвращает
blocked: true в тот момент, когда любая превышена). Бюджет области
входит как ТРЕТЬЯ независимая проверка, в тот же Promise.all, с той же
семантикой «любая блокирует всё». Не существует «бюджет области
перекрывает бюджет проекта» и наоборот — агент может быть заблокирован
ЛЮБОЙ из трёх, независимо от того, что говорят две другие.
2. Столбцы живут в agent_areas, а не в общей таблице budgets и не в новой
На столе было три варианта:
- Общая таблица
budgets. У неё есть CHECK constraint (budgets_scope_check), требующий, чтобы РОВНО ОДИН изproject_id/session_idбыл заполнен, — вся форма таблицы построена вокруг взаимного исключения двух областей. Втиснуть третью (area_id, которой нужен собственный составной ключ сproject_id, поскольку области не уникальны глобально) через этот constraint означало бы переделать его в правило исключения из трёх в таблице, никогда не рассчитанной нести больше двух, а область — принципиально другой тип области действия: ей не нужныpolicy(block/allow) и уведомления о порогах 70/90/100, которые есть у двух других, — это более простой потолок вкл/выкл, без режима «только предупреждение». - Новая таблица
area_budgets. Технически чисто, но означало бы два обращения к базе (и два потенциальных окна несогласованности) для каждого чтения и записи, уже касающихсяagent_areas, — ровно та же форма вопроса, на которыйmax_parallelуже отвечает прямо в строке: «сколько lead этой области может сделать, не спрашивая». Join для того, что уже является отношением 1:1 со строкой, читаемой при каждом решении о параллелизме. agent_areas.budget_micros/spent_micros(выбран). Точно повторяетmax_parallel— та же таблица, та же строка, тот же шаблон «потолок lead, решение пользователя», та же формаsetBudget/setMaxParallelвAgentAreaRepository.budget_microsдопускает null (null= без потолка, по умолчанию — большинство проектов его никогда не настроит) с CHECK (IS NULL OR >= 0);spent_micros—NOT NULL DEFAULT 0с собственным CHECK (>= 0), атомарно увеличиваемый (spent_micros = spent_micros + delta) тем же единственным путём metering (RecordLlmUsageUseCase), который уже увеличивает расход проекта и сессии, — ВСЕГДА, настроен ли потолок или нет, чтобы реальный расход области был виден с первого хода агента, ещё до того, как кто-то задаст лимит.
3. Определение того, к какой области принадлежит актор, использует чистую функцию каталога, а не запрос членства
areaDo(agentId) (src/domain/agents/agent-areas.ts, ADR 0053) уже отвечает
«в какой области этот агент — lead или участник», НЕ касаясь базы, — это чистая
функция над статическим каталогом (фиксированные участники qa/infra, dev сопоставляется
по префиксу dev-, что покрывает dev-<modulo> И dev-<modulo>-2, не нуждаясь
в знании module_map проекта). CheckBudgetGateUseCase и
RecordLlmUsageUseCase вызывают её первой, только со строкой agentId, и лишь
обращаются к AgentAreaRepository.findByKey, когда она возвращает
соответствие, — поэтому актор без области (человек в чате или агент
вне какой-либо области) не стоит ничего, кроме вызова чистой функции. Это
ДРУГОЙ путь разрешения, чем у RequestParallelizationUseCase, который уже
знает, что контекст — dev, и ищет этот ключ напрямую
(this.areas.findByKey(projectId, 'dev')), — ему не нужно определять область
произвольного актора, лишь прочитать уже известную.
4. maintainer, без доменного события, то же рассуждение, что и для max_parallel
SetAreaBudgetUseCase повторяет SetAreaMaxParallelUseCase: maintainer
(изменить потолок расходов — значит решить, сколько продукт может потратить, не спрашивая, то же
правило, что и для включения выполнения), и никакое событие не испускается (конфигурация
проекта, нет сессии, где регистрировать, — то же рассуждение, уже записанное в
SetAreaMaxParallelUseCase). В отличие от max_parallel, значение может быть
null, и его очистка — полноценный ввод, а не отдельный маршрут удаления,
— та же форма DTO ValidateIf-поверх-IsOptional, уже используемая
RenameSessionDto (name: null очищает имя сессии).
Последствия
За
- Пункт «бюджет по области» уходит из backlog (
docs/explanation/backlog.md), будучи открытым с ФАЗЫ 8. - Расход области виден даже без потолка —
spent_microsне зависит от предварительной настройкиbudget_micros. - Никакой стоимости миграции, кроме двух столбцов и двух CHECK в существующей
таблице, — ни новой таблицы, ни нового FK, ни join, добавленного
в горячий путь (
agent_areasи так читается при каждом решении о параллелизме).
Против
- Нет уведомлений о порогах (70/90/100%) для бюджетов области, в отличие от проекта/сессии, — намеренное упрощение (пункт 2), а не недосмотр: если окажется необходимым, его можно добавить позже, не затрагивая решение аддитивность-против-каскада.
RecordLlmUsageUseCaseначинает зависеть отAgentAreaRepositoryпомимоBudgetRepository— на одну зависимость больше в единственном пути metering, принятую потому, что разделение его на два пути записи вновь открыло бы ровно тот риск «не обязательно по построению», о котором предупреждает собственный документирующий комментарий класса.
Рассмотренные альтернативы
Четвёртое значение budgetScope: 'area' в общей таблице budgets.
Отвергнуто в пункте 2 — CHECK constraint и форма policy/порогов не
вписываются в настолько более простую область, а у области уже есть естественный дом
(agent_areas), который max_parallel уже доказал.
Сделать каскад с проектом/сессией (побеждает самое специфичное). Это стёрло бы разницу между этой функциональностью и binding модели из ADR 0064 и было бы БОЛЕЕ СЛАБОЙ гарантией, чем существующая сегодня, — «бюджет проекта всегда блокирует, независимо от чего-либо ещё» перестало бы быть правдой в момент, когда нижняя область смогла бы его перекрыть. Отвергнуто сразу; записано здесь главным образом потому, что совпадение имён с ADR 0064 делает соблазн их спутать достаточно реальным, чтобы стоило это написать.
Ссылки
- ADR 0053 —
agent_areas/max_parallel, шаблон, который повторяет этот ADR - ADR 0063 — бюджеты
проекта/сессии,
CheckBudgetGateUseCase/RecordLlmUsageUseCase - ADR 0064 — каскад binding модели, механизм, ОТЛИЧНЫЙ от этого
apps/api/src/domain/llm/area-budget.ts—isAreaBudgetExceeded, чистый предикатapps/api/src/domain/agents/agent-areas.ts—areaDo, чистый определитель членства, повторно используемый здесь- RN-443