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

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