# Аудит webhook: прослеживайте процессы агентов от начала до конца

У действия агента, которое запускает асинхронный процесс, есть две истории: отправленный запрос и вернувшееся событие. Команды часто достаточно хорошо журналируют первое, чтобы ответить на вопрос «вызывал ли агент API?», и второе, чтобы понять, «получил ли наш приемник webhook?». А во время инцидента выясняется, что никто не может доказать: эти записи относятся к одной и той же работе.

Аудит webhook должен связывать намерение, авторизацию, исходящие попытки, подтверждение удаленной стороны, входящее получение, проверку и бизнес-состояние, которое вы решили принять. Если считать ответ 200 концом записи, через три дня становится невозможно объяснить платеж, развертывание, заявку или изменение доступа.

## Аудит webhook фиксирует два разных факта

Аудит webhook должен сохранять различие между запросом на действие и уведомлением о событии, поскольку они отвечают на разные вопросы. Исходящая часть показывает, что агент попросил сделать удаленный сервис. Входящая часть показывает, что некоторый отправитель позже заявил о произошедшем.

Эти факты могут относиться к одному процессу, но у них разные сценарии сбоя. Агент может отправить команду, потерять соединение и отправить ее снова. Удаленный сервис может принять команду, выполнить ее через несколько минут и отправить два одинаковых callback. Приемник может проверить первый callback, но завершить работу до сохранения нового состояния. Одна аккуратная строка в журнале приложения не объяснит такую цепочку.

При проверке таких систем я использую шесть типов записей:

- Запись действия указывает запуск агента, авторизацию человека, запрошенную операцию и предполагаемую цель.
- Запись исходящей попытки описывает каждую HTTP-передачу, включая дайджест запроса и полученный ответ.
- Запись удаленной ссылки сохраняет идентификатор, который вернул провайдер, например ID задачи или операции.
- Запись получения описывает каждую входящую HTTP-доставку до того, как бизнес-обработка что-либо изменит.
- Запись проверки точно объясняет, почему приемник принял, отклонил или отправил доставку в карантин.
- Запись перехода состояния показывает, что изменилось в процессе после обработки проверенного события.

Не объединяйте попытку с действием. Одно действие может породить несколько попыток. Не объединяйте и получение с событием. Одно событие провайдера может несколько раз дойти до вашего endpoint. Это различие кажется излишне формальным, пока инженеру не приходится объяснять, откуда взялось второе развертывание: агент повторил действие, HTTP-клиент повторил запрос или провайдер повторно отправил одно событие.

RFC 9110 намеренно дает широкое определение POST: целевой ресурс обрабатывает представление в соответствии со своими правилами. Поэтому ответ 202 обычно означает «принято для последующей работы», а 200 означает лишь, что endpoint завершил обработку запроса. Это не подтверждение удаленного бизнес-результата. Если у провайдера есть отдельный endpoint со статусом операции или callback, именно это последующее свидетельство определяет результат.

Хорошая запись позволяет прочитать историю по порядку, не додумывая факты по временным меткам:

```text
agent session sess_7c1e authorized action act_01
act_01 created outbound attempt out_01 with idempotency ref idem_44
remote service accepted out_01 and returned operation op_903
receiver accepted delivery rcp_01 for provider event evt_775
rcp_01 verified its signature and linked evt_775 to op_903
workflow wf_18 moved from pending to completed
```

Это цепочка утверждений, а не одно поле статуса. Для каждого утверждения нужны собственные источник и время.

## Для корреляции нужен не один идентификатор

Один ID корреляции не решает проблему трассировки webhook, поскольку разные участники создают идентификаторы для разных областей. Используйте небольшой набор идентификаторов с явно указанным владельцем и сохраняйте связи между ними.

Начните с внутреннего ID действия. Создайте его до любого сетевого вызова и свяжите с сессией агента, запрошенной операцией, решением об авторизации и неизменяемой записью аудита. Этот ID отвечает на вопрос: «Какая инструкция агента запустила работу?» Он не должен меняться при повторе запроса клиентом.

Создавайте ID исходящей попытки каждый раз, когда HTTP-клиент передает запрос. Он отвечает на вопрос: «Какая передача по сети привела к этому ответу или ошибке?» Если удаленный API поддерживает идемпотентность, добавьте ссылку идемпотентности. Она означает, что повторные отправки должны соответствовать одной логической удаленной операции. Но она не говорит, дошла ли конкретная HTTP-попытка до сервера.

Когда удаленный сервис возвращает ID операции, сразу сохраните его рядом с попыткой, которая его получила. Если запрос поддерживает ссылку клиента или поле метаданных, поместите туда ID действия после того, как убедитесь, что провайдер вернет его в callback или ответах со статусом. Никогда не помещайте в поле ссылки секрет, имя сотрудника или полный prompt. Такие поля часто появляются в консолях поставщиков, тикетах поддержки и payload событий.

Входящие callback добавляют еще два ID: ID события провайдера и ID получения у вас. ID события провайдера помогает устранять дубликаты для этого отправителя. ID получения обозначает конкретную HTTP-доставку, принятую вашей инфраструктурой, включая заголовки, адрес источника, если вы его сохраняете, дайджест исходного тела и результат проверки.

Таблица связей должна выглядеть так:

| Идентификатор | Кто создает | Стабилен при повторах? | На какой вопрос отвечает |
|---|---|---:|---|
| ID действия | Ваш сервис действий | Да | Какой запрос агента запустил работу? |
| ID попытки | Ваш HTTP-клиент | Нет | Какая передача привела к этому результату? |
| Ссылка идемпотентности | Ваш сервис действий | Да | Какие отправки означают одну удаленную команду? |
| ID удаленной операции | Провайдер | Обычно да | Какая удаленная задача или сущность изменилась? |
| ID события провайдера | Провайдер | Да, для одного события | Какой callback нужно устранить как дубликат? |
| ID получения | Ваш приемник | Нет | Какую доставку мы получили? |

CloudEvents полезен и в том случае, если провайдер его не отправляет. Спецификация разделяет `id`, `source`, `type`, `subject` и `time`. Это помогает избежать распространенной ошибки: считать ID события ID процесса. ID события обозначает одно событие от одного источника. ID процесса обозначает работу, которую вы отслеживаете. Они могут указывать на один удаленный объект, но не означают одно и то же.

Если провайдер присылает только payload callback с ID объекта, связывайте его осторожно. Считайте связь точной лишь тогда, когда ID объекта пришел из сохраненного исходящего ответа или аутентифицированного запроса статуса. Совпадение по адресу электронной почты, заголовку, сумме или времени не превращается в надежную корреляцию. Не включайте такую связь в выводы аудита.

## Ответ 2xx и callback отвечают на разные вопросы

Ответ 2xx завершает HTTP-обмен. Проверенный callback может подтвердить изменение состояния на удаленной стороне. Процессу нужны оба факта, а разрыв между ними нужно описывать честно.

Представьте, что агент просит облачный сервис сборки опубликовать артефакт. Сервис возвращает 202 и ID операции. Ваш сервис записывает запрос как принятый и ждет. Через десять минут callback сообщает, что публикация не удалась: репозиторий ниже по цепочке отклонил обязательный манифест. Если запись аудита стала «успешной» уже при 202, она противоречит собственным доказательствам провайдера.

Используйте состояния, которые называют имеющиеся у вас доказательства. Например:

1. `requested` означает, что действие агента прошло авторизацию и создало рабочий элемент.
2. `submitted` означает, что хотя бы одна исходящая попытка получила ответ о принятии либо после неоднозначного результата ожидается проверка.
3. `confirmed` означает, что проверенный callback или аутентифицированный ответ статуса подтвердил ожидаемый результат.
4. `failed` означает, что авторитетное свидетельство подтвердило ошибку.
5. `unknown` означает, что пока нельзя установить, выполнила ли удаленная сторона действие.

Состояние `unknown` необходимо. Команды его не любят, потому что панели становятся менее красивыми. Но тихое дублирование хуже. Тайм-аут после отправки POST означает неоднозначную доставку: удаленная система могла получить и обработать запрос, а могла вообще его не увидеть. Повтор без механизма идемпотентности способен создать две удаленные операции. Если назвать первую попытку «неудачной», вы сами подталкиваете систему к этой ошибке.

Поздний callback тоже не всегда побеждает. Допустим, после исходной команды агент просит отменить действие, и ваш внутренний процесс записывает корректную отмену. Позже приходит callback о завершении, описывающий то, что удаленная сторона успела сделать до вступления отмены в силу. Сохраните его, проверьте, свяжите и зафиксируйте конфликт. Не позволяйте универсальному обработчику перезаписать конечное состояние отмены только потому, что «завершено» стоит выше в чьем-то перечислении.

Для каждого типа callback напишите правило перехода. Для одобрения платежа, завершения сборки, создания пользователя и подтверждения удаления не должны использоваться одинаковые переходы. В правиле нужно указать допустимые предыдущие состояния, требуемые доказательства и необходимость решения оператора при конфликте.

## Приемник должен сохранить доказательства до разбора

Приемник должен сохранить исходную доставку, проверить ее и устранить дубликат до выполнения побочного эффекта. Если сначала разобрать JSON и сохранить только выбранные поля, при ошибке парсера, схемы или кода приложения доказательства исчезнут.

В момент получения сохраните в защищенном хранилище событий:

- ID получения и серверную временную метку.
- Метод запроса, маршрут, выбранные заголовки и криптографический дайджест точного исходного тела.
- Ожидаемую личность отправителя и примененную схему проверки.
- ID события провайдера, если он есть в payload, и разобранный тип события.
- Решение: принято, дубликат, отклонено или помещено в карантин, с кодом причины.

Храните исходные payload только столько, сколько оправдано потребностями расследования и соответствия требованиям. Для доказательства совпадения двух payload обычно достаточно дайджеста. Если вы храните тело, шифруйте его, ограничьте доступ и не копируйте в обычные журналы приложения. Webhook часто содержат персональные данные, метаданные репозиториев, адреса и внутренние заметки. Хранилище аудита, из которого утек payload, становится источником риска, а не доказательством.

Проверяйте подпись по телу ровно в том виде, в каком его подписал отправитель. Если промежуточный слой разбирает JSON, форматирует его и проверяет уже измененные байты, он отклонит легитимную доставку или, что еще хуже, создаст разные правила обработки. Внимательно изучите документацию провайдера. Одни схемы подписывают `timestamp + "." + raw_body`, другие только исходное тело, третьи используют асимметричные подписи и сменяемые открытые ключи.

Для общей схемы HMAC, которая подписывает только исходное тело, эта команда показывает ожидаемую форму дайджеста для неизмененных байтов:

```sh
printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET"
# SHA2-256(stdin)= 4d3c...hex digest...
```

Это диагностическая команда, а не замена точной схеме провайдера. Если провайдер включает временную метку или префикс версии, универсальная команда даст неверный результат. Такая ошибка часто возникает, когда инженеры проверяют удобное приближение, а не алгоритм, описанный отправителем.

Корректная подпись не останавливает повторную отправку. Если отправитель передает подписанную временную метку, отклоняйте доставки за пределами короткого окна с учетом измеренного расхождения часов. Затем сохраняйте ID событий провайдера в постоянном хранилище устранения дубликатов до запуска дальнейшей работы. Если на ID события полагаться нельзя, используйте дайджест в области конкретного отправителя и подходящий срок хранения, понимая, что два законных одинаковых события могут потребовать особой обработки.

Возвращайте HTTP-ответ только после того, как решение о получении стало постоянным. Если сначала вернуть успех, а затем завершить работу до записи о дубликате, отправитель повторит запрос, а обработчик может дважды обработать одно событие. При малом объеме тестов ошибка скрывается, но проявляется именно при сбое, когда поток webhook резко возрастает.

## Повторы показывают, где записи слишком расплывчаты

Повторы - нормальная часть работы, а не редкий крайний случай, и каждый слой может повторять запрос независимо. Агент повторяет после тайм-аута. HTTP-библиотека повторяет после сбоя соединения. Провайдер повторяет callback. Потребитель очереди повторяет неудачный обработчик. Запись «число повторов: 3» не помогает разобраться.

Рассмотрим сбой, который я видел в разных вариантах. Агент просит создать удаленную запись доступа. Клиент отправляет POST и ждет ответа, но тайм-аут происходит после того, как байты покинули машину. Удаленный сервис создает запись и ставит callback в очередь. Фреймворк агента видит тайм-аут и повторяет запрос. Второй запрос создает еще одну запись, потому что слой действий генерировал новую ссылку идемпотентности при каждой попытке. Приходят оба callback. Приемник использует для сопоставления только адрес электронной почты, решает, что это дубликаты, и подавляет второй. На странице аудита виден один выполненный запрос. У удаленного сервиса теперь две записи доступа.

Каждый компонент действовал вполне правдоподобно. Система сломалась потому, что не сохранила одну логическую команду между границами повторов.

Исправьте последовательность:

1. Создайте ID действия и ссылку идемпотентности один раз, до первой исходящей попытки.
2. Записывайте каждую попытку отдельно, включая тайм-ауты и транспортные ошибки.
3. При неоднозначности запросите провайдера по ссылке идемпотентности или ссылке клиента до отправки новой команды.
4. Принимайте каждый аутентифицированный callback как отдельное получение, а затем устраняйте дубликаты только по ID события провайдера, а не по самому удаленному объекту.
5. Сверьте ожидаемое число удаленных объектов с записанным действием до объявления процесса завершенным.

Первая проверка идемпотентности должна происходить у отправителя, вторая - у приемника. Они решают разные задачи. Идемпотентность отправителя предотвращает повторные удаленные команды. Устранение дубликатов у приемника не дает повторно обработать одно удаленное событие. Команды часто устанавливают одно и думают, что получили оба механизма.

Не используйте время поступления как порядок определения бизнес-истины. Провайдеры могут доставлять события с задержкой или не по порядку, а ваша очередь может отложить обработку. Сохраняйте как минимум три времени: когда сервис действий создал действие, когда HTTP-клиент отправил попытку или получил ответ и когда приемник принял callback. Отдельно сохраняйте заявленное отправителем время события. Часы отправителя - это свидетельство со стороны отправителя, а не ваши часы.

## Авторизация должна сохраняться за асинхронной границей

Согласование человеком действия агента должно относиться к самому действию, а не к callback, который появится позже. Callback содержит сведения об удаленной работе. Он не должен незаметно получать право запускать новую привилегированную операцию только потому, что у него есть поле корреляции с одобренным запросом.

Это важно, когда callback может содержать URL, имена объектов, пользовательские метаданные или инструкции, которым следует внутренний обработчик. Распространенный плохой дизайн получает событие «задача завершена», после чего универсальный автоматический обработчик загружает URL результата или запускает следующую команду с широкими полномочиями. Исходное одобрение агента относилось к отправке задачи, а не к произвольному набору действий, вложенных в событие.

Фиксируйте авторизованное действие конкретно: сессию исполнителя, запрошенный endpoint или шаблон SSH-команды, область цели, идентификатор учетных данных, результат согласования и время согласования. Для каждого исходящего вызова указывайте эту запись авторизации. Для каждого callback указывайте действие только после проверки и корреляции. Направление здесь важно. Входящий запрос не должен искать в базе любое подходящее старое одобрение и занимать его.

Sallyport не дает агенту доступ к учетным данным и записывает как запуски агентов, так и отдельные вызовы. Благодаря этому исходящую часть доказательств проще сохранить. Но приемнику callback все равно нужны собственные записи получения и процесса, поскольку журнал HTTP-действий не может знать, отправила ли удаленная система позже корректное событие.

Используйте разные учетные данные для двух направлений. Учетные данные для исходящего API-вызова обычно не должны проверять входящие подписи, а секрет проверки входящих запросов не должен давать обработчику callback право обращаться к произвольным внешним API. Раздельное хранение ограничивает ущерб при проблемах с маршрутом приемника, зависимостью или системой журналирования.

## Защита от изменений должна охватывать связи, а не только вызовы

Журнал исходящих вызовов, доступный только для добавления, полезен, но не доказывает правильность решений о корреляции. Оператор или ошибка приложения может связать неправильный callback с неправильным действием, не изменив ни одну из исходных HTTP-записей.

Сделайте корреляцию отдельным событием аудита. В событии должны быть ID действия, ID получения, основание связи, исполнитель или процесс, принявший решение, и дайджест использованных полей. Используйте явные основания, например `remote_operation_id_exact`, `client_reference_exact`, `authenticated_status_lookup` или `manual_review`. Не пишите просто «совпало», заставляя расследующего угадывать.

Журнал с цепочкой хешей может показать, что записи не менялись после создания, если вы защищаете путь добавления и сохраняете контрольные точки. Но он не доказывает, что приложение в тот момент приняло правильное решение. Это ограничение полезно прямо признавать. Защита от изменений дает устойчивую картину того, что записала система, но не превращает слабую корреляцию в факт.

Зашифрованный журнал аудита Sallyport с цепочкой хешей можно проверить офлайн командой `sp audit verify`, даже без ключа хранилища. Используйте такую проверку для записей действий, а затем храните сопоставимую неизменяемую ссылку из хранилища процесса на соответствующие идентификаторы действий и вызовов.

Для важных процессов добавьте задачу сверки, которая сравнивает три группы: отправленные действия, известные провайдеру удаленные операции и принятые приемником callback. Для несвязанного элемента задача должна создать запись исключения, а не закрывать его автоматически. Отсутствующий callback может означать сбой провайдера, неправильный endpoint, ошибку при смене подписи или ошибку процесса. Сначала нужны доказательства, а уже потом оптимизм.

## Наблюдаемость должна позволять воспроизвести решение

Расследующий должен начинать с любого идентификатора и восстанавливать процесс без привилегированного доступа к prompt агентов или секретам API. Спроектируйте пути поиска до выпуска интеграции.

По ID действия запись должна показывать сессию агента, авторизацию, метку учетных данных, форму запроса после удаления чувствительных данных, все попытки, удаленные ссылки, связанные получения и конечное состояние процесса. По ID события провайдера должны быть видны все доставки этого события, результаты проверки, результат устранения дубликатов, связанная операция и изменения состояния. По внутреннему бизнес-объекту должна отображаться точная цепочка доказательств, связавшая его с действием агента.

Используйте структурированные поля, а не одну описательную строку. Такой контракт события можно перенести в проверку схемы или конвейер журналов:

```json
{
  "record_type": "callback_receipt",
  "receipt_id": "rcp_01J...",
  "received_at": "2025-03-08T22:14:31Z",
  "sender": "build-service",
  "provider_event_id": "evt_775",
  "event_type": "publication.finished",
  "raw_body_sha256": "4d3c...",
  "signature": {"scheme": "hmac-sha256", "result": "valid"},
  "correlation": {
    "action_id": "act_01J...",
    "remote_operation_id": "op_903",
    "basis": "remote_operation_id_exact"
  },
  "processing": {"deduplication": "new", "result": "completed"}
}
```

Дайджест тела, результат проверки и основание корреляции полезнее расплывчатого `status: success`. Они позволяют проверять утверждения. Если провайдер оспаривает callback, сравните сохраненные дайджесты. Если инженер оспаривает связь, изучите ее основание. Если дубликат вызвал побочные эффекты, проверьте, записал ли приемник решение об устранении дубликата до отправки работы дальше.

Не записывайте заголовки авторизации, bearer-токены, закрытые ключи, секреты подписей или полные URL с учетными данными. Удаляйте значения параметров, если в них есть чувствительные данные, но сохраняйте достаточно идентификации запроса, чтобы различать две цели. Я видел, как команды настолько очищали URL, что потом не могли понять, обращался ли агент к production или к тестовому endpoint. Храните нормализованный хост, шаблон маршрута, метод и тщательно ограниченный идентификатор цели.

## Постройте трассировку до того, как агенты начнут асинхронные вызовы

Определите идентификаторы, правила получения и переходы состояний до того, как дать агенту действие, запускающее асинхронную работу. Восстанавливать их после спора дорого, потому что отсутствующие доказательства уже не появятся.

Проведите одну заранее подготовленную тренировку сбоя. Отправьте безопасное тестовое действие, если среда позволяет, принудительно вызовите тайм-аут клиента после передачи, отправьте тот же callback повторно, отправьте callback с неверной подписью и доставьте корректный callback после перехода процесса в конечное состояние. Проверьте, что запись аудита объясняет каждый результат без восполнения пробелов по памяти.

Если ваша система не может ответить на вопрос «какое авторизованное действие вызвало этот callback, какими точными доказательствами это подтверждается и что мы с ним сделали?», значит, у нее пока нет полноценной истории аудита webhook. Есть только два набора журналов, которым случайно синхронизировали часы.
