ADR 0013 — Gates PR: QAAgent и SecOpsAgent
- Статус: принят
- Дата: 2026-07-24
- Фаза: 4a (сессия 3 — gates QA и SecOps)
Контекст
Реальный DevAgent (предыдущая сессия) реализует task и открывает PR, но PR не
проходит никакого автоматизированного ревью — она идёт прямо в in_review. Эта
сессия добавляет два недостающих gate: QA (запускает suite, строит
матрицу правило→тест, указывает правила без покрытия) и SecOps (запускает
semgrep/gitleaks, сверяет с ADR по безопасности) — каждый с заключением,
зарегистрированным как артефакт + комментарий в PR, возвратом dev в ТУ ЖЕ
ветку при отклонении и пределом исправлений, прежде чем стать blocked.
Критерий приёмки: task с (a) правилом без теста и (b) захардкоженным
секретом → QA возвращает первое, dev исправляет, SecOps останавливает второе, dev
исправляет, PR доходит до awaiting_user с 4 заключениями на временной шкале.
Решения
1. Машина состояний gate как чистый предел
domain/execution/pr-gate-state-machine.ts (тот же шаблон, что
action-state-machine.ts/story-state-machine.ts): PrGateStatus = 'awaiting_qa' | 'awaiting_secops' | 'awaiting_user'. nextGateStatus
получает ТЕКУЩИЙ gate + вердикт и возвращает следующий статус (или 'blocked',
если предел исправлений превышен) — каждый gate может действовать только над СВОИМ
статусом (InvalidGateActionError, если QA пытается решать по awaiting_secops
или наоборот), гарантируя неизменяемый ПОРЯДОК (одобрение QA никогда не перескакивает прямо
в awaiting_user). tasks получает gate_status/gate_correction_count
(миграция 0015) — без новой таблицы для заключений: они являются
session_events artifact.qa_verdict/artifact.secops_verdict, по тому же
шаблону, что EmitArtifact.
RecordGateVerdictUseCase — ЕДИНСТВЕННОЕ место, которое применяет машину, публикует
комментарий в PR (GitProvider.commentOnPullRequest, 10-я операция
контракта — best-effort, никогда не блокирует решение gate) и возвращает engine
следующее действие (correct/run_secops/done/blocked) — тот же принцип,
что всегда: api решает, engine выполняет.
2. Разговорный handoff НЕ переиспользован
domain/sessions/handoff.entity.ts смоделирован для активации РАЗГОВОРНОГО
агента (offered/accepted), без понятия ветки/worktree/task, —
втискивание в него было бы хуже, чем создание нового пути.
Возврат "QA/SecOps отклонил → назад к dev в ТУ ЖЕ ветку" — это
прямой вызов engine→engine: DevAgentServer.correct/3 (отличается от
work/2, который забирает НОВУЮ task) переиспользует уже сохранённые state.worktree/
state.branch/state.task_id — НИКОГДА не вызывает
worktree_manager().create/3 заново. В report_done исправления только
propose_commit/propose_push (PR уже существует, та же ветка) — никогда
повторный propose_pr.
3. QA использует ToolLoop/LLM; SecOps детерминирован — намеренная асимметрия
Сопоставление описания бизнес-правила с именем/содержимым теста
— это СЕМАНТИЧЕСКОЕ суждение: QaAgentServer использует реальный ToolLoop (тот же
harness, что у DevAgent), с Engine.Gates.Tools.EmitQaVerdict, ПРИНУДИТЕЛЬНЫМ
ровно как ReportDone: принимает veredito: "approved", только если последний
terminal в истории завершился с exit 0.
Поиск захардкоженного секрета или уязвимости — это СТРУКТУРИРОВАННАЯ проверка
вывода сканера: ДЕТЕРМИНИРОВАННЫЙ SecOps (без LLM) надёжнее,
чем модель, пересказывающая находку безопасности (риск
галлюцинации/пропуска в проверке, которая должна быть бинарной).
SecOpsAgentServer запускает gitleaks+semgrep (Engine.Actions. GitleaksDetector/SemgrepDetector, тот же шаблон необязательного обнаружения, что у
RtkDetector — System.find_executable/1, никогда не предполагает установленным) и перечисляет
ADR securityRelevant (новое необязательное поле в payload
open_adr_pr — информационный чек-лист, без глубокой построчной
корреляции). Ноль находок → approved; любая находка → changes_requested.
Оба сканера протестированы в реальном Dockerfile engine (Alpine) и
устанавливаются/работают без проблем (gitleaks через статический бинарник из релиза
GitHub; semgrep через pip) — первоначальное опасение нестабильности на
musl/Alpine в этой сессии не подтвердилось. Необязательное обнаружение остаётся
эшелонированной защитой (окружение без бинарников не ломает gate).
4. QA/SecOps разделяют worktree dev
Ни один из них не создаёт собственного worktree — они находят worktree dev через
DevAgentState.find_by_task_id/2 (новый запрос), — поскольку только читают/выполняют
команды и никогда не пишут код. Engine.Gates.Diff.compute/2 вычисляет
git diff <default_branch>...HEAD (до этого в engine не было никакого вычисления diff)
— используется для сводки заключения (количество изменённых
файлов), а не как построчный фильтр находок сканера
(задокументированное упрощение: надёжная корреляция пути сканера ↔ diff
достаточно хрупка, чтобы не стоить усилий в этой сессии).
5. Косвенность запуска (Dispatcher) — тестируемость
Engine.Gates.Dispatcher (.run_qa/2, .run_secops/2) — единственная точка,
где DevAgentServer/QaAgentServer запускают СЛЕДУЮЩИЙ gate, — заменяемая
в тесте (Engine.Gates.FakeGateDispatcher) по той же причине, что
worktree_manager(): без этой косвенности тесты DevAgentServer
поднимали бы РЕАЛЬНЫЙ QaAgentServer вне Ecto-песочницы тестового процесса
(обнаружено при запуске suite — GenServer падал, пытаясь выполнить DevAgentState. find_by_task_id без владельца соединения). Возврат gate→dev
(DevAgentServer.correct/3) НЕ проходит через косвенность — это GenServer. cast через :via/Registry, который по природе OTP уже fire-and-forget
(молчит, если процесса нет, никогда не роняет вызывающего).
Последствия
- UI:
ProjectApprovalsTabполучает раздел "PRs em revisão" — горизонтальный stepper dev→qa→secops→você (PrGateTimeline, новый компонент) для каждой task с открытым gate, раскрываемые заключения иcoverageMatrixQA, отрисованную черезui/Table.activity.tsповествуетpr.gate_changed/artifact.qa_verdict/artifact.secops_verdict. - Тесты: машина состояний (неизменяемый порядок, предел исправлений),
RecordGateVerdictUseCase(best-effort комментарий, превышение K блокирует),DevAgentServer.correct/3(та же ветка/worktree, без новой PR),QaAgentServer(принуждениеemit_qa_verdict),SecOpsAgentServer(подложенный секрет → changes_requested; отсутствующий сканер → пропуск без поломки).
Область и допущения
QA/SecOps только для PR DevAgent — Infra и полная панель команды через
каналы Phoenix остаются вне. securityRelevant в ADR — лишь информационный
флаг (Arquiteto Фазы 3b не меняется, кроме принятия необязательного
поля). Без глубокой корреляции diff↔находка сканера. in_review → done (финальное одобрение пользователя) остаётся вне этой сессии — gate
заканчивается в awaiting_user, а человеческое действие merge всегда ручное.