Перейти к основному содержимому
Перевод от 2026-10-15; оригинал с тех пор изменился.

Открытая поверхность api

Каждый зарегистрированный маршрут — с классификацией аутентификации и обоснованием тех, что остаются открытыми. Решения — в ADR 0027.

Этот документ — источник истины для теста, а не копия теста. apps/api/test/interfaces/route-surface.spec.ts поднимает приложение, перечисляет маршруты, зарегистрированные в runtime, и сравнивает их с таблицей ниже. Новый маршрут без строки здесь проваливает тест; строка, классификация которой расходится с реальной аннотацией, проваливает тест; осиротевшая строка (не совпадающая ни с одним маршрутом) проваливает тест. Документация не устаревает молча.

Классификации​

значениечто означает
public@Public() — без токена. Каждый такой маршрут обоснован ниже
engine-serviceEngineServiceGuard: X-Brabo-Service-Token, равный общему секрету. Это внутренняя поверхность api↔engine, вне JWT
role:<role>аутентифицирован и ограничен RBAC домена (@RequireRole)
jwtаутентифицирован, без требуемой в маршруте роли — область видимости задаётся самим ресурсом

Пятнадцать публичных маршрутов​

До Фазы 6 их было четыре. Фаза 7a добавила сразу восемь — first-party auth, — ADR 0084 ещё два, вход через соцсети, а волна ключа устройства runner добавила прокси бинарника. Каждый обоснован ниже. Чтобы открыть ещё какой-либо, по-прежнему нужно менять утверждение в route-surface.spec.ts, которое перечисляет публичные маршруты буквально, чтобы вынудить обсуждение.

Инфраструктура​

GET /live — liveness. Намеренно не обращается к базе: отвечает, пока процесс жив. Требование токена здесь заставило бы kubelet перезапускать pod при первом сбое на пути auth, превращая деградацию в полный отказ.

GET /health — readiness, с select 1. То же рассуждение: вызывает kubelet, а он токена не несёт. Раскрывает только то, отвечает ли база.

GET /metrics — scrape Prometheus, который тоже не несёт никакого токена. Доступ контролируется СЕТЬЮ, а не auth: NetworkPolicy открывает его только для namespace monitoring, а Ingress production блокирует путь. Без @Public() цель была бы down при всём остальном зелёном.

GET /git/oauth/:provider/callback — возврат OAuth от GitHub/GitLab. Браузер пользователя приходит сюда от провайдера, без сессии api. Но маршрут не безусловный: параметр state проверяется HMAC (GIT_OAUTH_STATE_SECRET), и без валидного state вызов отклоняется.

Эта гарантия стоит ровно столько, сколько стоит ключ, и поэтому у него больше нет значения по умолчанию: в production api отказывается стартовать с примерным ключом из репозитория, который публичен (ADR 0059, RN-093). С известным ключом этот маршрут на практике снова становится безусловным — любой может подписать state для любого проекта.

GET /runner-releases/binary — проксирует standalone-бинарник runner из GitHub Releases, чтобы браузер никогда не общался с GitHub напрямую. Публичен по той же причине, что и /metrics/JWKS: сам бинарник не секрет, а требовать сессию для скачивания того самого инструмента, который позволяет аутентифицироваться, было бы шиворот-навыворот. platform — закрытый список разрешённых значений (linux-x64/linux-arm64/darwin-arm64 — три цели, которые собирает матрица релиза; darwin-x64 ушёл в ADR 0174, а win32-x64 — в ADR 0187, и оба отклоняются с собственным сообщением, указывающим на npm-пакет), никогда не подставляется сырым в URL GitHub — это закрывает вектор SSRF/инъекции пути, который оставил бы открытый параметр. Разрешённый URL asset (никогда не байты бинарника) кэшируется в памяти на несколько минут — только чтобы оставаться ниже rate limit GitHub для неаутентифицированных запросов при конкурентных скачиваниях; начиная с сессии 3 ФАЗЫ 29 разобранный checksums.txt — несколько сотен байт текста — мемоизируется в той же записи, поэтому хэш и бинарник всегда берутся из одного и того же релиза.

Начиная с RN-525 (ADR 0149) маршрут больше не передаёт непроверенные байты: он сверяет sha256 скачанного с checksums.txt релиза и отвечает 502 с именованным motivo, когда это не удаётся, — в том числе когда релиз вообще не публикует манифест, что является отказом, а не байтами, отданными с предупреждением. Понимать гарантию следует узко: это целостность относительно манифеста, а не происхождение. Маршрут не проверяет подпись cosign манифеста (checksums.txt.bundle), так что тот, кто может переписать Release, переписывает оба файла и проходит. Оба способа закрыть это были измерены и пока отклонены — cosign в образе занимает 155 MB, а @sigstore/verify сделал бы маршрут @Public() зависимым от второго стороннего хоста (tuf-repo-cdn.sigstore.dev) для проверки того, чего пока не несёт ни один Release. Потребитель, который проверяет подпись, — это install.sh. Поскольку проверка хэша означает чтение всех байтов, загрузка идёт во временный файл под /tmp (emptyDir pod, смонтированный, потому что rootfs только для чтения) с потолком 256 MiB и отдаётся только после совпадения хэша — никогда не буферизуется в процессе на 512Mi.

First-party auth​

Семь маршрутов /auth/* плюс JWKS. Все они должны быть публичными по одной и той же структурной причине: это путь, по которому access token ПОЛУЧАЮТ, а глобальный JwtAuthGuard его требует. Маршрут auth за guard запрашивал бы учётные данные, которые сам же выдаёт. logout — единственный, который мог бы быть аутентифицированным, и он намеренно таким не является: он уже несёт значимые учётные данные — refresh в cookie, с парой CSRF, — а выход должен работать даже с истёкшим access token.

Эти маршруты защищает не rate limit. RateLimitGuard освобождает маршруты @Public() — намеренно, чтобы не душить /health до перезапуска pod kubelet. Эту поверхность удерживает прогрессивная блокировка по email и по IP внутри сценариев использования. Это не необязательное усиление: это единственная защита, которая здесь есть. См. RN-030 и RN-031.

POST /auth/register — регистрация. Отвечает 202 как для нового адреса, так и для уже зарегистрированного; во втором случае ничего не создаётся, а владелец адреса получает уведомление. 409 Conflict, которого требовал бы здравый смысл REST, выдал бы список пользователей любому со словарём.

POST /auth/login — аутентификация. Несуществующий email, неверный пароль и заблокированная учётная запись возвращают один и тот же ответ 401 и занимают одно и то же время (ветка без учётной записи сверяет с фиктивным хэшем с идентичными параметрами).

POST /auth/refresh — ротация. Сам refresh token является учётными данными, поэтому маршрут не может требовать других.

POST /auth/logout — отзывает семейство предъявленного токена. Всегда 204, даже для неизвестного токена: ответ 401 здесь был бы оракулом валидности токена.

POST /auth/verify-email, POST /auth/request-password-reset, POST /auth/reset-password — потоки учётной записи. У того, кто приходит сюда, по определению ещё нет сессии. Учётные данные — одноразовый токен, пришедший по email; недействительная, истёкшая или уже использованная ссылка получают идентичный ответ.

GET /.well-known/jwks.json — публичная половина пары Ed25519, которой подписываются access tokens. То же рассуждение, что и для /metrics: у потребителя нет токена, и требовать его означало бы запрашивать учётные данные для проверки учётных данных. Публикация открытого ключа — весь смысл формата; то, что никогда не может отсюда выйти, — компонент d JWK, зафиксированный тестом.

Вход через соцсети (ADR 0084)​

GET /auth/oauth/:provider/start — перенаправляет прямо к провайдеру (GitHub/GitLab). Публичен по той же структурной причине, что и остальные семь: это сама точка входа, до появления сессии. state, который он генерирует, подписывается HMAC с СОБСТВЕННЫМ назначением (signSocialOauthState/RN-273) — никогда не тот же state, что у GET /git/oauth/:provider/callback выше, хотя оба маршрута подписывают ОДНИМ И ТЕМ ЖЕ ключом GIT_OAUTH_STATE_SECRET. Поле purpose в payload — то, что не даёт state одного потока быть принятым верификатором другого.

GET /auth/oauth/:provider/callback — принимает возврат от провайдера. То же рассуждение, что и для callback подключения git: браузер приходит без сессии, state проверяется HMAC, а маршрут никогда не возвращает JSON — он всегда перенаправляет: на WEB_ORIGIN/ при успехе (с уже записанными cookies сессии) и на WEB_ORIGIN/login?oauth_error=1 при неудаче, не раскрывая причину в URL.

Примечания​

  • POST /workspaces/:workspaceId/projects решает, где агент будет писать, и именно поэтому это маршрут поверхности безопасности, а не только регистрации (ADR 0072/ ADR 0104, RN-169/RN-421/ RN-422). Тело получило executionMode (container — по умолчанию и привычное поведение, —, mounted, бывший local, переименованный, или runner) и workspacePath. В mounted/ runner указанный абсолютный путь становится корнем области видимости терминала из ADR 0055: то, что здесь вводится, — то, что агент может читать и писать. Ни одного нового маршрута и никакой смены роли (RequireRole('maintainer'), как и было) — изменилось то, насколько широко то, что маршрут предоставляет.

    Начиная с ADR 0142 (RN-501) ОБА режима, отличные от container, при создании проверяют ОДНО И ТО ЖЕ: только ЛЕКСИЧЕСКУЮ форму — абсолютный, без .., никогда не корень и не системная папка, никогда не пересекающийся с checkout Brabo ни в одном из направлений (RN-422/RN-423) — без какого-либо дискового I/O. mounted добавляет собственное правило: путь должен находиться внутри BRABO_PROJECTS_BASE, единственной папки машины, которую видят оба контейнера (ADR 0141), а без настроенной базы отказ сообщает, что РЕЖИМ недоступен в этой установке, вместо того чтобы винить путь.

    С точки зрения безопасности в этом изменении важно то, что НЕ сдвинулось. Указанный путь по-прежнему является корнем области видимости терминала, а лексический предикат по-прежнему его ограничивает — он выполняется при каждом выводе корня, в том числе при ЧТЕНИЯХ, потому что запись напрямую в базу — единственный способ обойти создание. Отложена была проверка ДИСКА (существует? это каталог? может ли этот процесс в него писать?), которая никогда ничего не ограничивала: она отвечала «это будет работать», а не «это разрешено». Теперь она выполняется в materializarWorkspaceMontado, когда Infra поднимает контейнер (и, как объявленное исключение, при конвертации режима), и правило базы применяется там повторно ДО mkdir -p — так что путь, записанный в обход продукта, не заставляет api создавать папку в любом месте, куда она дотягивается. Правило базы намеренно находится ВНЕ лексического предиката: тот выполняется при каждом чтении, и УНАСЛЕДОВАННЫЙ проект mounted вне базы начал бы падать только из-за того, что его прочитали. runner по-прежнему подтверждается самим runner, единственной стороной, имеющей власть над реальным хостом (RN-423, см. POST /internal/projects/:projectId/workspace-verification ниже); mounted теперь подтверждается материализацией, которая точно так же проставляет workspace_verified_at. После этого режим заморожен: UpdateProjectDto намеренно опускает оба поля, иначе PartialType(CreateProjectDto) открыл бы их в PATCH без какой-либо защиты.

  • GET /workspaces/:workspaceId/containers теперь перечисляет КАЖДЫЙ проект workspace, а страница, которую он питает, стала человеческим путём ПОДНЯТЬ контейнер (RN-521, ADR 0136). Роль не изменилась (viewer, как и было), область видимости тоже: страница по-прежнему отвечает только за проекты workspace, которые видит вызывающий. Изменился набор строк — проект, который никогда не провизионировал контейнер, ОТСУТСТВОВАЛ, а теперь появляется с registrado: null, ТРЕТЬИМ состоянием, которое не является ни stopped, ни «не удалось наблюдать». Чтение по-прежнему ограничено так же: три пакетных запроса при любом числе проектов и не более TETO_DE_VERIFICACOES_POR_CARGA (20) вызовов broker за загрузку, причём строки без контейнера никогда не подлежат проверке и поэтому никогда не могут отнять очередь у настоящей.

    Ничто на этой странице не действует напрямую. Остановить, удалить и поднять — всё это proposed_action, всегда с кликом ЧЕЛОВЕКА, никогда агента, и ни один потолок decide.ts не затрагивается: оба типа подъёма требуют maintainer, а container_remove находится в абсолютном потолке семейства привилегированных команд (RN-418) — никогда не автоодобряется, «всегда разрешать» отклоняется у источника. Экран ветвит действие подъёма по execution_mode ровно так же, как backend (RN-497/RN-503): container/mounted предлагают container_start (broker), а runner предлагает container_start_via_runner (локальный агент), с payload, который схема каждого из них действительно принимает.

    Отказы самого экрана — это честность, а не принуждение: он не предлагает кнопку без выбранного образа (ворота RN-105, действующие во ВСЕХ ТРЁХ режимах начиная с RN-494), ни для проекта runner, папку которого ни один локальный агент никогда не подтвердил, ни для роли ниже maintainer — и говорит текстом, какой из трёх случаев имеет место. По-настоящему отказывают по-прежнему RolesGuard и сценарии использования. Здесь читается роль WORKSPACE, а не эффективная роль проекта (projectRole ?? workspaceRole, RN-471): участник, понижённый внутри проекта, всё ещё видит кнопку и получает 403 — объявленная цена отказа от N+1 по project_members на странице, проходящей через проекты.

  • PUT /projects/:projectId/mirror-path записывает путь на машине ПОЛЬЗОВАТЕЛЯ, а api эту машину никогда не видит (RN-515, ADR 0147, пункт 4). Он хранит место назначения, куда возможность espelho копирует работу проекта, — папку ВНЕ смонтированной базы, именно поэтому bind mount до неё не дотягивается, а локальный агент должен дотянуться.

    Минимум — maintainer, как у execution-mode и projects-base выше, и по той же причине: этот маршрут имеет дело с путём в собственной файловой системе оператора, а не с метаданными проекта.

    Здесь api может проверить ТОЛЬКО ЛЕКСИЧЕСКУЮ форму, и сообщение об отказе это говорит. Он переиспользует caminhoDeWorkspaceLocalValido — абсолютный, без ../., никогда не корень, никогда не системная папка, никогда не пересекающийся с checkout самого Brabo — ровно так же, как это делает создание в runner (RN-423), и по той же причине: здесь нет диска, у которого можно спросить. Он НЕ требует BRABO_PROJECTS_BASE, потому что быть вне базы и есть цель.

    Два отказа присущи только этому маршруту. Место назначения не может находиться внутри workspacePath или содержать его — одна и та же петля, увиденная с двух сторон, поскольку запись зеркала внутрь собственного источника заставляет зеркало копировать само себя, — и сравнение идёт по СЕГМЕНТУ (dentroDoEscopo), так что /base-outra не находится внутри /base. А проект в execution_mode: container вообще не может иметь места назначения: его источник — управляемый том НА СЕРВЕРЕ, а процесс, который копировал бы, работает на машине ПОЛЬЗОВАТЕЛЯ, которая его не видит. Оба — 400.

    Другая половина защиты — разрешение symlinks через realpath, чтобы ссылка в любом сегменте места назначения не указывала обратно внутрь источника, — принадлежит RUNNER, на машине, где оба пути реально существуют. Этот маршрут такой гарантии не устанавливает; он лишь отбрасывает ЗАПИСАННУЮ петлю. Очистка — это mirrorPath: null, и ключ обязателен: его пропуск был бы неотличим от просьбы очистить, а молчаливая очистка — это и есть дефект. Запись зеркала никогда не является proposed_action — это конфигурация, объявленная пользователем, а не агент, просящий разрешения действовать.

  • GET /projects/:projectId/mirror-state — viewer, на ступень ниже маршрута, который ЗАПИСЫВАЕТ место назначения (RN-517, ADR 0147, пункт 7). Он возвращает, что сделал последний прогон зеркала: какое из трёх состояний действует сейчас (never | synced | failed), последнюю успешную синхронизацию с её счётчиками, замороженное место назначения того прогона и последнюю ошибку.

    Асимметрия с PUT .../mirror-path (maintainer) намеренна и является применённым правилом RN-102: минимум определяется ENDPOINT, а этот только ЧИТАЕТ. Само место назначения уже входит в каждое чтение проекта (GET /projects/:projectId, viewer), так что требование maintainer здесь заперло бы информацию от тех, кто уже видит её в том же проекте, — худший из двух дефектов, потому что он невидим для того, кто утратил возможность.

    Стоит сказать, чего он НЕ раскрывает: ни одного имени файла, никакого содержимого, никакого пути, кроме места назначения, которое вызывающий уже может прочитать. Счётчики — это счётчики.

  • GET /workspaces/:workspaceId/projects-base раскрывает часть топологии файловой системы оператора, и поэтому он не viewer (ADR 0141, RN-500). Он возвращает { projectsBase: string | null } — единственную папку хоста, смонтированную по идентичности в контейнеры api и engine, под которой живёт каждый проект mounted. null — нормальное состояние установки, которая не предлагает режим Montado, и именно так мастер проекта узнаёт, что не надо предлагать режим, который эта установка не может обеспечить.

    Минимум — maintainer: тот же, что у POST /workspaces/:workspaceId/projects выше, потому что решать, что предлагает тот маршрут, — единственное применение этого значения. Унаследовать viewer от соседних чтений projects-* — они стоят рядом в одном controller — означало бы отдать абсолютный путь хоста каждому, кто может смотреть workspace, без какой-либо возможности взамен. workspaceId в расчёте не участвует: база — конфигурация установки, одинаковая для каждого workspace, а параметр существует только для того, чтобы дать область видимости RolesGuard.

    Начиная с ADR 0161 (RN-573) ответ также несёт brokerConfigurado: boolean — задан ли BROKER_URL, считываемый через ContainerBrokerPort.configurado(). Он раскрывает один бит топологии установки (есть ли broker с сокетом Docker), никогда не его адрес, и минимум остаётся maintainer. Тот же бит входит в каждую строку GET /workspaces/:workspaceId/containers (viewer, RN-574) — намеренное расширение: та страница должна сказать ДО клика, что container/mounted здесь не поднимаются, а тот, кто может видеть страницу, уже мог вывести отсутствие по naoObservado: broker-nao-configurado всякий раз, когда строка находится в provisioning/running. Он говорит, что переменная существует, но никогда — что broker отвечает.

  • GET /workspaces/:workspaceId/project-folders отдаёт листинги каталогов по пути, переданному клиентом, и вся его безопасность — ОДНО ограничение (RN-504, ADR 0141). Он существует, потому что мастер проекта разом лишился обоих своих механизмов «найти папку»: FolderBrowserModal ходил через websocket RUNNER, а RunnerOnboardingPanel использовал showDirectoryPicker, который возвращает handle браузера и никогда — абсолютный путь. Когда runner ушёл из создания проекта, у браузера не стало способа перечислять файловую систему — поэтому перечисляет api, а api видит только одну папку: базу смонтированных проектов.

    Ограничение — это база и ничего больше. path необязателен, по умолчанию BRABO_PROJECTS_BASE; каждый переданный path должен удовлетворять dentroDaBaseDeProjetos — тому же dentroDoEscopo, который использует область видимости терминала из ADR 0055, так что /home/you/brabo2 НЕ находится внутри /home/you/brabo, даже если строка начинается так же. Выход за пределы базы — 400, а не 403: 403 намекал бы, что какая-то другая роль это увидит, а не видит никакая. Запрос некорректен, а не недостаточно авторизован. .. и . отклоняются сразу, а не разрешаются, по той же причине, по которой их отклоняет предикат создания: разрешать — значит соглашаться, что прочитанный путь не тот, что запрошен.

    Потолки — часть контракта, а не деталь реализации: только каталоги, не более 500 (отсортированных ДО отсечения), никогда рекурсивно, записи с префиксом точки исключены, а symlinks сообщаются, но никогда не обходятся — так что ссылка, указывающая за пределы базы, не является выходом из неё. Исключённое ПОДСЧИТЫВАЕТСЯ (arquivos, simbolicos, truncado), так что папка, полная кода, никогда не возвращается похожей на пустую (RN-180). Парного POST нет: создание папки — дело материализации смонтированного workspace, никогда не селектора.

    Минимум — maintainer, тот же, что у POST /workspaces/:workspaceId/projects и projects-base выше, и по той же причине, доведённой на шаг дальше: projects-base раскрывает ОДИН путь на машине оператора, этот маршрут раскрывает ТОПОЛОГИЮ под ним. Тому, кто не может создать проект, нечего делать со списком папок, где проект мог бы быть создан.

  • GET / — это «Hello World!» из scaffold NestJS (src/app.controller.ts). Он стоит за guard и ничего не раскрывает, но ни для чего не нужен — кандидат на удаление. Он записан здесь, а не удалён, потому что это решение продукта, вне рамок этой сессии.

  • GET /internal/projects/:projectId/git-remote — единственный маршрут продукта, возвращающий РАСШИФРОВАННЫЙ секрет — git-токен owner workspace (ADR 0056). Он существует, потому что engine работает с файловой системой и не имеет мастер-ключа; реплицировать его в engine удвоило бы радиус поражения самого чувствительного секрета продукта. Два свойства делают его защитимым: origin, который он возвращает, чистый (учётные данные приходят в отдельном поле, никогда не встроены в URL), а потребитель обязан внедрять их при каждом вызове, никогда не в файл — см. Engine.Actions.GitAuth и обоснование этого в RN-076. Если этот маршрут когда-нибудь начнёт возвращать уже аутентифицированный URL, токен окажется в .git/config, внутри папки, где у dev agent автоодобренные чтения.

    В режиме runner это внедрение при каждом вызове проходит на один прыжок больше — поле env сообщения exec (RN-507), — и останавливается на границе контейнера: порт Docker продукта не имеет поля env, намеренно (ADR 0130), так что команда, направленная внутрь контейнера через docker exec, никогда не может унести токен. Это ограничение удержалось; не удержалось сообщение об ошибке. До RN-558 runner выполнял эту команду всё равно, с установленным credential helper и пустыми переменными, и получавшаяся ошибка аутентификации была неотличима от плохого токена или сломанной сети — на обычном пути, поскольку зарегистрированный контейнер running, которого требует RN-507, существует только потому, что этот же runner его поднял. Теперь runner отклоняет пару (учётные данные присутствуют, контейнер активен) до выполнения чего-либо, а engine классифицирует отказ с источником politica вместо codigo. Текст отказа не называет ни переменные, ни их значения, только их количество. Учётные данные по-прежнему не пересекают границу контейнера, и начиная с ADR 0193 (RN-676) им это и не нужно: engine помечает аутентифицированный fetch (gitCredenciado: true, выставляется из одного места, RunnerRouter.exec_git_credenciado/5), и runner выполняет ЭТУ команду на хосте, где env доходит до дочернего процесса; команды dev agent по-прежнему идут в docker exec. Граница доверия — пометка, а не env: команда, несущая env БЕЗ пометки, по-прежнему отклоняется, пока есть активный контейнер, так что поле учётных данных никогда не становится выходом из контейнера. Ни один порт ADR 0130 не изменился.

  • Три маршрута чтения PO — GET /internal/projects/:projectId/business-rules, GET /internal/projects/:projectId/backlog (RN-164) и GET /internal/projects/:projectId/product-metrics (RN-407) — не возвращают никакого секрета и не принимают ничего, кроме id проекта: ни поискового термина, ни пагинации, ни фильтра. Это намеренно. Маршрут чтения для агента — это поверхность, которую модель решает вызвать, а параметр — место, куда модель пишет что угодно; здесь писать некуда. Область видимости замкнута на проект через путь, а стоимость вызова постоянна (три чтения в backlog, два в правилах, один запрос к proposed_actions, отфильтрованный по индексу, в метриках продукта).

  • Чтение контрактов dev agent — GET /internal/projects/:projectId/module-contracts (RN-684, ADR 0200) — следует тому же правилу: никаких секретов, ничего, кроме id проекта, одна запись на модуль действующего module_map. Парный маршрут записи, POST /internal/sessions/:sessionId/module-contracts, принимает только декларацию Arquiteto (что каждый модуль предоставляет) и проверяет каждое имя по действующему module_map; ни один из двух маршрутов не касается worktree.

  • POST /internal/projects/:projectId/workspace-verification (RN-423, ADR 0104) вызывается только engine, после того как runner подключается и отправляет workspace_confirm по каналу, — никогда напрямую runner, у которого нет service token. Runner — ИСТОЧНИК ИСТИНЫ о пути (он ПЕРЕЗАПИСЫВАЕТ workspacePath, не требуя совпадения с тем, что было введено при создании), но переданный путь всё равно проходит ТУ ЖЕ лексическую проверку, что и при создании (caminhoDeWorkspaceLocalValido), — корень системы и пересечение с checkout Brabo по-прежнему запрещены, даже если они пришли от runner. 400, если проект не в режиме runner.

    Начиная с ADR 0151 (RN-529) runner может ТАКЖЕ появиться с базой — папкой той машины, под которой каждый проект является подпапкой. Две вещи о ней уместны на этой странице. Во-первых, база локальна и никогда не приходит по сети: она берётся из --base или из $XDG_CONFIG_HOME/brabo/runner.json, никогда не из поля сервера, так что инвариант ADR 0130/ ADR 0144 действует и на этой стороне — корнем владеет тот, кто исполняет, и передаётся только относительный сегмент. resolverPastaDoProjetoNaBase лексически отклоняет АБСОЛЮТНЫЙ сегмент, а не переинтерпретирует его. Во-вторых, база не входит в проверку --dir, точно так же как правило базы находится вне лексического предиката api: проект, папка которого старше базы, продолжает работать. Защита — ТРЕТЬЯ сестра guard.ts, переиспользующая dentroDoEscopo/realpathMaisProximo/semBarraFinal и тот же двойной проход лексика-затем-realpath, — и письменно наследует ту же оговорку о TOCTOU: наилучшее усилие, никогда не граница безопасности.

    Начиная с RN-532 (тот же ADR, пункты 3–6) у этой базы есть ПОТРЕБИТЕЛЬ: пара workspace_create/workspace_create_result. Три вещи о ней уместны на этой странице. Во-первых, ни одного нового маршрута записи не появилось: создав папку, runner отправляет уже существовавший workspace_confirm, и именно он — через этот же endpoint — проставляет workspace_verified_at. Engine по-прежнему не пишет в таблицу, и единственный путь проставления остаётся единственным. Во-вторых, передаётся относительный сегмент, никогда не абсолютный путь, и runner лексически отклоняет абсолютный. В-третьих, возможность workspace — единственная из четырёх, объявление которой зависит от СОСТОЯНИЯ runner, а не от его версии, — она объявляется только когда база была согласована, — так что именно по этому объявлению, и ни по чему другому, сервер узнаёт, что база существует. Никто не ТРЕБУЕТ её при join: runner без базы подключается и обслуживает свой проект как всегда, и отклоняется только workspace_create, с ИМЕНОВАННЫМ ответом. Создание этой папки — согласованная конфигурация, а не агент, просящий разрешения действовать: это не proposed_action, и ни один потолок decide.ts не получает исключения.

  • POST /internal/projects/:projectId/container-exec (RN-492, ADR 0134) вызывается только engine, когда Engine.Actions.TerminalExecutor решил, что команда терминала относится к реальному контейнеру проекта. Он передаёт её в ContainerBrokerPort.exec — api никогда не выполняет команду сама. В отличие от container-spec ниже, здесь ENGINE вызывает api, а не broker; тело ответа никогда не выбрасывает исключение из-за отказа broker или недоступности broker ({ sucesso: false, motivo } — нормальная форма, согласно RN-486, — строка running никогда не гарантирует, что контейнер работает сейчас), так что мёртвый контейнер — это обычная неудавшаяся команда, а не 5xx.

  • POST /internal/projects/:projectId/mirror-sync-result (RN-517, ADR 0147, пункт 7) вызывается только engine, после того как runner отправляет mirror_sync_result по каналу, — никогда напрямую runner, у которого нет service token. Та же форма и тот же путь, что у workspace-verification выше, и по той же причине: runner — единственная сторона, знающая, что произошло на машине пользователя, а engine передаёт дальше, а не пишет в таблицу сам.

    Он записывает ТЕЛЕМЕТРИЮ и ничего больше: строку в project_mirror_states, никогда не event log (у прогона зеркала нет сессии, а session_events.session_id — NOT NULL) и никогда не proposed_action. Тело несёт исход, три счётчика и сообщение об ошибке — ни одного пути, по которому api действовал бы, ничего не исполняется, ничего не предоставляется. Запись никогда не роняет то, что измеряет: копирование уже завершилось, когда это вызывается, а отказ здесь лишь записывается в лог engine.

  • GET /internal/projects/:projectId/container-spec (ADR 0130, RN-485) — единственный маршрут engine-service, вызывающий которого НЕ engine, — это broker контейнеров, единственный процесс продукта с доступом к демону Docker. Классификация называет МЕХАНИЗМ (BRABO_SERVICE_TOKEN в собственном заголовке, сравниваемый за постоянное время), а не отправителя, и секрет намеренно тот же: три сервиса работают в одном кластере и читают один и тот же Secret, так что второй секрет создавал бы впечатление разграничения, ничего не разграничивая (полное обоснование — в service-token.ts). Ценность маршрута — в направлении вызова: broker не ПОЛУЧАЕТ спецификацию контейнера, он приходит сюда ПРОЧИТАТЬ идентичность проекта, режим исполнения и действующее решение Arquiteto об образе и составляет спецификацию сам. Спецификация, путешествующая в теле запроса, поставила бы ограничение процесса, эквивалентного root, в зависимость от правоты вызывающего. Он не возвращает ни одного абсолютного пути — источник bind разрешается демоном относительно файловой системы ХОСТА, так что путь изнутри контейнера api молча смонтировал бы пустую папку. Вместо этого передаётся localizacao (RN-503, ADR 0144): локатор с дискриминатором, говорящий, какой из ДВУХ корней broker разрешает папку (gerenciada → PROJECT_WORKSPACES_HOST_ROOT, montada → BRABO_PROJECTS_HOST_BASE или indisponivel с причиной), плюс ОТНОСИТЕЛЬНЫЙ сегмент, который этот корень не покрывает. Api никогда не узнаёт, где эти корни находятся на хосте, а broker никогда ничего не узнаёт о проекте — ни одна из сторон в одиночку не может записать произвольный путь, и в этом ограничение. Broker отклоняет start, называя отсутствующий корень, никогда не переходя на другой, и повторно проверяет сегмент (.., абсолютный, пустой, NUL — всё отклоняется) плюс конкатенированный результат, прежде чем тот станет -v. Он также отбрасывает rationale, который существует для того, чтобы человек проверял решение, и не имеет потребителя в docker run.

  • GET /internal/projects/:projectId/container-spec получил usuarioDaPasta, а классификация не изменилась — по-прежнему engine-service, по-прежнему вызывается только broker (ADR 0180, RN-627). Поле — это { uid, gid } папки проекта, как её видит api через stat, или null (режим runner, папка недоступна, папка root). Broker повторно его проверяет (целые числа в 1..2^31-1; 0 отклоняется по имени) и использует как --user; он никогда не читает пользователя из тела запроса, так что по-прежнему нет поля, в которое вызывающий мог бы записать privileged, cap_add или монтирование. Он запускает контейнер с МЕНЬШИМИ полномочиями, чем раньше (non-root вместо root), с нетронутым --cap-drop ALL.

  • POST /projects/:projectId/runner-ticket классифицирован role:developer, как любой другой маршрут, но НЕ принимает JWT сессии (ADR 0105, RN-424) — только Personal Access Token (brb_…) ИЛИ ключ устройства runner (Ed25519, см. ниже). Автоматическая классификация (route-surface.spec.ts) не различает механизмы, потому что ТРЕБОВАНИЕ роли то же; меняется лишь то, как устанавливается request.user. PatAuthGuard работает вместо JwtAuthGuard на этом маршруте (@RequirePatAuth(), тот же структурный шаблон, что @ServiceRoute()/EngineServiceGuard, — обход по метаданным, никогда не if, который новый маршрут может забыть), и это ЕДИНСТВЕННОЕ место api, принимающее любой из этих форматов учётных данных: на любом другом маршруте brb_... обычным образом проваливает проверку JWT, как и JWT ключа устройства (его kid никогда не разрешается среди ключей собственного эмитента токенов сессии). Пять маршрутов под /projects/:projectId/personal-access-tokens (выпустить/перечислить/отозвать собственный PAT, плюс два для maintainer — RN-427, перечислить/отозвать PAT ЛЮБОГО пользователя проекта) остаются обычным JWT сессии — механизм меняется только у маршрута, который аутентифицирует САМ ТОКЕН.

  • GET /runner/projects классифицирован jwt и НЕ принимает никакого JWT сессии (RN-543, ADR 0154). Это маршрут, по которому агент МАШИНЫ узнаёт проекты, которые обслуживает, и это первый маршрут @RequirePatAuth() без :projectId в пути — что ровно то, ради чего он существует: вызывающий ещё не знает, какие проекты существуют. Два следствия, оба зафиксированы тестом:

    • Принимаются только учётные данные МАШИНЫ (runner_device_keys.project_id NULL). Учётные данные, привязанные к проекту, — PAT или ключ устройства из потока ADR 0118, — описывают проект и им нечего обнаруживать; PatAuthGuard отклоняет их с 403 и СОБСТВЕННЫМ сообщением, никогда не сообщением «не тот проект», которое солгало бы о причине. Сравнение, отклоняющее ключ-от-A-в-B, НЕ было удалено: оно исчезает только для null, и оба направления зафиксированы.
    • @RequireRole нет, потому что нет проекта, относительно которого разрешать роль. Минимум developer — ТОТ ЖЕ, что у runner-ticket, — применяется ПОСТРОЧНО внутри ListRunnerProjectsUseCase, единым правилом продукта (ResolveEffectiveRoleUseCase.forProject). Ничто не ослаблено: на выходе ровно тот набор проектов, для которых runner-ticket уже авторизовал бы этого пользователя. Автоматический классификатор читает @RequireRole и не видит механизма, поэтому попадает в jwt — то же приближение, которое этот документ уже поправляет прозой для runner-ticket выше. Маршрут никогда не касается JWT сессии: JwtAuthGuard воздерживается на маршрутах @RequirePatAuth(), а открытый ключ, который проверяет PatAuthGuard, берётся из runner_device_keys, никогда не от эмитента сессий.

    Он возвращает id проекта, имя, workspaceDirName и состояние проверки — никогда абсолютный путь. По проводу передаётся СЕГМЕНТ, а корень принадлежит тому, кто исполняет, — тот же инвариант, что у broker (ADR 0144) и у базы runner (ADR 0151).

    Начиная с RN-544 у маршрута есть ПОТРЕБИТЕЛЬ: запущенный без --project, brabo-runner вызывает его в начале и открывает одно подключение на каждый перечисленный проект — а начиная с RN-550, когда этот список приходит пустым, он продолжает вызывать с объявленной периодичностью (15s, 30s, затем 60s с повтором), пока не появится первый проект. У этого ожидания есть потолок со стороны сбоя, а не со стороны ожидания: десять вызовов подряд с ошибкой — и процесс выходит с 1, называя счётчик; любой ответ (в том числе пустой) обнуляет счётчик, а в момент, когда есть живое подключение, ожидание прекращается — при живом подключении список по-прежнему читается только в начале, по причине ниже. Каждое подключение имеет корень в <base>/<workspaceDirName> через те же защиты (resolverPastaDoProjetoNaBase, затем RN-434/RN-435). Три вещи об этом принадлежат этой странице. Во-первых, в engine ничего не изменилось: топик terminal:<projectId>, id сокета и ticket описывают ПОДКЛЮЧЕНИЕ, и N подключений удовлетворяют им байт в байт — отказ второму runner в том же проекте не затронут, как и зеркало (RN-516) и workspace_create (RN-532), которые уже передавались в разрешении join ТОГО подключения. Во-вторых, JWT обнаружения не несёт claim projectId — защита сравнивает payload.projectId !== request.params.projectId, и на маршруте без проекта в пути оба должны быть undefined; подпись там выдуманного id проекта была бы отклонена с 403, говорящим не то. В-третьих, runner никогда не угадывает вид собственного ключа: на диске ключ машины и ключ проекта — один и тот же файл (приватный JWK с kid), знает сервер, и именованный 403 передаётся с собственным сообщением и собственным исправлением (--project), а не сплющивается в общую ошибку подключения. Новый режим требует ОБОИХ — учётных данных машины и согласованной базы: без базы неоткуда вывести папку каждого проекта, а выдумать её означало бы записать на диск пользователя путь, на который он никогда не соглашался; без обоих запуск без --project по-прежнему печатает справку об использовании.

  • Клиент сокета /runner НИКОГДА не может позволить phoenix.js переподключаться самостоятельно (RN-108). Ticket одноразовый, а встроенное автопереподключение повторяет ТЕ ЖЕ params — так что сокет, созданный без reconnectAfterMs, вечно повторяет мёртвый ticket. Это не гипотеза: apps/runner/src/channel.ts вышел таким, пока его собственный docblock утверждал обратное, и это было измерено в реальном использовании — один и тот же ticket отклонялся каждые ~5,13s (потолок внутреннего backoff библиотеки), 61 REFUSED CONNECTION TO EngineWeb.RunnerSocket за несколько часов и, в сумме с СОБСТВЕННОЙ политикой повторов runner, 530 запросов за минуту при потолке 300 req/min RATE_LIMIT_USER. Лимит — на пользователя, так что отказ в обслуживании пришёлся на БРАУЗЕР владельца учётной записи, в виде 429, — неаутентифицированная третья сторона не участвует, но плохо построенного клиента достаточно, чтобы запереть собственного пользователя снаружи. Переподключение — всегда собственная политика вызывающего, с новым ticket при каждой попытке; опция теперь ОБЯЗАТЕЛЬНА в типе (OpcoesDoSocket) и проверяется тестом на опцию, переданную в конструктор, поскольку тест, проверявший только «подключается», всё время проходил.

  • Два маршрута /projects/:projectId/runner-device-keys ЯВЛЯЮТСЯ обычным JWT сессии, в отличие от runner-ticket выше — вызывает экран (раздел ключей устройства в Настройках и RunnerOnboardingPanel, распознающий уже сопряжённую машину). До ADR 0203 (RN-687) был третий, POST, через который браузер регистрировал только что сгенерированный открытый ключ Ed25519 (ADR 0118). Этот поток был выведен из эксплуатации, и маршрут ушёл вместе с ним, потому что другого вызывающего у него не было: ключи теперь рождаются на машине — brabo-runner device-key create (RN-551) генерирует пару и записывает приватную половину на диск, режим 600, в $XDG_CONFIG_HOME/brabo/ (иначе ~/.config/brabo/), — и регистрируются установщиком через внутренний маршрут ниже. Приватная половина по-прежнему никогда не пересекает провод; пересекают только открытый JWK и id записи. Чего у этого CLI намеренно нет — так это учётных данных для регистрации: регистрация остаётся за тем, кто владеет service token (установщик, ADR 0155 пункт 1), так что каждая сторона владеет ровно одним секретом и ни одна не видит секрет другой. Файл, который читает runner, никогда не существует без своего kid: create записывает имя .parcial, которое читатель игнорирует, а finish --id <id> проставляет и переименовывает. Ключи проекта, зарегистрированные браузером ДО ADR 0203, продолжают действовать: PatAuthGuard всё ещё их принимает, GET перечисляет, а DELETE отзывает. GET перечисляет ключи самого вызывающего, включая отозванные (RN-519), — это то, что делает отзыв достижимым, и до его появления осиротевший ключ (вкладка, закрытая посреди потока автоматической настройки) был невидим и вечен. Он никогда не возвращает открытый JWK: список существует для отзыва, а lastUsedAt: null — признак осиротевшего ключа. DELETE отзывает ключ самого вызывающего, идемпотентно, в том же формате, что и самостоятельный отзыв PAT. PatAuthGuard — это то, что ПОТОМ принимает JWT, подписанный приватной половиной этого ключа, в runner-ticket, отыскиваемый по заголовку kid, равному id этой таблицы; защита проверяет, что ключ не отозван, но никогда не срок действия — сам ключ не истекает, истекает только JWT с коротким TTL (≤60s, exp - iat), который runner каждый раз им подписывает. Начиная с RN-543 GET возвращает ДВА вида и ГОВОРИТ, какой есть какой (especie): ключ МАШИНЫ (projectId: null) обслуживает каждый проект своего владельца, поэтому появляется в листинге каждого проекта — без этого он был бы невидим и вечен на каждом экране, тот же дефект, который закрыла RN-519, возродившийся в новом виде. Маршрут, создающий ключи МАШИНЫ, — это POST /internal/machine-device-keys, ниже (RN-552) — другой маршрут, другие учётные данные и другой вызывающий. МАТЕРИАЛ ключа, который он регистрирует, производится на машине: brabo-runner device-key create (RN-551) генерирует пару Ed25519 локально и печатает только открытый JWK, так что приватная половина никогда не путешествует. Начиная с RN-547 install.sh сцепляет оба в конце установки и устанавливает unit машины с полученным ключом (ADR 0155) — именно это делает реальной, а не гипотетической, объявленную цену этого маршрута. Отзыв ключа МАШИНЫ просит engine сбросить живой runner в КАЖДОМ проекте в режиме runner, до которого дотягивается владелец, — один вызов {project, user} на проект, не затрагивая engine.

  • Ключ МАШИНЫ также достижим по УЧЁТНОЙ ЗАПИСИ, без проекта в пути (RN-611): GET /users/me/machine-device-keys и DELETE /users/me/machine-device-keys/:deviceKeyId, классифицированные jwt, потому что область видимости — сам вызывающий, как и у остальных /users/me/*. Они существуют, потому что установка одной строкой создаёт учётную запись и ключ машины ДО какого-либо проекта, а каждый другой листинг требует :projectId: там ключ был жив и недостижим. Они намеренно узкие — только ключи МАШИНЫ (ключ проекта отвечает 404, так же как несуществующий или чужой ключ, так что ничего не утекает) и только ключи ВЫЗЫВАЮЩЕГО (userId берётся из JWT сессии и входит в WHERE; ни один параметр не называет другого пользователя, а представление maintainer остаётся за рамками, как решила RN-519). DELETE — тот же отзыв, что у маршрута по проекту, делегированный тому же сценарию использования: он сбрасывает живые подключения, открытые С этим ключом, в каждом проекте (начиная с RN-685 целью является ключ, а не {project, user}); пока проекта нет, он только регистрирует отзыв — это всё, что можно остановить: ожидающий агент машины (RN-550) не имеет подключения, а его следующий ticket отклоняется.

  • Отзыв ключа устройства теперь достигает ЖИВОГО подключения, и цель — {project, user}, никогда {key} (RN-520, ADR 0147 пункт 6). DELETE блокировал только СЛЕДУЮЩИЙ ticket: уже подключённый brabo-runner держал канал terminal:<projectId> живым, выполняя одобренные команды с отозванным ключом, пока не отваливался сам. Теперь api просит engine (POST /internal/projects/:projectId/runner/disconnect) его сбросить, и engine дотягивается до pid канала — api никогда не говорит с каналом, а engine никогда не читает таблицу ключей. Точности, которой НЕТ, — по учётным данным, и это свойство пути ticket, а не предпочтение: runner_socket_tickets хранит project_id/user_id/kind и ничего больше, так что какой PAT или какой kid открыл тот сокет, умирает в PatAuthGuard и никогда не доходит до engine. Объявленная цена: runner ТОГО ЖЕ пользователя, подключённый с PAT или с другим ключом того же проекта, тоже падает — и переподключается сам, потому что следующий раунд запрашивает новый ticket, а всё ещё действительные учётные данные его получают. Runner другого пользователя в том же проекте остаётся работать. Сброс подключения — ПОБОЧНЫЙ ЭФФЕКТ: недоступный engine, отсутствие подключённого runner или timeout никогда не могут заставить DELETE (204, идемпотентный) упасть или стать 5xx — то же правило, что у rag_searches (RN-479) и mirror_sync_result (RN-517). Пересмотрено RN-685 (ADR 0201): целью теперь является КЛЮЧ. Ticket записывает, какие учётные данные его запросили (kid или id PAT), и отзыв просит engine (POST /internal/runner/disconnect-credential) аннулировать ожидающие tickets этих учётных данных и сбросить только runners, рождённые от них, — объявленная выше цена исчезла: runner того же пользователя с PAT или другим ключом остаётся работать. Отзыв PAT (собственного или в роли maintainer) теперь тоже сбрасывает его подключения. Подключение, открытое с ticket, предшествующим deploy (без учётных данных), по-прежнему падает по паре, а engine, не знающий маршрута, заставляет api вернуться к runner/disconnect; маршрут пары также остаётся целью удаления участника. Представление maintainer, которое есть у PAT (RN-427, перечислить/отозвать ЛЮБОГО пользователя), остаётся ЗА РАМКАМИ для ключей устройства — теперь по решению, а не по упущению: та пара родилась из реагирования на инцидент с циркулирующим ОБЩИМ секретом, а приватная половина ключа устройства никогда не покидает машину, которая её создала. Эта фраза говорила «браузер, который её создал», и RN-551 расширила изготовителя, не ослабив утверждения: терминал теперь второй генератор, а приватная половина по-прежнему никогда не путешествует — выходит открытый JWK. ИЗМЕНИЛОСЬ то, где она лежит: ключ, созданный в браузере, оседает в папке проекта, выбранной пользователем, ключ, созданный CLI, оседает в $XDG_CONFIG_HOME/brabo/ с режимом 600. Ни один из них не достижим для api, и от этого зависит решение выше.

  • Маршруты engine-service не являются «внутренними» по соглашению об именовании. Их защищает EngineServiceGuard, сравнивающий X-Brabo-Service-Token с общим секретом за постоянное время, плюс NetworkPolicy. Префикс /internal — сигнализация для людей. Они находятся вне JWT через @ServiceRoute(): пользовательский токен здесь не работает, а service token не работает ни на каком другом маршруте — два механизма никогда не пересекаются (RN-035).

  • POST /internal/first-account — второй путь, создающий ПОЛЬЗОВАТЕЛЯ, и он работает в production (RN-546, ADR 0155). Объявлен здесь, а не оставлен на обнаружение. Обычная регистрация — первый путь, и она не затронута; этот существует, потому что новая установка одной строкой никого не впускает — сгенерированный .env не содержит переменной e-mail, MAIL_TRANSPORT падает в log, а регистрация ждёт e-mail, который никто не отправляет.

    Он узок по построению, по трём независимым причинам. Он engine-service, так что открывает его BRABO_SERVICE_TOKEN — который сам install.sh сгенерировал и записал с режимом 600, делая его доказательством контроля над МАШИНОЙ, а не над почтовым ящиком. Отклоняет с 409, когда в установке есть ХОТЬ ОДИН пользователь, — условие об установке, а не о запрошенном e-mail, так что его нельзя вызывать повторно с разными адресами, и он навсегда замолкает, как только кто-то существует (в том числе после восстановления миграции, RN-530). И ни одного публичного маршрута не появилось: публичный «создать первого owner» — это гонка между тем, кто установил, и тем, кто просканировал порт, а owner первого workspace — не та роль, которую кто-то вернёт себе через HTTP.

    Учётная запись рождается с уже подтверждённым e-mail, и это единственное, что этот маршрут ослабляет, — для одного именованного случая, никогда для другого пути. Подтверждение доказывает «этот человек контролирует этот почтовый ящик»; тот, кто запускает установщик, уже доказал машину, .env и демон Docker. Пароль вводится в TTY, читается без эха, хэшируется argon2id и отбрасывается: никогда в .env, никогда в маркере, никогда в логе и никогда не генерируется каким-либо кодом.

    Одно окно объявлено, а не закрыто: проверка «есть ли какой-нибудь пользователь?» выполняется внутри транзакции, но READ COMMITTED не мешает двум конкурентным вызовам с разными e-mail пройти оба. Закрытие потребовало бы advisory lock на отсутствие строк, а ограничение живёт не там — тот, кто дотягивается до этого маршрута, уже владеет service token, то есть уже контролирует установку.

  • POST /internal/machine-device-keys ЧЕКАНИТ ДОЛГОВЕЧНЫЕ УЧЁТНЫЕ ДАННЫЕ ПОЛЬЗОВАТЕЛЯ из секрета машины (RN-552, ADR 0155 пункт 4). Это самая острая грань service token, и она объявлена здесь по той же причине, что и сестра выше: следующий шаг установщика после создания первой учётной записи — сопряжение локального агента, а сопряжение требует учётных данных — единственные существовавшие были привязаны к ПРОЕКТУ, в установке, где проектов ещё нет.

    Чего это стоит, сказано прямо: утёкший BRABO_SERVICE_TOKEN теперь может ИЗГОТОВИТЬ ключ устройства, который действует как пользователь против маршрутов с проверкой роли (POST .../runner-ticket, а через него — канал runner), а не только говорить с внутренними маршрутами. Альтернативные учётные данные — данные самого только что созданного пользователя — были рассмотрены и отклонены, потому что противоречат самому ADR 0155: пароль читается в TTY, используется и отбрасывается, а пункт 5 утверждает, что установщик «ни за кого не входит в систему», и именно поэтому ответ первой учётной записи намеренно не несёт токена сессии. Требование их здесь вынудило бы установщик создать на машине ровно ту живую сессию, которую тот ADR отказался создавать.

    Три ограничения живут в МАРШРУТЕ, а не в этом абзаце. В теле нет userId: владелец — ЕДИНСТВЕННЫЙ пользователь установки, разрешаемый api, так что токен никогда не покупает выбор, какие учётные данные чеканить, — при нуле пользователей или более чем одном он отклоняет с 409 и ничего не записывает, та же форма условия, что у первой учётной записи (об установке, никогда об аргументе), и навсегда замолкает, как только у установки появляется команда. Регистрация ЗАМЕЩАЕТ: активные ключи машины владельца отзываются в той же транзакции, так что переустановленная машина — легитимный случай, а тысяча ключей машины невозможна — потолок по условию, а не по числу, которое стареет. Приватный JWK отклоняется по имени: присутствие d отвечает 400 с указанием, что пришло, по тому же доменному правилу, которое использует регистрация браузера, потому что хранение приватной половины — худший исход, который есть у этого маршрута.

    То, что предоставляет ключ, не расширяется: он даёт локальному агенту ровно те проекты, до которых владелец уже дотягивается как developer, разрешённые относительно запрошенного проекта (RN-543). Два пробела объявлены, а не спрятаны: здесь нет ни GET, ни DELETE (перечисление опубликовало бы инвентарь учётных данных человека тому, кто владеет только секретом машины; отзыв уже существует там, где есть владелец-человек), а в установке, где ещё нет ни одного проекта, ни один экран не дотягивается до ключа машины — именно поэтому регистрация замещает, а не оставляет сирот позади.

  • /docs и /docs-json НЕ входят в таблицу, и это пробел известный. Swagger UI монтируется SwaggerModule.setup() на уровне Express, а не как controller, а тест перечисляет через DiscoveryService — он структурно их не видит. Оба существуют только при NODE_ENV !== 'production' (main.ts), публичны и отдают тот же документ, который публикует сгенерированный справочник. Записано здесь, а не оставлено за рамками: то, до чего не дотягивается тест, должно быть в прозе.

  • GET /projects/:projectId/agent-areas начал возвращать реальные данные, а классификация не изменилась — по-прежнему role:developer, тогда как PATCH потолка остаётся role:maintainer. До ФАЗЫ 18 таблица agent_areas никогда не записывалась, и маршрут отвечал [] всем, из-за чего классификация казалась разрешительной случайно, а не по решению. С областью, рождающейся вместе с проектом (RN-094), разделение возвращается к тому, что задумывала ФАЗА 14d: читать потолок — работа того, кто исполняет; менять его — решать, сколько продукт тратит без спроса, и поэтому это требует той же роли, что активирует исполнение.

  • Пять маршрутов /projects/:projectId/rag/* делят роль по тому же критерию, что и потолок параллелизма области (RN-083) (ПРОГРАММА 28, Волна 4 — RN-231..234, ADR 0080): search и coverage — role:viewer (чистое чтение того, что уже проиндексировано), а reindex — role:maintainer: он запускает N вызовов к репозиторию проекта и к провайдеру embedding, то же «меняет то, что продукт тратит, без спроса», которое уже оправдывает более высокую роль на других маршрутах дорогого запуска. local (RN-455, ADR 0113) тоже role:maintainer, по тому же рассуждению: он вызывает провайдер embedding и заменяет то, что у проекта проиндексировано для этой области. Тело — ТЕКСТ, прочитанный браузером, никогда не путь хоста. feedback (RN-480) — role:viewer, та же роль, что у search, и критерий выше объясняет почему: голосование ничего не тратит и ничего не настраивает — это наблюдение, и это единственный сигнал истины, который есть у измерения RAG (medir:rag). Поднять его до maintainer значило бы опустошить этот сигнал, ничего не защитив. Голос по-прежнему ограничен на сервере: searchId другого проекта или chunkId, которого не было среди результатов того поиска, — 400: голос без позиции ничего не измеряет.

  • Четыре маршрута /projects/:projectId/code/* — role:viewer и ТОЛЬКО ДЛЯ ЧТЕНИЯ (ФАЗА 26b). Видеть код проекта — то же разрешение, что видеть проект, — тот же срез, что у GET /projects/:id/git/repository. Три вещи делают эту кажущуюся разрешительность решением, а не небрежностью:

    • в controller нет глагола записи, и быть не может: вкладка Código предназначена для чтения, а запись — внешний эффект, который рождается proposed_action и принадлежит более поздней фазе. @Post в этом файле — изменение фазы, а не маршрута;
    • путь ограничивается в ОДНОМ месте (RN-095), той же центральной проверкой RN-092, — и ограничение здесь важнее роли, потому что в удалённых провайдерах путь становится сегментом URL API провайдера, и ../ меняет endpoint, а не файл;
    • расходуются учётные данные owner workspace (RN-058/RN-082), как и при записи. Чтение стоит rate limit провайдера, и поэтому у поиска есть бюджет: без потолка viewer мог бы раздувать счёт owner сколько угодно.
  • GET /workspaces/:workspaceId/spend-report начал возвращать срез по провайдеру, который является срезом по УЧЁТНЫМ ДАННЫМ (ADR 0076, RN-186/RN-187). Ни одного нового маршрута и никакой смены роли — по-прежнему role:owner, как и было, — но то, что он ПРЕДОСТАВЛЯЕТ, изменилось, и поэтому существует это примечание. ADR 0063 отклонил эту ось ровно по этой причине; 0076 пересматривает его по решению владельца продукта. Теперь границу удерживают ДВА независимых барьера: GET /projects/:projectId/spend/me (role:viewer) не имеет никакого параметра измерения, а ТИП отклоняет комбинацию — область с actor принимает только Exclude<SpendDimension, 'provider'>, так что запрос провайдера в представлении участника не компилируется. Второй барьер слабее прежней гарантии («измерения не существовало»), и поэтому их два.

  • POST /projects/:projectId/sessions/:sessionId/socket-ticket — role:viewer в таблице, но это НИЖНЯЯ граница, а не потолок (RN-108). @RequireRole('viewer') покрывает scope: "heartbeat" — уже существующий сокет heartbeat/живых событий; scope: "terminal" требует developer, проверяемого ВНУТРИ CreateSocketTicketUseCase по request.effectiveRole (тому же, что уже разрешил RolesGuard), потому что минимальная роль зависит от ТЕЛА запроса, а не только от маршрута, — тот же шаблон, что MIN_ROLE_FOR_ACTION_TYPE.terminal в domain/actions/decide.ts. Сегодня ни один реальный путь не запрашивает scope: "terminal" (сокет интерактивного терминала — ФАЗА 25); значение уже рождается правильным на тот момент, когда он появится.

  • POST /projects/:projectId/members — role:maintainer в таблице, и эта роль НЕОБХОДИМА, но не ДОСТАТОЧНА (ADR 0127, RN-472). Это зеркало примечания о socket-ticket выше: там роль таблицы — нижняя граница, и тело может её поднять; здесь роль таблицы — все ворота, которые применяет RolesGuard, а сценарий использования затем отклоняет два движения с 403 даже для того, у кого она есть: понижение того, кто является owner WORKSPACE, и понижение САМОГО СЕБЯ. Ни одного нового маршрута и никакой смены роли (RequireRole('maintainer'), как и было); изменилось то, что классификация перестала быть полным ответом на вопрос «кто что может здесь делать». Причина, по которой это не может жить в RolesGuard, та же, что приводится в примечании о socket-ticket, — решение зависит от ТЕЛА запроса (dto.role) и его ЦЕЛИ (dto.userId), которых guard не видит, — поэтому правило является чистой функцией в domain/iam/tetos-de-rebaixamento.ts, применяемой AddProjectMemberUseCase, по ОБРАЗЦУ абсолютных потолков domain/actions/decide.ts (RN-418): никакого ключа конфигурации, ничего, что могло бы их включить. Защищённый owner — это workspace_members.role, никогда workspaces.created_by. То, чего потолки НЕ покрывают, записано в ADR и в RN.

  • DELETE /projects/:projectId/members/:userId тоже role:maintainer и тоже недостаточен — вторые ворота потолка самопонижения (ADR 0156, RN-556). ADR 0127 оставил этот маршрут без потолка на основании предпосылки, оказавшейся ложной, — «удаление безвредно». Удаление строки project_members не удаляет роль: оно заменяет эффективную, потому что forProject — это projectRole ?? workspaceRole (RN-471). maintainer по строке проекта, являющийся viewer в workspace, понижал себя необратимо через экран — вернуть строку можно через POST :projectId/members, который требует только что оставленного maintainer; без какой-либо роли в workspace падение — до полного отсутствия доступа. Потолок теперь применяется RemoveProjectMemberUseCase, которому пришлось начать получать АКТОРА (маршрут его не передавал, так что ни один потолок не мог быть применён), и правило ПЕРЕИСПОЛЬЗУЕТ ehAutoRebaixamento — remocaoEhAutoRebaixamento делегирует ему с ролью workspace вместо запрошенной, никогда не второе правило. Потолок 1 здесь пары не имеет, по решению, зафиксированному в тесте: owner — вершина ROLE_ORDER, так что удаление строки проекта может только ПОВЫСИТЬ эффективную роль этого пользователя — и именно так отменяется ограничение, создавать которое запрещает потолок 1. Объявленная цена, поскольку у потолка 2 нет порога: самоудаление owner проекта до maintainer workspace обратимо и тоже отклоняется — единственное безвредное движение, меняющее исход, всё ещё достижимое другим maintainer.

  • POST /workspaces/:workspaceId/members — role:owner в таблице, и это тоже перестало быть полным ответом — третий маршрут этого семейства (ADR 0157, RN-557). ADR 0127 назвал этот маршрут тем же классом дефекта на одну область выше, а ADR 0156 оставил его отдельным решением; он был двенадцатистрочным передатчиком, который никогда не получал актора, так что ни один потолок не мог быть применён. Здесь хуже, чем в проекте, по двум причинам, которых там нет: никакой уровень выше не останавливает падение (в проекте понижение самого себя опускает эффективную роль до роли workspace, которая часто удерживает), и в этом controller нет никакого @Delete участника (измерено) — отменить можно только этим же маршрутом, требующим только что оставленного owner. Поэтому AddWorkspaceMemberUseCase теперь получает актора и отклоняет изменение СВОЕЙ СОБСТВЕННОЙ роли с 403, в обоих направлениях. Потолок не считает owners, намеренно: у условия нет числа, которое могло бы устареть (критерий ADR 0127), и оно уже даёт инвариант, ради которого существовал бы подсчёт, — workspace никогда не доходит до нуля owners, поскольку удаление последнего потребовало бы, чтобы этот owner сделал это сам. Потолок 1 не имеет пары в этой области, рассмотрен и не отзеркален: это правило об инверсии иерархии, а @RequireRole('owner') уже делает инверсию невозможной — тот, кто может вызвать, никогда не ниже того, кого он затрагивает. Его добавление, вкупе с отсутствующим маршрутом удаления, сделало бы owner поглощающим состоянием, из которого никто не выходит по HTTP, — тот класс состояния, для устранения которого родился ADR 0127. Понижение другого owner, следовательно, по-прежнему разрешено — единственный способ отозвать владение, обратимый тем же маршрутом любым оставшимся owner.

  • DELETE /workspaces/:workspaceId/members/:userId — role:owner, и это пятые ворота того же семейства (ADR 0173, RN-615). Его не существовало, когда писался ADR 0157, — фраза выше про «никакого @Delete участника» описывает тот момент. Он несёт тот же потолок без нового правила: remocaoEhAutoRebaixamento с ролью workspace как сегодняшней эффективной и никакой как последующей, поскольку никакой уровень выше не останавливает падение, — так что удалить САМОГО СЕБЯ — всегда 403. Это же условие защищает последнего owner, без подсчёта: маршрут вызывает только owner, никто не удаляет сам себя, поэтому каждый успешный вызов оставляет как минимум вызывающего в роли owner. Удаление другого owner разрешено (так владение отзывается полностью), а потолок 1 здесь по-прежнему без пары — @RequireRole('owner') всё так же предотвращает инверсию иерархии. Удаление также каскадируется в одной транзакции: строки project_members цели в этом workspace (без этого правило проект-перекрывает-workspace удерживало бы его внутри каждого проекта, где у него была собственная строка), его ключи устройства ПРОЕКТА и его PAT здесь (каждый PAT принадлежит проекту); после commit его живое подключение runner в каждом проекте сбрасывается, по принципу наилучшего усилия, путём RN-520. Объявлено и не сделано: ключи машины (всей учётной записи; они перестают дотягиваться до этого workspace, потому что роль разрешается в никакую), уже подключённые сокеты сессии. Титульного владельца (workspaces.created_by, чьи учётные данные LLM и git тратят агенты, RN-058) удалить нельзя: 409 criador_do_workspace, пока PUT /workspaces/:workspaceId/owner-of-record (role:owner, получатель уже должен быть owner, иначе 409 titular_precisa_ser_owner) её не передаст — RN-616. Любой owner может её передать, в том числе другому owner, который об этом не просил.

  • GET /workspaces/:workspaceId/members — role:viewer, а не owner трёх соседних маршрутов записи (RN-652, AT-335). Минимум определяется endpoint (RN-102): читать список не значит его вести. viewer — то, что используют соседние чтения (GET /workspaces/:workspaceId, /projects), и то, что использует GET /projects/:projectId/members, — чтение, которое этот маршрут дополняет, поскольку у того, кто входит в проект только по роли workspace, нет строки проекта. Тот, кто может открыть сессию, может видеть, кто в ней говорил, а viewer workspace уже видит каждый проект workspace. Тело имеет ту же форму, что и чтение проекта, — userId, name, email, role — и ничего больше (ни createdAt, ни состояния учётной записи).

  • Само-ПОВЫШЕНИЕ теперь отклоняется на обоих маршрутах членства, что меняет и POST /projects/:projectId/members. ADR 0127 записал его как остающуюся возможность («потолки — о спуске»); ADR 0157 это пересматривает. Обе половины — одно движение — человек в одиночку решает, какими полномочиями обладает, — и половина подъёма единственная, которая повышает привилегии, — ровно то, чего, как мог утверждать ADR 0127, его потолки никогда не делали. Перезапись ТОЙ ЖЕ роли по-прежнему проходит: идемпотентный upsert — не движение. Сравнение оказалось в одном месте: autoMovimentoDoProprioPapel возвращает НАПРАВЛЕНИЕ вместо булева значения (вызывающему оно нужно, чтобы выбрать сообщение), а ehAutoRebaixamento сохраняется как его прочтение, чтобы ворота УДАЛЕНИЯ продолжали видеть только нижнюю половину, как решил ADR 0156.

  • jwt без роли не означает без авторизации. В /users/me/* область видимости — сам пользователь; в GET /workspaces листинг уже отфильтрован по членству вызывающего.

  • X-Brabo-Service-Token теперь редактируется в логе (ADR 0035). Он несёт весь трафик api↔engine и не был в списке redact pino: попав в залогированное тело ошибки, он ушёл бы в Loki открытым текстом и с хранением. Вместе с ним вошли serviceToken, privateKey, encryptedDek и dek. Полный список — в apps/api/src/infrastructure/observability/logger.config.ts, и есть тест, утверждающий каждый путь, — список является контрактом, а не удобством.

  • allowedHeaders CORS задан явно, и список должен содержать каждый заголовок, который отправляет web: Content-Type, Authorization, X-CSRF-Token и traceparent. Отсутствие одного не ломает ни одного теста (ни один тест не делает preflight) и ломает браузер.

  • У engine CORS только на маршрутах здоровья (ADR 0037). /health, /live и /ready возвращают Access-Control-Allow-Origin для источников из WEB_ORIGIN; /internal/* и /metrics — нет, и исключение — в этом суть. 13 внутренних маршрутов — сервер-к-серверу с общим секретом (RN-035); CORS там ничего бы не включил — HTTP-клиент api игнорирует эти заголовки, — но объявил бы браузеру, что он ожидаемый клиент этого канала. Есть тест, утверждающий отсутствие, и тест, утверждающий, что в списке путей ровно три записи, чтобы перемещение границы было видно в diff.

  • Неизвестный источник получает ответ, а не 403. В обоих сервисах запрос обслуживается и уходит без заголовка; блокирует чтение браузер, которому принадлежит решение. Ответ 403 сломал бы каждого клиента, не отправляющего Origin, — probe kubelet, curl, docker/smoke.sh.

  • POST .../delegations — engine-service, как и другие внутренние маршруты (QA Фазы 8b, Infra Фазы 8c — ADR 0038) — lead каждой области регистрирует исход каждого делегата (completed/failed/dispensed) ОТДЕЛЬНО от вызова, которым область сообщает консолидированный результат наружу (gates/verdict в QA, open_infra_pr в Infra). Область видимости — сессия, а не задача: taskId необязателен в теле. Делегирование никогда не видно как handoff.

  • POST /projects/:projectId/execution/activate получил необязательный originSessionId в теле, а классификация не изменилась — по-прежнему role:maintainer (RN-135). Поле позволяет тому, у кого уже есть эта роль, закрыть сессию ЧАТА, породившую запрос, но с двумя ограничениями, не дающими использовать его для закрытия сессии другого человека: findInProject(projectId, originSessionId) молча отклоняет id, не принадлежащий СОБСТВЕННОМУ проекту пути, а закрытие происходит только если GetSessionPendingWorkUseCase (та же защита, что у heartbeat бездействия, RN-073) подтвердит, что там нет висящего handoff, действия или хода. Он никогда не закрывает сессию исполнения, которую сам вызов только что активировал.

  • GET /internal/sessions/:sessionId/pending-work получил aguardandoUsuarioDesde, и ни один маршрут не сменил классификацию (RN-581). По-прежнему engine-service: поле — момент, когда завершился ход разговорного агента, считанный из того же event log, который уже пишет engine, и оно лишь решает, как долго engine держит сессию открытой. Та же RN заставила маршруты сессии, добавляющие разговор (POST .../agents/:agent/message, чат, handoffs, POST .../agents/:agent/start, POST /internal/sessions/:sessionId/events), отвечать 409 sessao_encerrada в терминальной сессии — отказ по СОСТОЯНИЮ, после проверки роли, никогда не новая роль. terminationReason в ответах сессии получил задокументированное значение conversation_idle_timeout.

  • POST /projects/:projectId/execution/activate отклоняет с 409, когда у проекта нет репозитория, а классификация не изменилась — по-прежнему role:maintainer (RN-582, ADR 0165). Отказ происходит до какого-либо эффекта. Сообщение выбирается по handoffs проекта, прочитанным во ВСЕХ его сессиях, — поэтому оно может сказать «примите handoff к Arquiteto/Dev Lead». Оно раскрывает лишь то, что этот handoff существует, и его статус, которые любой viewer проекта уже читает через GET .../sessions/:sessionId/handoffs; оно никогда не называет ни сессию, ни пользователя.

  • Предложение handoff агенту, уже активному в проекте, — 409 agente_ja_ativo, а классификация не изменилась — человеческий маршрут POST .../sessions/:sessionId/handoffs остаётся role:developer, а внутренний — engine-service (RN-635, ADR 0182). В отличие от отказа выше, этот НАЗЫВАЕТ сессию: сообщение говорит, в какой сессии того же проекта получатель активен, потому что это текст, который агент читает как результат инструмента, и указатель, который нужен человеку, чтобы пойти с ним поговорить. Любой viewer проекта уже перечисляет его сессии и читает agent.activated в каждой, так что ничто не пересекает границу проекта. Те же маршруты теперь отвечают ТЕКУЩИМ предложением вместо всегда новой строки (desfecho), а внутренний принимает seAusente — ключ, который может заставить вызов только писать МЕНЬШЕ.

  • Внутренний POST /internal/sessions/:sessionId/handoffs теперь может ПРИНЯТЬ создаваемое им предложение Criativo→PO, а классификация не изменилась — по-прежнему engine-service (RN-658, ADR 0185). Engine не получает права активировать агента: решает api, на основании readiness.confirmed, записанного ЧЕЛОВЕКОМ через POST .../readiness (role:developer, тот же минимум, что и для принятия через карточку), и она записывает этого человека как актора. Engine не может подделать отметку — он никогда не пишет readiness.confirmed, — а предложение должно нести product_brief, рождённый после того клика, в той же сессии. POST .../agents/criativo/validate-necessity сохраняет маршрут и роль, без вызывающего в web (RN-657).

  • GET /projects/:projectId/execution/session — role:viewer, та же роль, что у GET /sessions/:sessionId (RN-139). Возвращает ТЕКУЩУЮ сессию исполнения проекта — active с записанным execution.activated — или null; никогда самую свежую сессию проекта, которую читала вкладка Executores и которая молча меняла сессию в тот момент, когда после неё рождалась другая.

  • GET /workspaces/:workspaceId/projects-summary получил roster.activatedAgents, а классификация не изменилась — по-прежнему role:viewer. Поле — список id агентов с agent.activated в самой свежей сессии проекта (RN-630), вычисленный в том же проходе по сессиям, который уже даёт executionActivated и gatesEverOpened (RN-568). Не называет ни пользователя, ни сессию, ни учётные данные и не добавляет запроса.

  • POST .../llm-turn и POST .../llm-turn-stream получили modelName в теле ответа/финальном кадре, а классификация не изменилась — по-прежнему engine-service, как всегда (RN-146). Имя модели уже разрешалось для вызова провайдера; оно лишь начало путешествовать обратно в engine, который включает его в payload agent.response. Никакие новые данные не читаются, никакие новые учётные данные не раскрываются — это то же имя, что уже фигурирует в token_usage.

  • PUT /projects/:projectId/agent-autonomy начал принимать actionType: "*" — «автоматический режим» (RN-153) — а классификация не изменилась: по-прежнему role:maintainer, как и соседний GET. Разница в том, что тело теперь РАЗРЕШАЕТ, а не в том, кто может вызвать: подстановочный знак предоставляет автономию для ЛЮБОГО типа действия агента сразу, а не по одному типу, как раньше. Разрешение (КОНКРЕТНОЕ правило всегда побеждает подстановочный знак) целиком живёт в репозитории (DrizzleAgentAutonomyRepository.findMode), никогда в decide() — которая по-прежнему получает только уже разрешённую PermissionPolicy, ровно как до появления подстановочного знака. Именно поэтому три абсолютных потолка — merge в защищённую ветку, instruction_patch, parallelize/raise_max_parallel — продолжают блокировать даже при подстановочном знаке в auto_approve (RN-154): они реагируют на current.policy === 'auto_approve', никогда на то, откуда она пришла, и ни одному исключению не пришлось войти в decide(), чтобы это продолжало действовать. ApprovalCard.tsx предлагает кнопку, которая записывает подстановочный знак, только клиенту, который уже знает, что у него maintainer/owner, — но реально роль гарантирует этот же @RequireRole('maintainer'), неизменный.

  • Маршрутизатор инструментов Jev — новый ИСХОДЯЩИЙ вызов, несущий контекст агента (ADR 0179, RN-625). Перед вызовом чата к модели OpenRouter api делает POST на https://openrouter.ai/api/alpha/decisions (Decisions API, альфа) с ключом OpenRouter owner workspace — ТЕМ ЖЕ ключом, который уже тратит чат, расшифрованным в том же сценарии использования, никогда не логируемым и никогда не возвращаемым. Передаётся state: id агента, последнее сообщение пользователя (обрезанное до 6.000 символов), начало системного сообщения (1.500) и до шести недавних вызовов инструментов с аргументами и результатами, обрезанными до 500 символов каждый, — плюс ИМЕНА и ОПИСАНИЯ инструментов, никогда их parameters. Это содержимое, которое вызов чата уже отправляет в ту же учётную запись OpenRouter, в меньшем объёме, но теперь оно доходит до второй модели (typesafe/jev-1.13) за ней. Переключатель — на уровне workspace (PUT /workspaces/:workspaceId/tool-router, role:owner, включён по умолчанию) и срабатывает только с моделью чата OpenRouter. Маршрутизатор сужает меню инструментов и ничего больше: он не может одобрить, отклонить или расширить что-либо, а вызов, который модель делает вне меню, по-прежнему становится Proposed Action под той же политикой. Любой сбой вызова приводит к полному каталогу.

  • POST .../actions/:actionId/approve_always получил desfecho и padraoGravado в ответе, а классификация не изменилась — по-прежнему role:developer (RN-642). Одобрение и записанный шаблон теперь делят одну транзакцию; клик по действию, которое уже было ОДОБРЕНО, отвечает 201 с desfecho: "ja_aprovada" вместо 409, а ОТКЛОНЁННОЕ действие по-прежнему отвечает 409 (acao_ja_recusada) без какой-либо записи. Потолки RN-418 и container_remove по-прежнему отклоняются с 400 до чтения какого-либо состояния, так что идемпотентный путь никогда не записывает шаблон, который потолок запрещает.

  • Ошибка git получила новый именованный ответ: 409 merge_conflict — ни маршрут, ни роль, ни guard не изменились (RN-704). GitProviderErrorFilter отображает GitMergeConflictError (merge локального репозитория обнаружил конфликт) в 409 с конфликтующими файлами в сообщении; целевая ref остаётся нетронутой, а pull request — открытым. Через конвейер одобрения действие git_merge завершается failed с этим сообщением — тот же путь, что у GitPullRequestAlreadyMergedError.

  • GET /projects/:projectId/actions принимает status=failed — тот же маршрут, та же роль developer (RN-705). До сих пор принимался только pending; failed перечисляет действия проекта, исполнение которых упало, и ТРЕБУЕТ actionType (400 без него), чтобы чтение было по одному типу и никогда — всей историей. Возвращает ту же форму ProposedAction, что и чтение ожидающих; упавшее действие git теперь несёт собственный kind, сообщение об ошибке и, при конфликте merge, имена конфликтующих файлов — пути репозитория, которые та же роль уже читает в diff.

Таблица​

métodocaminhoclassificação
GET/.well-known/jwks.jsonpublic
POST/auth/loginpublic
POST/auth/logoutpublic
GET/auth/oauth/:provider/callbackpublic
GET/auth/oauth/:provider/startpublic
POST/auth/refreshpublic
POST/auth/registerpublic
POST/auth/request-password-resetpublic
POST/auth/reset-passwordpublic
POST/auth/verify-emailpublic
GET/gatesjwt
GET/git/oauth/:provider/callbackpublic
GET/healthpublic
GET/livepublic
GET/metricspublic
GET/runner-releases/binarypublic
POST/internal/sessions/:sessionId/actionsengine-service
GET/internal/sessions/:sessionId/anamnese-contextengine-service
POST/internal/sessions/:sessionId/anamnese-windowengine-service
POST/internal/sessions/:sessionId/delegationsengine-service
GET/internal/sessions/:sessionId/dev-contextengine-service
POST/internal/sessions/:sessionId/epicsengine-service
GET/internal/sessions/:sessionId/eventsengine-service
GET/internal/sessions/:sessionId/pending-workengine-service
POST/internal/sessions/:sessionId/eventsengine-service
POST/internal/sessions/:sessionId/gates/verdictengine-service
POST/internal/sessions/:sessionId/handoffsengine-service
POST/internal/sessions/:sessionId/hypothesesengine-service
GET/internal/sessions/:sessionId/infra-artifacts/:prActionId/filesengine-service
GET/internal/sessions/:sessionId/infra-contextengine-service
POST/internal/sessions/:sessionId/infra-gates/verdictengine-service
POST/internal/sessions/:sessionId/instruction-patchesengine-service
POST/internal/sessions/:sessionId/max-parallel-proposalsengine-service
POST/internal/sessions/:sessionId/llm-turnengine-service
POST/internal/sessions/:sessionId/llm-turn-streamengine-service
POST/internal/sessions/:sessionId/c4-diagramengine-service
POST/internal/sessions/:sessionId/module-mapengine-service
POST/internal/sessions/:sessionId/module-routingengine-service
POST/internal/sessions/:sessionId/module-contractsengine-service
POST/internal/sessions/:sessionId/project-imageengine-service
POST/internal/sessions/:sessionId/proficiencyengine-service
POST/internal/models/syncengine-service
GET/internal/graph/prompt-templates/:nameengine-service
POST/internal/graph/prompt-templatesengine-service
POST/internal/rag/searchengine-service
POST/internal/rag/feedbackengine-service
GET/internal/gatesengine-service
GET/internal/projects/:projectId/git-remoteengine-service
GET/internal/projects/:projectId/business-rulesengine-service
GET/internal/projects/:projectId/backlogengine-service
GET/internal/projects/:projectId/product-metricsengine-service
GET/internal/projects/:projectId/module-contractsengine-service
POST/internal/projects/:projectId/workspace-verificationengine-service
POST/internal/projects/:projectId/container-execengine-service
POST/internal/projects/:projectId/mirror-sync-resultengine-service
GET/internal/projects/:projectId/container-specengine-service
POST/internal/first-accountengine-service
POST/internal/machine-device-keysengine-service
GET/internal/sessions/:sessionId/psychologist-contextengine-service
POST/internal/sessions/:sessionId/storiesengine-service
POST/internal/sessions/:sessionId/stories/:storyId/completeengine-service
POST/internal/sessions/:sessionId/stories/:storyId/updateengine-service
POST/internal/sessions/:sessionId/stories/:storyId/archiveengine-service
POST/internal/sessions/:sessionId/semantic-duplicate-checkengine-service
POST/internal/sessions/:sessionId/story-modulesengine-service
POST/internal/sessions/:sessionId/tasksengine-service
POST/internal/sessions/:sessionId/tasks/:taskId/blockengine-service
POST/internal/sessions/:sessionId/tasks/:taskId/unblockengine-service
POST/internal/sessions/:sessionId/tasks/:taskId/gate/openengine-service
POST/internal/sessions/:sessionId/tasks/:taskId/statusengine-service
POST/internal/sessions/:sessionId/tasks/claimengine-service
GET/internal/sessions/:sessionId/tasks/in-progress-by-devengine-service
POST/internal/sessions/:sessionId/terminationengine-service
GET/jwt
GET/runner/projectsjwt
GET/llm/provider-capabilitiesjwt
GET/users/me/credentialsjwt
POST/users/me/credentialsjwt
POST/users/me/credentials/:provider/testjwt
DELETE/users/me/credentials/:providerjwt
POST/users/me/git-credentialsjwt
GET/users/me/machine-device-keysjwt
DELETE/users/me/machine-device-keys/:deviceKeyIdjwt
GET/users/me/preferencesjwt
PATCH/users/me/preferencesjwt
POST/users/me/preferences/detected-languagejwt
GET/workspacesjwt
POST/workspacesjwt
DELETE/projects/:projectIdrole:maintainer
GET/projects/:projectIdrole:viewer
PATCH/projects/:projectIdrole:maintainer
PUT/projects/:projectId/execution-moderole:maintainer
PUT/projects/:projectId/mirror-pathrole:maintainer
GET/projects/:projectId/mirror-staterole:viewer
GET/projects/:projectId/modelsrole:viewer
GET/projects/:projectId/actionsrole:developer
GET/projects/:projectId/agent-autonomyrole:maintainer
PUT/projects/:projectId/agent-autonomyrole:maintainer
DELETE/projects/:projectId/agent-bindings/:agentSlugrole:developer
GET/projects/:projectId/agent-bindings/:agentSlugrole:viewer
PUT/projects/:projectId/agent-bindings/:agentSlugrole:developer
DELETE/projects/:projectId/area-bindings/:areaKeyrole:maintainer
GET/projects/:projectId/area-bindings/:areaKeyrole:viewer
PUT/projects/:projectId/area-bindings/:areaKeyrole:maintainer
GET/projects/:projectId/agent-costsrole:developer
GET/projects/:projectId/agents/:agent/instruction-versionsrole:viewer
POST/projects/:projectId/agents/:agent/instruction-versions/:version/rollbackrole:maintainer
POST/projects/:projectId/anamnese/runrole:maintainer
GET/projects/:projectId/architecturerole:viewer
GET/projects/:projectId/backlogrole:viewer
GET/projects/:projectId/budgetrole:maintainer
PUT/projects/:projectId/budgetrole:maintainer
GET/projects/:projectId/agent-areasrole:developer
PATCH/projects/:projectId/agent-areas/:key/max-parallelrole:maintainer
PUT/projects/:projectId/agent-areas/:key/budgetrole:maintainer
GET/projects/:projectId/code/blamerole:viewer
GET/projects/:projectId/code/branchesrole:viewer
GET/projects/:projectId/code/filerole:viewer
GET/projects/:projectId/code/pull-requestsrole:viewer
GET/projects/:projectId/code/pull-requests/:pullRequestId/diffrole:viewer
GET/projects/:projectId/code/searchrole:viewer
GET/projects/:projectId/code/treerole:viewer
POST/projects/:projectId/rag/searchrole:viewer
POST/projects/:projectId/rag/reindexrole:maintainer
POST/projects/:projectId/rag/localrole:maintainer
POST/projects/:projectId/rag/feedbackrole:viewer
GET/projects/:projectId/rag/coveragerole:viewer
GET/projects/:projectId/containerrole:viewer
GET/projects/:projectId/container/lifecyclerole:viewer
GET/projects/:projectId/coveragerole:viewer
GET/projects/:projectId/events/:eventIdrole:viewer
POST/projects/:projectId/execution/activaterole:maintainer
GET/projects/:projectId/execution/sessionrole:viewer
GET/projects/:projectId/git/:provider/connectrole:maintainer
POST/projects/:projectId/git/:provider/repositoryrole:maintainer
POST/projects/:projectId/git/:provider/repository/adoptrole:maintainer
GET/projects/:projectId/git/bootstraprole:viewer
GET/projects/:projectId/git/bootstrap/planrole:viewer
POST/projects/:projectId/git/bootstrap/plan/approverole:maintainer
POST/projects/:projectId/git/bootstrap/plan/skiprole:maintainer
POST/projects/:projectId/git/bootstrap/acknowledge-protection-failurerole:maintainer
GET/projects/:projectId/git/repositoryrole:viewer
GET/projects/:projectId/hypothesesrole:viewer
POST/projects/:projectId/hypotheses/:hypothesisId/acceptrole:developer
POST/projects/:projectId/hypotheses/:hypothesisId/dismissrole:developer
GET/projects/:projectId/infra-artifactsrole:viewer
GET/projects/:projectId/instruction-versionsrole:viewer
GET/projects/:projectId/membersrole:viewer
POST/projects/:projectId/membersrole:maintainer
DELETE/projects/:projectId/members/:userIdrole:maintainer
GET/projects/:projectId/model-bindingrole:viewer
PUT/projects/:projectId/model-bindingrole:maintainer
GET/projects/:projectId/model-bindings/resolvedrole:viewer
GET/projects/:projectId/permissionsrole:maintainer
PUT/projects/:projectId/permissionsrole:maintainer
POST/projects/:projectId/personal-access-tokensrole:developer
GET/projects/:projectId/personal-access-tokensrole:developer
GET/projects/:projectId/personal-access-tokens/allrole:maintainer
DELETE/projects/:projectId/personal-access-tokens/:tokenIdrole:developer
DELETE/projects/:projectId/personal-access-tokens/:tokenId/adminrole:maintainer
GET/projects/:projectId/open-questionsrole:viewer
POST/projects/:projectId/open-questions/:questionId/answerrole:viewer
GET/projects/:projectId/run-instructionsrole:viewer
GET/projects/:projectId/runner-device-keysrole:developer
DELETE/projects/:projectId/runner-device-keys/:deviceKeyIdrole:developer
GET/projects/:projectId/proficiencyrole:viewer
DELETE/projects/:projectId/proficiency/merole:viewer
POST/projects/:projectId/proficiency/me/opt-inrole:viewer
GET/projects/:projectId/psychologist/analysesrole:viewer
GET/projects/:projectId/psychologist/statusrole:viewer
POST/projects/:projectId/runner-ticketrole:developer
POST/projects/:projectId/terminal-ticketrole:viewer
GET/projects/:projectId/sessionsrole:viewer
POST/projects/:projectId/sessionsrole:developer
GET/projects/:projectId/sessions/:sessionIdrole:viewer
PATCH/projects/:projectId/sessions/:sessionIdrole:developer
GET/projects/:projectId/sessions/:sessionId/actionsrole:developer
POST/projects/:projectId/sessions/:sessionId/actionsrole:developer
POST/projects/:projectId/sessions/:sessionId/actions/:actionId/approverole:developer
POST/projects/:projectId/sessions/:sessionId/actions/:actionId/approve_alwaysrole:developer
POST/projects/:projectId/sessions/:sessionId/actions/:actionId/denyrole:developer
POST/projects/:projectId/sessions/:sessionId/agents/:agent/cancelrole:developer
POST/projects/:projectId/sessions/:sessionId/agents/:agent/messagerole:developer
POST/projects/:projectId/sessions/:sessionId/agents/:agent/messages/:messageId/cancelrole:developer
POST/projects/:projectId/sessions/:sessionId/agents/:agent/startrole:developer
POST/projects/:projectId/sessions/:sessionId/agents/:agent/structured-question/:questionSetId/answerrole:developer
POST/projects/:projectId/sessions/:sessionId/agents/:agentId/rearmrole:developer
POST/projects/:projectId/sessions/:sessionId/agents/arquiteto/handoff-infrarole:developer
POST/projects/:projectId/sessions/:sessionId/agents/criativo/validate-necessityrole:developer
GET/projects/:projectId/sessions/:sessionId/budgetrole:developer
PUT/projects/:projectId/sessions/:sessionId/budgetrole:developer
POST/projects/:projectId/sessions/:sessionId/chatrole:developer
GET/projects/:projectId/sessions/:sessionId/eventsrole:viewer
POST/projects/:projectId/sessions/:sessionId/eventsrole:developer
GET/projects/:projectId/sessions/:sessionId/events/:eventIdrole:viewer
GET/projects/:projectId/sessions/:sessionId/context-summaryrole:viewer
POST/projects/:projectId/sessions/:sessionId/execution/parallelizerole:developer
GET/projects/:projectId/sessions/:sessionId/handoffsrole:viewer
POST/projects/:projectId/sessions/:sessionId/handoffsrole:developer
POST/projects/:projectId/sessions/:sessionId/handoffs/:handoffId/acceptrole:developer
GET/projects/:projectId/sessions/:sessionId/model-bindingrole:viewer
PUT/projects/:projectId/sessions/:sessionId/model-bindingrole:developer
POST/projects/:projectId/sessions/:sessionId/psychologist/reanalyzerole:maintainer
POST/projects/:projectId/sessions/:sessionId/readinessrole:developer
GET/projects/:projectId/sessions/:sessionId/response-languagerole:viewer
PUT/projects/:projectId/sessions/:sessionId/response-languagerole:developer
POST/projects/:projectId/sessions/:sessionId/socket-ticketrole:viewer
POST/projects/:projectId/sessions/:sessionId/tasks/:taskId/unblockrole:developer
GET/projects/:projectId/sessions/:sessionId/token-usagerole:developer
POST/projects/:projectId/sessions/:sessionId/transitionrole:developer
POST/projects/:projectId/sessions/:sessionId/reopenrole:developer
POST/projects/:projectId/tasks/:taskId/gates/:gate/resumerole:developer
GET/projects/:projectId/spend/merole:viewer
POST/projects/:projectId/stories/:storyId/returnrole:developer
PATCH/projects/:projectId/stories/:storyId/titlerole:developer
POST/projects/:projectId/stories/:storyId/archiverole:developer
POST/projects/:projectId/stories/promoterole:developer
DELETE/workspaces/:workspaceIdrole:owner
GET/workspaces/:workspaceIdrole:viewer
PATCH/workspaces/:workspaceIdrole:maintainer
GET/workspaces/:workspaceId/membersrole:viewer
POST/workspaces/:workspaceId/membersrole:owner
DELETE/workspaces/:workspaceId/members/:userIdrole:owner
PUT/workspaces/:workspaceId/owner-of-recordrole:owner
PUT/workspaces/:workspaceId/tool-routerrole:owner
GET/workspaces/:workspaceId/model-bindingrole:viewer
PUT/workspaces/:workspaceId/model-bindingrole:maintainer
GET/workspaces/:workspaceId/credential-spendrole:owner
GET/workspaces/:workspaceId/spend-reportrole:owner
GET/workspaces/:workspaceId/huggingface/modelsrole:maintainer
POST/workspaces/:workspaceId/huggingface/pull-requestsrole:maintainer
POST/workspaces/:workspaceId/huggingface/pull-requests/:id/confirmrole:maintainer
GET/workspaces/:workspaceId/huggingface/pull-requests/:idrole:maintainer
POST/workspaces/:workspaceId/models/activaterole:owner
GET/workspaces/:workspaceId/models/catalogrole:maintainer
POST/workspaces/:workspaceId/models/syncrole:owner
POST/workspaces/:workspaceId/models/usesrole:owner
GET/workspaces/:workspaceId/models/:modelId/price-changesrole:maintainer
PATCH/workspaces/:workspaceId/models/:modelId/pricingrole:owner
GET/workspaces/:workspaceId/projectsrole:viewer
POST/workspaces/:workspaceId/projectsrole:maintainer
GET/workspaces/:workspaceId/projects-baserole:maintainer
GET/workspaces/:workspaceId/project-foldersrole:maintainer
GET/workspaces/:workspaceId/projects-statusrole:viewer
GET/workspaces/:workspaceId/projects-summaryrole:viewer
GET/workspaces/:workspaceId/summaryrole:viewer
POST/workspaces/:workspaceId/unread-eventsrole:viewer
GET/workspaces/:workspaceId/containersrole:viewer