Человек разрешил workflow заказать не более 100 единиц товара. Workflow сформировал три заказа по 40 единиц: поставщику A, поставщику B и поставщику C. Каждый запрос прошёл через слой авторизации и получил ответ ALLOW. Независимое чтение ERP показало 120 единиц. Разбор кейса опубликован на Хабре.
Ни один отдельный заказ не превышал 100. Ни один запрос не обошёл контроль. Слой авторизации отработал ровно так, как был запрограммирован: три раза оценил присланный запрос и три раза вернул ALLOW. Неверным оказался итог, а не отдельное решение. Именно поэтому такой случай опасно списывать на «сбой авторизации»: чинить нужно не ответ компонента, а вопрос, на который этот компонент отвечал.
Дальше разберём по порядку: чем локальная проверка отличается от лимита на всю задачу, почему гонка check-then-act ломает даже проверку остатка, как атомарное резервирование части мандата до вызова ERP закрывает окно гонки, зачем агенту расходуемый мандат с grant, scope и остатком, почему ALLOW, отчёт исполнителя и чтение целевой системы нельзя смешивать, и какие пять вопросов стоит задать своей схеме до прода.
Что произошло: три ALLOW, 120 единиц и лимит 100
Картина кейса укладывается в четыре строки. Мандат от человека: не более 100 единиц. План агента: три закупки по 40 у трёх поставщиков. Решения авторизации: ALLOW, ALLOW, ALLOW. Факт в ERP: 120.
| Шаг | Запрос | Ответ слоя авторизации | Накопленный итог |
|---|---|---|---|
| 1 | Поставщик A, 40 единиц | ALLOW | 40 |
| 2 | Поставщик B, 40 единиц | ALLOW | 80 |
| 3 | Поставщик C, 40 единиц | ALLOW | 120 |
Обратите внимание на последний столбец: он не входил ни в один запрос. Слой авторизации видел только строку «40» и правило «не больше 100». Сумма по задаче жила где-то отдельно, и никто её не проверял.
Почему это не «сбой авторизации», а неверно поставленный вопрос
Простейший код такого слоя выглядит безобидно:
if requested_quantity <= 100:
return "ALLOW"
Этот код отвечает на вопрос «этот конкретный заказ не больше 100?». Человек давал разрешение на другой вопрос: «останется ли сумма всех заказов в пределах 100?». Два вопроса совпадают только в частном случае одного заказа. При трёх заказах разрыв между ними и даёт 120.
Формулировка из разбора звучит так: локально всё выглядело допустимым, но глобально система вышла за пределы разрешённого. Это ключевой тезис статьи. Любая проверка, которая смотрит только на текущий вызов и не видит накопленный расход, отвечает на локальный вопрос. Локально корректные ответы при этом складываются в недопустимое бизнес-состояние.
Локальная проверка против общего лимита на всю задачу агента
Исходное разрешение человека касается бизнес-состояния: сумма заказов по этой задаче не должна превысить 100 единиц. Это ограничение на итог, а не на вызов. Чтобы его соблюдать, нужен объект, который хранит состояние задачи: один grant (само разрешение), один scope (что именно разрешено: какие поставщики, какие товары, какой период) и один остаток, который уменьшается по мере расхода.
Проверка «запрос ≤ 100» живёт без этого объекта. Она не знает и не обязана знать про соседние заказы, поэтому три запроса по 40 проходят спокойно. Уберите размер шага из уравнения: если бы агент сформировал заказы по 90, 90 и 90, та же проверка пропустила бы каждый, а в ERP оказалось бы 270. Проблема не в том, что шаг оказался великоват. Проблема в отсутствии общего счётчика, который знает про все шаги сразу.
Для AI-агента это особенно чувствительно. Агент генерирует последовательность шагов и делает это уверенно: каждый следующий вызов выглядит разумным и подтверждается предыдущим результатом. Ограничение, привязанное к отдельному вызову, спокойно пропускает длинную цепочку разумных шагов, которая в сумме выходит за границу. Лимит должен жить на уровне задачи, тогда как проверка размера запроса живёт на уровне вызова.
Гонка check-then-act: почему конкурентное исполнение ломает stateful-проверку
Первое очевидное улучшение: хранить остаток и проверять его перед решением. Код читает текущий остаток, сравнивает с размером запроса и только потом уменьшает счётчик. В одном потоке это работает. При параллельных вызовах ломается.
Паттерн называется check-then-act: проверка и действие не атомарны, между ними есть окно. Три обработчика приходят примерно одновременно, все три читают одно и то же значение остатка (например, 100), каждый локально корректно решает, что его 40 помещаются, и только после этого происходят списания и вызовы ERP. Каждое решение по отдельности верное. Суммарный эффект неверный.
Типичная диагностическая ошибка при ревью: разработчик смотрит на обработчик, видит корректную логику и не находит дефекта. Дефект и не в обработчике. Он в разрыве между чтением и записью, в том месте, где два потока успевают прочитать устаревшее значение.
Почему последовательная схема выглядит рабочей
В последовательном исполнении каждый следующий обработчик видит уже обновлённый остаток: 100, потом 60, потом 20, и третий заказ отсекается по лимиту. Поэтому схема проходит ручные прогоны и модульные тесты, где вызовы идут друг за другом, и разваливается при первом же реальном параллелизме. На воспроизводимом стенде проверяли как раз три варианта одного ограничения: последовательную схему, stateful-проверку, которая ломается при конкурентном исполнении, и вариант с атомарным резервированием части общего разрешения до обращения к ERP. Детали первых двух вариантов в разборе не раскрыты, зафиксирован сам факт их сравнения.
Исправление: атомарное резервирование части общего мандата до вызова ERP
Работающая схема строится на одном правиле: ALLOW выдаётся не после проверки размера запроса, а после резервирования части общего лимита. Порядок такой:
- Транзакция читает остаток мандата и блокирует строку от параллельного чтения.
- Если остаток больше или равен размеру запроса, транзакция уменьшает его на эту величину и фиксирует резерв.
- Запрос уходит в ERP только после успешного резерва.
- Если остатка не хватает, резерв не создаётся, и авторизация возвращает DENY_CAPACITY.
Резерв должен происходить именно до внешнего эффекта. Проверка после вызова ERP уже не спасает: товар заказан, окно гонки осталось позади, откатывать придётся вручную и с участием поставщика.
Транзакция с SELECT ... FOR UPDATE в PostgreSQL
Механизм, который делает шаг 1 и 2 атомарными, в описанном варианте исправления опирается на блокировку строки в PostgreSQL. Конструкция SELECT ... FOR UPDATE захватывает строку мандата на время транзакции, и второй обработчик не может прочитать тот же остаток параллельно: он ждёт завершения транзакции первого и видит уже уменьшенное значение. Схематично это выглядит так:
BEGIN;
SELECT remaining FROM agent_grants WHERE id = :grant_id FOR UPDATE;
-- remaining: 100 → 60 → 20
UPDATE agent_grants SET remaining = remaining - :qty WHERE id = :grant_id;
COMMIT;
Это набросок, а не готовая библиотека: транзакции и блокировки PostgreSQL дают механизм, а политику (кто владеет мандатом, что входит в scope, как возвращается резерв при отмене заказа) пишет команда под свою задачу. Как одна PostgreSQL тянет доменную логику без дополнительных хранилищ, разобрано в кейсе системы управления корпоративной архитектурой; там же показано, почему явные границы проекта дешевле, чем набор разрозненных сервисов.
Что меняется в ответах авторизации
| Сценарий | Ответ 1 | Ответ 2 | Ответ 3 | Итог в ERP |
|---|---|---|---|---|
| Проверка «запрос ≤ 100» | ALLOW | ALLOW | ALLOW | 120 |
| Атомарное резервирование | ALLOW (остаток 60) | ALLOW (остаток 20) | DENY_CAPACITY | 80 |
После двух резервов по 40 остаток равен 20, поэтому третий запрос на 40 не проходит и получает DENY_CAPACITY. Важная деталь для исполнителя: это не ошибка интеграции и не повод ретраить в цикле. Это корректный отказ авторизации при исчерпанном мандате, и обработчик должен уметь его обработать: зафиксировать в логе, вернуть агенту причину и остановить цепочку вместо бесконечных повторов.
Почему AI-агенту нужен расходуемый мандат, а не разовая проверка
Из кейса вытекают три свойства, без которых авторизация агента остаётся локальной проверкой в красивой обёртке.
- Общий grant. Один лимит на всю задачу, а не на вызов. Он привязан к поручению человека и живёт до его закрытия.
- Scope. Явно перечислено, что разрешено: какие действия, какие поставщики, какой период, какие суммы. Без scope мандат превращается в универсальную отмычку.
- Остаток. Число, которое уменьшается атомарно при каждом расходе. Именно он отвечает на глобальный вопрос «сколько ещё можно потратить».
Проверка без остатка отвечает на локальный вопрос о конкретном запросе. Мандат с остатком отвечает на вопрос о состоянии задачи. Для агента разница принципиальна: он способен сгенерировать десятки шагов, каждый из которых по отдельности выглядит разумным и обоснованным, и только общий счётчик удержит сумму в границах.
Универсальным такое решение назвать нельзя. Деньги, товары, вызовы внешних API и изменения конфигурации требуют разных мандатов и разных политик: где-то нужен резерв с возвратом, где-то квота по времени, где-то обязательное подтверждение человека. Подход с детерминированным исполнителем, который применяет только допущенные действия, и принципом «unknown ≠ OK» подробно описан в разборе методологии Agent-Ops 0.4.0: мандат с остатком укладывается в неё как один из механизмов плоскости контроля.
Три слоя свидетельств: ALLOW, ответ исполнителя и чтение ERP
В кейсе сошлись три разных типа информации, и путать их опасно.
- ALLOW это решение слоя авторизации. Он говорит, что запрос допущен по правилам, и ничего не говорит о состоянии ERP.
- Ответ исполнителя это отчёт агента или workflow о том, что он сделал. Отчёт может быть честным и при этом неверным, если исполнитель неверно понял результат вызова.
- Повторное чтение ERP это независимая проверка факта в целевой системе. Только она показывает, сколько единиц реально оказалось в заказе.
Три ALLOW и отчёты исполнителя выглядели согласованно, расхождение поймало чтение ERP. Практическое следствие: логируйте все три слоя отдельно и сверяйте их между собой, а в аудите опирайтесь на целевой источник, а не на сумму ответов агента. Похожий сюжет про то, как «read-only» агент всё равно изменил CRM, разобран в материале про runtime-контроль на n8n, DeepSeek и HubSpot: полномочия модели и полномочия системы, которая вызывает внешнее последствие, это разные вещи.
Пять вопросов для проверки схемы перед продом
- На какой вопрос отвечает ваша проверка? Хороший ответ: она считает сумму по задаче и знает про остаток. Плохой: она сравнивает размер текущего запроса с лимитом.
- Есть ли общий мандат с остатком, который расходуется атомарно? Хороший ответ: есть строка мандата с остатком, и её изменение защищено транзакцией или блокировкой. Плохой: остаток считается запросом к истории операций в момент проверки.
- Резервирование идёт до внешнего эффекта или после? Хороший ответ: резерв фиксируется до вызова ERP, отмена возвращает его обратно. Плохой: сначала заказ, потом сверка.
- Что получает запрос при исчерпанном остатке? Хороший ответ: явный DENY_CAPACITY с причиной, который исполнитель умеет обработать. Плохой: неопределённое поведение, таймаут или бесконечный ретрай.
- Сверяете ли вы итог с независимым чтением целевой системы? Хороший ответ: периодическая сверка ERP с мандатом и логами решений. Плохой: вера в отчёты агента как в источник истины.
Ответы стоит проверять на своей нагрузке и своих сценариях. Чеклист задаёт направление, но не заменяет прогон с параллельными вызовами: именно он вскрывает окно гонки, которое не видно в последовательных тестах.
Границы применимости эксперимента
Ограничения нужно назвать прямо. В разборе подтверждён сам кейс: три запроса по 40, три ALLOW, итог 120 при разрешённых 100. Детали последовательной схемы и stateful-проверки, которые сравнивались на стенде, в материале не раскрыты, поэтому восстановить их точный код по публикации нельзя. Упоминание транзакции с SELECT ... FOR UPDATE в PostgreSQL и ответа DENY_CAPACITY для третьего запроса относится к описанному варианту исправления, а не к универсальному рецепту: в другой СУБД и другой архитектуре механика блокировки будет иной.
Стенд это не продакшен. Перенос в свою систему требует проверки на своих сценариях, своей нагрузке и своей целевой системе, а также ответа на вопрос, что делать с уже отправленными заказами при сбое между резервом и вызовом ERP. Одно исправление не закрывает все классы гонок: оно закрывает конкретный разрыв между проверкой и действием для конкретного лимита. Итог, который стоит запомнить: ALLOW, не связанный с расходуемым остатком, гарантирует ровно одно, что конкретный запрос был допущен. Соблюдение общего лимита он не гарантирует.