# Как записывать отмененные действия агента, не искажая результат

Аудит-лог, который помечает каждое отмененное действие агента как `failed`, лжет. Агент мог перестать ждать, но строка в базе данных уже могла появиться, развертывание уже могло начаться, а удаленная команда могла продолжить работу после разрыва соединения.

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

## Результат вызывающей стороны не равен результату действия

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

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

Для операций чтения нужна такая же честность, хотя риск здесь другой. Отмененный запрос может вернуть неполное представление, и агент примет неверное решение на следующем шаге. Обычно сам по себе он не меняет систему. А вот POST, PATCH, DELETE или команда в удаленной оболочке могут.

В каждой записи разделяйте три вещи:

- **Состояние вызывающей стороны:** завершено, отменено, отключено или истек срок ожидания.
- **Свидетельства отправки:** не начато, начато локально, байты переданы транспорту или удаленная сторона подтвердила получение.
- **Результат эффекта:** эффекта нет, выполнено, отклонено, выполнено частично или эффект неизвестен.

Команды часто объединяют первое и третье поле, потому что один столбец статуса удобен. Это удобство заканчивается во время разбора инцидента. Тогда кому-то приходится объяснять, почему рядом с надписью «запрос отменен» стоит объект, который явно существует в production.

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

## Записывайте границу свидетельств, а не придумывайте историю

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

Не делайте вид, что один Boolean `sent=true` решает все. Локальные записи могут буферизоваться. Библиотека транспорта может сообщить о записи раньше, чем приложение на другой стороне прочитает данные. Узел может получить запрос, применить изменение и потерять ответ по дороге обратно. Журнал должен описывать самые сильные доступные свидетельства, а не превращать деталь реализации в доказательство.

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

```json
{
  "action_id": "act_01JQ7M4V6K",
  "session_id": "ses_01JQ7M2Y8A",
  "channel": "http",
  "intent": {
    "method": "POST",
    "target": "api.example.internal/v1/releases",
    "request_fingerprint": "sha256:...",
    "idempotency_token": "release_01JQ7M4V6K"
  },
  "observations": [
    {"at": "2026-07-22T16:40:01Z", "kind": "authorized"},
    {"at": "2026-07-22T16:40:02Z", "kind": "dispatch_started"},
    {"at": "2026-07-22T16:40:03Z", "kind": "transport_write_completed"},
    {"at": "2026-07-22T16:40:33Z", "kind": "caller_deadline_exceeded"}
  ],
  "caller_disposition": "timed_out",
  "effect_outcome": "unknown_effect",
  "outcome_basis": "response_not_observed_after_dispatch"
}
```

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

Между `dispatch_started` и `transport_write_completed` есть важная разница. Первое говорит, что шлюз начал операцию. Второе, что локальный транспорт принял исходящие данные. Ни то ни другое не подтверждает выполнение удаленным приложением. Если реализация не умеет их различать, записывайте более слабый факт и указывайте это в основании результата.

## Отмена до отправки может означать отсутствие эффекта

Отмена может поддерживать вывод «эффекта нет», но только до того, как шлюз зафиксировал действие во внешнем канале. Это чистый случай: агент отзывает запрос, пока тот еще ждет локальной авторизации, до разблокировки хранилища, до начала HTTP-запроса или до передачи SSH-команды вспомогательному процессу транспорта.

В аудиторской записи нужно показать, почему такой вывод безопасен. Одного слова «отменено» недостаточно: оно не говорит расследующему, на каком этапе произошла отмена. Запишите этап и локальную точку подтверждения.

```json
{
  "action_id": "act_01JQ7P1N2R",
  "caller_disposition": "canceled",
  "effect_outcome": "no_effect",
  "outcome_basis": "cancellation_observed_before_dispatch",
  "last_observed_stage": "awaiting_authorization"
}
```

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

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

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

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

## Тайм-аут после отправки означает неопределенный эффект

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

В HTTP легко ошибиться, потому что названия статусов звучат окончательно. RFC 9110 говорит, что 408 означает: сервер не получил полное сообщение запроса за время, в течение которого был готов ждать. 504 означает: шлюз не получил вовремя ответ от вышестоящего сервера. Оба определения описывают конкретного наблюдателя и конкретный обмен. Ни одно не доказывает, что отдельная система не обработала уже полученные данные.

Представьте агента, который отправляет `POST /v1/releases` с крайним сроком 30 секунд. API проверяет запрос, добавляет строку выпуска, просит контроллер развертывания начать работу, а затем зависает при подготовке ответа. Через 30 секунд агент видит тайм-аут. Выпуск существует. Повтор без токена идемпотентности может создать еще один выпуск, хотя в расшифровке работы агента первый вызов отмечен как «ошибка».

Честная запись выглядит так:

```json
{
  "caller_disposition": "timed_out",
  "effect_outcome": "unknown_effect",
  "outcome_basis": "deadline_after_transport_write_no_remote_receipt",
  "recovery_required": "lookup_by_idempotency_token"
}
```

Не используйте `failed` как сокращение для неопределенности. Результат «ошибка» оставляйте для установленных фактов: удаленный сервис вернул ошибку проверки, команда завершилась с ненулевым кодом, соединение не удалось установить до выхода запроса из шлюза или локальное решение об авторизации запретило выполнение. После возможной исходящей отправки тайм-аут этому стандарту не соответствует.

Для частичного выполнения нужен отдельный результат, если удаленная сторона дает соответствующие свидетельства. Пакетные API и скрипты часто успевают выполнить часть работы до ошибки. Если сервис возвращает список созданных идентификаторов объектов, а затем ошибку, запишите `partial_effect`, сохраните идентификаторы там, где это разрешено политикой, и укажите причину, названную сервисом. Простое «ошибка» скроет конкретную работу по очистке, которую предстоит выполнить оператору.

## Потерянные подтверждения нужно записывать отдельно

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

Обычно последовательность выглядит вполне обычно до последнего момента:

1. Шлюз авторизует и отправляет действие.
2. Удаленный сервис принимает его и выполняет либо ставит в очередь запрошенную работу.
3. Ответ задерживается, соединение обрывается или локальный процесс завершается.
4. У шлюза нет надежного подтверждения выполнения, хотя в удаленной системе оно может быть.

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

Добавьте новое наблюдение:

```json
{
  "action_id": "act_01JQ7M4V6K",
  "reconciliation": {
    "at": "2026-07-22T16:43:10Z",
    "method": "GET /v1/operations/release_01JQ7M4V6K",
    "remote_reference": "op_8f2c",
    "result": "succeeded"
  },
  "effect_outcome": "succeeded",
  "outcome_basis": "remote_operation_lookup"
}
```

Исходное состояние вызывающей стороны остается `timed_out`. Не переписывайте его в `completed`. Вызывающая сторона действительно дождалась тайм-аута. Позже система узнала, что удаленное действие завершилось успешно. Эти факты не противоречат друг другу.

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

Например, обнаружение нового пользователя с именем `build-bot` не доказывает, какой запрос его создал. Обнаружение объекта с сохраненным токеном запроса, равным исходному токену действия, дает гораздо более сильное подтверждение. От этого зависит, можно ли безопасно повторить запрос.

## Идемпотентность превращает восстановление из рискованной попытки в проверку

Идемпотентность не является разрешением на повтор, прикрепленным после события. Это договоренность, установленная до отправки. Клиент передает стабильный токен, а сервис гарантирует, что повторные запросы с этим токеном относятся к одной логической операции, а не создают новые эффекты.

Для каждой интеграции с HTTP, меняющей состояние, задайте владельцу сервиса четыре прямых вопроса:

- Принимает ли сервис токен идемпотентности, заданный вызывающей стороной?
- Какие поля запроса должны оставаться неизменными при повторном использовании токена?
- Как долго сервис хранит соответствие токена результату?
- Может ли вызывающая сторона получить исходный результат после потери ответа?

Если ответы расплывчаты, не объявляйте автоматический повтор безопасным. Фраза «обычно мы удаляем дубликаты» не является договоренностью. Очистка кэша, переключение региона или изменение анализатора запросов может превратить это предположение в дублированную работу.

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

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

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

Договоренность об идемпотентности помогает и при расследовании. Оператор может задать один конкретный вопрос: «Что целевая система решила относительно токена X?» Без этого приходится восстанавливать намерение по времени, логам и именам. Это медленно, подвержено ошибкам и часто невозможно после окончания срока хранения.

## Закрытие SSH и завершение команды, это разные факты

SSH провоцирует похожую ошибку: закрытая сессия кажется остановленной командой. Но это не так. Разрыв сети может отключить клиента, пока удаленный процесс продолжает работу под управлением родительской оболочки, супервизора или менеджера сервисов. И наоборот, процесс может завершиться, а клиент не получить сообщение о коде выхода.

RFC 4254 рассматривает закрытие канала, конец файла и код выхода как отдельные события протокола. В документе рекомендуется возвращать код выхода при завершении удаленной команды, но закрытие канала не доказывает, что клиент его получил. Спецификация также говорит, что узлы обмениваются сообщениями о закрытии до того, как каждый из них считает канал закрытым. Это сообщает о состоянии SSH-канала, но не о том, успела ли удаленная команда изменить файловую систему или сервис до обрыва.

Записывайте свидетельства SSH как отдельные наблюдения:

```json
{
  "channel": "ssh",
  "observations": [
    {"kind": "command_request_sent"},
    {"kind": "stdout_received", "bytes": 1840},
    {"kind": "connection_lost"}
  ],
  "caller_disposition": "disconnected",
  "effect_outcome": "unknown_effect",
  "outcome_basis": "no_exit_status_or_remote_process_identity"
}
```

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

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

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

## Расследование начинается с хронологии, а не с финальной отметки

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

Хорошее расследование идет по таким вопросам:

1. Кто авторизовал действие и какой процесс агента отправил запрос?
2. Пересек ли шлюз границу отправки?
3. Какие наблюдения транспорта произошли после отправки?
4. Пришло ли надежное удаленное подтверждение?
5. Если нет, какой запрос сверки может найти исходную логическую операцию?

Такая последовательность помогает не поддаться распространенной плохой привычке, сначала искать в удаленных логах похожее событие и объявлять его ответом. Начните с запланированного действия и данных для его связи. Затем проверьте, соответствует ли удаленное свидетельство именно этой операции.

Аудит-запись должна защищать от незаметного изменения. Если оператор может заменить `unknown_effect` на `succeeded`, не сохранив прежнее состояние и основание обновления, запись превращается из свидетельства в утверждение. Записи только для добавления, цепочка хешей и офлайн-проверка усложняют сокрытие изменений после инцидента. Они не доказывают, что каждая удаленная система сказала правду, но сохраняют то, что шлюз наблюдал и когда узнал больше.

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

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

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

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

Используйте такие правила:

- Автоматически повторяйте после `no_effect`, только если исходное намерение все еще разрешено и актуально.
- Повторяйте после `rejected`, только если агент изменил некорректные данные или человек разрешил описанный конфликт.
- Перед повтором любого действия, меняющего состояние, проверяйте `unknown_effect`.
- Рассматривайте `partial_effect` как задачу очистки или продолжения, а не как чистый лист.
- Передавайте на ручное решение случай, когда сверка не может найти одну подходящую удаленную операцию.

Именно последнее правило чаще всего вызывает нетерпение. Командам хочется, чтобы агент продолжал работу. Для запроса чтения это разумно, но для действий, которые тратят деньги, меняют доступ, затрагивают production или удаляют данные, такой подход опасен. Незакрытое действие должно оставаться видимым, пока человек или надежный удаленный запрос не устранят пробел в свидетельствах.

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

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