diff --git a/editorial/agent-rewrites/101.json b/editorial/agent-rewrites/101.json index fb57da3..e5bbf05 100644 --- a/editorial/agent-rewrites/101.json +++ b/editorial/agent-rewrites/101.json @@ -2,6 +2,6 @@ "index": 101, "slug": "editorial-2025-03-mechanism-knowledge-retrieval", "title": "Почему высокий vector score не доказывает ответ", - "excerpt": "Retrieval находит похожие фрагменты, но не подтверждает их свежесть, доступность и смысл. Разбираем границу между ranking и проверяемым источником.", - "contentHtml": "
Проблема. Поиск по инженерной базе возвращает первый фрагмент с высоким vector score. Фрагмент похож на вопрос, поэтому ответ выглядит уверенно. Через несколько дней выясняется, что документ описывает старый контракт, закрыт для автора запроса или не содержит точного правила, на которое ссылается ответ.
\nСимптом. В результате есть title, excerpt и число вроде 0.98, но нет версии источника, срока действия, access decision и anchor. Инженер меняет код по старой инструкции. Цена ошибки. Команда тратит время на повторное расследование: нужно найти нужную редакцию, выяснить права и отделить цитату от пересказа. Ошибка также подрывает доверие к базе знаний. Следующий хороший фрагмент начинают игнорировать вместе с плохим.
Тезис. Vector score решает одну задачу: упорядочивает похожие candidates в выбранной модели поиска. Он не доказывает истинность claim, право читать источник, актуальность документа и полноту найденных материалов. Поэтому retrieval должен завершаться не выбором top-1, а проверяемым решением: candidate допускается к ответу только после проверок access, freshness, provenance и exact citation.
\nNearest-neighbor поиск сравнивает запрос с векторами документов. Его результат помогает сократить очередь чтения. Это полезно, когда корпус велик и человек не может открыть все записи. Но score не знает, кто задаёт вопрос. Он не знает, отозвал ли владелец документ. Он не знает, поддерживает ли найденный абзац именно тот claim, который собирается сделать система.
\nПорог похожести не исправляет эту границу. Представим учебный корпус из трёх записей. Просроченная инструкция получила score 0.97, закрытое исключение — 0.99, а текущая разрешённая инструкция — 0.91. Фильтр score >= 0.90 оставит все три. Сортировка поставит опасные записи выше нужной. Это не результат реального сервиса, а минимальный пример, который показывает, почему ranking нельзя использовать как authorization или evidence.
| Сигнал | Вопрос | Действие | Чего он не доказывает |
|---|---|---|---|
| vectorScore | Насколько candidate похож на query? | Ставит запись в очередь проверки. | Что claim верен, свеж и доступен. |
| accessLabels | Разрешён ли класс источника requester scope? | Отбрасывает закрытый фрагмент. | Что выполнена вся реальная policy identity. |
| publishedAt / expiresAt | Входит ли запись в объявленное окно свежести? | Отбрасывает будущие и просроченные записи. | Что это единственная актуальная редакция. |
| citationUri#anchor | Можно ли открыть точное место в конкретной версии? | Делает candidate адресуемым. | Что источник поддерживает более широкий вывод. |
| human verification | Совпадает ли claim с открытым фрагментом? | Разрешает сформулировать ответ. | Что одна проверка заменяет владельца policy. |
Chunking часто оставляет в индексе только текст и embedding. Заголовок, версия и срок действия остаются в исходном документе. После retrieval система показывает удобный snippet, но не может ответить на простой вопрос: из какой редакции он взят? Одинаковая фраза может встречаться в новой инструкции, старом RFC и закрытом исключении.
\nМинимальная запись должна связывать chunk с source record. Source record хранит sourceId, sourceVersion, owner, publishedAt и URI. Chunk хранит sourceId, текстовый фрагмент, citationAnchor, indexedAt, expiresAt и access labels. Retrieval event сохраняет query, момент поиска, список candidates и причины отказа. Система может не передавать все поля в пользовательский интерфейс, но decision должен иметь к ним доступ.
Если у chunk нет стабильной связи с редакцией, он остаётся подсказкой для поиска. Если у него нет anchor, он не становится точной цитатой. Если у него нет declared policy свежести, приложение не должно молча называть его текущим. Эти ограничения лучше показать явно, чем восстанавливать по смыслу из текста.
\nНадёжный порядок начинается с evidence, а не с готового текста. Сначала система получает candidates и их metadata. Затем она применяет policy к каждому candidate. Только оставшиеся фрагменты передаются человеку или компоненту, который формулирует ответ. Так нельзя незаметно добавить в draft тезис, которого не было в источнике.
\ntype Candidate = {\n id: string;\n score: number;\n sourceVersion?: string;\n accessLabels: string[];\n publishedAt?: string;\n expiresAt?: string;\n citationUri?: string;\n citationAnchor?: string;\n};\n\nfunction admissible(candidate, requesterLabel, retrievalAt) {\n const allowed = candidate.accessLabels.includes(requesterLabel);\n const fresh = candidate.publishedAt <= retrievalAt &&\n (!candidate.expiresAt || retrievalAt < candidate.expiresAt);\n const citable = Boolean(\n candidate.sourceVersion &&\n candidate.citationUri &&\n candidate.citationAnchor\n );\n\n return { allowed, fresh, citable, ok: allowed && fresh && citable };\n}\n\n// Учебный код: локальные значения, без реального corpus и identity provider.\nКод показывает контракт, а не готовую систему доступа. Проверка accessLabels.includes не заменяет authentication, authorization policy и аудит. Сравнение дат работает только после фиксации формата и часового пояса. Human verification всё равно нужен: технически доступный anchor может содержать исключение, которое не подтверждает общий claim.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Top-1 отвечает на вопрос, но описывает старый контракт. | Ranking не учитывает срок действия или version policy. | Сравнить publishedAt и expiresAt с зафиксированным retrievalAt. | Отклонить просроченный candidate и показать актуальную редакцию либо остановить ответ. |
| Фрагмент релевантен, но его нельзя открыть. | Индекс получил текст без согласованной access boundary. | Проверить requester scope, labels и решение владельца ресурса. | Убрать candidate из answer path; не маскировать отказ новым пересказом. |
| Ссылка ведёт на страницу, но не на правило. | Сохранён URI без версии и anchor. | Открыть ссылку на том же snapshot и найти точный фрагмент. | Оставить запись кандидатом, пока источник не станет адресуемым. |
| Ответ звучит шире цитаты. | Генератор обобщил локальное исключение. | Сопоставить каждое предложение claim с одним или несколькими фрагментами. | Сузить формулировку, добавить limitation или отправить вопрос на ручную проверку. |
| После пустого результата система всё равно отвечает. | Fallback подменяет отсутствие evidence вероятным текстом. | Проверить negative path: все candidates expired, закрыты или без anchor. | Вернуть stop status с причиной и запросить источник или решение владельца. |
Проверять нужно не только случай, где нашлась хорошая запись. Система должна остановиться, если все candidates просрочены, закрыты или не имеют точного locator. Пустой результат честнее уверенного ответа без основания. Его можно объяснить и исправить: обновить документ, выдать доступ, добавить anchor или уточнить вопрос.
\nНе смешивайте причины в один статус вроде relevance_low. access_denied ведёт к владельцу policy. expired_at_retrieval ведёт к владельцу документа. missing_citation_anchor ведёт к ingestion или структуре источника. Такое разделение экономит время и не толкает команду сразу менять embedding model.
retrievalAt. Один и тот же запрос должен иметь воспроизводимый срез времени.Эта схема не обещает, что vector search найдёт полный корпус. Она не сравнивает качество embedding-моделей и не утверждает, что keyword search всегда лучше. Она также не превращает metadata в доказательство смысла. Свежая доступная цитата может быть двусмысленной или слишком узкой для вопроса.
\nУчебный код использует локальные значения и упрощённые labels. Он не проверяет реальную личность, RBAC, ABAC, SSO, журнал аудита, конкурентное обновление документа или кэш. Применять его как готовую authorization implementation нельзя. В реальной системе policy должен иметь владельца, а source version и правила expiry должны быть частью явного контракта.
\nОграничение есть и у даты. HTTP-поле Last-Modified сообщает время, когда origin считает представление изменённым. Оно не доказывает, что инженерное правило всё ещё применимо. Документ может не меняться и устареть из-за миграции. Поэтому freshness policy должна учитывать смысл вопроса, а не только HTTP timestamp.
Механизм готов к ограниченному применению, когда для одного зафиксированного query можно показать полный decision trace: candidates с score, source version, access result, freshness result, citation URI с anchor и итоговый human verification. На учебном отрицательном наборе expired, closed и no-anchor записи получают отдельные причины и не попадают в answer.
\nДополнительный критерий — повторный запуск с тем же retrievalAt даёт тот же набор допущенных записей, если corpus и policy не менялись. При пустом пересечении система возвращает stop status, а не догадку. Только после этого можно измерять top-k, reranking и стоимость запросов: эти настройки ускоряют поиск кандидатов, но не заменяют границу доказательства.
Проблема. Поиск по инженерной базе возвращает фрагмент с vector score 0.98, и система сразу строит из него ответ. Через несколько дней выясняется, что фрагмент относится к старой редакции API, закрыт для автора запроса или содержит исключение, а не общее правило.
Симптом. В интерфейсе есть title, excerpt и число похожести, но нет версии источника, результата проверки доступа, срока действия и точного места цитаты. Инженер меняет код по устаревшей инструкции. Цена ошибки — повторное расследование и потеря доверия к базе знаний: после нескольких ложных попаданий команда начинает игнорировать и полезные результаты.
\nВывод. Vector score решает задачу ranking: ставит candidates в порядок по выбранной моделью поиска мере близости. Он не является вероятностью истины и не доказывает, что claim (утверждение в ответе) поддержан источником. Перед генерацией нужно получить отдельное решение по доступу, свежести и происхождению. Если пересечение условий пусто, система должна остановиться, а не заполнять пробел правдоподобным текстом.
\nVector query сравнивает вектор запроса с векторами документов и возвращает ближайшие результаты по настроенной similarity metric. Такая сортировка сокращает объём чтения: вместо тысячи документов инженер получает несколько candidates. Это полезный этап поиска, но не этап доказательства.
\nСамо число зависит от реализации. При cosine similarity, dot product и L2 distance используются разные шкалы и преобразования. Даже один движок может считать итоговый score по-разному для разных типов полей и запросов. Поэтому порог score >= 0.90 нельзя переносить между моделями, индексами и метриками без размеченного набора проверок. Значения 0.98, 0.91 и другие числа ниже — учебные, не измерение качества конкретного сервиса.
Score также не знает контекст requester. Он не видит, отозвал ли владелец документ, и не умеет определить, что цитата отвечает только на частный случай. Эти свойства должны приходить из источника и policy, а не угадываться из текста фрагмента.
\n| Сигнал | Какой вопрос решает | Что можно сделать | Чего не доказывает |
|---|---|---|---|
vectorScore | Насколько candidate близок к query по выбранной метрике? | Отсортировать очередь чтения. | Истинность claim, право доступа и актуальность. |
sourceVersion | Из какой редакции получен chunk? | Сопоставить результат с известным snapshot. | Что редакция подходит к данному вопросу. |
accessDecision | Может ли этот requester увидеть ресурс? | Исключить закрытый candidate из answer path. | Что открытый текст подтверждает claim. |
expiresAt | Не вышел ли source за объявленный срок? | Отклонить устаревшую запись или направить к владельцу. | Что документ семантически полон и верен. |
citationAnchor | Можно ли открыть точное место в нужной версии? | Показать адресуемую цитату. | Что узкий фрагмент поддерживает более широкий вывод. |
При индексации документ обычно разбивают на chunks. Если сохранить только текст и embedding, после поиска останется удобный snippet без ответа на вопрос «из какой редакции он взят?». Одинаковая фраза может встретиться в текущем руководстве, старом RFC и закрытом исключении.
\nМинимальный контракт связывает каждую запись с исходным документом. У source record есть sourceId, sourceVersion, владелец, статус публикации и URI. У chunk есть тот же sourceId, текст, anchor, время индексации и access labels. У retrieval event есть зафиксированные query, requester scope, retrievalAt, список candidates и причины отказа. Пользовательскому интерфейсу не обязательно показывать всё, но компонент, принимающий решение, должен иметь эти поля.
Не стоит восстанавливать происхождение по заголовку или совпадению текста. При обновлении документа старый chunk должен быть выведен из индекса или помечен как недопустимый для текущего вопроса. Иначе высокий score может быть честным результатом поиска по неправильному snapshot.
\nРешение полезно представлять не флагом relevant: true, а набором проверяемых полей. Ниже — самостоятельный пример на Node.js без внешних пакетов. Он фиксирует момент поиска, применяет одну policy к четырём синтетическим candidates и печатает причины отказа. Команда не моделирует identity provider или настоящую БД; она воспроизводит именно порядок принятия решения.
node <<'NODE'\nconst retrievalAt = new Date('2025-03-18T12:00:00Z');\nconst requester = 'engineering';\nconst candidates = [\n { id: 'fresh-v3', score: 0.91, access: ['engineering'],\n publishedAt: '2025-03-01T00:00:00Z', expiresAt: '2025-04-01T00:00:00Z',\n sourceVersion: 'v3', citation: 'https://docs.example.test/runbook/v3#rollback' },\n { id: 'expired-v1', score: 0.96, access: ['engineering'],\n publishedAt: '2024-01-01T00:00:00Z', expiresAt: '2025-01-01T00:00:00Z',\n sourceVersion: 'v1', citation: 'https://docs.example.test/runbook/v1#rollback' },\n { id: 'closed-v2', score: 0.98, access: ['security'],\n publishedAt: '2025-02-01T00:00:00Z', expiresAt: '2025-04-01T00:00:00Z',\n sourceVersion: 'v2', citation: 'https://docs.example.test/runbook/v2#rollback' },\n { id: 'no-anchor', score: 0.89, access: ['engineering'],\n publishedAt: '2025-03-01T00:00:00Z', expiresAt: '2025-04-01T00:00:00Z',\n sourceVersion: 'v3', citation: null },\n];\n\nfunction decide(candidate) {\n const accessOk = candidate.access.includes(requester);\n const date = retrievalAt.getTime();\n const fresh = Date.parse(candidate.publishedAt) <= date &&\n (!candidate.expiresAt || date < Date.parse(candidate.expiresAt));\n const citable = Boolean(candidate.sourceVersion && candidate.citation);\n const reasons = [];\n if (!accessOk) reasons.push('access_denied');\n if (!fresh) reasons.push('expired_at_retrieval');\n if (!citable) reasons.push('missing_citation');\n return { id: candidate.id, score: candidate.score,\n ok: reasons.length === 0, reasons };\n}\n\nconsole.table(candidates.sort((a, b) => b.score - a.score).map(decide));\nNODE\nОжидаемый результат: closed-v2 окажется первым по score, но получит access_denied; expired-v1 — expired_at_retrieval; no-anchor — missing_citation. В answer path попадёт только fresh-v3. Если убрать его из массива, код не должен выбирать следующий candidate: приложение должно вернуть stop-статус с причиной no_admissible_evidence.
Доступ. Проверяйте requester scope до передачи фрагмента генератору. Даже если индекс технически вернул закрытый текст, это не разрешает показать его пользователю или пересказать его содержание. Проверка массива labels в примере — только замена policy для демонстрации. В реальном сервисе authorization должен опираться на подтверждённую identity и правила владельца ресурса.
\nСвежесть. Сравнивайте фиксированный retrievalAt с правилами домена: сроком действия, статусом публикации и версией контракта. HTTP Last-Modified сообщает время, когда origin считает выбранное представление изменённым. Он не говорит, что правило всё ещё подходит к эксплуатационному вопросу. Документ может не меняться, но стать неприменимым после миграции.
Происхождение и смысл. Открываем URI в той же редакции и проверяем anchor. Затем сопоставляем каждое предложение ответа с фрагментом. Доступная ссылка может вести на исключение, а не на общее правило; наличие anchor не превращает локальное условие в универсальный вывод.
\n| Симптом | Гипотеза | Проверка | Следующее действие |
|---|---|---|---|
| Top-1 описывает старый API-контракт. | Ranking не учитывает version или expiry policy. | Сравнить source version, опубликованный статус и retrievalAt. | Отклонить candidate и вернуть текущую редакцию либо stop. |
| Релевантный текст нельзя открыть. | Индекс и answer path используют разные access boundaries. | Сверить requester scope с policy владельца ресурса. | Не раскрывать фрагмент и записать access_denied. |
| Ссылка открывается, но правило не находится. | Сохранён URI без версии или стабильного anchor. | Открыть тот же snapshot и проверить точное место. | Оставить candidate вне citations до исправления ingestion. |
| Ответ шире приведённой цитаты. | Генератор обобщил условие или исключение. | Разметить claim-to-source mapping по предложениям. | Сузить ответ, добавить ограничение или передать вопрос человеку. |
| Все candidates отфильтрованы, но ответ всё равно есть. | Fallback подменяет отсутствие evidence вероятным текстом. | Запустить negative case с expired, закрытым и no-anchor набором. | Вернуть no_admissible_evidence, а не догадку. |
Удачный top-1 показывает лишь, что поиск нашёл похожий текст. Надёжность видна в обратной ситуации: все candidates просрочены, закрыты или не имеют точного locator. Пустой ответ можно объяснить и исправить — обновить документ, запросить доступ, добавить anchor или уточнить вопрос. Уверенный ответ без основания скрывает причину сбоя.
\nРазделяйте reason codes. access_denied направляет к владельцу policy, expired_at_retrieval — к владельцу документа, missing_citation — к ingestion или разметке источника, а relevance_low — к качеству retrieval. Такая диагностика не заставляет команду менять embedding model, когда проблема находится в правах или жизненном цикле документа.
retrievalAt. Без момента проверки нельзя воспроизвести решение по сроку.retrievalAt при неизменных corpus и policy.Эта схема не доказывает полноту поиска, не выбирает лучшую embedding model и не заменяет оценку retrieval на размеченных вопросах. Она также не утверждает, что keyword search всегда лучше vector search. Score помогает ранжировать candidates; качество покрытия корпуса, reranking и стоимость запроса нужно измерять отдельно.
\nУчебный код не реализует authentication, RBAC, ABAC, SSO, аудит, отзыв сессий, конкурентное обновление документов или защиту кэша. В нём намеренно используются домен example.test, четыре записи и упрощённый массив access labels. Перед применением в сервисе нужно описать policy, владельцев полей, формат дат и поведение при отсутствии версии или anchor.
Критерий готовности ограниченного запуска — полный decision trace для фиксированного запроса: candidates со score, access result, freshness result, source version, citation URI с anchor и результат проверки claim. В отрицательном наборе expired, closed и no-anchor записи должны получать разные причины и не попадать в citations.
\nИнженер задаёт вопрос по внутренней документации. Поиск возвращает фрагмент с высоким score. Заголовок совпадает, формулировка выглядит знакомой, ответ можно написать за минуту. Потом выясняется, что фрагмент описывает старый контракт, закрыт для этого читателя или ведёт на страницу без точного места в тексте. Ошибка уже повлияла на решение: изменился адаптер, в ответ попал закрытый материал, а reviewer не может быстро проверить источник.
Цена такой ошибки складывается из отката, повторного расследования и потери доверия к базе знаний. Пустой результат заметен. Уверенный пересказ устаревшего правила — нет. Поэтому поисковый ответ должен сначала доказать право на использование конкретной записи, её применимость на момент запроса и адресуемость цитаты. Только после этого можно формулировать вывод.
Similarity search решает узкую задачу: он упорядочивает похожие документы или фрагменты. В score нет ответа на вопросы «кто может читать запись», «действует ли правило сейчас» и «подтверждает ли этот абзац конкретный claim». Эти вопросы требуют других данных и других проверок. Если объединить их в одно число, система перестанет объяснять, почему кандидат отклонён.
Рабочая граница выглядит так: query → candidates → access check → freshness check → exact citation → human verification. Retrieval сокращает очередь чтения. Metadata задают условия допуска. Человек сопоставляет утверждение с источником. Если пересечение условий пусто, система останавливается и сообщает причину. Она не заменяет недостающий evidence правдоподобной фразой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Первый кандидат имеет высокий score, но у него нет версии | Индекс хранит chunk и embedding без связи с редакцией источника | Найти stable recordId, sourceVersion и URI исходного документа | Оставить кандидата для поиска, но не использовать как цитату |
| Фрагмент точно отвечает на вопрос, но expiresAt уже прошёл | Ранжирование не учитывает локальную политику актуальности | Сравнить expiresAt и зафиксированный retrievedAt | Отклонить запись и запросить действующую редакцию |
| Кандидат закрыт для requester | Access policy проверяется после генерации или не проверяется | Сопоставить accessLabels записи и scope запроса до показа excerpt | Скрыть закрытый текст, сохранить безопасную причину отказа |
| Ссылка ведёт на документ, но не на нужный абзац | В индексе нет citationAnchor | Открыть URI и проверить точный раздел, строку или якорь | Остановить ответ до появления адресуемого фрагмента |
| Ответ написан, ссылки добавлены позже | Текст успел включить выводы, которых нет в evidence | Сравнить каждый claim с источником до публикации | Сначала собрать допущенные citations, затем писать ответ |
Голый фрагмент удобен для векторного поиска, но плох для проверки. Минимальная запись связывает chunk с источником и сохраняет границы его использования. Нужны устойчивый recordId, sourceVersion, publishedAt, indexedAt, expiresAt, класс доступа, citationUri и citationAnchor. Поле vectorScore тоже полезно, но только для порядка кандидатов.
publishedAt отвечает на вопрос, существовала ли редакция к моменту поиска. indexedAt показывает задержку между публикацией и попаданием в индекс. expiresAt выражает локальное правило применимости. retrievedAt фиксирует срез, на котором система приняла решение. Ни одна из этих дат сама по себе не доказывает смысл утверждения. Вместе они позволяют воспроизвести проверку.
Такая схема важна после chunking. Когда документ режут на части, title и текст часто сохраняют, а версию, владельца и раздел оставляют в исходном хранилище. Затем в ответ попадает удобный snippet, который нельзя связать с конкретной редакцией. Metadata должны наследоваться каждым chunk или однозначно находиться по stable source id. Иначе retrieval создаёт видимость точности, а не проверяемую provenance.
Ниже — учебный пример на фиксированных синтетических записях. Он показывает порядок решения и отрицательный путь. Он не обращается к реальной базе, identity provider, часам, production или сети. Синтетические значения нужны только для проверки контракта обработки.
const retrievedAt = '2025-03-17T10:00:00Z';\nconst requesterLabels = ['engineering-read'];\n\nconst candidates = [\n {\n recordId: 'adapter-v1',\n sourceVersion: 'v1',\n expiresAt: '2025-03-01T00:00:00Z',\n accessLabels: ['engineering-read'],\n citationUri: 'https://docs.example.test/adapter',\n citationAnchor: '#old-field',\n vectorScore: 0.97,\n },\n {\n recordId: 'adapter-v3',\n sourceVersion: 'v3',\n expiresAt: '2025-04-01T00:00:00Z',\n accessLabels: ['engineering-read'],\n citationUri: 'https://docs.example.test/adapter',\n citationAnchor: '#schema-upgrade',\n vectorScore: 0.91,\n },\n];\n\nconst allowed = candidates.filter((item) =>\n item.expiresAt > retrievedAt &&\n item.accessLabels.some((label) => requesterLabels.includes(label)) &&\n item.citationUri && item.citationAnchor,\n);\n\nif (!allowed.length) {\n throw new Error('stop-no-fresh-authorized-citable-source');\n}\n\n// Учебный результат: v1 имеет больший score, но истёк.\n// v3 остаётся кандидатом для human verification.Здесь высокий score у adapter-v1 не отменяет истечение срока. adapter-v3 проходит технический фильтр, но это ещё не автоматическое доказательство ответа. Reviewer должен открыть citationUri#schema-upgrade и проверить, что фрагмент действительно говорит о нужной замене. Если у всех записей истёк срок, нет доступа или отсутствует anchor, функция должна вернуть stop condition. Скрытый fallback на старый текст создаёт именно ту ошибку, от которой защищает схема.
Безопасный порядок начинается с вопроса и времени retrieval. Затем система сохраняет candidates вместе с score и metadata. После этого она проверяет права, срок и citation. В ответ проходит только допущенная запись. Generator или автор получают ограниченный набор evidence, а не абстрактное «знание базы». Для каждого отклонённого кандидата остаётся причина: access-label-not-granted, expired-at-retrieval-time или missing-exact-citation-anchor.
Ссылка в конце готового текста не исправляет неверный порядок. Draft уже мог добавить условие, которого нет в документе, или смешать две версии. Citation должна появиться в момент выбора evidence. Тогда reviewer видит claim, sourceVersion, дату и точный fragment, а не пытается восстановить происхождение ответа по памяти.
HTTP-метаданные помогают, но не заменяют внутреннюю политику. В RFC 9110 Last-Modified описывает время, когда origin server считает изменённым выбранное представление. Это полезный сигнал о представлении, но не вся политика актуальности инженерного правила. Документ может иметь собственный срок пересмотра, дату deprecation или область действия. В ответе нужно назвать, какое условие применялось.
retrievedAt. Не меняйте эти значения в середине проверки.Проверяемый ответ не обязан раскрывать внутренний record целиком. Читателю достаточно названия источника, версии, citation, дат публикации и retrieval, а также краткого ограничения. Конкретные роли, токены и закрытые поля не нужно включать в provenance карточку. Класс доступа можно показать только тогда, когда это разрешает сама policy.
Отдельно храните result decision и human verification. Статус candidate-found означает, что поиск нашёл похожий материал. Статус citation-accepted означает, что запись прошла технические условия. Это всё ещё не равно «утверждение истинно», пока человек не сопоставил claim с фрагментом. Такое разделение делает интерфейс честнее: пользователь видит, что уже проверено, а что ещё нет.
Эта схема не доказывает полноту корпуса, качество embeddings, корректность access policy или семантическую истинность ответа. Она не говорит, что vector search лучше keyword search. Она задаёт более узкую границу: score не заменяет version, freshness, authorization и citation. Учебный код не является benchmark и не сообщает production-результаты.
Если источник закрыт, просрочен или не имеет точного anchor, система не должна пересказывать его «для справки». Безопасное действие — показать безопасную причину, сохранить recordId без restricted excerpt и направить вопрос владельцу документа или policy. Если policy неизвестна, нельзя молча выбрать allow или deny как окончательное решение: нужен owner и явное правило. Отрицательный путь входит в контракт наравне с успешным.
Один вопрос должен проходить тестовый набор из четырёх случаев: свежая разрешённая запись с anchor, просроченная запись с высоким score, закрытая запись с высоким score и запись без anchor. В первом случае результат содержит citation и требует human verification. В трёх остальных случаях citation не появляется, а decision содержит конкретную причину отказа. Повторный запуск с теми же query, retrievedAt и входными записями даёт тот же decision. Это проверяемый критерий готовности маршрута.
После этого можно отдельно измерять recall, latency и качество ранжирования. Такие метрики улучшают поиск кандидатов, но не отменяют проверку допуска. Если команда не может показать, почему выбран конкретный fragment и почему отклонены остальные, retrieval ещё не стал надёжным источником ответа.
Инженер задаёт вопрос по внутренней документации. Поиск возвращает фрагмент с высоким score: заголовок совпадает, формулировка знакома, ответ можно написать за минуту. Позже выясняется, что фрагмент относится к старой редакции контракта, закрыт для этого читателя или ведёт только на страницу без нужного абзаца. Решение уже принято: изменён адаптер, в ответ попал закрытый материал, а reviewer не может быстро восстановить источник.
Цена ошибки — откат, повторное расследование и потеря доверия к базе знаний. Пустой результат виден сразу. Уверенный пересказ устаревшего правила — нет. Поэтому результат поиска должен пройти три независимых вопроса: имеет ли читатель право использовать запись, применима ли она в зафиксированный момент и можно ли открыть точное место, подтверждающее claim. Score помогает выбрать, что читать первым, но сам по себе ничего из этого не доказывает.
\nСемантический или векторный поиск упорядочивает документы по близости представлений. В OpenSearch, например, запрос возвращает ближайшие векторы и параметр k задаёт число кандидатов. Это полезный механизм retrieval: он сокращает очередь чтения. Но score зависит от модели, метрики и индекса; это не вероятность истинности и не разрешение раскрыть текст.
Надёжный маршрут выглядит так: query → candidates → policy checks → exact citation → human verification. Поиск отвечает на вопрос «что может быть связано с запросом». Политика отвечает на вопрос «что разрешено использовать». Проверка источника отвечает на вопрос «подтверждает ли этот фрагмент конкретное утверждение». Если обязательные условия не выполняются, результатом должен быть остановленный ответ с причиной, а не правдоподобный fallback.
\nДля проверки недостаточно сохранить chunk, embedding и score. Каждый фрагмент должен быть связан с устойчивой записью источника. Минимальный контракт может содержать recordId, sourceRevision, sourceUri, citationAnchor, publishedAt, indexedAt, retrievedAt, область доступа и vectorScore. Время следует хранить в RFC 3339 и сравнивать в одной временной шкале.
publishedAt говорит, когда редакция была опубликована. indexedAt позволяет измерить задержку доставки в индекс. retrievedAt фиксирует срез, на котором принято решение. expiresAt, если он используется, — это внутренняя политика пересмотра, а не универсальное свойство документа. Ссылка и anchor должны вести к конкретной редакции и месту, которое reviewer может открыть.
После chunking метаданные не должны теряться. Если версия и права лежат только в исходном хранилище, а индекс отдаёт независимый snippet, сервис обязан однозначно разрешать recordId обратно в source record и повторно проверять авторизацию. Индекс может ускорять поиск, но не должен становиться неявной копией security boundary. Отклонённые записи полезно сохранять в диагностике по идентификатору и причине, не показывая закрытый excerpt.
| Наблюдение | Что это означает | Проверка | Решение |
|---|---|---|---|
| Высокий score, но нет sourceRevision | Есть похожий chunk без доказуемой редакции | Разрешить recordId в хранилище источника и проверить provenance | Оставить только кандидатом, не цитировать |
| expiresAt раньше retrievedAt | Фрагмент не проходит объявленную policy свежести | Сравнить даты в UTC и проверить часовой срез | Отклонить, запросить действующую редакцию |
| Scope читателя не пересекается с accessLabels | Найденный текст не разрешён этому requester | Проверить policy до раскрытия excerpt | Не показывать текст, вернуть безопасную причину |
| Есть sourceUri, но нет citationAnchor | Ссылку нельзя сопоставить с claim | Открыть источник и найти стабильный раздел или якорь | Остановить ответ до адресуемой цитаты |
| Все проверки пройдены, но claim не совпадает по смыслу | Технический допуск не равен семантическому подтверждению | Сопоставить формулировку claim с точным фрагментом | Изменить claim, найти другой источник или остановиться |
Ниже — полностью синтетический пример на JavaScript. В нём нет сети, часов операционной системы, реального identity provider или production-данных. Функция сортирует кандидатов только после фильтрации и возвращает явную причину отказа. В настоящем сервисе accessLabels должны приходить из доверенной policy, а не из пользовательского запроса.
const retrievedAt = '2025-03-17T10:00:00Z';\nconst requesterLabels = new Set(['engineering-read']);\n\nconst candidates = [\n {\n recordId: 'adapter-v1',\n sourceRevision: 'v1',\n publishedAt: '2025-02-01T09:00:00Z',\n expiresAt: '2025-03-01T00:00:00Z',\n accessLabels: ['engineering-read'],\n citationUri: 'https://docs.example.test/adapter',\n citationAnchor: '#old-field',\n vectorScore: 0.97,\n },\n {\n recordId: 'adapter-v3',\n sourceRevision: 'v3',\n publishedAt: '2025-03-10T09:00:00Z',\n expiresAt: '2025-04-01T00:00:00Z',\n accessLabels: ['engineering-read'],\n citationUri: 'https://docs.example.test/adapter',\n citationAnchor: '#schema-upgrade',\n vectorScore: 0.91,\n },\n];\n\nfunction admitCandidate(item) {\n if (!item.sourceRevision) return { ok: false, reason: 'missing-revision' };\n if (Date.parse(item.expiresAt) <= Date.parse(retrievedAt)) {\n return { ok: false, reason: 'expired-at-retrieval-time' };\n }\n if (!item.accessLabels.some((label) => requesterLabels.has(label))) {\n return { ok: false, reason: 'access-label-not-granted' };\n }\n if (!item.citationUri || !item.citationAnchor) {\n return { ok: false, reason: 'missing-exact-citation' };\n }\n return { ok: true, item };\n}\n\nconst checked = candidates.map(admitCandidate);\nconst allowed = checked\n .filter((result) => result.ok)\n .map((result) => result.item)\n .sort((a, b) => b.vectorScore - a.vectorScore);\n\nconst decision = allowed.length\n ? { status: 'needs-human-verification', evidence: allowed[0] }\n : { status: 'stop', reasons: checked.filter((x) => !x.ok).map((x) => x.reason) };\n\nconsole.log(decision);\nУ adapter-v1 score выше, но редакция истекла до retrievedAt, поэтому первым допустимым evidence становится adapter-v3. Это ещё не автоматическое подтверждение ответа: reviewer должен открыть citationUri#schema-upgrade и убедиться, что фрагмент действительно подтверждает нужную замену. Если разрешённых и адресуемых записей нет, система получает stop. Она не должна возвращаться к старой версии только потому, что та лучше ранжирована.
Своевременность нужно определить до внедрения поиска. Для части документации достаточно версии источника и даты публикации. Для быстро меняющегося контракта может понадобиться срок пересмотра, статус deprecation, effective date или проверка владельца. Важно записать policy словами и тестами: например, «после expiresAt запись не участвует в генерации».
\nHTTP-метаданные могут быть дополнительным сигналом. RFC 9110 описывает Last-Modified как время, когда origin считает изменённым выбранное представление. Это не равно дате вступления инженерного правила в силу, не описывает права читателя и не доказывает смысл claim. Не следует подменять внутреннюю version документа заголовком ответа CDN или датой попадания chunk в индекс.
Для воспроизводимости сохраняйте входной retrievedAt и фактически применённую policy. Тогда повторная проверка может ответить, почему вчерашний кандидат был допущен, а сегодняшняя редакция — нет. Если сервис использует eventual consistency и источник читается с реплики, это отдельное ограничение: нужно назвать возможное окно рассогласования и не обещать строгую свежесть без соответствующей гарантии хранилища.
Проверку прав нельзя оставлять генератору. Генератор получает только evidence, которые уже прошли authoritative authorization для конкретного requester. Фильтр по метке в поисковом индексе может быть оптимизацией, но не заменяет проверку в системе, владеющей политикой доступа. Иначе ошибка индекса или устаревшая роль может раскрыть excerpt ещё до того, как сервис вернёт отказ.
\nРазделяйте безопасный ответ и диагностическую информацию. Пользователю можно сообщить, что подходящий источник недоступен или требует другого доступа. Внутреннему журналу можно записать recordId, policy version и код причины. Не кладите в журнал сам закрытый фрагмент без отдельного основания: диагностический канал тоже имеет владельца и срок хранения.
Ссылка на главную страницу не является точной цитатой. Минимальная citation должна позволять восстановить source revision, URI и anchor; для Markdown или HTML это может быть стабильный заголовок, номер раздела или диапазон строк в immutable-документе. Если источник перемещается, храните идентификатор редакции и проверяйте, что anchor по-прежнему разрешается.
\nПишите ответ только после отбора evidence. Удобный формат — таблица claims: для каждого утверждения указать ссылку, фрагмент, ограничение и статус human verification. Если один фрагмент подтверждает только часть предложения, разделите claim или ослабьте формулировку. Citation, добавленная в конце уже готового текста, не исправляет вывод, который возник без источника.
\nМаршрут можно считать проверенным на базовом уровне, если один и тот же набор входов даёт четыре предсказуемых исхода: свежая разрешённая запись с anchor проходит технический допуск и помечается как needs-human-verification; просроченная запись получает expired-at-retrieval-time; закрытая — access-label-not-granted; запись без anchor — missing-exact-citation. В последних трёх случаях excerpt не попадает в генерацию.
Повторный запуск с теми же query, retrievedAt, policy и записями должен дать тот же decision. После этого отдельно измеряйте recall, latency и качество ранжирования. Эти метрики улучшают очередь кандидатов, но не превращают score в доказательство и не заменяют проверку claim.
Описанный маршрут не доказывает полноту корпуса, качество embeddings, корректность модели, истинность утверждения или отсутствие всех уязвимостей. Он не говорит, что vector search лучше keyword search. Он задаёт более узкую гарантию: система не использует найденный фрагмент как evidence, пока не проверены его provenance, доступ, локальная свежесть и адресуемая цитата.
\nСинтетический код не является production-библиотекой: в нём нет транзакций, конкурентного обновления policy, ревокации ролей, подписи источника, кешей и сетевых ошибок. В реальном проекте нужно отдельно проверить escaping, изоляцию tenant, права на логи, версионирование policy и поведение при недоступности authoritative source. Если policy неизвестна, нельзя молча выбрать allow или deny как доказанный результат: нужно назначить владельца правила и вернуть stop condition.
\nk и отдельные этапы индексации и поиска. Она подтверждает механику retrieval, но не истинность claim и не права читателя.Проблема: основной тест проходит, но изменение возвращает не то поле, меняет входной объект или пропускает запрос без роли; цена ошибки — повторная отладка, задержка релиза и риск отказа пользователю.
\\nТезис простой: проверяйте не убедительность AI-предложения и не число зелёных статусов, а границы контракта. Для каждой границы нужна короткая цепочка доказательств: что обещает код, что наблюдает проверка, какой отрицательный путь рассмотрен и какое действие следует из результата. Если сигналы относятся к разным контрактам, merge нельзя считать готовым.
\\nКонтракт описывает неизменяемое условие. Это форма результата, сохранность аргумента или список разрешённых ролей. Статический анализ показывает структуру кода. Позитивный тест показывает разрешённый путь. Негативный тест показывает отказ. Review связывает эти наблюдения с контекстом вызова. Ручной сценарий проверяет эффект на границе системы.
\\nНи один сигнал не даёт общего вердикта. Линтер не знает, какое имя поля ждёт потребитель. Тест строки не видит мутацию объекта. Успешный сценарий редактора не доказывает запрет для пустой роли. Поэтому сначала назовите риск как наблюдаемый разрыв, затем выберите проверку, которая может его опровергнуть.
\\nMapper получает subtotalCents и taxCents. Он правильно складывает их. Потребитель ожидает объект с полем amountCents, но предложенный код возвращает total. Тест арифметики остаётся зелёным: число не изменилось. Ошибка появляется на границе потребителя.
Проверка должна сравнить не только значение, но и публичную форму. Нужны assertion на ключи результата и тест, который читает объект так же, как реальный потребитель. Если переименование действительно нужно, его оформляет владелец API. Нельзя переписать тест под новое поле и объявить проблему решённой: так тест закрепит решение, которого ещё никто не принял.
\\nФункция с именем preview должна вычислить результат и вернуть его. Внутри она выполняет draft.status = 'normalized'. Caller передал свой объект и ожидает, что после preview он останется прежним. Проверка ответа не замечает побочный эффект, потому что строка выглядит правильно.
Здесь нужен снимок входа до вызова и сравнение после вызова. Статическое правило может искать присваивание аргументу, но оно не заменяет проверку владения данными. Исправление — создать производное значение без мутации или вынести изменение в отдельную явно названную операцию. Название функции не является доказательством поведения.
\\nКонтракт разрешает только роль editor. Условие actorRole !== 'viewer' блокирует viewer, но пропускает запрос без роли. Тест для editor сообщает только один факт: разрешённый путь работает. Даже тест для viewer не подтверждает, что отсутствие значения отклоняется.
Явный allow-list делает правило проверяемым:
\\nfunction canEdit(actorRole) {\\n const allowedRoles = new Set(['editor']);\\n return allowedRoles.has(actorRole);\\n}\\n\\nexpect(canEdit('editor')).toBe(true);\\nexpect(canEdit('viewer')).toBe(false);\\nexpect(canEdit()).toBe(false);\\nЭто учебный пример малого контракта. Он не проверяет токены, identity provider, tenant boundary, сессии или threat model реального приложения. Он показывает другое: разрешённое значение задано явно, а отрицательная ветка входит в контракт. Для production нужны отдельные проверки всей цепочки авторизации.
\\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Число верно, потребитель не находит поле | Проверили значение, но не форму результата | Сравнить ключи и выполнить тест через потребитель | Вернуть имя поля или принять отдельное решение о совместимости |
| Preview меняет draft | Вычисление смешано с мутацией входа | Сравнить объект до и после; проверить присваивания аргументу | Создать производное значение или назвать мутацию отдельной операцией |
| Запрос без роли проходит | Условие перечисляет исключение, а не разрешённые роли | Добавить проверку отсутствующей роли и прочитать ветви от allow | Оставить явный allow-list и default-deny |
| Все статусы зелёные, но scope различается | Проверки относятся к разным контрактам | Сопоставить вход, выход и границу каждого сигнала | Заблокировать merge до единого scope или решения владельца |
preview, format и calculate отдельно подтвердите отсутствие мутации.Stop означает только одно: не продолжать подготовку merge, пока доказательства расходятся. Он не меняет историю системы и не восстанавливает доставленное состояние. Revert отменяет конкретное изменение в истории версий. Rollback возвращает уже работающую систему к прежнему состоянию и требует отдельной проверки данных, scope и полномочий. Называть любой block rollback нельзя: это создаёт видимость готового пути восстановления.
\\nHuman approval нужен после ясной формулировки выбора. Например, владелец может сохранить старое поле ради совместимости, принять переименование с обновлением потребителей или потребовать исправить правило доступа. Approval не закрывает пробел в доказательствах. Если reviewer не может назвать контракт, границу пользователя и остаточный риск, вопрос ещё не готов.
\\nПримеры в статье учебные. В них нет настоящего репозитория, CI, токенов, пользователей, telemetry или production-нагрузки. Имена полей, ролей и функций подобраны для объяснения механизма. Из зелёного учебного теста нельзя вывести отсутствие уязвимости в реальной авторизации. Из одной цепочки доказательств нельзя вывести готовность релиза.
\\nОфициальная документация инструментов тоже имеет границы. GitHub описывает code review как комментарии и рекомендации, а обязательное решение об изменении остаётся за процессом команды. NIST задаёт практики безопасной разработки, но не выбирает ваши контракты и владельцев риска. OWASP подчёркивает пользу ручной проверки там, где автоматические средства не видят бизнес-логику и контекст. Эти источники поддерживают многослойную проверку, но не подтверждают конкретный учебный пример.
\\nИзменение готово к approval, если для каждой затронутой границы выполнены четыре условия: контракт записан; основной и отрицательный пути проверены; входное состояние не меняется без явного разрешения; каждый сигнал относится к тому же scope, что и diff. В записи есть вердикт merge, revise или block. Для merge указан владелец решения. Для block указано доказательство, которое снимет блокировку. Если хотя бы одно условие не выполнено, готовность не доказана.
AI-ассистент предложил короткое исправление: основной тест прошёл, diff выглядел аккуратно, а запрос без роли всё равно получил доступ к операции. Такой дефект легко пропустить, потому что проверка ответа и проверка права отвечают на разные вопросы. Цена ошибки — не только повторная отладка: при неверной авторизации пользователь может прочитать или изменить чужой ресурс.
\nНадёжный вопрос перед merge звучит так: какой контракт меняется, где находится граница доверия и каким наблюдением можно опровергнуть предложение? AI не является доказательством. Он ускоряет перебор вариантов, но каждое принятое утверждение нужно проверить в коде, тесте и контексте системы.
\nНачните с diff, а не с объяснения ассистента. Запишите вход, выход, потребителя и правило, которое нельзя нарушить. Для функции доступа это субъект, действие и ресурс; для mapper — форма результата; для функции preview — неизменность входного состояния. Одно предложение может затронуть сразу несколько границ, даже если изменена одна строка.
git diff --check\ngit diff --unified=80 -- src/auth/can-edit.ts\nrg -n 'canEdit|authorize|role|tenant|owner' src test\nПервые две команды показывают пробельные ошибки и полный локальный контекст. Третья помогает найти параллельные точки входа, но не доказывает, что найден полный граф вызовов. Имена каталогов здесь примерны: в конкретном репозитории подставьте фактические пути, а результат поиска сопоставьте с маршрутом запроса.
\nПредставим mapper, который получает subtotalCents и taxCents. AI заменил поле ответа на total. Арифметика осталась верной, поэтому тест, сравнивающий только число, зелёный. Потребитель читает amountCents и получает undefined. Это не «мелкое переименование»: форма ответа — часть контракта.
Проверяйте ключи и способ чтения результата тем же кодом, который использует приложение. Если изменение имени действительно нужно, сначала меняется контракт и все потребители, а затем тесты. Переписать assertion под новый ключ — не исправление, пока владелец API не принял несовместимое изменение.
\nfunction toAmount({ subtotalCents, taxCents }) {\n return { amountCents: subtotalCents + taxCents };\n}\n\nconst result = toAmount({ subtotalCents: 1000, taxCents: 180 });\nif (JSON.stringify(Object.keys(result)) !== JSON.stringify(['amountCents'])) {\n throw new Error('response shape changed');\n}\nif (result.amountCents !== 1180) {\n throw new Error('amount calculation changed');\n}\nЭтот фрагмент проверяет учебный объект и порядок ключей, заданный самим примером. В production контракт лучше закрепить схемой или типом и тестом реального потребителя; здесь намеренно показана минимальная проверка, которую можно запустить без фреймворка.
\nВторая ловушка — побочный эффект под безобидным именем. Функция preview должна построить представление, но предложенная реализация присваивает draft.status = 'normalized'. Caller передал собственный объект и после preview ожидает исходный статус. Проверка только строки ответа этого не увидит.
Снимите состояние до вызова и сравните его после. Для простого плоского объекта пример выглядит так:
\nfunction preview(draft) {\n return { ...draft, status: 'normalized' };\n}\n\nconst draft = { id: 'd-17', status: 'new' };\nconst before = JSON.stringify(draft);\nconst view = preview(draft);\n\nif (JSON.stringify(draft) !== before) {\n throw new Error('preview mutated input');\n}\nif (view.status !== 'normalized' || draft.status !== 'new') {\n throw new Error('preview contract failed');\n}\nПоверхностная копия не защищает вложенные массивы и объекты: если функция меняет draft.items[0], понадобится глубокая копия, иммутабельная структура или отдельный тест на каждый изменяемый уровень. Поэтому нельзя автоматически заменить любой Object.assign на знак качества. Сначала определите владение данными и разрешённые мутации.
Третий пример — проверка роли. Условие actorRole !== 'viewer' блокирует viewer, но пропускает undefined, опечатку и любую новую строку. Happy path для editor и даже отрицательный тест только для viewer не закрывают эту дыру.
Если в учебном контракте разрешена ровно одна роль, разрешённое множество должно быть явным, а неизвестное значение — отклоняться:
\nfunction canEdit(actorRole) {\n const allowedRoles = new Set(['editor']);\n return allowedRoles.has(actorRole);\n}\n\nfor (const [role, expected] of [\n ['editor', true],\n ['viewer', false],\n [undefined, false],\n ['edtiro', false],\n]) {\n if (canEdit(role) !== expected) {\n throw new Error('unexpected decision for ' + String(role));\n }\n}\nЭто тест чистой функции, а не всей авторизации. В реальном запросе нужно проверить, откуда взялась identity, кто выбирает tenant и ресурс, где сервер принимает решение и что происходит при отказе. Проверка роли в браузере может улучшить интерфейс, но не должна выдавать доступ: решающий контроль находится на сервере, шлюзе или серверной функции.
\n| Сигнал | Что он доказывает | Чего не доказывает | Следующее действие |
|---|---|---|---|
| Unit-тест happy path | Разрешённый вход даёт ожидаемый результат | Отказ, форма ответа, побочные эффекты и соседний ресурс | Добавить отрицательный и contract-тест |
| Линтер или SAST | Найден или не найден известный шаблон | Бизнес-правило и смысл конкретного ресурса | Разобрать finding вручную по data flow |
| AI code review | Ассистент сформулировал комментарии в scope review | Полноту находок и обязательное решение владельца | Проверить комментарии и повторить review после нового diff |
| Ручной сценарий | Наблюдаемое поведение на выбранном окружении | Другие входы, окружения и параллельные entry point | Закрепить сценарий автоматическим тестом |
| Approval | Уполномоченный принял решение по известному scope | Отсутствие неизвестных рисков | Записать остаточный риск и условия отката |
Разные статусы не складываются в магическое «всё безопасно». У каждого сигнала есть scope и слепая зона. Например, GitHub описывает Copilot code review как источник комментариев; по умолчанию это не approval, а автоматическая оценка готовности сама по себе не считается обязательным подтверждением. Это важное различие между подсказкой инструмента и полномочием на merge.
\ngit diff --check. Сохраните команды и версии инструментов.merge означает, что заявленные проверки пройдены; revise — известен следующий фикс; block — есть расхождение, которое должен снять владелец риска.Минимальный журнал проверки может быть обычным текстом в описании изменения:
\nscope: src/auth/can-edit.ts, test/auth/can-edit.test.ts\ncontract: only editor may edit a resource in the actor's tenant\npositive: editor + own tenant -> allow\nnegative: viewer, missing role, other tenant -> deny\nside_effects: request context unchanged\nchecks: npm test -- can-edit && git diff --check\nowner: team-auth\nverdict: revise\nСтрока verdict: revise здесь не формальность: если не проверен чужой tenant, честный результат — не merge. Журнал не заменяет тест и не делает проект безопасным, но оставляет проверяемую связь между риском, наблюдением и решением.
Если форма API не совпала с потребителем, остановите подготовку merge и найдите владельца контракта. Если preview мутирует данные, зафиксируйте вход до и после, затем выберите иммутабельный результат или явно названную мутацию. Если запрос без роли или с чужим tenant получает доступ, блокируйте изменение до серверной проверки и негативного теста.
\nStop, revert и rollback — разные действия. Stop не меняет историю и лишь запрещает продолжать merge. Revert создаёт новое изменение, отменяющее конкретный коммит. Rollback возвращает уже доставленную систему к прежнему состоянию и требует отдельного плана для данных, миграций, флагов и совместимости. Не называйте блокировку rollback: это создаёт ложное ощущение, что восстановление уже подготовлено.
\nПримеры выше — небольшие чистые функции, а не доказательство безопасности приложения. Они не проверяют токены, identity provider, срок сессии, CSRF, rate limit, кеши, гонки, multi-tenant запросы, базу данных, конфигурацию шлюза или эксплуатационные права. Даже полный набор unit-тестов не исключает дефект в маршруте, middleware или другом entry point.
\nAllow-list подходит для показанного правила с одной ролью, но не заменяет policy engine для сложных правил, где важны атрибуты ресурса, отношения владельца, время и состояние. Для таких систем тестируйте таблицу разрешений и отказов на уровне API и интеграции. Секреты, персональные данные и production-токены нельзя помещать в prompt или учебный репозиторий; используйте обезличенные фикстуры.
\nОфициальные рекомендации также не являются сертификатом. OWASP говорит, что ручной security-review дополняет автоматические инструменты и особенно нужен для бизнес-логики, потоков данных и контекстных уязвимостей. NIST SSDF задаёт общий набор практик безопасной разработки и общий словарь, но не выбирает контракт конкретного сервиса. Эти документы помогают построить процесс, а границы и остаточный риск всё равно определяет команда.
\nAI-предложение готово к merge, когда можно независимо ответить на четыре вопроса: какой контракт оно меняет; какой позитивный и какой отрицательный путь проверены; где выполняется окончательная проверка доступа; какой владелец принял остаточный риск. К ответам приложены текущий diff, воспроизводимые команды и результат тестов.
\nЕсли хотя бы один вопрос остаётся без наблюдаемого ответа, меняйте статус на revise или block. Такой вердикт не обвиняет инструмент и не требует отказаться от AI. Он просто оставляет merge только для изменения, чья граница, проверка и ответственность видны.
Сгенерированный diff может пройти линтер и unit test, но сломать следующего потребителя. Mapper вернул число, хотя API требует поле amountCents. Функция preview показала правильную строку, но изменила объект, который передал вызывающий код. Проверка роли пропустила редактора, но не проверила отсутствие роли.
Цена ошибки начинается не с плохого ответа модели. Она начинается с ложной уверенности. Команда видит несколько зелёных статусов, считает риск закрытым и узнаёт о нарушенной границе в следующем consumer, review или релизном сценарии. По числу зелёных индикаторов нельзя вывести размер ущерба. Но можно заранее не принять их за одно доказательство.
\nТезис статьи простой: проверка должна связывать наблюдаемый риск с конкретным свидетельством. Контракт проверяет форму и инварианты. Статический анализ ищет формальный паттерн. Узкий тест проверяет названную ветку. Человек проверяет намерение и контекст. Ручной сценарий смотрит на путь потребителя. Эти способы пересекаются, но не заменяют друг друга.
\nНачните не с инструмента, а с разрыва. «AI ошибся» не помогает выбрать проверку. «Публичное поле переименовано», «входной объект изменился после вызова» и «пустая роль получила доступ» уже задают наблюдаемые вопросы.
\nЗатем выберите свидетельство, которое может опровергнуть именно этот разрыв. Для формы результата подойдёт assertion на схему и тест реального consumer. Для ownership нужен снимок входа до и после вызова. Для доступа нужен allow-list и отрицательный тест для неизвестного значения. Если check не способен увидеть риск, его зелёный результат ничего о риске не говорит.
\nПоследняя часть — граница. Контракт не доказывает безопасность всей системы. Линтер не понимает смысл каждого вызова. Тест не проверяет ветку, которую в него не внесли. Review зависит от контекста и внимания. Ручное воспроизведение не становится регрессионным тестом само по себе.
\nНиже приведены фиксированные учебные случаи. Они не читают репозиторий, не вызывают модель и не показывают результат реального проекта. Их задача — показать форму рассуждения: наблюдение, причина, проверка и действие.
\nКонтракт mapper требует объект { amountCents: number }. Сгенерированный код складывает subtotalCents и taxCents, но возвращает { total: 1234 }. Happy-path test проверяет только значение суммы. Он зелёный: арифметика правильная.
Потребитель читает result.amountCents и получает undefined. Симптом — зелёный тест рядом с неверной формой результата. Причина — тест описывает значение, а не публичный shape. Проверка — сравнить assertion контракта, тест consumer и вопрос reviewer: «Переименование поля принято отдельно?». Действие — вернуть amountCents или оформить совместимость как отдельное решение. Нельзя переписать тест на total только ради зелёного статуса.
Helper с именем preview получает объект заявки и возвращает правильный текст. Внутри он выполняет draft.status = 'normalized'. Если объект принадлежит caller, после preview следующий код видит изменённое состояние. Результат на экране правильный, но операция имеет побочный эффект.
function preview(draft) {\\n draft.status = 'normalized'; // скрыто меняет объект caller\\n return render(draft);\\n}\\n\\nconst draft = { status: 'raw' };\\nconst text = preview(draft);\\n\\n// text выглядит правильно, но draft.status уже равен 'normalized'\nСимптом — output совпал, а state изменился. Причина — контракт не разделяет производное значение и владение входом. Проверка — сравнить вход до и после вызова и отдельно просмотреть записи в аргумент. Действие — создать производный объект, оставить аргумент неизменным или назвать мутацию отдельной операцией. Проверка результата строки здесь недостаточна.
\nУчебный контракт разрешает роль editor и отклоняет viewer и отсутствие роли. Условие actorRole !== 'viewer' пропускает редактора, отклоняет viewer, но также пропускает undefined. Если suite содержит только тест редактора, она доказывает один accept path и ничего не говорит о неизвестном значении.
Это не доказанная уязвимость реальной авторизации: в примере нет токенов, tenant boundary, identity provider и threat model. Но конкретный контракт уже нарушен. Проверка должна включить viewer и missing-role, а reviewer должен прочитать условие как allow-list. Для доступа отрицательная ветка — часть правила, а не дополнительный тест «на всякий случай».
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Тест проверяет число, consumer не находит поле | Значение приняли за форму API | Contract assertion и consumer test | Сохранить поле или принять отдельное compatibility decision |
| Preview возвращает правильный текст, вход изменился | Не определено владение аргументом | Сравнить input до/после и найти запись в аргумент | Вернуть derived value без мутации или назвать мутацию явно |
| Редактор проходит, пустая роль тоже | Accept-case заменил allow-list | Проверить viewer и missing-role | Явно разрешить роли и отклонять неизвестные |
| Линтер зелёный, риск не назван | Для риска нет формального правила | Сверить scope правила с contract boundary | Добавить assertion, тест или вопрос reviewer |
Два свидетельства независимы не потому, что их назвали разными словами. Они независимы, когда могут опровергнуть разные предпосылки. Contract check и consumer test частично пересекаются: один смотрит на declared shape, другой — на использование поля. Это полезное пересечение. Ошибка в одном тесте не должна автоматически скрыть ошибку в другом.
\nОпаснее повторить одну неверную модель. Сгенерированный код и сгенерированный тест могут вместе решить, что отсутствие роли означает «не viewer». Второй зелёный статус тогда только усиливает доверие к ошибке. Добавьте проверку, которая получает другое основание: allow-list, внешний контракт или вопрос владельцу политики.
\nНе нужно складывать статусы в процент корректности. Static check быстро ловит формальный паттерн, но требует правила. Focused test делает один сценарий воспроизводимым, но не видит неназванную ветку. Human review замечает скрытую границу, но не заменяет точный expected result. Manual reproduction показывает путь потребителя, но плохо масштабируется. Выбирайте самый дешёвый способ, который способен оспорить текущую гипотезу.
\nВ этом примере report собирает пять видов свидетельств. При расхождении он не превращает результат в «почти готово». Он возвращает решение остановить подготовку merge до выяснения контракта.
\nconst report = inspectGeneratedDiff({\\n risk: 'public-field-renamed',\\n contract: { amountCents: 'number' },\\n result: { total: 1234 },\\n checks: ['static', 'focused-test', 'review', 'manual']\\n});\\n\\nif (report.contractAgrees === false) {\\n return { merge: 'blocked', reason: 'evidence-disagrees' };\\n}\nКод выше — учебная схема, а не готовый пакет и не production-рецепт. Она показывает важное свойство: решение опирается на конкретное расхождение, а не на число пройденных команд. В реальном проекте названия полей, правила и тестовые входы должны соответствовать фактическому контракту.
\nЭта схема не доказывает корректность кода, безопасность, отсутствие дефектов или готовность релиза. Она не заменяет threat model, анализ зависимостей, интеграционные тесты, наблюдение и правила доступа. Один учебный отрицательный тест не покрывает все реальные роли и переходы состояний.
\nStop означает только «не продолжать подготовку merge при расхождении evidence». Он не означает revert или rollback. Revert требует решения о конкретном изменении в истории VCS. Rollback относится к уже доставленному состоянию и требует подтверждённого пути восстановления. Не подменяйте отсутствие решения словом rollback.
\nИсточники тоже не дают готового verdict. Они поддерживают практику, но не выбирают порог для вашего репозитория. GitHub описывает Copilot code review как комментарий, который не заменяет required approval. NIST связывает review и analysis с процессом разработки и triage findings. OWASP подчёркивает, что автоматизированные инструменты дополняют ручной анализ там, где нужен бизнес-контекст.
\nDiff готов к передаче на human approval только тогда, когда для каждого заявленного риска записаны scope, прямое свидетельство, отрицательный путь и граница того, чего проверка не доказывает. Contract, test и reviewer rationale не должны противоречить друг другу. Если противоречие осталось, готовый результат — не зелёный статус, а понятный stop с вопросом владельцу.
" + "title": "Почему зелёный тест не доказывает корректность сгенерированного кода", + "excerpt": "Разбираем, как проверять сгенерированный diff по контракту, состоянию, отрицательной ветке и пути потребителя — и когда расхождение доказательств должно остановить merge.", + "contentHtml": "Проблема начинается там, где зелёный unit-тест отвечает только на тот вопрос, который в него записали. Он может подтвердить сумму, но не имя публичного поля; правильную строку — но не отсутствие мутации; доступ редактора — но не отказ неизвестной роли. Поэтому сгенерированный diff принимают не по числу зелёных статусов, а по совпадению контракта, наблюдаемого поведения и решения владельца границы.
\nНиже — учебный разбор небольшого JavaScript-изменения. Он не читает репозиторий, не запускает модель и не описывает реальный инцидент. Все входы фиксированы, а код можно выполнить локально штатным Node.js. Это позволяет проверить сам способ рассуждения и не приписывать примеру доказательств, которых он не собирает.
\nКонтракт — это не фраза «функция работает правильно». Для проверки diff запишите вход, форму результата и запрещённое побочное действие. Если функция участвует в авторизации, добавьте правило отказа для неизвестного значения. Такой список превращает размытый риск в наблюдаемый вопрос.
\nconst contract = {\n input: { subtotalCents: 900, taxCents: 100 },\n output: { amountCents: 1000 },\n invariant: 'input remains unchanged',\n deny: [undefined, 'viewer']\n};\nВ этом контракте четыре разных утверждения. amountCents задаёт публичную форму ответа, сумма — его значение, input remains unchanged — границу владения состоянием, а deny — отрицательные варианты политики. Один assertion не способен подтвердить их все.
Официальный Secure Software Development Framework NIST описывает безопасную разработку как набор практик, который встраивается в жизненный цикл продукта. Это полезная рамка для процесса, но не автоматический сертификат корректности конкретного diff. Для локальной проверки всё равно нужно назвать инвариант и способ его опровергнуть.
\nПредставим mapper, который готовит данные для счёта. Его контракт требует amountCents, а consumer читает именно это поле. Ассистент сгенерировал короткую функцию и тест, проверяющий только итоговое число:
function toInvoice(input) {\n return {\n total: input.subtotalCents + input.taxCents\n };\n}\n\nconst result = toInvoice({ subtotalCents: 900, taxCents: 100 });\nconsole.assert(result.total === 1000);\nТест зелёный, потому что арифметика действительно даёт 1000. Но следующий consumer делает result.amountCents и получает undefined. Ошибка находится на границе формы ответа, а не в вычислении. Если заменить контракт в тесте на фактическое поведение функции, можно получить ещё один зелёный статус и сохранить дефект.
Проверка должна смотреть на потребителя и на ключ результата:
\nconst input = { subtotalCents: 900, taxCents: 100 };\nconst result = toInvoice(input);\n\nconsole.assert(result.amountCents === 1000);\nconsole.assert(Object.hasOwn(result, 'amountCents'));\nconsole.assert(!Object.hasOwn(result, 'total'));\nconsole.assert(input.subtotalCents === 900);\nconsole.assert(input.taxCents === 100);\nПосле такого теста причина видна сразу: первая функция вернёт ошибку на assertion для amountCents. При этом проверка не доказывает, что все consumers, сериализаторы и реальные ответы системы используют ту же схему. Она доказывает только перечисленные свойства фиксированного вызова.
Чтобы не спорить о слове «прошло», запустите минимальный независимый скрипт. Он проверяет три свойства: публичное поле, отсутствие мутации входа и default-deny для ролей. Команда использует только встроенный Node.js; версия Node должна поддерживать Object.hasOwn (Node.js 16.9 и новее).
node --input-type=module <<'NODE'\nfunction toInvoice(input) {\n return { amountCents: input.subtotalCents + input.taxCents };\n}\n\nfunction canEdit(role) {\n return role === 'editor';\n}\n\nconst input = { subtotalCents: 900, taxCents: 100 };\nconst result = toInvoice(input);\nconst checks = {\n shape: result.amountCents === 1000 && !Object.hasOwn(result, 'total'),\n noMutation: input.subtotalCents === 900 && input.taxCents === 100,\n defaultDeny: canEdit('editor') && !canEdit('viewer') && !canEdit(undefined)\n};\n\nfor (const [name, ok] of Object.entries(checks)) {\n console.log((ok ? 'PASS ' : 'FAIL ') + name);\n}\nif (Object.values(checks).some((ok) => !ok)) process.exitCode = 1;\nNODE\nОжидаемый вывод — три строки PASS. Если изменить возвращаемое поле на total, заменить role === 'editor' на проверку «не viewer» или присвоить что-либо в input, соответствующая строка станет FAIL, а процесс завершится с ненулевым кодом. Это воспроизводимое наблюдение, не измерение «процента качества AI».
| Риск | Прямое свидетельство | Что остаётся вне проверки | Следующее действие |
|---|---|---|---|
| Публичное поле переименовано | Assertion схемы и тест consumer | Другие consumers и совместимость API | Сверить контракт всех затронутых границ |
| Preview меняет входной объект | Снимок input до и после вызова | Мутации глубоко вложенных или внешних объектов | Уточнить ownership и проверить нужную глубину |
| Неизвестная роль получает доступ | Allow-list плюс тесты viewer и missing role | Проверка identity, tenant и серверного enforcement | Подключить владельца политики и threat model |
| Секрет или данные уходят в AI-инструмент | Проверка отправляемого контекста и правил исключения | Практики конкретного провайдера и конфигурация организации | Проверить настройки и журнал фактического обмена |
| Линтер не видит бизнес-ошибку | Предметный review и тест правила | Неназванные сценарии и неверный бизнес-контекст | Записать invariant и отрицательный путь |
Матрица не складывает свидетельства в одну оценку. У каждого способа есть область действия. Static analysis хорошо находит формальный паттерн, если для него есть правило. Узкий тест делает конкретный вход воспроизводимым. Review проверяет смысл и границы, но зависит от контекста. Ручной сценарий повторяет маршрут потребителя, но покрывает только выбранный путь.
\nДва теста независимы, когда способны обнаружить разные нарушения. Тест на сумму и screenshot с отображённой суммой могут повторять одну и ту же предпосылку: «полученное значение достаточно». Ни один из них не обязан заметить, что API ждёт другое имя поля. Тест consumer задаёт уже другой вопрос — может ли следующий участник прочитать результат.
\nГенерация кода и теста одним инструментом создаёт дополнительный риск: оба фрагмента могут повторить неверное толкование требования. Поэтому для security-критичных правил нужен источник, не скопированный из того же предположения: явный allow-list, контракт владельца, независимый тестовый вход или проверка специалистом. OWASP описывает ручной code review как дополнение к автоматизированным инструментам там, где нужны бизнес-контекст и анализ логики.
\nДокументация GitHub отдельно оговаривает границу Copilot code review: такой review оставляет комментарии и не считается обязательным approval для pull request. Для других AI-инструментов правила могут отличаться, поэтому название продукта нельзя превращать в универсальное утверждение. Общий вывод уже: автоматический комментарий не заменяет решение человека, которому принадлежит риск.
\nОтрицательный тест выводится из правила. Для доступа безопаснее перечислить разрешённые роли, чем разрешать всё, что не равно одной запрещённой роли. Для mapper нужно проверить форму и отсутствие старого ключа. Для функции без побочных эффектов — сравнить состояние после вызова. В каждом случае вопрос звучит как «что должно быть отвергнуто?», а не только «что должно сработать?».
\nfunction canEdit(role) {\n return role === 'editor';\n}\n\nconsole.assert(canEdit('editor') === true);\nconsole.assert(canEdit('viewer') === false);\nconsole.assert(canEdit(undefined) === false);\nconsole.assert(canEdit('admin') === false);\nЭтот пример проверяет только функцию для четырёх строковых входов. Он не доказывает авторизацию приложения: здесь нет identity provider, проверки владельца ресурса, tenant boundary, токена, серверного маршрута и журнала событий. Переносить его verdict в production можно только после добавления фактических условий политики.
\nПрактический stop condition появляется, если контракт и observed behavior требуют разных результатов, если отрицательная ветка не проверена, если consumer не входит в scope или если единственное evidence сгенерировал тот же источник, что и код. Stop здесь означает прекратить подготовку merge до выяснения границы. Это не команда git revert и не rollback уже доставленной версии.
Решение можно вернуть в рабочее состояние тремя способами: привести реализацию к существующему контракту; оформить изменение контракта и проверить совместимость потребителей; или собрать недостающее evidence и явно принять остаточный риск. Если после ревью нельзя одним предложением ответить, какой инвариант доказан, статус следует оставить «не готово» независимо от количества зелёных job.
\nСхема подходит для небольшого diff, у которого можно назвать границу и фиксированные входы. Она не заменяет интеграционные и end-to-end тесты, анализ зависимостей, threat model, проверку конфигурации, нагрузочное испытание, наблюдение после доставки или аварийный план. Для конкурентного кода одного сравнения input до и после может быть мало: понадобится проверка гонки и конечного состояния.
\nПримеры намеренно используют чистые функции и встроенный Node.js. В реальном проекте результаты зависят от версии runtime, схемы API, сериализатора, прав процесса и конкретной конфигурации CI. Команда из статьи не проверяет сеть, секреты и production. Если diff меняет такие границы, расширьте план проверки, а не объявляйте его доказанным по локальному прогону.
\nDiff можно передавать владельцу на approval, когда для каждого заявленного риска записаны инвариант, прямое свидетельство, отрицательный вход и blind spot. Код, тест и consumer должны описывать один контракт. Если они расходятся, результат проверки — не «почти зелёный», а конкретный вопрос владельцу границы и остановка merge до ответа.
" } diff --git a/editorial/agent-rewrites/105.json b/editorial/agent-rewrites/105.json index ab4eb6c..feb4f4b 100644 --- a/editorial/agent-rewrites/105.json +++ b/editorial/agent-rewrites/105.json @@ -2,6 +2,6 @@ "index": 105, "slug": "editorial-2025-02-practice-ai-code-verification", "title": "Зелёный тест не доказывает корректность AI-изменения", - "excerpt": "Как проверить сгенерированный diff по контракту, отрицательной ветке, побочным эффектам и независимому review — и когда остановить merge.", - "contentHtml": "Сгенерированный diff может пройти линтер и один unit-тест, но сломать потребителя. Типичный симптом — тест проверяет значение, а публичный контракт требует другое имя поля. Другой симптом — preview возвращает правильный текст, но меняет входной объект. Третий — роль editor проходит, а пустая роль тоже получает доступ. Цена ошибки начинается с повторного разбора и задержки merge. В реальной системе она может включать неверные данные, отказ функции или нарушение политики доступа. По одному зелёному статусу цену не определить.
\nТезис простой: результат AI-инструмента — это материал для проверки, а не доказательство. Корректность появляется только тогда, когда заявленный контракт, наблюдаемое поведение и решение ревьюера совпадают. Каждый сигнал должен отвечать на свой вопрос. Тест проверяет названный сценарий. Статический анализ ищет известный паттерн. Review проверяет намерение и границы. Ручное воспроизведение смотрит на путь потребителя.
\nДо запуска тестов запишите, что именно нельзя нарушить. Для mapper это форма результата и имена полей. Для функции preview — владение входным объектом и запрет на скрытую мутацию. Для проверки роли — список разрешённых значений и поведение при отсутствии роли. Для обработчика ошибки — статус, формат ответа и отсутствие утечки деталей.
\nФраза «функция должна вернуть итог» слишком широкая. Рабочая формулировка выглядит так: «при входе с subtotalCents и taxCents вернуть объект { amountCents: number }; вход не менять». В ней есть вход, выход и инвариант. По ней можно написать проверку и понять, где заканчивается её область действия.
contract = {\n input: { subtotalCents: 900, taxCents: 100 },\n output: { amountCents: 1000 },\n invariant: 'input remains unchanged'\n}\nЭтот фрагмент — учебный пример. Он не обращается к репозиторию, базе, CI или production. Его задача — показать форму контракта, а не предсказать поведение конкретной модели.
\nПредставим учебную функцию, которую изменил ассистент:
\nfunction toInvoice(input) {\n return {\n total: input.subtotalCents + input.taxCents\n };\n}\n\nconst result = toInvoice({ subtotalCents: 900, taxCents: 100 });\nconsole.assert(result.total === 1000);\nТест зелёный. Арифметика верна. Но контракт требует amountCents, а consumer читает result.amountCents. Значение становится undefined. Ошибка возникла не в сложной математике. Тест задал слишком узкий вопрос и не проверил форму публичного результата.
Исправленная проверка должна смотреть на поле, доступное потребителю:
\nconst input = { subtotalCents: 900, taxCents: 100 };\nconst result = toInvoice(input);\n\nconsole.assert(result.amountCents === 1000);\nconsole.assert(!('total' in result));\nconsole.assert(input.subtotalCents === 900);\nconsole.assert(input.taxCents === 100);\nВторой пример тоже учебный. Он не доказывает безопасность функции и не заменяет тесты всех реальных потребителей. Он показывает, как превратить контракт в наблюдаемое утверждение.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Тест проверяет число, consumer не находит поле | Проверили значение, но не shape | Сравнить ожидаемые ключи и чтение consumer | Вернуть контрактное поле или отдельно принять изменение API |
| Preview выдаёт верный текст, вход изменился | Не зафиксировано владение input | Сравнить объект до и после вызова | Создать derived value или назвать мутацию отдельной операцией |
| Editor проходит, missing role тоже проходит | Есть accept case, но нет default-deny | Проверить viewer и отсутствие значения | Записать allow-list и добавить отрицательные тесты |
| Линтер чистый, смысл изменения неясен | Паттерн проверен вместо намерения | Сопоставить diff с контрактом и owner boundary | Уменьшить scope и запросить предметный review |
| Ручной сценарий расходится с unit-тестом | Тест не повторяет путь потребителя | Воспроизвести тот же вход от API или UI до результата | Остановить merge до объяснения расхождения |
Статический анализ видит то, для чего у него есть правило. Он может найти присваивание аргументу или подозрительный вызов. Он не знает, разрешена ли мутация в конкретном API. Unit-тест видит только входы и ожидания, которые записал автор. Он не проверяет незаписанный путь. Review видит контекст, но зависит от размера diff, ясности требования и времени ревьюера. Ручная проверка видит один маршрут пользователя и может не охватить редкий вариант.
\nПоэтому пять зелёных сигналов могут повторять одну предпосылку. Например, линтер, unit-тест и screenshot подтверждают, что экран получил число. Ни один из них не подтверждает, что имя публичного поля осталось прежним. Независимость означает не количество инструментов, а разные вопросы и разные способы обнаружить нарушение.
\nНужно заранее написать blind spot каждого шага. Контракт не доказывает реализацию. Тест не доказывает покрытие всех consumers. Review не доказывает отсутствие runtime-ошибки. Такой список не делает систему безопасной сам по себе. Он не даёт зелёному статусу большего смысла, чем тот, который реально проверен.
\nОтрицательная ветка должна следовать из контракта. Если неизвестная роль не должна получать доступ, проверяйте и viewer, и отсутствие роли. Если preview не меняет вход, проверяйте равенство объекта после вызова. Если поле нельзя переименовывать без совместимости, проверяйте ключ результата и чтение старого consumer.
function canEdit(actorRole) {\n return actorRole === 'editor';\n}\n\nconsole.assert(canEdit('editor') === true);\nconsole.assert(canEdit('viewer') === false);\nconsole.assert(canEdit(undefined) === false);\nЭто не threat model и не доказательство безопасности авторизации. В примере нет identity provider, tenant boundary, токена или реального хранилища прав. Он проверяет только заявленное правило для трёх фиксированных входов. Если production-контракт шире, пример нужно расширить фактическими условиями, а не переносить его verdict.
\nПрактический stop condition таков: contract, отрицательный тест и rationale ревьюера описывают разные результаты. Тогда merge preparation блокируется. Это не автоматический revert и не rollback. Ничего доставленного система не меняет. Команда только прекращает движение спорного diff, пока владелец границы не выберет одно из действий: вернуть код к контракту, оформить совместимое изменение или собрать недостающее свидетельство.
\nЕсли расхождение нельзя объяснить одним предложением, не передавайте diff на approval. Approval должен отвечать на конкретный вопрос: можно ли принять переименование поля, кто владеет совместимостью, почему мутация разрешена или какое правило действует для отсутствующей роли. Человек утверждает осознанное исключение, а не пустоту в проверке.
\nAI-проверка не заменяет знание домена, threat model, тесты реальных интеграций и ответственность владельца. Ассистент может придумать несуществующий API, пропустить условие, удалить падающий тест или предложить зависимость с неподходящей лицензией. Статический инструмент может не понимать бизнес-правило. Ручной review тоже может пропустить ошибку.
\nВсе функции и данные в примерах вымышлены и предназначены только для обучения. В статье нет production-измерений, утверждений о качестве конкретной модели и обещания, что описанный набор шагов обнаружит любой дефект. Перед применением нужно заменить учебный контракт фактическим API, входами, ролями и условиями доставки.
\nНебольшой diff готов к следующему решению, когда выполнены четыре условия: контракт записан; позитивный и отрицательный сценарии проходят на одних и тех же входных данных; побочный эффект либо запрещён и проверен, либо назван частью контракта; ревьюер может объяснить, что проверено и что осталось вне scope. Если хотя бы одно условие не выполнено, статус «зелёный» описывает запуск инструмента, а не готовность изменения.
\nЗелёный тест отвечает только на тот вопрос, который в него записали. Если тест проверяет сумму, а потребитель ждёт другое имя поля, изменение может пройти CI и сломать следующий вызов. Если preview возвращает правильный текст, но меняет входной объект, ошибка проявится у другого обработчика. Если проверка роли знает только editor, пустая роль может случайно получить доступ.
Разберём учебный diff, похожий на тот, который способен предложить AI-ассистент. Цель не в том, чтобы измерить качество конкретной модели, а в том, чтобы сделать решение проверяемым: сначала назвать контракт, затем увидеть контрпример, повторить путь потребителя и только после этого обсуждать merge.
\nПредставим функцию расчёта счёта. Контракт старого кода прост: на входе два целых значения в копейках, на выходе объект с полем amountCents. Входной объект остаётся неизменным. Потребитель использует именно это имя:
function renderTotal(invoice) {\n return `${invoice.amountCents} коп.`;\n}\n\nconst invoice = toInvoice({ subtotalCents: 900, taxCents: 100 });\nrenderTotal(invoice);\nСгенерированная реализация может выглядеть убедительно:
\nfunction toInvoice(input) {\n return {\n total: input.subtotalCents + input.taxCents\n };\n}\n\nconst result = toInvoice({ subtotalCents: 900, taxCents: 100 });\nconsole.assert(result.total === 1000);\nЭтот assert проходит: арифметика верна. Но renderTotal читает amountCents, которого нет. Ошибка не в том, что тест написан на JavaScript. Он проверяет внутреннее промежуточное решение, а не публичную границу. Вторая ловушка — название результата: одно переименованное поле может затронуть несколько consumers, даже если функция изолированно выглядит исправной.
Контракт — это короткое описание наблюдаемого поведения, а не просьба «сделать правильно». Для нашего примера достаточно четырёх условий: форма входа, форма результата, запрет на мутацию и реакция на недопустимые числа. Последнее условие нельзя додумывать: если проект не определил отрицательные значения, сначала нужно решить, допускаются ли они.
\n| Граница | Условие | Как увидеть нарушение | Ограничение |
|---|---|---|---|
| Вход | subtotalCents и taxCents — целые числа | Проверить тип и пример с дробным значением | Не покрывает валидацию внешнего API |
| Результат | Есть только контрактное поле amountCents | Проверить ключи и вызвать consumer | Совместимость старого API нужно решать отдельно |
| Состояние | Входной объект не меняется | Сравнить снимок до и после вызова | Глубокая мутация вложенных данных требует отдельного теста |
| Отказ | Неверный тип или диапазон отклоняется явно | Проверить исключение или согласованный результат ошибки | Точная ошибка зависит от публичного API |
Такая таблица отделяет проверенный факт от решения, которое ещё должен принять владелец API. Не стоит молча добавлять «удобную» нормализацию, округление или обратную совместимость: это новые свойства, а не бесплатное исправление.
\nНиже — самодостаточная проверка для Node.js 20 или новее. Встроенный модуль node:test стабилен начиная с Node.js 20. Сохраните реализацию в invoice.mjs, тест — в invoice.test.mjs, затем выполните команды:
node --version\nnode --check invoice.mjs\nnode --test invoice.test.mjs\nПервый вариант реализации намеренно показывает дефект shape:
\n// invoice.mjs\nexport function toInvoice(input) {\n return {\n total: input.subtotalCents + input.taxCents\n };\n}\n// invoice.test.mjs\nimport test from 'node:test';\nimport assert from 'node:assert/strict';\nimport { toInvoice } from './invoice.mjs';\n\ntest('возвращает контрактную форму и не меняет вход', () => {\n const input = { subtotalCents: 900, taxCents: 100 };\n const before = JSON.stringify(input);\n const result = toInvoice(input);\n\n assert.deepEqual(result, { amountCents: 1000 });\n assert.equal(JSON.stringify(input), before);\n});\nТест должен упасть на неправильной реализации. Это полезный RED: он показывает конкретное расхождение, а не сообщает, что «AI ошибся». После исправления ожидаемый результат такой:
\n// invoice.mjs\nexport function toInvoice(input) {\n if (!Number.isInteger(input.subtotalCents) || !Number.isInteger(input.taxCents)) {\n throw new TypeError('amounts must be integers');\n }\n\n return {\n amountCents: input.subtotalCents + input.taxCents\n };\n}\nКоманда node --test invoice.test.mjs проверяет только этот модуль и один заданный контракт. Она не доказывает корректность округления, работу базы, права пользователя или совместимость всех вызовов. Эти границы должны появиться в следующих тестах, если они есть в настоящем API.
Отрицательный сценарий выбирают из риска, а не добавляют для количества. Для mapper это может быть дробная сумма, отсутствующее поле и неизменность входа. Для проверки доступа — неизвестная роль и отсутствие роли. Для зависимости — пакет с неверным именем, неожиданная версия или новый транзитивный компонент. Сначала задайте ожидаемое поведение, иначе тест закрепит случайное решение.
\nexport function canEdit(role) {\n return role === 'editor';\n}\n\nconsole.assert(canEdit('editor') === true);\nconsole.assert(canEdit('viewer') === false);\nconsole.assert(canEdit(undefined) === false);\nconsole.assert(canEdit('admin') === false);\nЗдесь действует правило «разрешено только явно названному значению». Но это не полноценная авторизация: пример не проверяет identity provider, tenant, токен, срок действия сессии или серверную границу. Нельзя переносить его как готовый security control. Он лишь фиксирует поведение одной чистой функции, если такая функция действительно является частью вашего контракта.
\nЛинтер ищет правила, которые ему известны. Компилятор проверяет синтаксис и типы в пределах настроенной системы. Unit-тест повторяет записанные входы. Интеграционный тест смотрит на соединение компонентов. Review проверяет смысл, архитектуру и стоимость изменения. Ручной путь потребителя показывает то, что реально вызывается дальше. Screenshot может подтвердить отображение, но не форму данных и не разрешение операции.
\n| Сигнал | Подтверждает | Не подтверждает | Следующий вопрос |
|---|---|---|---|
node --check или компилятор | Код разбирается в выбранной среде | Бизнес-правило и runtime-данные | Какие входы нарушают контракт? |
| Unit-тест | Названный пример и его ожидание | Незаписанные ветки и consumers | Есть ли отрицательный путь? |
| Статический анализ | Известные паттерны и часть уязвимостей | Намерение команды и все зависимости | Какая проверка требует человека? |
| Review владельца | Соответствие задаче и границам | Полное отсутствие runtime-дефектов | Что осталось вне scope? |
| Путь потребителя | Совместимость конкретного вызова | Другие маршруты и нагрузку | Какие ещё consumers нужно найти? |
Пять зелёных строк в CI не превращаются в математическое доказательство. У них могут быть общие фикстуры, одинаковые предположения и один пропущенный consumer. Ценность проверки растёт, когда инструменты независимы по вопросу: shape, состояние, отказ, безопасность и путь доставки нельзя заменить пятью вариантами happy path.
\nУ сгенерированного кода есть обычные дефекты и дополнительные источники риска. Ассистент может сослаться на несуществующий API, принять устаревшую сигнатуру, удалить падающий тест вместо исправления причины или добавить правдоподобную, но лишнюю зависимость. Поэтому перед предметным review полезно сравнить diff с документацией проекта и отдельно посмотреть новые пакеты.
\nMerge следует остановить, если контракт и код описывают разные формы, отрицательный сценарий не определён, тест удалён или пропущен ради зелёного CI, новая зависимость не подтверждена, либо ручной путь потребителя расходится с unit-тестом. Это не означает автоматический rollback и не доказывает наличие инцидента. Это граница принятия решения: владелец должен либо вернуть код к контракту, либо оформить совместимое изменение, либо добавить недостающее свидетельство.
\nПолезная запись решения занимает несколько строк: что изменилось, какой риск проверен, какой риск остался, кто его принимает и каким условием можно закрыть остаток. Если ответ невозможно сформулировать без слов «должно работать», проверка ещё не закончена.
\nОписанный маршрут подходит для небольшого локального изменения с понятным входом и выходом. Он не заменяет threat model, тестирование распределённой системы, нагрузочные испытания, аудит лицензий, проверку секретов или ручное решение для регулируемого домена. Для миграции схемы, платежей, авторизации и работы с персональными данными нужны дополнительные владельцы и контрольные точки.
\nПримеры вымышлены и не сообщают о production-результате. Они не измеряют вероятность ошибки AI и не доказывают, что любой дефект будет найден. Поведение Number.isInteger, встроенного test runner и команд зависит от версии Node.js; используйте версию проекта и проверяйте её через node --version. Для другого языка замените команды, но сохраните те же вопросы к контракту.
Небольшое AI-изменение готово к обсуждению merge, когда у него есть проверяемый контракт, проходящий позитивный и отрицательный сценарии, явная проверка побочного эффекта, просмотр потребителя и зафиксированные ограничения. Review должен отделять факт запуска теста от решения о корректности. Такой порядок не делает код безошибочным, зато не позволяет одному зелёному assert выдать локальное совпадение за доказательство всей цепочки.
\nnode:test и команда запуска тестов; пример требует Node.js 20+.Перед merge лежит небольшой сгенерированный diff. Он исправляет видимый симптом, тест рядом зелёный, а код выглядит аккуратно. Через час выясняется, что правка изменила ветку отказа, добавила доступ к данным или превратила ошибочный вход в допустимый. Цена ошибки — не только откат. Нужно найти затронутый контракт, остановить выпуск, проверить уже собранные артефакты и вернуть доверие к проверке.
\nТезис простой: ответ AI-помощника — кандидат на изменение, а не доказательство корректности. Перед merge нужно проверить три границы: diff меняет только разрешённый scope, сохраняет контракт и имеет evidence для каждой изменённой ветки. Линтер и успешный happy path закрывают лишь часть вопросов.
\nVerification gate отделяет candidate diff от решения человека. Он не одобряет pull request автоматически и не обещает безопасность. Он собирает четыре наблюдаемых поля: задачу, разрешённые пути, условия контракта и проверку результата. Если поле не заполнено, verdict должен остановиться на stop, а не превращаться в «скорее всего, всё хорошо».
const review = {\n task: 'normalize invoice key',\n allowedPaths: ['src/invoice/normalizeKey.ts'],\n forbiddenEffects: ['change authorization', 'write on invalid input'],\n evidence: [\n { input: 'valid-key', expected: 'valid-key', writes: 0 },\n { input: 'invalid-key', expected: 'error', writes: 0 },\n ],\n};\n\nfunction canMerge(candidate, contract) {\n const pathsOk = candidate.paths.every((path) => contract.allowedPaths.includes(path));\n const behaviorOk = candidate.invalidInput.writes === 0;\n return pathsOk && behaviorOk;\n}\n\n// Учебный пример: он не запускает CI и не проверяет реальный репозиторий.\n// false означает остановку, а не разрешение исправить scope молча.\nВ примере canMerge показывает только форму решения. Реальный проект должен связать каждое условие с тестом, ревьюером и фактическим diff. Нельзя считать этот фрагмент защитой доступа, транзакций или всех потребителей API.
Задача просит нормализовать ключ счёта. Помощник меняет parser и соседний accessDecision. Изменение авторизации может быть технически небольшим, но его риск не равен риску форматирования строки. Если path не назван в задаче, его нельзя включать в тот же merge под видом удобного сопутствующего исправления.
Правильный отрицательный путь — остановить diff и удалить лишний hunk либо вынести его в отдельный запрос. Не нужно объяснять расширение scope красивым комментарием. Сначала возвращают границу, затем отдельно обсуждают новую задачу и её владельца.
\nКонтракт различает три входа: непустой ключ, пустую строку и недопустимый маркер. Первый нужно нормализовать. Второй означает отсутствие значения. Третий возвращает ошибку. Сгенерированный код может свести последние два случая к пустой строке. Такой код короче и проходит happy path, но теряет смысл ошибки.
\nПроверка должна сравнивать не только типы и снимки ответа. Для каждого входа нужно назвать ожидаемый результат и запрещённый побочный эффект. Если invalid input не должен писать в базу, это условие обязано появиться в тесте. Иначе тест докажет лишь то, что функция что-то вернула.
\nТест может быть зелёным и не относиться к изменённой ветке. Например, он проверяет уникальный ключ, а diff добавляет запись до того, как обработает duplicate key. На duplicate-ветке результат должен быть ошибкой, а write helper не должен вызываться. Наличие файла с тестом не доказывает эту связь.
\nДля каждой changed branch запишите три значения: input, expected output и forbidden side effect. Если ветка не имеет отдельного наблюдаемого условия, её следует считать непроверенной. Это правило действует и для кода, написанного человеком.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| В diff появился файл, которого нет в задаче | Помощник расширил scope по соседнему контексту | Сопоставить каждый changed path с карточкой задачи | Остановить merge и вынести лишний hunk в отдельную задачу |
| Happy path зелёный, invalid input принят | Код изменил смысл ошибки или absence | Прогнать фиксированные valid, absent и invalid входы | Вернуть различие в контракт и добавить negative test |
| Тест есть, но побочный эффект не проверен | Тест видит output, но не вызовы write helper | Проверить число вызовов и порядок до error | Зафиксировать forbidden side effect и повторить тест |
| Линтер прошёл, поведение неизвестно | Правило стиля не видит доменный контракт | Сверить ветки, статус, данные и права отдельно | Не считать lint verdict доказательством correctness |
| Исправление выглядит локальным, но меняет доступ | Соседний security-sensitive код попал в контекст | Показать владельцу access path и отрицательные случаи | Остановить merge до отдельного security review |
Схема полезна как напоминание о порядке. Сначала фиксируют границу задачи. Затем читают diff. После этого проверяют контракт и тест. Решение человека появляется в конце. Если начать с впечатления от кода, предыдущие вопросы легко пропустить.
\nНи один слой не гарантирует correctness для всей системы. Модель может не знать скрытый consumer. Unit test может не увидеть реальный сериализатор. Линтер не знает, что 403 нельзя превращать в повторяемый 400. CI подтверждает только запущенные сценарии и окружение, в котором они запустились.
Учебный код выше не заменяет review, contract test, security analysis, интеграционный запуск и процедуру отката. Он также не измеряет качество модели и не подтверждает экономию времени. В статье нет production-метрик и результатов реального внедрения. Примеры нужны только для проверки границы: что изменилось, какой вход это наблюдает и какой эффект запрещён.
\nЕсли помощник удалил тест, ослабил проверку прав, изменил миграцию или добавил неизвестную зависимость, остановка важнее скорости. Верните diff к минимальному scope. Сохраните причину отказа. Повторно запросите только тот фрагмент, который можно проверить отдельным контрактом.
\nDiff готов к решению о merge, когда reviewer может показать: каждый изменённый path разрешён задачей; для каждой изменённой ветки есть вход, ожидаемый результат и проверка запрещённого эффекта; отрицательные случаи возвращают согласованный отказ; lint и тесты выполнены в заявленном окружении; неизвестные записаны отдельно и не выданы за pass. Для изменения доступа, схемы или внешнего контракта нужен отдельный владелец соответствующего риска.
\nПроверяемый результат — не фраза «код выглядит правильно». Это короткий набор ссылок на diff, contract rows и focused tests. Если хотя бы одна changed branch не связана с evidence, merge не готов. Такой критерий одинаково применим к ручному и AI-assisted коду.
\nНа ревью приходит небольшой diff, который подготовил AI-помощник. Основной тест зелёный, названия аккуратные, объяснение звучит уверенно. При чтении полного изменения обнаруживается второй файл с правами доступа, новый пакет в lock-файле или ветка ошибки, в которой запись выполняется до проверки входа. Такой diff выглядит локальным только в окне редактора. Цена пропуска — сломанный контракт, утечка данных или откат, который придётся готовить уже после merge.
\nРазберём учебный сценарий: помощник должен нормализовать входящее событие, но предложил изменить ещё и вызывающий код. Главный вывод не зависит от конкретного инструмента: ответ модели — кандидат на изменение, а не доказательство корректности. Перед merge человек должен связать каждый изменённый путь с задачей, каждую изменённую ветку — с контрактом, а каждое существенное утверждение — с воспроизводимой проверкой.
\nГотовность здесь означает не «код выглядит правильно», а возможность показать короткую цепочку evidence. Сначала есть формулировка задачи и список разрешённых областей. Затем — diff, который не вышел за эти области. Для изменённого поведения записаны вход, ожидаемый результат и запрещённый побочный эффект. Наконец, тест или другой запуск действительно проходит через эту ветку в окружении, которое указано в результате проверки.
\nУ gate есть три исхода. approve означает, что reviewer готов принять конкретный diff. request-changes означает, что проблему можно исправить в той же задаче. stop означает, что обнаружена неизвестная граница, security-sensitive изменение или несоответствие контракта; такой случай сначала возвращают владельцу риска.
Первый вопрос к diff — не «хорошо ли написан код», а «имеет ли он право здесь находиться». Для pull request можно получить список изменённых путей и отдельную проверку пробелов или конфликтных маркеров:
\ngit diff --name-status origin/main...HEAD\ngit diff --check origin/main...HEAD\ngit diff --stat origin/main...HEAD\nЭти команды показывают состав изменения и базовые технические дефекты, но не знают смысла задачи. Сопоставьте каждый путь с карточкой: исходный модуль, тест, схема, миграция, lock-файл и конфигурация — это разные виды риска. Если помощник добавил пакет «для удобства», изменил генератор или затронул авторизацию, не прячьте это в общий diff. Уточните границу либо вынесите изменение в отдельную задачу с отдельным владельцем.
\nОсобенно легко пропустить сгенерированные файлы. Они могут появиться после команды сборки, хотя не были частью замысла. Проверьте статус репозитория до и после запуска инструментов, а также правила, по которым артефакты попадают в commit. Для rename и удаления смотрите не только имя нового файла: контракт мог переехать, а тест — остаться привязанным к старому пути.
\nНебольшая функция часто имеет больше одного смысла входа. В учебном обработчике события есть валидное сообщение, отсутствие обязательного поля и неизвестный статус. Если все три случая свести к пустому объекту, вызывающий код может принять ошибку за отсутствие данных и продолжить обработку. Это не косметическая разница: меняется решение на границе системы.
\n| Вход | Ожидаемый результат | Запрещённый эффект | Evidence |
|---|---|---|---|
Поле type известно, payload полный | Нормализованный объект передан дальше | Не создавать запись дважды | Unit test и проверка вызова downstream |
Нет type | invalid-input | Не вызывать запись и повторную доставку | Negative test с проверкой числа вызовов |
type неизвестен | unsupported-type | Не угадывать обработчик по похожему имени | Тест на неизвестное значение и лог причины |
| Payload содержит лишнее поле | Результат зависит от версии контракта | Не молча терять данные, если контракт их запрещает | Сверка схемы и тест сериализации |
Таблица не заменяет спецификацию API. Она делает видимым место, где тест должен отличать результат от побочного эффекта. Проектные значения — имена статусов, политика лишних полей и способ дедупликации — нельзя переносить в другой сервис без сверки его контракта.
\nМинимальный воспроизводимый пример можно выполнить обычным Node.js без внешних пакетов. Он проверяет три входа и отдельно фиксирует, что downstream не вызывается при ошибке:
\nconst calls = [];\n\nfunction normalizeEvent(event) {\n if (!event?.type) return { status: 'invalid-input' };\n if (event.type !== 'invoice.created') return { status: 'unsupported-type' };\n return { status: 'ok', type: event.type, invoiceId: event.invoiceId };\n}\n\nfunction handle(event) {\n const result = normalizeEvent(event);\n if (result.status !== 'ok') return result;\n calls.push(result.invoiceId); // downstream: только после проверки\n return result;\n}\n\nconsole.log(handle({ type: 'invoice.created', invoiceId: 'inv-7' }));\nconsole.log(handle({ invoiceId: 'inv-8' }));\nconsole.log(handle({ type: 'invoice.deleted', invoiceId: 'inv-9' }));\nconsole.log({ calls });\n\n// Ожидается: два отказа и calls только с ['inv-7'].\nЗапустите этот фрагмент как node example.mjs или вставьте его в Node REPL. Важно не само число строк, а наблюдаемый инвариант: ошибки возвращаются до побочного действия. Если сгенерированный вариант вызывает calls.push до проверки статуса, happy path останется зелёным, а отрицательный тест покажет нарушение.
Тест должен быть связан с изменённой веткой, а не просто лежать рядом по имени. Прочитайте assertion: он проверяет значение, тип ошибки, количество вызовов и состояние после отказа? Если проверяется только непустой ответ, неизвестно, что произойдёт с записью, повторной доставкой или метрикой.
\nAI-помощник может предложить несуществующий пакет, устаревший API или библиотеку с неподходящей лицензией. Может также перенести в prompt секрет, персональный идентификатор или фрагмент закрытого кода. Поэтому просмотр diff должен включать не только строки программы.
\nGitHub в руководстве по проверке AI-generated code отдельно выделяет функциональные проверки, контекст и намерение, зависимости, AI-специфичные ошибки и человеческое ревью. Это полезная карта вопросов, но не сертификат качества конкретного diff. NIST SSDF и профиль для generative AI задают практики безопасной разработки и управления рисками; они не подтверждают, что учебный код или конкретная модель безопасны.
\nПри mismatch не исправляйте сразу весь diff новым широким запросом к помощнику. Сначала зафиксируйте наблюдение: какой path лишний, какая строка контракта нарушена, какая проверка отсутствует и какой эффект запрещён. Затем выберите узкое действие.
\nstop.Остановка не означает автоматический rollback. До merge обычно достаточно сузить кандидат и запросить изменения. После поставки нужна отдельная процедура инцидента и отката, зависящая от системы. Не смешивайте эти решения: gate отвечает на вопрос «можно ли принимать этот diff сейчас», а не «как восстановить production после уже случившегося эффекта».
\ngit diff --name-status, прочитайте весь diff и проверьте незапланированные артефакты.git diff --check; сохраните команды и фактический результат.Этот gate снижает риск пропустить очевидное несоответствие, но не доказывает полноту поиска потребителей, отсутствие уязвимостей или корректность бизнес-решения. Unit test может не увидеть реальный сериализатор, конкурентный вызов, миграцию или конфигурацию в другом окружении. Static analysis может найти подозрительный путь, но не понять, что конкретный статус запрещено менять в этом продукте.
\nПример с invoice.created синтетический: он не подключён к очереди, базе и системе прав. Значения статусов, формат идентификатора и правило дедупликации — иллюстрация, а не универсальный API-контракт. Для платежей, персональных данных, авторизации и миграций нужен профильный владелец, проверка реального окружения и принятый в проекте план отката.
Не следует приписывать модели результат, которого не измеряли. Без сравнимых задач и заранее заданной метрики этот процесс не доказывает экономию времени, рост качества или отсутствие дефектов. Он лишь делает решение о конкретном diff проверяемым и оставляет явное место для остановки.
\nDiff можно передавать на решение о merge, когда reviewer показывает: каждый path разрешён задачей; каждая изменённая ветка связана с тестом или другим воспроизводимым evidence; valid, absent и invalid входы не смешаны; запрещённые побочные эффекты проверены; зависимости, данные и права просмотрены; команды и окружение записаны; неизвестные не выданы за доказанный факт. Если хотя бы один пункт не выполнен, корректный результат — запросить конкретную проверку или остановить merge.
\n