# Изменения схемы API: контрактные тесты для более безопасных агентов

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

Опасность в том, что такие сбои часто выглядят нормально в обычном мониторинге. Поставщик возвращает HTTP 200. Клиент не падает. Агент выдаёт правдоподобное объяснение. Контрактные тесты должны проверять смысл, который агент придаёт данным API, а не только возможность разобрать JSON.

## Решения агента превращают совместимость в свойство безопасности

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

Представьте агента для очистки, который вызывает `GET /projects?state=inactive`, читает поле `owner` у каждого объекта и запрашивает подтверждение перед архивированием проектов за пределами разрешённого списка. Позже поставщик меняет `owner` на `owner_id`, сохраняя старую конечную точку и код статуса. Нестрогий разбор подставляет пустую строку вместо отсутствующего `owner`. Если правило агента считает пустого владельца объектом без назначения, агент подготовит гораздо более широкий запрос на архивирование.

Это не ошибка аутентификации и не ошибка промпта. Это ошибка интерпретации на границе API. Исправляющий контроль тоже должен находиться там.

Считайте частью контракта безопасности любое поле, влияющее на такие решения:

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

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

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

## Различие схем не доказывает поведенческую совместимость

Инструменты сравнения схем обнаруживают полезные изменения, но не могут сказать, безопасно ли изменение для конкретного агента. Они сравнивают объявления. Агент зависит от семантики, которая часто остаётся за их пределами.

Предположим, поставщик меняет `limit` с необязательного параметра с неявным значением по умолчанию 100 на необязательный параметр с неявным значением 1000. Стандартное сравнение OpenAPI может не показать изменения обязательных свойств. Однако агент, не передавший `limit`, теперь просмотрит в десять раз больше объектов и может выполнить пакетное действие над всеми ними.

Спецификация OpenAPI описывает Schema Object как расширение словаря JSON Schema и говорит, что его свойства содержат сведения о полезных нагрузках запросов и ответов. Это полезный материал для документации и проверки. Но из него не следует, что `state: "pending"` разрешает повтор или что пропущенный `limit` всё ещё ограничен значением 100. Это утверждения о рабочем процессе, и тесты должны формулировать их простыми словами.

Точно так же аннотация `default` в JSON Schema не заставляет валидатор или клиент подставлять значение. Многие разработчики предполагают обратное. Документация JSON Schema рассматривает `default` как данные аннотации, а не команду, изменяющую экземпляр. Если безопасность зависит от значения, передавайте его явно из клиента и проверяйте точный исходящий запрос. Не ждите, что аннотация схемы исправит пропуск.

Используйте инструмент сравнения как сигнал тревоги. Затем классифицируйте каждое обнаруженное изменение по пути действия, на который оно может повлиять:

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

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

## Контрактные тесты должны фиксировать запросы и решения

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

Для значений по умолчанию перехватывайте настоящий HTTP-запрос на локальном тестовом сервере или в песочнице поставщика. В следующем примере на Python используется `httpx.MockTransport` для проверки исходящего запроса. Это предотвращает распространённую ошибку, когда клиент молча полагается на значение поставщика по умолчанию при опасной операции.

```python
import httpx

seen = []

def handler(request: httpx.Request) -> httpx.Response:
    seen.append({
        "method": request.method,
        "path": request.url.path,
        "query": dict(request.url.params),
    })
    return httpx.Response(200, json={"items": []})

client = httpx.Client(transport=httpx.MockTransport(handler))

response = client.get(
    "https://api.example.test/projects",
    params={"state": "inactive", "limit": "100"},
)

assert response.status_code == 200
assert seen == [{
    "method": "GET",
    "path": "/projects",
    "query": {"state": "inactive", "limit": "100"},
}]
```

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

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

```python
from dataclasses import dataclass

@dataclass
class ArchivePlan:
    project_ids: list[str]
    requires_approval: bool

def plan_archives(items: list[dict], allowed_owners: set[str]) -> ArchivePlan:
    targets = []
    for item in items:
        owner = item.get("owner")
        if owner is None:
            raise ValueError("provider response lacks owner")
        if item["state"] == "inactive" and owner in allowed_owners:
            targets.append(item["id"])
    return ArchivePlan(targets, requires_approval=bool(targets))

items = [
    {"id": "p17", "state": "inactive", "owner": "team-a"},
    {"id": "p18", "state": "inactive", "owner": "team-b"},
]

plan = plan_archives(items, {"team-a"})
assert plan.project_ids == ["p17"]
assert plan.requires_approval is True
```

Этот тест делает выбор, которого часто избегает нестрогий код: отсутствие `owner` вызывает ошибку. Для поля, выбирающего действие, безопаснее остановиться. Пустая строка, `None` или догадка могут сохранить процесс, но заменяют заметный сбой интеграции потенциально небезопасным планом.

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

## Переименованные поля требуют явного поведения при ошибке

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

Худший вариант выглядит так:

```python
owner = item.get("owner", "")
if owner not in blocked_owners:
    archive(item["id"])
```

Когда `owner` исчезает, каждый объект выглядит так, будто его владелец не заблокирован. Разбор выполнил ровно то, что запросил код. Скорее всего, инженер хотел избежать `KeyError`. Это небольшое удобство превратило отсутствие данных в разрешение на действие.

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

Если вы поддерживаете псевдоним, сделайте приоритет очевидным и временным:

```python
def read_owner(item: dict) -> str:
    if "owner" in item:
        return item["owner"]
    if "owner_id" in item:
        return item["owner_id"]
    raise ValueError("owner identity missing")
```

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

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

## Значения по умолчанию и пропуски требуют отдельных тестов

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

Конструктор запроса может пропустить `dry_run`, когда его внутреннее значение равно `None`. Поставщик может понимать пропуск как `false`. В следующем выпуске он может начать считать пропуск указанием «использовать настройку аккаунта», а у одного из клиентов эта настройка будет false, у другого true. Код агента не изменился, но действие изменилось.

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

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

| Намерение | Исходящее представление | Ожидаемый смысл для поставщика |
| --- | --- | --- |
| Прочитать неактивные проекты | `state=inactive&limit=100` | Ограниченная выборка |
| Смоделировать архивирование | `{"dry_run": true}` | Архивирование не выполняется |
| Архивировать один проект | `{"project_ids":["p17"],"dry_run": false}` | Измениться может только p17 |
| Режим не выбран оператором | Запрос отклоняется локально | Поставщик ничего не получает |

Будьте так же строги с пагинацией. Ответ с добавленным `next_cursor` может подтолкнуть агента автоматически загрузить все страницы. Для отчёта это может быть разумно, для планировщика действий, безрассудно. Проверьте максимальное число объектов, которое может рассматривать планировщик, и условие, разрешающее загрузить вторую страницу. Курсор продолжает выборку, но не даёт согласия на неограниченную область действия.

Значения по умолчанию поставщика важны и в ответах. Если API начнёт не передавать `archivable`, когда значение равно false, код вроде `if item.get("archivable", True)` изменит поведение в небезопасную сторону. Для поля, дающего разрешение, используйте явное сравнение, например `item.get("archivable") is True`. Это менее изящно, зато намного проще для проверки.

## Проверка ответа должна сохранять смысл, а не только форму

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

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

Например, этот разбор обрабатывает изменившееся представление статуса, не выдавая себе разрешение на повтор:

```python
ALLOWED_STATES = {"queued", "running", "succeeded", "failed"}

def retryable(job: dict) -> bool:
    status = job.get("status")
    if status not in ALLOWED_STATES:
        raise ValueError(f"unknown job status: {status!r}")
    return status == "failed" and job.get("retry_allowed") is True
```

Если поставщик поменяет `status` со строки на объект вроде `{"phase":"failed"}`, этот код остановится. Это правильно, пока кто-то не решит, как новое представление соотносится со старым рабочим процессом. Если поставщик добавит `cancelled`, остановка тоже правильна, пока команда не определит, является ли отмена окончательной, допускает ли повтор или требует участия человека.

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

Проверяйте также взаимосвязи между полями. Ответ может быть структурно правильным, но содержать противоречивую комбинацию, например `status: "succeeded"` и `retry_allowed: true`. Проверка схемы обычно не выражает все бизнес-инварианты. Контрактный тест должен утверждать, что успешная задача не создаёт план повтора независимо от случайного логического флага.

## Тестируйте весь маршрут действия, а не удобную имитацию

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

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

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

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

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

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

## Этапы выпуска должны блокировать смысловые поломки

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

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

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

Версионированные фикстуры упрощают проверку. Храните идентификатор вроде `projects-list-v1` вместе с ожидаемой парой запроса и ответа. Когда поставщик намеренно вводит `projects-list-v2`, сохраняйте старую фикстуру до окончания политики миграции. Не перезаписывайте старый JSON, объявляя тесты актуальными. Так вы потеряете доказательство того, какую совместимость прекратили.

Полезный этап выпуска сообщает об ошибках на языке эксплуатации. Фраза «Поле owner отсутствует, планирование архивирования остановлено» сразу объясняет проверяющему, что произошло. «ValidationError at path items.0» лучше, чем ничего, но заставляет проверяющего самому восстанавливать риск во время выпуска.

## Экран подтверждения не исправит вводящий в заблуждение план

Подтверждение человеком остаётся хорошим контролем для внешних действий, но оно появляется слишком поздно, если агент построил неправильный план из-за изменившегося контракта. Человек, который видит «Архивировать 847 неактивных проектов», может отклонить действие. Человек, который видит «Архивировать проект p17», не поймёт, появился ли p17 из-за отсутствующего поля owner, расширившегося значения по умолчанию или разборщика, перепутавшего `cancelled` с `failed`.

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

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

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