Архитектурные решения
ADR фиксирует, почему что-то было решено, в тот момент, когда это было решено. Они неизменяемы: ни один ADR не редактируется после принятия. Когда решение меняется, появляется новый ADR, заменяющий предыдущий, — так произошло с 0018, который фиксирует замену NoopDevAgent, решённую в 0011.
Практическое следствие: ADR может описывать состояние, которое уже не является текущим. Для текущего состояния используйте Бизнес-правила, Архитектуру и Runbook. За ходом рассуждений — сюда.
Из 61 все приняты, кроме тех, что ещё ждут реализации, — сегодня это 0053, 0054 и 0056, помеченные в самом файле как предложенные. 0055 был принят, когда его реализовали в Фазе F. 0154 и 0155 прошли тот же путь: приняты 2026-09-27, после того как Фаза 30 их испытала, с разделом «notas de aceite», где названо, в чём расходятся текст и код. Никакая проверка эту фразу не сверяет: она устарела, говоря 48, когда их уже было 54, и снова 58, когда было уже 59, — и это аргумент ADR 0029 (gerar > verificar > lembrar), подтверждающийся против самого себя.
Решения сгруппированы по теме — направлению продукта, о котором они решают, — и каждый ADR появляется один раз, под своей основной темой. Тема хранится в docs/adr/temas.yml, рядом с этой страницей, а не в ADR, потому что принятый ADR никогда не редактируется (ADR 0202); pnpm docs:check отклоняет ADR без темы или строку не в том разделе. История по фазам — в истории фаз.
Git, репозиторий и политика веток
Контракт git-провайдера, рождение и усыновление репозитория, защита и политика веток.
| # | решение |
|---|---|
| 0001 | Формат нормализованного контракта GitProvider: девять операций и объявленные capabilities, проверяемые единым контрактным набором тестов |
| 0002 | Нормализация ошибок: шесть конкретных классов, без общего базового класса — абстракция рассматривалась и была отвергнута, потому что пока не окупает собственной стоимости |
| 0003 | Автоматический retry только для чтений, никогда для мутаций, с Full Jitter и 4 попытками |
| 0004 | Регистрация учётных данных git: отдельный enum в базе, объединение с LLM-провайдерами только на уровне типа |
| 0005 | Bootstrap Gitflow в шесть идемпотентных и возобновляемых шагов; skip — это успех |
| 0028 | Защита веток различается между GitHub и GitLab — и поэтому это не шлагбаум; шлагбаум — это домен |
| 0030 | Механизированная политика веток: правило → механизм, логика в тестируемом скрипте, gate обратного распространения и стоимость повторного введения каждого среза |
| 0044 | Усыновление существующего репозитория с origin (created|adopted) в обеих таблицах и ПЛАН как шлагбаум: dry-run — это check() из ADR 0005 без run(), план живёт в курсоре (снимок, а не лог), и ничего не меняется, пока решение пустое — runner Фазы 2 уходит дословно к соавтору и никогда не фильтруется, его просто не вызывают; «расходящаяся защита» остаётся на булевом уровне, потому что только это контракт и раскрывает (ADR 0028) |
| 0056 | Engine работает с удалённым репозиторием. get_local_repo_path/1 возвращает unsupported_provider для всего, что не local, и вместе останавливаются четыре потребителя — worktree разработчика, исполнитель терминала, diff, который оценивают QA и SecOps, и контекст проекта. Корень — асимметрия: api говорит с GitHub по HTTP (создала репозиторий, сделала коммит, создала ветки), а engine работает в файловой системе, зная только локальный bare-репозиторий; удалённый проект проходит разговорную половину и останавливается на половине построения. Именно это мешает ФАЗЕ 13b существовать как запись. Git-обвязка уже универсальна — init + remote add + fetch + checkout всё равно, путь источник или URL, — так что не хватает УЧЁТНЫХ ДАННЫХ. Engine не получает мастер-ключ: он запрашивает рабочий remote у api по уже существующему каналу /internal/*, в тот момент, когда он нужен. И токен НИКОГДА не попадает в .git/config — решение, прямо вытекающее из 0055: записать https://x-access-token:TOKEN@… в origin значит положить секрет открытым текстом ВНУТРЬ папки, где RN-075 только что дала dev agent автоодобренное чтение, — cat .git/config вернул бы токен, который на следующем ходе уехал бы к LLM-провайдеру. Область защищает от чтения наружу, а не от секрета, который продукт сам положил внутрь. origin остаётся с чистым URL, а аутентификация подставляется при каждом вызове. Учётные данные — это данные OWNER (RN-058), local остаётся идентичным, а сбой учётных данных отклоняется с источником infra |
| 0139 | Продвижение — это --no-ff, и это правило нарушали ТРИЖДЫ (#367, #394, #464) — третьим был сам PR, исправивший первые два. Расследование нашло противоположное пробелу в обнаружении: проверка существует с 2026-07-27 и сработала корректно на обоих продвижениях через squash; лог от 2026-08-25 буквально говорит o merge em qa não é merge commit (1 pai). Не хватало АДРЕСАТА — workflow, запускаемый по push, который падает на постоянной ветке, не имеет PR, чтобы покраснеть (merge уже произошёл), поэтому оставляет запуск во вкладке Actions, на который ничто не указывает, а в репозитории за всю его историю было ноль issues. Поэтому tag-release получает job avisar, который открывает issue (и комментирует существующий, а не открывает второй для той же ветки, потому что лавина одинаковых issues — самый быстрый способ научить кого-то их игнорировать), аутентифицируясь через github.token и никогда через PAT — недействительный BRABO_BOT_TOKEN сам по себе один из сбоев, о которых сигнализация должна сообщать, а сигнализация, зависящая от того, что может быть сломано, молчит ровно тогда, когда нужна. Правило распространяется и на dev, УЗКО: dev намеренно получает работу через squash, поэтому «один родитель — дефект» срабатывало бы на каждом PR; дефект там — только PR, принёсший НОВОЕ РЕБРО (его head был merge, чей второй родитель ещё не был в базе), — #464, вся поставка которого и была этим ребром. Безобидный близнец, рабочий PR, втянувший в себя dev, тоже имеет merge в качестве head, но его второй родитель уже в базе. Определение PR-источника фильтрует по merge_commit_sha, а не по .[0]: commits/{sha}/pulls возвращает каждый связанный PR, и поскольку dev — это head PR продвижения, остающегося открытым весь цикл, .[0].head.ref читал бы dev на каждом squash рабочего PR и отклонял бы весь репозиторий, — ошибка, совершённая и пойманная на реальных данных до попадания в код. Отключение squash во всём репозитории было измерено и ОТВЕРГНУТО: шесть последних merge в dev — squash с одним родителем, а changelog.mjs запускает git log --no-merges, так что исправление обложило бы каждый рабочий PR и изменило бы генерацию changelog. Предотвращение остаётся открытым и является продуктовым решением: promote.yml, сам выполняющий merge --no-ff, сохранил бы полномочия, механизировав метод, но требует решить, считается ли запуск человеком «ручным merge» |
| 0165 | Репозиторий проекта создаётся, когда принимается handoff Arquiteto, и исполнение отказывается начинаться без него. Реальная установка v6.1.0 (AT-092) показала, как Arquiteto трижды предлагает open_adr_pr до того, как появился хоть какой-то репозиторий, handoff Dev Lead оставлен в offered, а POST .../execution/activate отвечает 201 в проекте, чьи три git-таблицы остались пустыми навсегда. RN-522 поставило триггер на handoff Dev Lead — ПОСЛЕ двух агентов, пишущих в репозиторий (Arquiteto и Infra Lead, которого он активирует), — и этот триггер можно было пропустить, поскольку активация исполнения его никогда не требовала. Arquiteto может быть активирован только принятым handoff, адресованным ему (от PO или вручную), поэтому триггер переходит в тот же AcceptHandoffUseCase, перед activateAgent, с тем же механизмом (local, сбой как событие). Принятие Dev Lead остаётся второй, идемпотентной дверью — выходом для проектов, прошедших через Arquiteto по старому правилу, и естественной повторной попыткой неудавшегося провижининга, — и идемпотентность доказана обеими дверьми против Postgres и реального локального провайдера. Активация без репозитория становится 409, выбрасываемым до любого эффекта, сообщение которого выбирается по handoff проекта (HandoffRepository.findByProject); Обзор делает кнопку неактивной и один раз текстом говорит почему, а карточка handoff Dev Lead теряет ярлык активации. Объявлено: сессия git-bootstrap становится самой последней сессией проекта на всю фазу Arquiteto |
| 0217 | Пока QA находится в тестовой фазе (QA_FASE_DE_TESTE=true), каждый merge в dev сам открывает и мержит продвижение dev → qa после того, как все проверки завершатся зелёными; лестница одобрения освобождает только эту пару, открытую ботом, а qa → main остаётся ручным |
Harness агента, ходы и сессии
Как работает агент — контекст, цикл инструментов, разговорный ход, сессия и её состояния.
| # | решение |
|---|---|
| 0006 | Детерминированная сборка контекста: упорядоченные слои, бюджет на слой, предсказуемая обрезка |
| 0007 | ToolLoop, каталог инструментов и ContextManager — цикл с настоящим LLM |
| 0061 | Тип сессии — это данные создания, а исполнение остаётся событием. Каждая сессия рождалась одинаковой, и чтобы добраться до Criativo, нужно было сначала её открыть, а ПОТОМ обнаружить кнопку в верхней панели, — так пользователь и сообщил о проблеме. Риск не в новом поле: продукт УЖЕ умел различать исполняющую сессию путём выведения, и findActiveExecutionSession ищет сессию active, несущую execution.activated (именно поэтому повторная активация попадает в сессию, где уже находятся dev agents, находка #11 первого dogfooding). Запись типа создаёт ВТОРОЙ источник, а два источника расходятся — здесь в вопросе о том, где пишут dev agents. Выводить всё из лога не годится, и причина временная: намерение существует ДО любого события, в клике, открывающем сессию, а у творческой сессии, которую никто не активировал, нет ни одного события для выведения. Решение — оба сосуществуют, отвечая на разные вопросы: kind — это НАМЕРЕНИЕ при создании и неизменяем, событие — СОСТОЯНИЕ исполнения, и findActiveExecutionSession НЕ начинает смотреть на kind, — под правилом, не дающим им конфликтовать: execution.activated в сессии consultiva — это 409, никогда не молчаливое преобразование. Блокировка живёт в ВОРОНКЕ (AppendSessionEventUseCase), потому что через неё проходят и маршрут пользователя, и /internal/* engine, а блокировка в одном use case оставила бы другой открытым; и она выполняется до incrementSeq, чтобы отклонённая попытка не расходовала seq. Поле обязательно в каждой точке создания, включая четыре внутренних: необязательное заставило бы вызывающих молча наследовать значение по умолчанию. DEFAULT колонки — consultiva (тип, который может меньше всего), а BACKFILL — criativa, в противоположном направлении и по аргументу 0046: каждая существующая строка старше этого различия, и некоторые из них — сессии, в которых сейчас работают dev agents. Вместе с этим приходят понятное имя, которое ДОБАВЛЯЕТСЯ к хэштегу и никогда его не заменяет (хэштег — то, что вставляют в URL; имя человека не уникально), и выход с экрана сессии, которого не было вовсе. Принятая цена: на вопрос «исполняет ли эта сессия?» теперь есть два места для поиска |
| 0098 | 413 request entity too large в PR имел причину в самой api Brabo, а не у LLM-провайдера (комментарии, винившие провайдера, были ошибочны). Два конца закрываются вместе (RN-412): apps/api/src/main.ts никогда не настраивал лимит тела Express (действовал default 100 KB против 8 MB, которые Phoenix принимает в самом тяжёлом направлении engine→api, POST .../llm-turn) — появился API_JSON_BODY_LIMIT (default 10 MB); а у Engine.Harness.ContextManager.Default сжатие было структурно недостижимо до переполнения — estimate/1 не считал toolCalls (они стоили ~ноль токенов), а окно использовало только context_window (128k, ~350 KB до сжатия). ЭФФЕКТИВНОЕ окно становится min(context_window, teto_de_transporte), с обрезкой всегда на границе итерации ToolLoop, чтобы никогда не отделять assistant с toolCalls от отвечающих на них tool |
| 0163 | Клик, запускающий ход агента, отвечает при ПРИНЯТИИ, а не когда ход заканчивается, и отказ перестаёт быть молчаливым. Измерено на реальной установке v6.1.0 (AT-089): ответ на форму вопросов Criativo занял 97,3 с, подтверждение готовности 97,3 с и подтверждение архитектуры 51,8 с — каждый клик удерживался ВСЕМ ходом, потому что handle_call разговорного агента отвечал на синхронный GenServer.call контроллера engine только тогда, когда Task хода завершалась, а ход сверх лимита 120/180 с превращался в 500 на команде, которая прошла успешно. Та же установка показывает и другую половину: «Continue», набранное во время kickoff Arquiteto, получило {:error, :turno_em_andamento}, которое контроллер отбрасывал перед ответом 202, — принято, никогда не прочитано, без следа. Решение: TurnoAssincrono.iniciar/3 отвечает :ok, как только Task поднимается (после сохранения agent.status: working, никогда до этого), обобщая то, что 0086 делал только для приостановленного Dev Lead, — один механизм для шести разговорных агентов, без второго пути. То, что агент отклоняет ДО начала, остаётся синхронным и получает собственный статус (409 turno_em_andamento/aguardando_aprovacao, 422 sem_regra_de_negocio), передаваемый api с фразой engine. Контракт успеха api НЕ меняется (201 { ok: true } всегда означал «принято»). Экран перестаёт читать «вызов завершился» как «ход закончился» и отслеживает конец по каналу плюс хвост лога. Handoff Dev Lead УДЕРЖИВАЕТСЯ, пока идёт завершающий ход Arquiteto, чтобы по-прежнему приходить после handoff Infra. Объявлено: chat.message, записанное до отказа engine, остаётся в логе (теперь с объяснением), а рестарт engine посреди хода оставляет полосу висеть до «Parar». |
| 0175 | Infra Lead разговаривает через composer и становится СЕДЬМЫМ разговорным агентом (AT-141, решение мейнтейнера). RN-584 оставила вопрос открытым и оценила «да»: весь ход Infra Lead выполнялся ВНУТРИ handle_call/handle_cast — без ответа при принятии, без «Parar», без закрытия осиротевшего хода, с лимитом вызова 180 с ниже 225 с у propose_action контейнера (RN-605), а сообщение, отправленное во время kickoff, ждало в очереди процесса весь ход. Поэтому ход изменился ПЕРВЫМ: kickoff, исправление gate и сообщение все начинаются через Engine.Agents.TurnoAssincrono, тот же модуль, что у остальных шести, — принятие с сохранённым до него working, 409 turno_em_andamento, via_for("infra") для «Parar», TurnoOrfao и toolloop.limit_reached на собственном лимите 14. Консолидация Workflows выполняется внутри Task; исправление gate, приходящее посреди хода, попадает в ОЧЕРЕДЬ и начинается, когда ход закрывается, потому что TurnoAssincrono без from его отбрасывает. Только тогда message/2 получила собственную клаузу для infra (именованный отказ ушёл), а web добавил infra в AGENTES_DE_CHAT, чтобы он был адресатом, когда он — последний активированный агент; его handoff сохраняет собственную карточку (RN-499) и исключается из ленты по имени карточки. Никакого нового внешнего эффекта: те же четыре инструмента, и всё, что имеет эффект, остаётся proposed_action. SOLO_CONVERSATIONAL_AGENTS не меняется — Infra Lead является lead области |
| 0177 | Язык, на котором агенты ОТВЕЧАЮТ, хранится в трёх местах — учётная запись, сессия и проект — как ОТКРЫТЫЙ список кодов BCP-47, никогда в users.locale (AT-162, решения мейнтейнера в AT-168/AT-169). Без enum: каждое место хранит канонический text, проверенный normalizarIdiomaBcp47 (форма через Intl.getCanonicalLocales, подтег языка, известный ICU api, никогда und и не частное использование, не более 35 символов), потому что открытие языка ответа не требует файла ресурсов, а enum потребовал бы миграции на каждый язык. Учётная запись: users.response_language (NULL — автоматический, как начинается любая учётная запись) и пара detected_language/detected_language_confirmed_at, связанная CHECK, — обнаружение учитывается только после подтверждения (записывается AT-163). Сессия: session_language_overrides, с ключом по паре {session, user}, конфигурация, а не событие. Проект: projects.language (AT-243, RN-619). Приоритет для хода с автором-человеком, по ЧЕЛОВЕКУ: override сессии > выбор учётной записи > подтверждённое обнаружение > users.locale, так что цепочка всегда заканчивается языком; ResolverIdiomaDaRespostaUseCase возвращает победителя, источник и цепочку. Объявлено: ни один ход пока не меняется — доставка до модели — это AT-164 |
| 0179 | Цикл агента спрашивает у Jev, какой инструмент подходит на каждом шаге, и чат-модель пользователя получает только оставшееся меню (AT-238, HS-063, решения мейнтейнера в AT-236 и от 2026-09-29). СОБСТВЕННЫЙ маршрутизатор, ToolRouter, вне контракта провайдера из ADR 0041 и вне каталога models: он действует только с чат-моделью OpenRouter и двумя или более инструментами и только ОГРАНИЧИВАЕТ (политика P3: {выбор Jev, предыдущий инструмент того же выполнения}; responder_sem_ferramenta или отсутствие предыдущего инструмента оставляет весь каталог). Критерий принятия был снижен владельцем с 90% до 80%, с цифрами перед ним (покрытие меню 91%, 85,1–94%; top-1 у Jev 73%); реальная проверка — AT-239. Любой сбой — HTTP, таймаут (2.000 мс), неожиданный JSON, выбор вне вариантов — откатывается на весь каталог с названной причиной, и ход никогда не падает из-за Jev. Jev не одобряет, не отклоняет и не выбирает модель. Его стоимость идёт в token_usage как РЕАЛЬНЫЙ usage.cost с НЕЯВНОЙ ценой (cost ÷ tokens, price_implicit), на актора самого агента, чтобы входить в бюджет области; выключатель — на уровне workspace (миграция 0066); из неверного меню выходят, повторив шаг один раз со всем каталогом. |
| 0183 | ЗАКРЫТУЮ сессию можно ПЕРЕОТКРЫТЬ (AT-071): один выход из терминальных состояний, только в active, через собственный маршрут (POST .../sessions/:id/reopen) и собственную проверку машины состояний (canReopen) — общий переход по-прежнему отклоняет closed → active, а closing → active остаётся запрещённым. Строка возвращается в active с очищенными closed_at/termination_reason, а НОВОЕ событие session.reopened, в том же seq, хранит предыдущее закрытие; ни одно событие не редактируется, kind не трогается. Три ВРЕМЕННЫХ значения по умолчанию ждут владельца: минимум maintainer, без срока, и сессия с execution.activated отклоняется с 409 sessao_com_execucao, чтобы findActiveExecutionSession не уводился в сторону. SessionLifecycleWorker engine игнорирует закрытие, которое переоткрытие уже отменило; второй автоматический анализ Psicólogo по-прежнему заблокирован alreadyAnalyzed |
| 0184 | Владелец отвечает на три ВРЕМЕННЫХ значения по умолчанию из ADR 0183 (AT-337): переоткрытие закрытой сессии требует developer — той же роли, что закрывает её общим переходом, — вместо maintainer, при этом viewer по-прежнему получает 403; отсутствие срока и отказ 409 sessao_com_execucao для сессии с execution.activated ПОДТВЕРЖДЕНЫ. ADR 0183 не редактируется |
| 0191 | Сообщение, приходящее, пока разговорный агент находится посреди хода, больше не отклоняется с 409 turno_em_andamento (AT-267, решение владельца от 01.10, пересматривающее ADR 0163 только для СООБЩЕНИЙ): оно попадает в ОЧЕРЕДЬ агента в TurnoAssincrono — одна очередь для всех семи, серверы лишь говорят, как сообщение становится ходом, — и когда ход заканчивается, ожидающие сообщения читаются ВМЕСТЕ, по порядку, в ОДНОМ ходе. Состояние живёт в event log (chat.message_queued/_delivered/_cancelled, все несут id chat.message), никогда в таблице: init/1 восстанавливает очередь, а boot будит тех, у кого есть ожидающие. Лимит 10 на агента и сессию (409 fila_de_mensagens_cheia); тот, кто ОТПРАВИЛ ожидающее сообщение, может его отменить. Dev Lead, приостановленный для одобрения, продолжает отказывать; команды, не являющиеся сообщениями, сохраняют turno_em_andamento |
Команда агентов — роли, области и handoff
Какие агенты существуют, что каждый передаёт кому, делегирование внутри области и handoff между ними.
| # | решение |
|---|---|
| 0008 | Разговорный Criativo (чат перестаёт быть stateless-эхом) и фундамент handoff |
| 0009 | PO, бэклог в трёх уровнях и трассируемость правило → история → task; готовность становится доменной валидацией |
| 0010 | Arquiteto: ADR через настоящий PR, module_map и перекрёстная валидация |
| 0014 | Предлагающий InfraAgent (предлагает через PR, никогда не применяет) и живая панель команды через каналы Phoenix |
| 0015 | Настоящий Psicólogo: гипотезы с доказательствами, указывающими на реальные события, и лёгкая/тяжёлая сортировка по стоимости |
| 0016 | Anamnese: закрытый каталог компетенций, версионируемые патчи инструкций с откатом и потолок, по которому патч никогда не автоодобряется |
| 0022 | Доказательство, которое доходит, стоимость, которая видна, анализ, переживающий kill; причина завершения как детерминированная классификация |
| 0023 | Каталог, который работает, дедупликация, различающая, кто отказал, и очередь, переживающая раунд |
| 0038 | Область, lead и делегирование: внешний handoff адресует только lead или агента без области, консолидация в один артефакт без изменения внешнего контракта gates, каскадный бюджет с подлимитом на делегирование, типизированный источник сбоя, ретрофитированный во всю систему, которая уже неформально классифицировала сбои |
| 0046 | Продвижение истории снова принадлежит пользователю: proposed_ready — булево, а не новое значение enum, потому что enum И ЕСТЬ шлагбаум claimNext; продвижение переиспользует TransitionStoryUseCase (мёртвый код из находки #13) и бесплатно наследует task.became_claimable из 12b; валидация объединена в assertPromotable ДО того, как триггер станет настраиваемым, иначе два режима были бы разными правилами с одним именем; НАПРАВЛЕННЫЙ backfill (существующий проект уходит в auto), потому что поведение использовалось; отказ записывается до обращения к engine — в обратном порядке по сравнению с rearm, потому что событие утверждает что-то о ПОЛЬЗОВАТЕЛЕ, а не об engine |
| 0053 | Dev Lead как область и параллелизм, разрешённый пользователем. Сегодня поднимается один dev на модуль плюс ещё один на каждое принятие в один клик, без всякого лимита на сессию: N модулей × 2 агента, и расходы растут без чьего-либо разрешения. 14d решает, что оценивает, сколько агентов имеет смысл, LEAD, и что превышение двух в сессии становится proposed_action через тот же конвейер, что и любое действие с внешним эффектом. Dev Lead не существовал и был запрещён вместе с динамическими областями через module_map и общим аппаратом областей — три среза падают вместе, потому что члены области dev — по одному на модуль module_map, решаемые Arquiteto и разные в каждом проекте: их невозможно захардкодить, как qa и infra. agent_areas/agent_area_members вводятся на проект, с max_parallel по умолчанию 2, настраиваемым на lead; внешний контракт gates не меняется, и критерий приёмки — зелёный без изменений набор тестов Фазы 4. Anamnese предлагает поднять лимит, когда разрешение становится повторяющимся, и никогда не поднимает его сама — автоматизация означала бы, что продукт сам поднимает собственный лимит расходов |
| 0068 | Диаграмма C4 (Context + Container, Simon Brown) от Arquiteto, отображаемая в Обзоре. artifact.c4_diagram без таблицы, тот же дизайн, что у artifact.project_image (ADR 0065): уровень Container ВЫВОДИТСЯ use case из действующего module_map (никогда не перепечатывается моделью — это исключает молчаливое расхождение между диаграммой и реальной картой), и только Context (имя системы + внешние акторы) приходит из вызова инструмента create_c4_diagram. mermaid входит как новая RUNTIME-зависимость apps/web (решение пользователя) — первая такого рода; изолирована за lib/mermaid-render.ts с динамическим import(), и vite build подтвердил, что жадно загружается только entrypoint, а тяжёлые чанки Mermaid (cytoscape.esm 435 KB, katex 258 KB, используемые только ДРУГИМИ типами диаграмм) остаются по требованию. Закрытый CSP из ADR 0058 подтверждён нетронутым: ни один чанк Mermaid не использует eval/new Function, а style-src 'unsafe-inline' уже покрывал <style>, который SVG может встраивать |
| 0069 | Условное слияние принятия handoff с активацией исполнения. У карточки Dev Lead было два клика, потому что это две намеренно разные авторизации (RN-137): принятие требует developer, активация требует maintainer, потому что тот, кто активирует, становится session.createdBy и определяет ЭФФЕКТИВНУЮ роль PR, которые затем открывают dev agents. Запрос не снижает это требование — для того, кто уже maintainer/owner (читается из того же useCurrentWorkspaceWithRole(), который уже авторизует «Auto mode» и Одобрения/Настройки), второй клик ничего не защищал, лишь повторял решение, которое роль уже разрешала; handleAcceptHandoff начинает цепочкой вызывать ТУ ЖЕ handleActivateExecution, которую уже вызывает кнопка «Ativar execução» (RN-137), переиспользуя её обработку ошибок, чтобы не путать «принятие не удалось» с «активация не удалась после успешного принятия». Backend не тронут — RequireRole('maintainer') остаётся прежним, это решение только apps/web; developer сохраняет сегодняшний поток с двумя раздельными кнопками |
| 0085 | docs/fluxo.yml фиксирует ОТНОШЕНИЯ между ролями команды агентов — кто что кому передаёт, — третья часть рядом с каталогом (agent-areas.ts, ФАЗА 18) и контролем (docs/gates.yml, ADR 0054), которые до сих пор существовали лишь неявно в промптах и use case. Контракты по ролям (миссия, типизированные входы/выходы, выходной gate со ссылкой на gates.yml) со статусом active/planned/proposto/em-refinamento; роль proposto ОБЪЯВЛЯЕТ, кто её поглощает сегодня, и объективный критерий разделения, чтобы целевая оргструктура стала последовательностью активации, а не стремлением. docs/.docmap.yml получает правило modelo-de-time в warn — для повышения до block не хватает теста перекрёстной проверки fluxo.yml × gates.yml × agent-areas.ts (бэклог) |
| 0086 | План исполнения Dev Lead становится настоящим proposed_action, пересматривая часть 0053 — аудит docs/fluxo.yml × код обнаружил, что поток уже объявлял via: proposed_action для этого выхода, а код так и не был приведён в соответствие. Прямой прецедент: 0052 и 0057, но оба запускаются через cast и никогда не ждали синхронного ответа — Dev Lead стал ПЕРВЫМ разговорным агентом (GenServer.call до 180 с), который приостанавливается. TurnoAssincrono.tratar_resultado/2 получает ветку: state с ключом :aguardando_aprovacao (значение не-nil, а не просто присутствует — Dev Lead несёт его с init/1) отвечает from так же и в тот же момент, но эмитирует только agent.status: awaiting_approval вместо agent.done. LiveBroadcast.agent_status/4 расширил проверку, чтобы принимать новый статус. DevLeadServer подписывается на Engine.Dev.Wake по собственному id (тот же модуль, который QaLeadServer уже переиспользует для субагентов QA) и возобновляется через {:action_settled, ...}; второе user_message во время ожидания отклоняется с agent.error (источник politica), без нового хода. propose_execution_plan был РАССМОТРЕН и оставлен вне блока абсолютных потолков decide.ts — в отличие от parallelize/raise_max_parallel, его можно настроить на автоодобрение. Принятый и объявленный пробел: без таблицы состояния рестарт во время приостановки теряет подписку на Wake — решение остаётся записанным и видимым в Одобрениях, но Dev Lead не рассказывает об исходе сам |
| 0087 | UX Designer входит как пятый разговорный агент (Criativo, PO, Arquiteto, Dev Lead и теперь он), SOLO и без области, ОПЕРЕЖАЯ триггер разделения, который docs/fluxo.yml (ADR 0085) всегда объявлял для этой роли («когда УПРАВЛЯЕМЫЙ проект будет иметь собственный интерфейс для проектирования»), — сознательное решение владельца продукта, а не ошибка прочтения критерия. UxDesignerServer повторяет DevLeadServer (лимит 14, ограниченный цикл); kickoff читает artifact.product_brief от Criativo, а дизайн-система (design/tokens.css/COMPONENTS.md) ОПИСАНА в идентичности — у разговорных агентов нет инструмента чтения репозитория. Единственный инструмент, propose_prototype, записывает artifact.prototipo_navegavel БЕЗ отдельного use case в api (в отличие от project_image/c4_diagram, которым он нужен из-за ВЫВОДИМОГО содержимого или общего доменного отказа — ни то, ни другое здесь не применимо): валидация формы в engine (ArtifactSchemas) и запись через общий append_event_returning. Один артефакт, два handoff на его основе (для «po» и «dev-lead») — никогда второй артефакт для «spec-visual», который И ЕСТЬ тот же прототип. Переиспользует ту половину дизайна ADR 0086, которая выживает без его приостановки: успех завершает ход, чтобы не дублировать предложения |
| 0088 | Staff (docs/fluxo.yml, camada_decisao_tecnica, status: planned с 0085) получает КОД раньше, чем появляется его автоматический триггер, — сознательное решение владельца продукта, знающего, что Anamnese (которая запускала бы handoff по «повторяющейся системной проблеме») поставлена на паузу (ANAMNESE_ENABLED=false). Шестой solo-разговорный агент, повторяющий Arquiteto (GenServer на сессию, ограниченный цикл с лимитом 14), с двумя отличиями: БЕЗ kickoff/1 (нет артефакта сессии для резюмирования — он простаивает до первого user_message) и активация ОБЩИМ путём canActivateAgent (handoff accepted, адресованный «staff»), без попадания в USER_STARTED_AGENTS — расследование подтвердило, что никаких доменных изменений в api не требовалось, потому что assertHandoffTargetAllowed отклоняет только субагента области, а у Staff области нет. Единственный инструмент, propose_rfc (проблема, варианты с компромиссами, рекомендация, одноразовый PoC), записывает artifact.rfc_staff НАПРЯМУЮ через append_event_returning — тот же бестабличный шаблон, что у emit_insight, а не у artifact.c4_diagram (0068), потому что RFC ничего не выводит на стороне api, — и возвращает handoff Arquiteto в ТОМ ЖЕ вызове инструмента, без proposed_action (запись документа — не внешний эффект). Объявлено, не скрыто: SessionPage.tsx/AGENTES_DE_CHAT не трогались — тот же уже принятый шаблон, что для infra, настоящего lead, который тоже остаётся вне этого списка |
| 0089 | analytics и delivery-metricas (docs/fluxo.yml, status: proposto) становятся status: active как СКРИПТ отчёта — решение владельца продукта опередить обе роли, не дожидаясь органического триггера, который поток уже объявлял. apps/api/scripts/analise-funil.ts — единственный новый артефакт, в ТОМ ЖЕ формате, что medir-execucao.ts (Фаза 13b): чистое чтение через Drizzle, ноль записи, без GenServer, без LLM-агента — форма, которую docs/fluxo.yml уже предписывал («никогда не становится агентом» для delivery-metricas). Измеряет реальную воронку (сессия → коммит → PR → merge, считая СЕССИИ по этапам, только действия executed), реальный lead time (от первого коммита до первого merge, по updated_at исполнения, а не предложения) и реальную deployment frequency (merge в ветку PROTECTED_BRANCHES, сопоставляя по ссылке с gate backmerge). Три метрики намеренно остаются ОБЪЯВЛЕННЫМИ отсутствующими: полная продуктовая воронка идея → коммит (sessions без storyId, RN-230), свидетельство принятия по фичам (Brabo не инструментирует то, что строит, — сегодня нет пути, а не только данных) и MTTR/change failure rate (зависят от сигнала реального инцидента, та же зависимость, что у secops-runtime/platform) |
| 0092 | platform (docs/fluxo.yml, status: planned, активация синхронизирована с DEPLOY_ENABLED — которого не существует) рождается как СКРИПТ отчёта по требованию, а не LLM-агент и не GenServer: pnpm --filter api relatorio:telemetria [--projeto <uuid>] задаёт ТЕ ЖЕ вопросы, которые DomainGaugesCollector уже собирает каждые METRICS_GAUGE_INTERVAL_MS для scrape Prometheus, — активные/closing сессии по проекту, заблокированные tasks по проекту, состояние последнего бэкапа (всегда ГЛОБАЛЬНОЕ, никогда по проекту), — но как РАЗОВОЕ, отдельное чтение, которое завершается после печати. SQL-запросы скопированы, а не импортированы из коллектора: его методы приватные и заканчиваются записью в gauge Prometheus, без чистой половины «только запрос», которую можно было бы переиспользовать, не привязывая CLI-скрипт к жизненному циклу @Injectable. Вывод всегда ссылается на то, что уже версионировано (дашборды, алерты, runbook, pnpm dev:obs), вместо дублирования, и объявляет — никогда не выдумывает — то, что он НЕ измеряет: формальный числовой SLO (ни одного не определено), postmortem (нет реального инцидента) и телеметрию обратно в продукт в замкнутом цикле (именно это сделало бы platform active, а триггер по-прежнему отсутствует). status: planned и gate_saida: operavel роли не меняются; docs/fluxo.yml лишь получает nota в выходе telemetria-consolidada, говорящую, что ручная версия уже реальна |
| 0093 | Роль dbre (docs/fluxo.yml, ADR 0085) становится двумя механическими скриптами, введёнными досрочно по решению владельца продукта, не дожидаясь триггера («реальный объём данных»), — ни LLM-агентом (нет суждений на естественном языке), ни GenServer (нет ни цикла, ни долгоживущего состояния), потому что ни один из двух результатов этого не требует: lint-migracao.ts — СТАТИЧЕСКИЙ анализ текста SQL (DROP TABLE/TRUNCATE/DROP COLUMN/ALTER COLUMN ... TYPE/ADD COLUMN ... NOT NULL без DEFAULT, чистая логика, отделённая от адаптера ввода-вывода, тот же дизайн, что у pr-police.ts), а relatorio-backup.ts лишь ЧИТАЕТ backup_runs с той же логикой, что у DomainGaugesCollector.collectBackup(), оформленной как отчёт по требованию, ссылаясь на уже протестированное восстановление (docs/runbook.md#restore) вместо его повторного выполнения. Линтер просматривает ВЕСЬ репозиторий (а не diff одного PR) и поэтому НЕ вошёл в CI — запуск сегодня находит три вхождения в уже смерженных и принятых миграциях (0006, 0007, 0034), а gate, отклоняющий весь репозиторий, отклонял бы каждый PR навсегда; чтобы стать настоящим gate, он должен ограничиться diff, как уже делает pr-police.ts, — на случай, когда dbre понадобится БЛОКИРОВАТЬ, а не только высказываться. plano-de-capacidade/tuning остаются ПРОБЕЛОМ, объявленным: они зависят от реального объёма данных, а симулировать без нагрузки значило бы выдумывать числа (та же мерка, что у 0042/0077) |
| 0094 | Делегирование Dev Lead → dev-<modulo> становится ДАННЫМИ в delegations (area: 'dev'), закрывая находку B1 аудита fluxo.yml × код и отменяя срез, объявленный в 0053, пункт 5. Записывается на стороне API, внутри AcceptParallelizationUseCase.execute (бесплатно покрывает оба пути — прямой и одобренный, — потому что оба уже сходятся там), в отличие от QA/Infra, которые пишут на стороне engine, потому что именно там субагент выдаёт заключение. status: 'completed' ПЕРЕОПРЕДЕЛЁН для этой области: он означает «делегирование ОСУЩЕСТВЛЕНО» (агент поднялся), а не «заключение выдано», — а parecerArtifactId указывает на id самого свежего события artifact.module_map проекта (через SessionEventRepository.listByTypeForProject, уже существующий метод, переиспользованный без обобщения), артефакт, обосновавший решение делегировать. Без module_map в проекте: НЕ записывает с поддельным id, лишь логирует неожиданное состояние (RN-059) и продолжает — а сбой в RecordDelegationUseCase никогда не роняет активацию, которая уже успешна, когда запись предпринимается |
| 0109 | Ручной handoff выбранному агенту, закрывающий пункт бэклога, который ADR 0088 назвал точно («общий UI остаётся в бэклоге»). Без третьего состояния handoff: CreateHandoffUseCase (единственный писатель toAgent, ADR 0038) получает необязательный actor?: Actor, чтобы handoff.offered, инициированный человеком, записывал {kind:'user'} вместо стандартного {kind:'agent'}, — существующий путь принятия/активации подхватывает его без изменений. addressableAgents() (leads ∪ SOLO_CONVERSATIONAL_AGENTS) строже, чем assertHandoffTargetAllowed: человек, выбирающий в выпадающем списке, получает закрытый каталог, а не просто «не субагент». Исследование перед написанием кода нашло ux-designer (ADR 0087) ровно в той же ситуации достижимости только через внутренний маршрут, что и Staff, — оба вошли вместе в AGENTES_DE_CHAT, без изменений в engine ни для одного из них |
| 0182 | ПРЕДЛОЖЕНИЕ handoff получает жизненный цикл: статус superseded (миграция 0064) с неизменяемым событием handoff.superseded и не более ОДНОГО offered на (проект, адресат) — та же сессия без нового артефакта возвращает существующее предложение, новый артефакт или другая сессия его заменяет (AT-291/292). Конкурентность через advisory lock с областью транзакции по адресату, а не через частичный уникальный индекс, чтобы уже существующие в таблице дубликаты сходились С событием, а не переписывались миграцией. Активация агента любым путём заменяет предложения ему; предложение агенту, уже активному в проекте, — это 409 agente_ja_ativo, сообщение которого и читает модель; «arquitetura pronta» и AppSec (seAusente, RN-636) перестают дублировать предложения |
| 0185 | ОДИН клик — «Estou pronto — a necessidade está validada» — закрывает готовность, gate necessidade-validada и принятие PO (AT-311/312), пересматривая ОТДЕЛЬНЫЙ клик из ADR 0095 по решению владельца. Тот же POST записывает readiness.confirmed (с пометкой) и, когда engine принимает ход, necessity.validated с productBriefId: null (brief приходит из хода, который клик запускает); gate остаётся warn. Предложение Criativo→PO, несущее brief, родившийся после этого клика, принимается во внутреннем маршруте, который его создаёт, тем же AcceptHandoffUseCase: те же события, человек как актор и метка implicito в handoff.accepted/agent.activated. Неудавшееся неявное принятие становится долговечным agent.error; engine не меняется |
| 0186 | Handoff от PO к Arquiteto принимается СИСТЕМОЙ, без клика, когда бэклог ПОКРЫТ (хотя бы одно бизнес-правило и ни одного без ссылающейся на него истории — та же computeCoverage, что во вкладке Backlog), репозиторий, который затрагивает принятие, — local без учётных данных git в проекте, а открывший сессию по-прежнему имеет developer (AT-314). Он опирается на ADR 0165, не редактируя его: провижининг остаётся в AcceptHandoffUseCase, перед activateAgent; новый use case лишь читает и решает и записывает handoff.accepted с системным актором handoff-auto-accept и критерием в payload. Сбой после решения становится handoff.auto_accept_failed; удалённый репозиторий или учётные данные git по-прежнему требуют клика |
| 0194 | ОДОБРЕНИЕ propose_execution_plan от Dev Lead — вот что активирует исполнение, а принятие handoff Dev Lead лишь приводит его ПЛАНИРОВАТЬ (AT-263, решение владельца от 01.10, пересматривающее слияние из ADR 0069, RN-161). План получает исполнителя в api, который перечитывает его против действующего module_map, записывает МОДУЛЬ каждой задачи и вызывает ТОТ ЖЕ ActivateExecutionUseCase, что и кнопка, так что 409 без репозитория, 409 консультативной сессии и findActiveExecutionSession остаются как были. Задачи получают колонку module (миграция 0068), назначаемую Dev Lead в плане (tarefas: [{ taskId, modulo }], AT-274); задача без модуля или с модулем вне module_map отклоняется в предложении с именованным 400. Её берёт только dev этого модуля; задачу без модуля можно взять, только когда у её истории ровно один модуль |
| 0212 | Историю можно АРХИВИРОВАТЬ, и это колонка — stories.archived_at/archived_reason (миграция 0069), — а не новое значение story_status, потому что статус — шлагбаум claim, а архивирование — не шаг прогресса (AT-406, решение владельца от 03.10 «PO и пользователь»). Каждое чтение, которое РЕШАЕТ (бэклог, покрытие, очередь продвижения, план Dev Lead, claim), фильтрует archived_at IS NULL, и задачи уходят вместе с историей; ничего не удаляется, и это фиксируют новые неизменяемые события backlog.story_archived/backlog.story_updated. Одна мерка для инструмента PO и вкладки Backlog: только draft без задачи в работе, именованный 409 в остальных случаях |
| 0213 | Результат работы запускается на машине пользователя через compose от Infra, никогда через порт, опубликованный broker (AT-517, решение владельца от 11.10; ADR 0130 не тронут). Compose собирает APP из Dockerfile модуля с FROM = решённый образ, на порту хоста ${APP_PORT:-8080}, с закоммиченным .env.example; propose_infra_pr получает обязательный payload run (command, 1–5 urls на localhost, envExample), который api превращает в версионируемый artifact.run_instructions. Devs отдают /openapi.json и /docs (Swagger из CDN в сети none), QA проверяет их ВНУТРИ контейнера, а карточка Обзора говорит «объявлено Infra, не проверено на вашей машине» — настоящий docker compose up остаётся объявленным пробелом |
Gates, QA и ревью безопасности
Реестр gates, вердикты QA и SecOps и golden-set, которые их измеряют.
| # | решение |
|---|---|
| 0013 | Gates QA и SecOps с машиной состояний неизменяемого порядка |
| 0020 | Разблокировка gates: gitleaks по рабочему дереву (а не только по diff), заключения, валидируемые как артефакт, восстановление вызова инструмента из текста |
| 0021 | Закрытие 4a: gate инфраструктуры, который действительно проверяет, и панель, которая говорит правду |
| 0054 | Gates как декларативный реестр в docs/gates.yml. Gates потока существовали и были проверены реальным исполнением, но оставались неявными — разбросанными между промптом агента, чистым правилом, use case, тестом и CI, — а урок Фазы 10/13 (метрика извлекается скриптом, никогда не записывается вручную) невозможно применить к тому, что не перечислимо. Этот ADR — верхняя половина 0048: тот сделал решение НАБЛЮДАЕМЫМ в event log, этот делает множество ПЕРЕЧИСЛИМЫМ. Каждый gate объявляет `evidencia: event_log |
| 0057 | Агент gate ждёт одобрения, как ждёт dev. ADR 0052 сделал это для dev agent; агенты GATE остались за бортом, и никто не заметил, потому что ни одно исполнение до них не доходило, — ФАЗА 13b дошла. В 6-м реальном исполнении qa-automacao запустил `ls -la && find … |
| 0067 | Gate переживает рестарт. 0057 уже объявлял «рестарт посреди ожидания теряет цикл» как известное ограничение, но повторное расследование нашло ВТОРОЕ окно, отличное от приостановки в {:awaiting, ...}: DevAgentServer.correct/3 и Dispatcher.run_secops/2 — прямые вызовы в памяти, сделанные ПОСЛЕ того, как record_gate_verdict уже долговечно продвинул gate_status в api, — падение между ними навсегда застревает PR, и это САМОЕ ЛЁГКОЕ для возникновения окно, а не самое редкое. gate_states (схема engine, ключ {project_id, task_id, gate}) записывает два step: in_progress (ни один вердикт ещё не записан — никакого хирургического возобновления ctx, который не переживает рестарт, но перезапуск области с нуля безопасен, потому что api отклоняет record_gate_verdict для gate, который уже не владеет gate_status) и dispatch_pending (вердикт уже записан, не хватает только вызова внутри процесса). Engine.Gates.GateRescuer прочёсывает строки, стоящие больше 15 мин (намеренно щедро — ToolLoop QA законно работает до 60 итераций, и слишком ранний спасательный запуск дублировал бы просто медленный цикл), вызываемый при boot и по самоперепланирующемуся тику Oban, переиспользуя тот же механизм очереди, что уже использует остальной engine. Две защиты от дублирования работы: локальный Registry.lookup перед любым спасением и сам порог устаревания как вторая линия для того, что не покрывает первая (удалённая реплика, Registry, локальный для узла, та же оговорка, что у Engine.Dev.Wake с ADR 0045), — худший исход остаточной гонки — дублированная и дешёвая работа, никогда не несогласованные данные |
| 0090 | Два «вторых момента» одного концептуального дизайна, каждый решён владельцем продукта, чтобы опередить триггер, который предусматривал docs/fluxo.yml. Gate implementavel переходит из planned в active: QA-стратегия перестаёт быть ролью proposto и становится САМИМ qa-lead во втором МОМЕНТЕ, тот же процесс (Engine.Gates.QaLeadServer.run_design/3, в дополнение к прежнему run/2), отдельный результат — Engine.Gates.QaEstrategiaAgent (без состояния, без Terminal, ЛЁГКИЙ контекст, переиспользующий list_backlog/get_infra_context без нового маршрута) производит artifact.plano_de_teste ДО РАЗРАБОТКИ. Dev Lead получает assess_implementability: без плана он запускает оценку и возвращает ошибку с просьбой повторить (RN-163); с планом предлагает заключение как proposed_action, ТОТ ЖЕ шаблон, что у propose_execution_plan (0086). appsec (RN-360/361) следует ТОМУ ЖЕ шаблону поверх secops: Engine.Gates.SecOpsAgentServer.run_design/2, в том же процессе, что secops для PR, производит artifact.threat_model (чек-лист STRIDE-lite по ДИЗАЙНУ story) и создаёт handoff для arquiteto, dev-lead и infra. Ни один из двух агентов НИКОГДА не приостанавливается — ни один инструмент не проходит через ActionPipeline, — а лимит итераций намеренно остаётся 8 (разговорный), по той же причине, что у infra-workflows: без token_budget_micros под ним поднятие лимита умножило бы худший случай, ничем его не сдерживая. Оба ЗАПУСКАЕМЫ, но автоматического триггера (assess_implementability, вызывающий run_design у appsec) ещё нет — вне объёма этой поставки (RN-340/341/360/361) |
| 0091 | secops-runtime (docs/fluxo.yml, роль proposto слоя безопасности) введён досрочно как СКРИПТ отчёта, а не LLM-агент и не GenServer, — сознательное решение владельца продукта, хотя объявленный триггер («production с реальным трафиком, после DEPLOY_ENABLED + активной platform») ещё не сработал. pnpm --filter api relatorio:seguranca-runtime читает rate_limit_hits (данные, которые RateLimitGuard из ADR 0027 уже записывает, в том числе под трафиком dev/CI) и выдаёт рейтинг корзин (user:<uuid>/ip:<endereço>, два единственных записываемых формата — без маршрута и без причины, которых таблица никогда не хранила) и распределение по времени в фиксированных срезах. Автоматическое обнаружение инцидентов, реагирование на инциденты и postmortem остаются ВНЕ — они зависят от того же несработавшего триггера, — и отчёт перечисляет их в постоянном разделе «не измерено», не симулируя пример инцидента и не выдумывая числа (тот же принцип, что в ADR 0041/0042/0077). Хранимое окно короткое (DomainGaugesCollector.pruneRateLimit удаляет hits старше 2 × RATE_LIMIT_WINDOW_MS, по умолчанию 240 с), и отчёт объявляет оба окна — НАСТРОЕННОЕ и НАБЛЮДАЕМОЕ, — никогда не выдавая второе за более длинную историю, чем она есть |
| 0095 | Gate necessidade-validada (docs/fluxo.yml, роль criativo, proposto с ADR 0085) закрывает ПОСЛЕДНЮЮ волну аудита fluxo.yml × код (находка B2) с ОТДЕЛЬНЫМ подтверждением человеком — никогда Criativo, валидирующий сам себя, антипаттерн, уже записанный в modelo-de-time.md. Отдельная кнопка в SessionPage.tsx (тот же шаблон, что «Confirmar arquitetura pronta», RN-160), маршрут POST .../agents/criativo/validate-necessity, ValidateNecessityUseCase, записывающий necessity.validated, — включается только ПОСЛЕ того, как confirm_readiness уже консолидировал artifact.product_brief (нельзя валидировать то, чего ещё нет), и не сигналит engine: handoff Criativo→PO уже произошёл внутри самого confirm_readiness, так что это событие — лишь запись заслуги, а не триггер активации. docs/gates.yml получает gate active/warn (сегодня ничто не ПРОВЕРЯЕТ его прохождение, прежде чем пустить PO дальше) |
| 0123 | Закрывает открытую находку самого ADR 0020 — семантический шаг gate QA (сопоставление бизнес-правила с тестом) недетерминирован на локальной модели, закрываясь на 10-м из 11 исполнений, — golden-set из шести регрессионных случаев, запускаемым намеренно (mix golden_set.qa) против НАСТОЯЩЕГО локального LLM (Ollama), никогда в CI (блокировка подтверждена: ни одного ollama в workflows, ни одного секрета LLM, а сама культура CI отвергает молчаливый allow-failure). Engine.Gates.QaAutomacaoAgent.run/5 вызывается напрямую, как в qa_automacao_agent_test.exs, но против НАСТОЯЩЕГО EngineApiClient.Live — которому нужны реально работающие api и engine, а также НОВЫЙ межъязыковой System.cmd (Elixir вызывает pnpm/ts-node), чтобы засеять настоящие строки проекта/сессии/binding модели, до которых изолированная база engine_test теста engine не дотягивается. apps/api/scripts/seed-golden-set-qa.ts делает СОБСТВЕННЫЙ checkout git (worktree должен жить внутри projectScopeRoot, чтобы потолок области путей decide() когда-нибудь автоодобрил npm test, — найдено эмпирически) и засевает agent_areas (без неё обязательный metering первого хода LLM даёт 404 — тоже найдено эмпирически). Тег ExUnit :golden_set_qa исключён ПОСТОЯННО, никогда по обнаружению бинарника/сервиса, потому что на этой машине Ollama уже работает постоянно, и автоматическое включение запускало бы набор внутри любого mix test. Храповик floor.json (зеркало coverage-floor.ts, >= и никогда >, редактируется только человеком) заменяет двоичное прошло/не прошло: измеренные реальные доли попаданий — qwen2.5-coder:latest 1–4/6 за четыре исполнения (доминирующий сбой: пустые аргументы команды terminal — та же ненадёжность в вызове инструментов, которую ADR 0020 уже находил в этом семействе моделей) против gpt-oss:20b 4–5/6 за два (в 4 раза медленнее на случай) — gpt-oss:20b является рекомендуемым значением по умолчанию для любого потока, зависящего от надёжности этого суждения. Связь с CI остаётся объявленным TODO(humano) |
| 0168 | Golden-set QA Automação (ADR 0123) начинает выполняться в CI в .github/workflows/golden-set-qa.yml, по образцу ADR 0138, — ПО РАСПИСАНИЮ и ЕЖЕНЕДЕЛЬНО (суббота 01:17 UTC, плюс workflow_dispatch), никогда в pull_request. Два измерения сделали это возможным: AT-067 показала, что время укладывается в ubuntu-latest без GPU (опровергая половину «новая инфраструктура» фразы ADR 0123), но набрала 0/6 по построению под RN-502; AT-076 заставила seed поднимать контейнер каждого случая через broker по пути production, дважды набрав 3/6 против пола 1/6, за 52 и 86 мин. Еженедельно, а не еженощно, потому что одно исполнение стоит 52–86 мин, а счёт МЕНЯЕТСЯ между исполнениями (тот же итог, разные случаи) — одно исполнение в неделю читает тренд, который хранит пол храповика; timeout-minutes: 150 покрывает измеренные 86 мин плюс разброс модели. Вердикт — пол самого теста; workflow добавляет лишь две защиты инструмента (строка счёта должна существовать, и ни один отказ RN-502 не может появиться в event log) и при сбое печатает логи точно так же, как job RAG, — без issue. api, engine и broker работают нативно на runner, и только broker говорит с Docker; образ для предварительной загрузки читается из собственной константы seed, чтобы job никогда не скачивал один образ, пока broker запрашивает другой внутри управляющего лимита 30 с (RN-604). Объявлено: у broker в CI нет пяти слоёв сдерживания compose (остаётся то, что он принимает), а PR, редактирующий workflow, его не запускает |
| 0192 | План тестирования пишется ПОСЛЕ поставки dev agent, исходя из поставленного кода, а не во время проектирования (AT-269, решение владельца от 01.10, выход «(b)», противоречащий ADR 0090). Он становится ПЕРВЫМ шагом цикла qa-verificada: QaLeadServer.run_area/3 запускает QaEstrategiaAgent.run/6 в worktree dev с файлами, которые затронула поставка (git diff dev...HEAD), один раз на ЗАДАЧУ (раунд исправления переиспользует план по taskId), и план приходит в Автоматизацию как ВХОДНЫЕ данные — правило вердикта и контракт вердикта не меняются, а неудавшийся план никогда не задерживает ревью. Gate implementavel остаётся (тот же владелец, warn, одобрение человеком) и оценивается Dev Lead по истории и module_map, которые уже есть в его контексте: вердикт выносится при ПЕРВОМ вызове, без полей плана. run_design/3, run_qa_estrategia/3 и QaEstrategiaContext уходят; artifact.plano_de_teste требует taskId; лимит итераций ОСТАЁТСЯ 8, теперь с бюджетом задачи под ним; appsec остаётся на этапе проектирования (RN-674) |
| 0207 | Цикл gate, стоящий больше 2 часов с последней активности, паркуется событием gate.rescue_parked, и возобновить его может только жест человека; спасатель спрашивает lead о ЗАДАЧЕ, а не о проекте, и оставляет в покое цикл с ожидающим человеческим решением (AT-391, пересматривает 0067). |
| 0214 | Открытый вопрос к пользователю становится ожидающим пунктом с состоянием в логе (artifact.open_question + open_question.answered + decision_record), объявляемым только инструментом declare_open_question и отвечаемым только автором сессии; QA получает вердикт needs_user_decision, который не расходует цикл исправления и паркует gate механизмом ADR 0207 до ответа; решение доходит до dev-context и среза QA, и PO может исправить историю ready на основе отвеченного вопроса (AT-521, расширяет 0207, пересматривает часть 0212). |
| 0215 | openQuestionId вердикта gate должен существовать в проекте и быть открытым (неизвестный — 404, уже отвеченный — 409 с решением), afeta.regras принимает точный заголовок правила и хранит id, а dev-context возвращает также остальные решения проекта как decisoesDoProjeto в пределах того же лимита 10 (AT-526, пересматривает часть 0214). |
Критерий приёмки всё ещё открыт. 0020 явно фиксирует, что демо gates «ЗАКРЫТО, но НЕ ДЕТЕРМИНИРОВАНО» — оно зависит от суждения локальной модели на 7B и не годится как регрессионный тест. В 0021 есть раздел «Состояние критерия приёмки: НЕ ЗАКРЫТ». Оба честны намеренно; не читайте их как завершённые.
Исполнение, политика терминала и одобрения
Работающий dev agent, proposed_action, политика терминала, область путей, автоматический режим и абсолютные потолки.
| # | решение |
|---|---|
| 0011 | Worktree на каждого dev agent, исполнители git и блокировка merge: защищённая ветка никогда не автоодобряется |
| 0012 | Настоящий DevAgent: ToolLoop в worktree, бюджет на task, ReportDone, требующий зелёного набора тестов, блокировка с диагнозом |
| 0017 | Блокировка инициализации workspace, Monitor dev agents и наследование лимита исправлений субагентом |
| 0018 | NoopDevAgent перестаёт быть строительными лесами и становится постоянным режимом исполнения |
| 0019 | Разблокировка настоящего DevAgent: запись, терминал, ADR на модуль и бюджет на проект |
| 0045 | Перепланирование dev agent по событию: outbox (а не вызов внутри процесса), потому что он переживает рестарт между вердиктом gate и wake; awaiting_gate удерживает worktree по АГЕНТУ (а не по task), пока gate не закончит; доставка через PubSub (а не Registry, локальный для узла) с сознательно принятым ограничением at-most-once; circuit breaker (RN-047) живёт в engine, потому что actor.id у backlog.task_blocked не идентифицирует последовательность одного агента |
| 0047 | Закрытие Фазы 12: валидация — это СКРИПТ, завершающийся с != 0, а не сценарий в прозе, и доказательство извлекается из базы, а не переписывается; запускается локально и без LLM по двум проверенным причинам (форк так и не был назван; суждение gates на локальной модели недетерминировано с ADR 0020), и это объявлено в первом абзаце, а не в сноске; Noop вошёл в машину состояний 12b — находка #10 выживала внутри самого измерительного инструмента, — перенеся её в AgentIo вместо копирования; урожай Фазы 10 был записан с дырами как não medido |
| 0048 | Две пересмотренные находки: РЕШЕНИЕ по действию (created/approved/denied) становится доменным событием с реальным актором — outbox это транспорт, а не память, и events.md всегда документировал все три, хотя их не существовало; status в payload у created — то, что отличает клик человека от автоодобрения по политике, метрика, которую Фаза 10 не смогла собрать. И D5: причина была не в том, что worktree переиспользовался, а в том, что propose/3 отбрасывал статус и gate открывался без PR — QA прочёсывал worktree, одобрял, и task закрывалась без единой закоммиченной строки. С исправленным порядком D5 умирает как следствие, а worktree-на-task (предусмотренный ADR 0045) перестаёт быть нужным |
| 0052 | Dev agent ждёт одобрения посреди цикла вместо того, чтобы сжигать итерации. Ожидающий инструмент :pipeline возвращал строку proposed_action <id> status pending как РЕЗУЛЬТАТ, и ToolLoop шёл дальше: агент ничего не узнавал о команде, пробовал что-то другое и умирал в toolloop.limit_reached {iteration: 8, max_iterations: 8}, не написав ни строки, — выданные одобрения приходили после исчерпания цикла и были бесполезны. Allowlist терминала не решает проблему, потому что это список предусмотренных команд, а модель выдумывает команды: когда разрешили команды чтения, тот же агент застрял на git branch -a. :awaiting_approval УЖЕ существовал (Фаза 12e, для pr_open в конце task) со всей готовой обвязкой — событие outbox, worker, Wake.deliver, удержание worktree, — так что не хватает лишь применить его к середине цикла. Ожидание НЕ блокирующее: ToolLoop работает внутри callback GenServer, и блокировка там застопорила бы почтовый ящик, который и принёс бы само решение |
| 0055 | Область путей в политике терминала. Лестница одобрения для terminal была ИЗМЕРЕНА как нежизнеспособная: 15 ходов, 209.031 входных токенов против 4.546 выходных, 14 действий и НОЛЬ строк, написанных для task, потому что почти каждый ход заканчивался ожиданием клика. Два независимых дефекта объясняют «спрашивает всегда»: агент выдаёт cd <caminho> && <verbo>, а cd нет ни в одном allow, и составная команда требует, чтобы совпали ВСЕ сегменты (decide.ts:201), так что засеянный allow почти никогда не достигается; а «Sempre permitir» записывает ВСЮ БУКВАЛЬНУЮ команду, которая больше никогда не совпадает, потому что сопоставление идёт по префиксу токена, — запасной выход не выпускает. То, о чём просит пользователь, «всегда, пока в папке проекта», сегодня невыразимо: понятие пути есть только у write_file, у terminal нет. Исполнение обнажило отягчающее обстоятельство: /workspace внутри исполнителя — это монорепозиторий САМОГО Brabo, и dev agent из hello-limpo дошёл до предложения прочитать git_executor.ex и context_builder.ex — границы проекта не существует, и охват больше проекта. Область разрешается через REALPATH, а не по строковому префиксу (.. вырвался бы наружу), РАЗРЕШАЕТ, не освобождая (сеть и установка по-прежнему спрашивают; нахождение в папке не делает curl с пайпом в shell безопасным), deny по-прежнему побеждает первым, а вне области — это require_approval, а не deny. Это политика, а не изоляция: пока монорепозиторий смонтирован в контейнер, который исполняет, граница зависит от того, правильно ли сработает политика |
| 0102 | Пересмотр ADR 0065, ГЛОБАЛЬНОЕ решение владельца продукта (явно подтверждённое после автоматического предупреждения безопасности): git push/PR/deploy (RN-106) и sudo/doas (новое) выходят из безусловного deny и становятся АБСОЛЮТНЫМ ПОТОЛКОМ — безусловный require_approval, тот же шаблон кода, что у других потолков, никогда не автоодобряемый ни auto mode, ни permissions.json. Лазейка «sempre permitir» была закрыта В ИСТОЧНИКЕ (ApproveAlwaysActionUseCase отказывается записывать шаблон для этих команд) — без этого потолок был бы декоративным |
| 0167 | Автоматический режим снимает потолок области путей, и только он. Измерено на установке владельца (AT-226, проект exp001, mounted с работающим контейнером): при включённом джокере "*": auto_approve 51 команда терминала всё равно запросила одобрение, 47 — только потому, что упоминали /work или /tmp: dev agent работает внутри контейнера, а область ADR 0055 сравнивает с корнем ХОСТА. Решено владельцем продукта: агент в автоматическом режиме выполняет любую команду терминала без одобрения, даже вне папки проекта. decide() начинает получать от репозитория ИСТОЧНИК автономии (конкретное правило или джокер), и репозиторий по-прежнему владеет приоритетом конкретного правила над джокером; автоматическим режимом является только джокер, разрешённый как auto_approve. В автоматическом режиме потолок области путей и require_approval, который составная команда синтезирует для непокрытого сегмента, перестают действовать; ask, записанный в permissions.json, любой deny, потолок внешнего эффекта/привилегий (RN-418), merge в защищённую ветку, instruction_patch, параллелизм и container_remove остаются ровно такими, какими были. Карточка одобрения и карточка агента говорят до клика, что автоматический режим разрешает и о чём всё ещё спрашивает. Отклонено: транслировать /work до проверки области и снимать область для любого конкретного auto_approve |
| 0189 | Автоматический режим становится АВТОПИЛОТОМ, а область путей сравнивается с папкой, где команда ВЫПОЛНЯЕТСЯ (AT-259/255/258, решение владельца от 01.10). ADR 0167 НЕ отменяется: джокер *: auto_approve одобряет всё — синтезированные составные команды, пути вне папки, git commit и создание ЛОКАЛЬНОЙ ветки — кроме абсолютных потолков (push/PR/merge/deploy, sudo/doas, merge в защищённую ветку, container_remove, instruction_patch, параллелизм); deny по-прежнему побеждает. Конкретный auto_approve, записанный через «Sempre permitir» под джокером, разрешается КАК джокер, так что больше не выключает автопилот. При зарегистрированном контейнере running (container/mounted) корень области ADR 0055 становится /work плюс /tmp контейнера, с cwd хоста, транслированным так, как транслирует engine; без него остаётся папка хоста, с /tmp вне области. UI показывает список того, что автопилот разрешает и что нет, в трёх местах, где он включается. И «Sempre permitir» записывает ГЛАГОЛ + ПОДКОМАНДУ, один шаблон на сегмент команды (AT-257, закрывая AT-170 и пункт 6 ADR 0055): глагол + флаг остаётся точным, и единица, которая была бы префиксом потолка RN-418 (git remote, gh pr), тоже остаётся точной. Сдерживание, на котором держится решение, доказано сквозным образом в broker, а то, что оно НЕ покрывает, объявлено |
| 0204 | Автоматический режим разрешает git_push и pr_open (типизированные, и тот же push/PR через команду терминала), по решению владельца от 02.10 (AT-385, RN-713), пересматривая ADR 0102 и RN-689 только для push/PR. Разрешает их ТОЛЬКО джокер *: auto_approve — конкретное правило, «Sempre permitir» и permissions.json по-прежнему не разрешают, а активация по-прежнему засевает только git_commit. Merge в защищённую ветку, deploy, sudo/doas, container_remove, instruction_patch и параллелизм остаются абсолютными потолками; возврат переключателя в ручной режим восстанавливает одобрение |
Где живёт код — режимы исполнения
Три режима исполнения, папка пользователя, база проектов и перевод проекта между режимами.
| # | решение |
|---|---|
| 0066 | Читаемое имя папки workspace. Пересматривает 0055: projectScopeRoot начинает получать workspace_dir_name, а не сырой projectId, сохраняя гарантию, что api и engine согласны насчёт корня проекта. Запрос пользователя: читаемая физическая папка (<slug>-<8 chars do id>) с уникальным id — PROJECT_WORKSPACES_ROOT это ОДИН корень на весь инстанс, а один slug не уникален глобально. Из двух представленных вариантов (symlink с меньшим риском против настоящего переименования папки) пользователь явно выбрал второй. Id рождается в КОДЕ (crypto.randomUUID() в CreateProjectUseCase, больше не defaultRandom() Postgres), потому что имени папки он нужен ДО INSERT. Проект, созданный до этой миграции, сохранил свою физическую папку — backfill записывает workspace_dir_name = id и НИКОГДА не переименовывает ни одного каталога, а имя ЗАМОРАЖИВАЕТСЯ при создании: последующее редактирование slug не пересчитывает папку. Триггер BEFORE INSERT (первый в истории миграций продукта) применяет тот же fallback id::text для insert, пришедшего без поля, — страховочная сеть, а не основной путь, и то, что избавляет от переписывания более пятидесяти тестовых spec, вставляющих projects без знания этого понятия. Engine читает ТУ ЖЕ колонку (Engine.Projects.Project.workspace_dir_name/1), чтобы разрешить Workspace.workspace_dir/1, никогда не вычисляя имя заново, — два выведения на практике являются одним и тем же чтением той же базы. Вне горячего пути цикла инструментов (который уже получает ctx[:workspace_root], разрешённый один раз, при создании worktree агента); WorktreeCleanup перешёл от прочёсывания диска к запросу Project.all_workspace_dirs/0 одним запросом, потому что папка перестала быть project_id, и перевести одно в другое без запроса невозможно |
| 0072 | Проект выбирает, где живёт код: папка пользователя (Local) или управляемая папка (Container). Пересматривает часть 0065 и вступает на территорию 0055 — оба приняли ПРОТИВОПОЛОЖНОЕ направление, к стене контейнера, и именно поэтому раздел последствий — самая важная часть этого ADR. Запрос пользователя, с явно выбранным вариантом: путь вводится свободно, с пониманием предупреждения, что это работает только смонтированным в контейнер. projects получает пару (workspace_mode, workspace_path) с CHECK, связывающим их, — local без пути был бы областью терминала, указывающей в никуда, container с путём был бы вторым источником истины, — и замок живёт в БАЗЕ, потому что колонку читают два процесса и пишут скрипты, не проходящие через use case. container — значение по умолчанию: существующий проект не меняет место. projectScopeRoot начинает получать LOCAL вместо имени папки и остаётся ЕДИНСТВЕННЫМ выведением — ни один вызывающий не получил собственной валидации, по той же причине, по которой существует функция. Защита при создании (RN-170) ОТКЛОНЯЕТ с 400 и обучает: абсолютный путь, без .., существующий, являющийся папкой и доступный процессу на запись (`W_OK |
| 0104 | ADR 0072 и 0103 никогда не разговаривали друг с другом: RN-170 требует bind-mount при создании, RN-420 направляет к runner под ТЕМ ЖЕ флагом workspace_mode == 'local' без всякого bind-mount — настоящее использование runner по-прежнему было обязано проходить валидацию смонтированной папки. workspace_mode (2 значения) становится execution_mode (3: container, mounted — бывший local, RN-170 теперь обусловлено им — и runner, без bind-mount). Проект runner рождается с ОБЪЯВЛЕННЫМ путём и состоянием workspace: unverified, повышаемым до verified только когда runner подключается и подтверждает его на ХОСТЕ, — тот же дух gate с доказательством по событию, что уже использует docs/gates.yml. Любое преобразование между тремя значениями становится разрешённым, без пересоздания проекта. Приоритизированный бэклог владельца продукта, записанный в этом порядке: распространение runner через npm publish (@brabo/runner, tsup), долгоживущий токен учётной записи (PAT), заменяющий повтор логина в apps/runner/src/auth.ts, — ДО распространения, — эксклюзивность по {project_id, machine_id} ОТЛОЖЕНА до появления реального второго одновременного разработчика, и guard.ts ПОДТВЕРЖДЁН как best-effort — настоящая граница безопасности — это аутентификация + одобрение + согласие, никогда не sandboxing |
| 0107 | Навигация по локальной папке, пересматривающая (без редактирования) ADR 0072: выбор папки, который 0072 отверг, потому что он требовал от api перечислять файловую систему КОНТЕЙНЕРА, начинает существовать через RUNNER (ADR 0103), у которого уже есть правильный вид — реальная машина пользователя. Два новых события в ТОМ ЖЕ канале terminal:<projectId> (fs_list_dir/fs_home_dir), тот же дизайн чистого ретранслятора, что у PTY, сопоставляемые по ref. Api по-прежнему не перечисляет никакую файловую систему — ни одного нового маршрута. Граница безопасности остаётся той же, что в 0103 (аутентификация + чтение без эффекта + согласие), а не allowlist путей — guard.ts (ограничивающий cwd одобренной команды) намеренно здесь НЕ применяется, потому что цель — СВОБОДНО перемещаться. Объявленный пробел: на экране СОЗДАНИЯ проекта тикет выдаётся на проект, а проект рождается только при подтверждении, так что «Procurar pasta...» там показывает объявленное состояние вместо имитации невозможного подключения — работает сквозным образом там, где проект уже существует |
| 0108 | Закрывает пробел, который сам ADR 0107 объявил: в режиме runner NewProjectWizard.tsx начинает создавать проект при клике на «Navegar pasta...», а не только при подтверждении, — используя уже введённый путь или временный лексически допустимый маркер (/workspace-a-confirmar, перезаписываемый, когда подключается настоящий runner, RN-423), если поле ещё пустое. Переиспользуется по снимку ИДЕНТИЧНОСТИ (name/externalId/adotando — никогда путь, уточнение которого и есть причина навигации): неизменная идентичность переиспользует тот же проект, изменённая — создаёт другой. Режим mounted не меняется — валидация пути затрагивает диск при создании, так что он по-прежнему ждёт подтверждения. Принятый побочный эффект: осиротевший проект «не провижинен», если мастер закрыт до завершения, — ТО ЖЕ состояние, которое уже сегодня порождает любое прерванное создание |
| 0111 | Перевод execution_mode СУЩЕСТВУЮЩЕГО проекта, исправляющий утверждение пункта 4 ADR 0104, что это уже было возможно «без пересоздания проекта», — не было; UpdateProjectDto по-прежнему намеренно исключал оба поля. Отдельный маршрут (PUT :projectId/execution-mode, role:maintainer) оркестрирует то, чего не может сырая запись в колонку: отказывает (409), пока какой-либо dev agent проекта не простаивает в ЛЮБОЙ сессии (engine.dev_agent_states, idle_tripped считается здесь активным, в отличие от подсчёта online в RN-409, потому что агент всё ещё держит захваченный workspace_root), — никогда не дренируя и не мигрируя живого агента; перемещает permissions.json в новый корень области через новый PermissionsFileStore.move() (содержимое никогда не меняется, только физический путь); переводит строку жизненного цикла project_containers в removed (проходя сначала через stopped, если она running) ДО смены колонки, при выходе из container, и никогда не провижинит сам при входе в него; обнуляет workspaceVerifiedAt при каждом реальном переводе. Объявлено, не скрыто: перевод никогда не копирует код между корнями области — engine заново выводит worktree из bare-репозитория проекта по требованию (Engine.Actions.Workspace.ensure!/4), так что закоммиченная работа в безопасности, но НЕЗАКОММИЧЕННЫЙ diff в старом брошенном worktree остаётся на месте сиротой, ни перенесённым, ни удалённым |
| 0141 | Создание проекта mounted требовало вручную править строку bind-mount в docker-compose.yml для api И engine и перезапускать их — что убивает каждый ход агента в полёте, сокет терминала и вызов LLM всей установки, ради приёма ОДНОГО проекта. Терпимо, пока mounted был запасным выходом; дисквалифицирующе теперь, когда он становится первоклассным выбором с предложенным путём. Итак: ОДНА база, BRABO_PROJECTS_BASE, настраиваемая оператором один раз и монтируемая по ИДЕНТИЧНОСТИ ($X:$X) в api и engine, где каждый смонтированный проект живёт под ней и ничто никогда не редактируется на проект. Идентичность, а не фиксированная точка монтирования, потому что projects.workspace_path — строка, которую пользователь ВВОДИТ, а экран показывает обратно: при хост ≠ контейнер продукту пришлось бы выбирать, какую из двух хранить, и любой выбор заставил бы экран лгать кому-то, — а идентичность ровно то, что позволяет projectScopeRoot (api) и Engine.Actions.Workspace.workspace_dir/2 (engine) оставаться правильными с нулём нового кода, при этом ведущий слэш по-прежнему решает. НОВАЯ переменная, никогда PROJECT_WORKSPACES_HOST_DIR, по трём причинам, первая — с последствиями для данных: управляемый workspace именуется по workspace_dir_name (UNIQUE), а смонтированный проект именует ПОЛЬЗОВАТЕЛЬ, так что <base>/loja и проект container, чьё имя папки loja, попали бы в ОДНУ физическую папку, и init_from_bare! выполнил бы git init внутри проекта другого человека — ничто в схеме этому не мешает, поскольку уникальность действует между значениями workspace_dir_name, а не против конечного имени workspace_path. Отсутствие — НОРМАЛЬНОЕ состояние: baseDeProjetos() возвращает null и никогда не бросает, GET /workspaces/:workspaceId/projects-base (maintainer, тот же минимум, что у POST .../projects, поскольку единственное назначение значения — решать, что этот маршрут предлагает) доносит null до клиента, и мастер просто не предлагает Смонтированный режим — никогда не предлагайте режим, который установка не может выполнить. И слепое пятно /workspace наконец получает защиту там, где она возможна: scripts/dev/preflight.mjs ОТКАЗЫВАЕТСЯ поднимать стек, когда база пересекается с checkout Brabo в любом направлении, потому что он работает на ХОСТЕ — api сравнивает с process.cwd(), /workspace внутри собственного контейнера, так что тот, кто клонирует Brabo в $HOME/brabo и направляет туда базу, проходит всю существующую валидацию и получает dev agents, исполняющиеся внутри дерева самого продукта, — сбой ADR 0055, входящий через дверь, которую он не охраняет. Две объявленные цены: symlink под базой разрешается по-разному с двух сторон (навигатор папок отказывается спускаться в symlinks и считает их; проверка диска при материализации — то, что ловит расхождение, как ИМЕНОВАННЫЙ сбой), а код вне базы требует перемещения папки — v1 поддерживает одну базу, и сообщение отказа говорит это, а не намекает на скрытую настройку |
| 0142 | ADR 0141 решил, ГДЕ живёт проект mounted, и явно оставил другую половину следующему PR: КОГДА эта папка начинает существовать. Требование владельца продукта буквально — «если это Смонтированная папка, bind-mount должен создаваться ПОСЛЕ решения архитектора» — и сегодняшний код делает это невозможным: validarCaminhoDeWorkspaceLocal выполняется при СОЗДАНИИ проекта и задаёт три вопроса диску (существует? это каталог? этот процесс может в него писать?), отклоняя с 400, когда какой-то не проходит. Создание — ПЕРВЫЙ экран потока; решение архитектора происходит много сессий спустя, так что требовать готовую папку при создании — значит требовать её до существования какого-либо решения, и это то, что мешает mounted быть первоклассным выбором, поскольку путь, который мастер ПРЕДЛАГАЕТ, по построению ещё не существует. Поэтому mounted перенимает дисциплину, которая уже была у runner (RN-423): при создании только ЛЕКСИЧЕСКИЙ предикат плюс одно собственное новое правило — путь должен лежать внутри BRABO_PROJECTS_BASE, единственной папки машины, которую видят контейнеры api и engine, а без настроенной базы РЕЖИМ в этой установке недоступен, и отказ говорит это. Отложенная проверка возвращается в materializarWorkspaceMontado (mkdir -p плюс те же три вопроса диску, с отказом за пределами базы ДО mkdir, чтобы путь, записанный вне продукта, не заставил api создавать папки везде, куда она дотянется), вызываемой из двух мест: ExecuteContainerStartUseCase, обычное, где Infra, поднимая контейнер, создаёт папку и ставит штамп workspace_verified_at, — сбой там это ИМЕНОВАННЫЙ failed, никогда throw, и жизненный цикл НЕ помечается provisioning, потому что пометить его и только потом обнаружить, что писать нельзя, оставило бы строку утверждающей состояние, которого никогда не было; и ConvertProjectExecutionModeUseCase, объявленное исключение, у которого нет шага контейнера, куда повесить работу, и который сразу после этого перемещает permissions.json внутрь той же папки. Что намеренно НЕ меняется — это caminhoDeWorkspaceLocalValido: правило базы остаётся вне его, потому что этот предикат выполняется при КАЖДОМ ЧТЕНИИ через projectScopeRoot, и УНАСЛЕДОВАННЫЙ проект mounted вне базы начал бы взрываться от одного чтения. Без миграции, и CHECK (execution_mode <> 'container') = (workspace_path IS NOT NULL) остаётся нетронутым — откладывание ПРОВЕРКИ никогда не затрагивает инвариант СПАРИВАНИЯ |
| 0146 | Режим Смонтированный был готов и недостижим: ADR 0141 построил единую базу, а ADR 0142 сделал возможным предложить путь, которого ещё нет, но ничто в продукте так и не просит оператора настроить BRABO_PROJECTS_BASE — поэтому у GET /workspaces/:workspaceId/projects-base нет вызывающего в web, и режим просто никогда не предлагается. Поэтому pnpm bootstrap получает шаг согласия: он предлагает $HOME/projetos-brabo (под $HOME, потому что именно это Docker Desktop расшаривает ПО УМОЛЧАНИЮ — значение по умолчанию, требующее сначала открыть настройки Docker, это замаскированный вопрос), а НЕ $HOME/brabo-projetos, потому что .env.example уже использует это точное имя как закомментированный пример PROJECT_WORKSPACES_HOST_DIR, корня, которым управляет ПРОДУКТ, — ровно та путаница, которую отверг ADR 0141, поскольку <base>/loja столкнулся бы с проектом container, чей workspace_dir_name тоже loja, и bootstrap выполнил бы git init внутри чужого проекта. Имя — лишь отклонение; защита — это МЕХАНИЗМ: скрипт отклоняет любую базу, равную этому корню, содержащую его или содержащуюся в нём, переиспользуя dentroDe, который уже поддерживает baseSobrepoeOCheckout. Написано в scripts/dev/consentir-base.mjs на Node и никогда на bash, по двум измеренным причинам: у bootstrap.sh нет помощника, пишущего переменную (garantir_env лишь копирует пример, а единственный писатель на месте — escreverEnv в preflight.mjs), и — решающее — пункт меню НЕ МОЖЕТ СПРОСИТЬ, поскольку каждая команда меню выполняется в фоне со stdin из /dev/null, чтобы ничто не крало стрелки у пользователя; шаг согласия, написанный там, был бы по построению шагом, который не может получить согласие. Общий доступ к файлам Docker Desktop ДОКАЗЫВАЕТСЯ МОНТИРОВАНИЕМ (docker run --rm -v <base>:/sonda ... ls /sonda), никогда чтением settings.json: этот файл не документирован, отличается между macOS и Windows, меняется между версиями и описывает то, что пользователь настроил, а не то, что сделает демон, — то же правило, которое продукт уже применяет к LLM-провайдерам, где capability объявляется только будучи доказанной исполнением (ADR 0041/0042). Это становится ВТОРОЙ платформенной защитой продукта рядом с validarDirDentroDoHomeNoLinux, и обе симметричны по принципу: каждая ограничивает только там, где ограничение что-то значит. И broker выходит из profiles: ["container-broker"] только в ЛОКАЛЬНОМ compose — ADR 0130 выбрал этот profile, потому что «ничто пока не вызывает его для записи... в обмен ни на что», и эта предпосылка умерла: ADR 0133/0136 дали ему четырёх реальных вызывающих, а ADR 0144 заставил mounted поднимать контейнер ЧЕРЕЗ broker, так что при mounted как локальном значении по умолчанию broker — то, что отделяет режим по умолчанию от BrokerIndisponivelError. На асимметрии всё и держится: у того, кто запускает pnpm dev, уже есть сокет Docker — он запускает docker compose, — так что передача его broker не даёт ничего, чего не хватало бы оператору, тогда как в production сокет — реальная граница привилегий, и оператор — не разработчик. Пять слоёв сдерживания остаются нетронутыми: это меняет, КОГДА работает broker, но никогда — ЧТО он принимает |
Контейнер проекта и broker
Жизненный цикл контейнера проекта, решение об образе, порт Docker и broker, который говорит с демоном.
| # | решение |
|---|---|
| 0065 | Контейнер на проект: граница перестаёт быть политикой. Пересматривает 0055, который сам объявил о незакрытом вопросе: область путей — это ПОЛИТИКА, а не изоляция — пока монорепозиторий Brabo смонтирован в тот же контейнер, что исполняет команды, граница зависит от того, правильно ли сработает политика, а symlink изнутри, указывающий наружу, не обнаруживается. Находки Z и AD (allowlist глаголов не сходится — глагол, форма и вызов являются разными пространствами) подкрепляют тот же вывод ФАЗЫ 13: путь к автономии лежит не через ослабление политики, а через смену границы. Решение пользователя, дословно: каждый проект получает собственный контейнер, внутри него агент СВОБОДЕН (читать, писать, устанавливать, собирать, тестировать, запускать), а Code открывается только после того, как ARQUITETO решит, какой образ поднимается, — он знает, какой runtime требует стек. Образ рождается АРТЕФАКТОМ (artifact.project_image в event log, версионируемый, без таблицы — пересмотр означает повторную эмиссию), с явным тегом OCI (latest отклоняется) и обязательным rationale; шлагбаум (RN-105) заставляет вкладку Code отвечать 409, пока sem_decisao, в ТОЙ ЖЕ функции, которая уже содержит путь (RN-092), никогда не дублируясь в четырёх маршрутах. Остающаяся граница — СНАРУЖИ: git push, PR и deploy не выходят через терминал — даже внутри области проекта, — и правило — deny, а не require_approval, потому что «sempre permitir» записывает шаблон в allow, и один клик открыл бы дверь навсегда; сообщение перенаправляет к ТИПИЗИРОВАННОМУ действию (git_push/git_merge/pr_open), которое уже является реальным путём dev agent. Сеть решается ОДИН РАЗ в артефакте (network: none по умолчанию, egress разрешается пользователем), а не от команды к команде — повторять allowlist для исходящего трафика значило бы повторять ошибку, которая, как доказали Z и AD, не сходится; расход (cpus/память/pids) имеет потолок, который ОТКЛОНЯЕТ, а не молча понижает. ЧЕСТНЫЙ и объявленный срез: жизненный цикл контейнера (провижининг, переработка, очистка, то, что переживает рестарт) остаётся для фазы со слотом миграции — состояние контейнера изменяемо и требует таблицы, а контейнер, который поднимается без переработки, хуже, чем никакой. Следствие: половина «внутри свободно» политики терминала ЕЩЁ НЕ изменилась; 0055 продолжает действовать как есть, пока стена действительно не поднимется |
| 0081 | Жизненный цикл контейнера: таблица состояния, без оркестратора. Закрывает срез, который 0065 объявил о себе («Чего этот ADR НЕ делает»), — project_containers (миграция 0046) записывает изменяемое СОСТОЯНИЕ (status, image_version, указывающий на решение Arquiteto без его дублирования, container_id, ОБЪЯВЛЕННЫЙ потолок ресурсов, failure_reason), одна строка на проект, как dev_agent_states в engine (0045), с чистой машиной состояний (provisioning → running ⇄ stopped, failed достижим из всех трёх, из removed выходят только повторным провижинингом) в том же формате, что session-state-machine.ts. Предварительное исследование подтвердило, что НИ ОДИН сервис продукта не монтирует /var/run/docker.sock и не работает privileged, — и поэтому оба use case (Registrar/ObterCicloDeVidaDoContainer) лишь ЗАПИСЫВАЮТ и ЧИТАЮТ, ни одного реального вызова Docker: монтировать сокет, чтобы «заработало по-настоящему», значило бы принять решение безопасности (вектор эскалации до root), о котором в этом срезе никто не просил. Первый переход (provisioning) требует уже решённого образа (RN-105, тот же шлагбаум, что у вкладки Code) и ЗАМОРАЖИВАЕТ версию/ресурсы в строке; проект в workspace_mode: 'local' (0072) отклоняется, потому что не поднимает собственного контейнера. Ни одного нового HTTP-маршрута — без реального потребителя раскрыть его значило бы угадывать контракт |
| 0083 | Вкладка Terminal получает реального потребителя жизненного цикла, а не терминал. Пересматривает часть 0081, которая откладывала HTTP-маршрут до появления реального потребителя, — это направление (ПРОГРАММА 28, Волна 5/F2) и было этим потребителем. Исходный план был полный интерактивный терминал, но ФАЗА 25b по-прежнему отрезана: ни один сервис не монтирует /var/run/docker.sock, и ничто в production не переводит project_containers (ADR 0081 создал таблицу, а не оркестратор). Реализовать притворный терминал — или работающий в том же контейнере, что монорепозиторий Brabo, долг, который 0055 уже описывает как политику, — значило бы выдумать возможность, ту же ошибку, которую ADR 0041/0042 отвергают для провайдера и модели. Реальная поставка: GET /projects/:projectId/container/lifecycle (RN-267), точное зеркало ObterCicloDeVidaDoContainerUseCase, и CodeBottomPanel.tsx, показывающий это состояние (переведённый бейдж, причина сбоя) ПОД уже существовавшим пояснительным текстом — никогда вместо него, — запрашиваемое только пока открыта вкладка Terminal (RN-268). null (никогда не провижинился) — честный и сегодня самый частый результат |
| 0128 | Доступ к Docker в runner уходит за ПОРТ ровно с пятью операциями (start/stop/remove/inspect/exec), а реализация была выбрана ДОКАЗАТЕЛЬСТВОМ, которое провалилось. dockerode был установлен, импортирован, инстанцирован и проверен (ping()) флагом --self-test-docker, запущенным против реальных артефактов, — никогда импортом, который бандлер мог бы удалить tree-shaking. tsup прошёл (он оставляет dependencies внешним require, так что dockerode никогда не попадал в бандл); bun build --compile (ADR 0112, standalone-бинарник) ПРОВАЛИЛСЯ, с буквально записанной ошибкой: Could not resolve: "../build/Release/cpufeatures.node". Цепочка обязательна, а не случайна — docker-modem/lib/modem.js делает require ./ssh в верхней части модуля, ssh2 делает require cpu-features, а этот необязательный нативный binding обёрнут в runtime-try/catch, которого у БАНДЛЕРА нет. Тот же класс проблемы, что ADR 0112 задокументировал для node-pty, с решающим отличием: там binding НЕОБХОДИМ, здесь он ускоряет SSH-транспорт, который этот runner никогда не использует (он говорит с локальным unix-сокетом). Измерено и НЕ принято, чтобы никто не повторял исследование: --external cpu-features компилируется (280 модулей против 79, +1,7 MB на бинарник × 5 платформ), и dockerode работает внутри бинарника — это ровно тот творческий обход, который владелец продукта заранее исключил. Поэтому runner использует execFile('docker', …), dockerode уходит из lockfile, а BROKER (PR 1.2) не наследует это решение: он поставляется в образе, который мы собираем, и никогда не становится standalone-бинарником. Сдерживание — это ТИП, а не дисциплина вызывающего: нет поля для privileged/cap_add, RedeDoContainer — это `'none' |
| 0130 | Рождается BROKER контейнеров — новый сервис Node/TS (apps/broker), единственный процесс продукта, говорящий с демоном Docker на СЕРВЕРЕ, а api никогда не получает сокет. Три неразделимых решения. Первое: порт МЕНЯЕТ место: docker-port.ts и docker-cli.ts (и два spec) уходят из apps/runner/src/ в новый пакет workspace, packages/docker-port, потребляемый runner и broker, — ровно то, что ADR 0128 написал, что сделает, никогда не копируя, потому что «второй файл с теми же пятью операциями и шестой "только для broker" — начало конца сдерживания». packages/shared был отвергнут по инварианту, который он объявляет и который packages-shared-so-tipos.spec.ts держит честным (100% тип, и production-образ api умирает с ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, если runtime-значение переживёт tsc); новый пакет НЕ имеет шага сборки и УПАКОВЫВАЕТСЯ обоими потребителями (tsup, bun build --compile), и именно поэтому api не может его потреблять и validarDecisaoDeImagem НЕ меняет места. Standalone-бинарник пережил перенос, измерено: build, build:bin, smoke и smoke:bin зелёные, бандл Bun с 79 → 83 модулей, npm pack --dry-run без изменений. Второе, и в этом назначение сервиса: broker не принимает spec контейнера. Он получает projectId плюс одну из пяти операций, ЧИТАЕТ решение Arquiteto в самой api (GET /internal/projects/:projectId/container-spec, RN-485) и СОСТАВЛЯЕТ образ, сеть, ресурсы и единственное монтирование — нет поля, где написать privileged, cap_add, network: host или свободный -v, потому что поля нет. Он перепроверяет то, что вернула api, поскольку сдерживание, зависящее от правоты вызывающего, — не сдерживание, и две валидации отвечают на разные вопросы (валидация api — о том, что АРТЕФАКТ пересматриваем; эта — парсинг недоверенного JSON в закрытый тип). Одна проверка существует только здесь и новая: ссылка на образ, начинающаяся с -, была бы прочитана CLI как ФЛАГ — execFile без shell решает инъекцию команд, а не инъекцию АРГУМЕНТОВ. Третье: api не передаёт НИКАКОГО пути: -v разрешается демоном против файловой системы ХОСТА, так что путь изнутри контейнера api молча смонтировал бы пустую папку; broker соединяет workspaceDirName (RN-109) с собственным PROJECT_WORKSPACES_HOST_ROOT и отклоняет start, когда его нет, а не угадывает. pidsLimit входит в порт по причине, которую стоит назвать: артефакт всегда нёс три числа, и отбросить одно значило бы, что он обещает потолок, которого контейнер не получает. Сдерживается пятью независимыми слоями: ни одного опубликованного порта, сеть internal: true, в которую входит только api, общий service token, сравниваемый за постоянное время, пять операций и вычисляемый spec. Он поставляется под profiles: ["container-broker"] в обоих compose и поэтому по умолчанию ВЫКЛЮЧЕН, а его образ НЕ публикуется в GHCR — docker-bake.hcl и ALVOS сохраняют четыре образа из ADR 0119. Api получает порт и HTTP-клиент, единственный ВЫЗЫВАЮЩИЙ которого — inspect: маршрут жизненного цикла контейнера начинает возвращать НАБЛЮДАЕМОЕ состояние рядом с ЗАРЕГИСТРИРОВАННЫМ, никогда не сливая их, с naoObservado, называющим причину, когда посмотреть было невозможно (RN-486), — так что таблица теперь может лгать, и чтение это говорит. Контейнеры не поднимаются: ни цикла, ни очереди, ни proposed_action container_start, а RegistrarTransicaoDeContainerUseCase остаётся побайтно прежним, включая защиту по режиму |
| 0131 | Arquiteto получает седьмой инструмент, route_modules_to_infra: один элемент {modulo, imagemCandidata, porque} на модуль ДЕЙСТВУЮЩЕГО module_map, записываемый как artifact.module_routing — без таблицы, тот же дизайн, что у artifact.module_map/artifact.project_image/artifact.c4_diagram, событие И ЕСТЬ артефакт. Arquiteto лишь ВЫДВИГАЕТ кандидатов; выбрать среди кандидатов (или отклонить всех) — работа Infra в последующем PR, этот лишь производит проверяемый список. Образ каждого элемента проходит через ТО ЖЕ правило, которое уже применяет choose_project_image (validarDecisaoDeImagem: явный tag/digest, latest отклоняется, настоящий rationale), переиспользуемое, а не реализуемое заново на элемент; новый домен (module-routing.ts) добавляет лишь то, что свойственно СПИСКУ, — пустой отклоняется (это не решение), повторяющийся модуль отклоняется (двусмысленно: какой из двух образов побеждает?), и каждый modulo должен называть существующий в действующем module_map, отклоняясь с перечислением ДОПУСТИМЫХ, то же правило, что у assign_story_modules. :direct, никогда proposed_action: маршрутизация не имеет внешнего эффекта. build_kickoff/1 — единственный источник ПОРЯДКА инструментов Arquiteto — перечисляет новый инструмент сразу после create_module_map |
| 0133 | ADR 0131 оставил висеть полфразы — «Arquiteto лишь выдвигает кандидатов, Infra выбирает, в отдельном PR», — и это тот самый PR, связанный с другой висящей фразой, в самом комментарии порта broker (ADR 0130): «поднятие — внешний эффект и не происходит без proposed_action; Infra её предлагает, с окончательными полномочиями пользователя, — другой PR». Соединение двух обнажает вопрос, на который не ответил ни один из двух ADR: broker не получает spec, он читает GET .../container-spec, который читает artifact.project_image (RN-105), — а не artifact.module_routing. Значит, выбор, который не становится новой версией artifact.project_image, проверяем и ИНЕРТЕН — поднимающийся контейнер тот, что сказал choose_project_image, без связи с тем, что Infra только что выбрала. Решение: выбор ПЕРЕИСПОЛЬЗУЕТ DecidirImagemDoProjetoUseCase целиком, вызываемый теперь с decidedBy: 'infra-lead' вместо стандартного 'arquiteto', — та же валидация (validarDecisaoDeImagem, тот же потолок ресурсов), тот же единственный «действующий» артефакт, тот же прецедент, который сам комментарий module-routing.ts уже закрепил: переиспользовать, а не реализовывать заново. Выбранный образ ДОЛЖЕН быть одним из imagemCandidata маршрутизации — никогда не выдуманным, — проверяется до прикосновения к артефакту и отклоняется по имени с перечислением допустимых кандидатов, когда это не так. Новая proposed_action container_start (maintainer, намеренно вне блока абсолютных потолков в decide.ts, по тому же рассуждению, уже записанному там для propose_execution_plan/assess_implementability, — первый выбор, а не повышение потолка) — ПЕРВЫЙ реальный вызывающий ContainerBrokerPort.start: из пяти операций, объявленных ADR 0128/0130, до этого вызывающий был только у inspect. RegistrarTransicaoDeContainerUseCase тоже получает первого вызывающего вне тестов, переводя provisioning → running (или напрямую в running из stopped, поскольку машина состояний ADR 0081 отклоняет stopped → provisioning) только после подтверждения broker. Dev agents ещё не работают внутри поднимаемого контейнера — это последующий PR |
| 0134 | Закрывает то, что ADR 0133 оставил объявленным: «dev agents ещё не работают внутри поднимаемого контейнера — это последующий PR». Engine.Actions.TerminalExecutor.decisao_de_execucao/1 получает ПЯТЫЙ исход, :executar_no_container — execution_mode: container со строкой project_containers, ЗАРЕГИСТРИРОВАННОЙ running (Engine.Containers.ProjectContainerLifecycle.running?/1, новая read-only схема Ecto по образцу Engine.Projects.Project), — и команда терминала проходит engine → api (POST internal/projects/:projectId/container-exec, новый маршрут в InternalProjectsController, а не в InternalContainersController, обращённом к broker, поскольку вызывающий здесь — engine и ничего не ЗАПИСЫВАЕТСЯ в таблицу жизненного цикла) → broker (ContainerBrokerPort.exec, последняя из пяти операций ADR 0128/0130, получившая реального вызывающего), выполняясь через docker exec вместо System.cmd внутри процесса engine. cwd, если присутствует, транслируется из пути ХОСТА (внутри PROJECT_WORKSPACES_ROOT) в /work самим engine до вызова — проверено в коде, а не предположено: raizDoProjeto broker (PROJECT_WORKSPACES_HOST_ROOT + workspaceDirName) и PROJECT_WORKSPACES_ROOT + workspace_dir_name engine разрешаются в ОДИН физический каталог, так что Workspace.ensure!/4 не потребовал никаких изменений — контейнер лишь второй наблюдатель worktree, который у dev agent уже был. Строка running — всегда лишь ЗАРЕГИСТРИРОВАННОЕ состояние (RN-486: зарегистрированное и наблюдаемое никогда не сливаются); мёртвый или удалённый извне контейнер превращает вызов broker в обычный failed_result — никогда не крэш, никогда молчаливый откат обратно за пределы контейнера, который вновь открыл бы пробел изоляции, закрываемый этим PR. Действия терминала в области настоящего контейнера проекта получают также ПОЛ автоодобрения — containerExecutionActive в DecideContext, выставляемый ProposeActionUseCase только для terminal в execution_mode: container, — который меняет НАЧАЛЬНОЕ значение decide() с require_approval на auto_approve; это пол, а не новый потолок: agent_autonomy/permissions.json по-прежнему могут понизить его ровно так, как понижают старое значение по умолчанию, и пять абсолютных потолков (область путей, git с внешним эффектом/привилегированная команда, merge в защищённую ветку, instruction_patch, параллелизм) продолжают работать поверх, без изменений. Аргумент безопасности — вторая, более сильная граница, а не отсутствие первой: decide() никогда не видит путь /work (эта трансляция происходит позже, в engine), так что лексический потолок области путей продолжает работать на тех же путях хоста, что и всегда, избыточный по отношению — а не заменённый — к пространству имён монтирования Docker и собственной перепроверке /work у broker |
| 0135 | Исполняет решение № 5 плана «реального исполнения в контейнере», уже принятое до появления этого PR: шлагбаум образа RN-105 — 409 во вкладке Код, пока кто-нибудь не решит artifact.project_image, — перестаёт отменяться для mounted/runner (ADR 0072/RN-169, пересмотрено ADR 0104/RN-421). ReadProjectCodeUseCase.portaoDoContainer теряет досрочный возврат executionMode !== 'container'; шлагбаум становится безусловным во всех трёх режимах, и ProjectCodeTab (web) перестаёт обрабатывать mounted/runner как особый случай — тот же useQuery, те же четыре состояния, ветка modoLocal удалена, что УПРОЩАЕТ компонент, а не добавляет случай. Принятая цена, сказанная прямо: каждый СУЩЕСТВУЮЩИЙ проект mounted/runner без решённого образа — включая dogfooding-проекты этого репозитория, exp001/exp002, — теряет доступ к вкладке Код в момент, когда это входит, пока Arquiteto (или выбор Infra, ADR 0133) не решит его; это действие оператора, требуемое ПОСЛЕ deploy, поэтому ветка — breaking/, а не bugfix/, даже если изменение выглядит исправлением, и скачок версии до MAJOR механический, а не ручной. RegistrarTransicaoDeContainerUseCase тоже теряет отказ 400 по режиму — project_containers теперь может регистрировать строку для всех трёх режимов, — но было исследовано и подтверждено неизменным: настоящий переход в running остаётся невозможным для mounted/runner, обеспеченный в одном месте, ModoDeExecucaoNaoSuportadoError у ContainerBrokerPort.start() (broker работает на СЕРВЕРЕ и не видит папку пользователя), который ExecuteContainerStartUseCase уже превращает в обычный container.start_failed — никогда не крэш, никогда не молчаливое падение — ДО того, как вообще будет предпринят какой-либо переход жизненного цикла, так что ни одна строка не рождается для этих режимов по реальному пути одобрения, несмотря на уход 400. Никакой второй проверки режима не добавлено на уровне api/предложения намеренно: дублировать шлагбаум, принадлежащий broker, — значит создать второе место для расхождения с первым, ровно тот урок, который уже фиксировал собственный комментарий broker, указывающий на этот ADR. Тоже исследовано и оставлено объявленным, а не исправленным здесь (вне объёма API/домена этого PR): ни propose_container_start (инструмент Infra Lead), ни GetInfraContextUseCase сегодня не ограничивают по executionMode, так что Infra Lead всё ещё может предложить container_start для проекта mounted/runner — предсуществующий пробел, тратит один цикл одобрения, падает громко и с названием, а не молча |
| 0136 | Глобальная страница контейнеров (/containers, между проектами) закрывает другой объявленный пробел комментария порта ADR 0130 — «у stop/remove ещё нет вызывающего» — двумя решениями, которые план не оговаривал. Первое: страница читает ЗАРЕГИСТРИРОВАННОЕ состояние тремя постоянными запросами (ContainersOverviewRepository, та же форма без N+1, что у ProjectsSummaryRepository), но спросить у broker НАБЛЮДАЕМОЕ состояние — это сетевой вызов на проект, поэтому подходят только строки provisioning/running, с потолком TETO_DE_VERIFICACOES_POR_CARGA (20) на загрузку, параллельно; то, что выпадает из любого правила, несёт СОБСТВЕННУЮ причину naoVerificado, никогда не путаемую с naoObservado broker (дисциплина RN-468/486, распространённая на новый экран). Второе: container_stop получает ровно калибр container_start (maintainer, настраиваемое автоодобрение, никогда не засеваемое); container_remove — единственный из трёх, который уничтожает контейнер и вынуждает переразвернуть всё, — входит в ТОТ ЖЕ абсолютный потолок git с внешним эффектом/привилегированных команд (RN-418): никогда не автоодобряемый, «sempre permitir» отклоняется в источнике. ContainerBrokerPort.remove уже был docker rm --force (удаляет даже running, за один вызов); вместо расширения машины состояний сокращением running -> removed ExecuteContainerRemoveUseCase регистрирует два реальных скачка (stopped, затем removed). «Поднять снова» буквально переиспользует container_start — ноль изменений в backend, — собирая payload из действующего решения об образе проекта вместо четвёртого типа действия |
| 0137 | Закрывает другую половину, которую ADR 0135 оставил объявленной: mounted/runner теперь требуют решённого образа (шлагбаум RN-105, во всех трёх режимах), но всё ещё не имели способа поднять контейнер — broker сервера отклоняет эти два режима на выходе, потому что их код живёт в папке, которую видит только машина ПОЛЬЗОВАТЕЛЯ. container_start/container_stop/container_remove получают ВТОРОЙ путь исполнения, разветвляемый по executionMode только в момент ИСПОЛНЕНИЯ (никогда в предложении или одобрении, оба остаются агностичными к режиму): container по-прежнему идёт через broker без изменений; mounted/runner теперь просят ENGINE передать подключённому RUNNER через три новые пары событий в том же канале Phoenix — container_start/_result, container_stop/_result, container_remove/_result, — в точности повторяя шаблон exec/exec_result, а не изобретая новый. Runner отвечает через DockerViaCli (@brabo/docker-port), ТОТ ЖЕ порт, что broker использует на сервере, доказанный до этого только --self-test-docker и теперь с реальным вызывающим; поднимаемый им образ ЧИТАЕТСЯ из действующего решения (ObterSpecDeContainerUseCase, тот же use case, который уже вызывает маршрут spec broker, вызываемый напрямую, поскольку оба работают в процессе api), никогда не выбирается заново. Engine.Actions.TerminalExecutor НЕ получил нового исхода: он уже безусловно направлял каждую команду подключённого проекта runner в канал (RN-423) — хост-или-контейнер — решение, которое RUNNER принимает внутри (EstadoDoRunner.containerAtivo, tratarExec, переключающийся на docker exec с cwd, транслированным заменой префикса, cwdParaContainer). Сдерживание в $HOME у guard.ts (RN-434) не потребовало нового кода для покрытия нового монтирования — проверено чтением всего файла: bind И ЕСТЬ estado.dir, тот же корень, уже валидированный при старте CLI, до подключения какого-либо канала. «Ни один runner не подключён»/таймаут (RunnerNaoConectadoError) и «runner попытался и отказал» (RunnerRecusouContainerError) — оба обычные исходы failed, никогда не необработанное исключение, — та же дисциплина, что у BrokerIndisponivelError/BrokerRecusouError |
| 0143 | Реальное исполнение exp001 дало десять задач dev, застрявших одновременно, и находкой была не конкретная упавшая папка — а то, что ничто не упорядочивало контейнер перед dev agent: ни один container_start так и не был предложен, а десять dev agents всё равно начали. Поэтому Engine.Dev.AgentIo.try_claim/2 — ЕДИНСТВЕННАЯ точка claim — начинает спрашивать ProjectContainerLifecycle.running?/1 (предикат, уже введённый ADR 0134, без новой машинерии) ДО вызова api, и без ЗАРЕГИСТРИРОВАННОЙ строки running агент падает в :idle, сохраняется и эмитирует dev.blocked_by_container. В ENGINE, а не только в шлагбауме activate-execution api, потому что у claim есть путь, который не покрывает ни один маршрут: РЕГИДРАТАЦИЯ не делает cast :work — DevAgentServer.init/1 -> finish_restart_recovery/1 -> try_claim/2 выполняет claim сам, так что шлагбаум только на HTTP позволил бы каждому регидратированному агенту вернуться к работе без контейнера, ровно в ситуации, когда никто не смотрит. :idle, а не новый статус, по причине, которую сам docblock try_claim/2 записал ещё в Фазе 12b: это единственное состояние, которое wake ещё спасает, и каждая защита handle_info/2 ключуется по нему — :blocked_by_container был бы состоянием, которое ничто не спасает. «Очередь опустела» от «контейнера нет» отличает СОБЫТИЕ, а не статус. Пробуждение — через outbox: RegistrarTransicaoDeContainerUseCase добавляет container.running в ТОЙ ЖЕ транзакции, что записывает прибытие, под агрегатом container (третьим, который читает Engine.Outbox.Drain, рядом с session/task), вместо заимствования task ради экономии строки запроса, с ПРОЕКТОМ в качестве aggregateId; доставляемое сообщение — СУЩЕСТВУЮЩЕЕ {:wake, :became_claimable}, чья семантика уже «теперь может быть работа», — новому понадобилась бы новая клауза handle_info/2 в обоих серверах с идентичной защитой и без какого-либо иного решения. Вторая половина, то же правило с другой стороны: TerminalExecutor.decisao_de_execucao/1 перестаёт молча деградировать — container без работающего контейнера проваливался в System.cmd ВНУТРИ процесса engine, так что изоляция ADR 0134 действовала только на счастливом пути. Теперь он отказывает (:recusar_container_ausente), повторяя два отказа, которые уже были у режима runner, с mounted в той же ветке, а catch-all сжимается до того, что ему всегда следовало покрывать одному: несуществующий проект или некорректный id. Объявленная цена и причина, по которой это могло войти только ПОСЛЕ того, как mounted получил контейнер: проект без него перестаёт работать — громко, в чём и цель |
| 0144 | Проект в режиме Смонтированный не мог иметь никакого контейнера, будучи заблокирован дважды: ExecuteContainerStartUseCase отправлял любой режим, кроме container, к RUNNER (который требует подключённого brabo-runner), а broker в источнике отклонял любой режим, кроме container. Оба отказа были о ГЕОМЕТРИИ, а не об имени режима — папка смонтированного проекта находилась где-то произвольно на диске оператора, которую демону Docker сервера незачем было видеть, — и ADR 0141 изменил ровно эту геометрию, поместив каждый смонтированный проект под ОДНУ базу, монтируемую по идентичности. Поэтому broker получает ВТОРОЙ корень, BRABO_PROJECTS_HOST_BASE, а api начинает отправлять РАЗМЕЧЕННЫЙ локатор, говорящий, какому корню принадлежит сегмент: gerenciada (управляемая папка, названная по workspace_dir_name), montada (ОТНОСИТЕЛЬНЫЙ путь под базой) или indisponivel (ни один здешний корень до него не дотягивается, с причиной). Инвариант ADR 0130 остаётся нетронутым и является причиной формы: по проводу по-прежнему идёт лишь та половина, которую корень broker не покрывает, api никогда не знает, где эти корни находятся на хосте, а broker никогда ничего не знает о проекте — ни одна сторона в одиночку не может записать произвольный путь, а отправка абсолютного вернула бы в руки вызывающего сдерживание процесса, равноценного root. Три варианта, а не два, потому что у indisponivel два РАЗНЫХ исправления — проект runner (исправление: runner, на другой стороне) и УНАСЛЕДОВАННЫЙ проект mounted, созданный вне базы (исправление: переместить папку), — и null отправлял бы оператора не туда в половине случаев; папка, которая И ЕСТЬ база, тоже попадает туда, поскольку <root>/ смонтировал бы каждый смонтированный проект внутрь контейнера одного проекта. Ни один корень не заменяет другой: недостающий называется в отказе (BaseDeProjetosNaoConfiguradaError, повторяя RaizDeWorkspacesNaoConfiguradaError), потому что управляемый корень именуется по workspace_dir_name, а базу именует пользователь, так что падение из одного в другой смонтировало бы чужой код без всякого указания почему. Над конкатенацией стоят три барьера, а не один — api отклоняет то, что вне базы, packages/docker-port отклоняет неотносительный сегмент (.., абсолютный, пустой, NUL), а raizDeProjetoValidada по-прежнему выполняется над root + segment, прежде чем он станет -v. На стороне api ветвление становится по НАЗНАЧЕНИЮ: container и mounted идут к broker, только runner — к runner, и container_stop/container_remove обязательно меняются вместе с container_start — поднять контейнер на сервере и потом попросить машину пользователя его остановить оставило бы стоять, без способа остановить, то, что стоит. И mounted ВЫБИРАЕТ образ как container, а не читает действующий, как путь runner: broker составляет из artifact.project_image, который он ЗАПРАШИВАЕТ у api, так что выбор, не записанный в этот артефакт, был бы инертным, — аргумент ADR 0133, применённый ко второму режиму |
| 0180 | Контейнер проекта работает от имени ВЛАДЕЛЬЦА ПАПКИ (--user uid:gid, измеряемый api, перепроверяемый broker, никогда не приходящий из тела запроса), чтобы dev agents могли писать в /work под --cap-drop ALL (AT-247). Добавляет поле в spec, не открывает никакой двери в сдерживании |
| 0190 | Infra поднимает контейнер САМА, когда принимается handoff к ней (AT-260, решение владельца от 01.10), пересматривая «никогда не засевается» из ADR 0133 без его редактирования: принятие засевает container_start: auto_approve для агента Infra, и СЕРВЕР Infra Lead — не модель, не новый инструмент — выбирает кандидата маршрутизации, покрывающего больше всего модулей (первого при равенстве), и предлагает container_start до первого вызова модели, ТЕМ ЖЕ путём, что инструмент, с нетронутыми отказами по режиму/состоянию (RN-566/610) и 409 без broker. runner сохраняет container_start_via_runner с одобрением, container_remove остаётся в абсолютном потолке, а закрытие RN-668 остаётся верным. То же решение владельца (AT-262): Dev Lead предлагается ОТ INFRA, только при контейнере проекта в running — двойной handoff Arquiteto уходит, и «Dev Lead приходит от Arquiteto» из ADR 0053/0085 меняется без редактирования |
| 0199 | МИНИМАЛЬНЫЕ ресурсы контейнера ВЫВОДЯТСЯ из module_map (AT-261, решение владельца от 01.10): каждый модуль может объявить resources (cpus, memoryMb, pidsLimit — все три или ни одного), и container_start с опущенными ресурсами — всегда случай подъёма сервером при принятии handoff (ADR 0190) — поднимается с СУММОЙ модулей, потому что все делят ОДИН контейнер. Модуль без объявления ставит пол контейнера на сегодняшнее значение по умолчанию вместо выдумывания числа; карта вообще без объявлений даёт ровно сегодняшнее значение по умолчанию. Сумма выше потолка — 400 для Arquiteto при создании карты; запрос ниже минимума — именованный failed |
| 0208 | Infra НАСЛЕДУЕТ сеть, которую Arquiteto объявил в artifact.project_image (AT-392, решение владельца от 02.10): подъём сервером при принятии handoff и инструмент propose_container_start без network используют egress/none от Arquiteto вместо буквального none и по-прежнему поднимаются без клика; пересматривает пункт 3 ADR 0190, отходит от §4 ADR 0065 и объявляет цену (egress достигает опубликованных портов хоста, ADR 0189). |
Локальный runner
CLI на машине пользователя — канал, учётные данные, ключи устройства, распространение, база и служба.
| # | решение |
|---|---|
| 0103 | apps/runner (новый workspace, Node/TS): CLI, работающий на машине пользователя, подключается к engine через новый канал Phoenix (/runner, одноразовый тикет, ВЫДАВАЕМЫЙ ENGINE), чтобы выполнять УЖЕ ОДОБРЕННУЮ команду агента в $SHELL пользователя, плюс интерактивный PTY-терминал для вкладки Code (engine делает чистый ретранслятор байтов). Маршрутизация всегда ПОСЛЕ одобрения — никогда второй путь исполнения. Без подключённого runner — откат к прежнему System.cmd (ADR 0072 становится fallback). Граница безопасности — НЕ sandboxing: это аутентификация + конвейер одобрения + согласие пользователя — guard.ts — best-effort, объявлено. Реальная находка: без механизма долгоживущего токена учётной записи в продукте runner повторяет логин браузера, пока не появится настоящий токен автоматизации |
| 0105 | Personal Access Token (brb_…) закрывает пункт бэклога ADR 0104: apps/runner/src/auth.ts заменяет повторённый логин (пароль+cookie на диске) долгоживущим токеном, ограниченным одним проектом. Новая таблица personal_access_tokens, хэш HMAC-SHA256+pepper через hashDeToken() (исправление бэклога, где было argon2 — неверно для секрета с ВЫСОКОЙ энтропией). Центральное решение: PAT никогда не аутентифицирует вне POST .../runner-ticket, по ПОСТРОЕНИЮ — IS_PAT_ROUTE_KEY/@RequirePatAuth() (третья ветка в JwtAuthGuard, тот же шаблон, что IS_SERVICE_ROUTE_KEY) + маршрутный PatAuthGuard, никогда не ветка в глобальном guard, которая позволила бы RolesGuard авторизовать PAT для любого маршрута роли пользователя. last_used_at — безусловный UPDATE в том же запросе валидации — без throttle, который отклонил бы с 401 законное переподключение менее чем через 5 минут |
| 0106 | Закрывает последний пункт бэклога ADR 0104: tsup упаковывает apps/runner в единый dist/index.cjs (node-pty как обязательный external — нативный binding, нельзя встроить), публикуемый как @brabo/runner СОБСТВЕННЫМ workflow, запускаемым тем же финальным тегом, что release.yml. Реальная находка, проверенная эмпирически: очевидное исправление защиты auto-run (import.meta.url === pathToFileURL(argv[1]).href) было СЛОМАНО в том самом случае, ради которого волна существует, — process.argv[1] никогда не разрешается через realpath, import.meta.url разрешается всегда, а npm install -g создаёт ровно эту асимметрию через symlink; итоговое исправление применяет realpathSync к argv[1] перед сравнением. Версия внедряется только в одноразовом checkout CI (npm pkg set), никогда не коммитится. Смягчает откат latest в npm при повторной публикации одного из шести уже существующих в репозитории осиротевших тегов — публикует с собственным dist-tag, когда целевая версия старше текущего latest. breaking/ — действие оператора (создать Automation Token npm), требуемое до того, как публикация заработает сквозным образом |
| 0112 | Standalone-бинарник @brabo/runner через bun build --compile — спутник ADR 0106, закрывающий пункт бэклога ADR 0104, явно отложенный там. node-pty разрешает свой нативный аддон .node по ВЫЧИСЛЯЕМОМУ в runtime пути, который Bun не может встроить статически (доказано эмпирически: наивная сборка компилируется чисто и падает в runtime); исправление встраивает каждый нужный node-pty файл через with { type: 'file' } (единственный механизм Bun, принимающий литеральный спецификатор) и заново извлекает их во ВРЕМЕННЫЙ РЕАЛЬНЫЙ каталог при старте, сохраняя исходную относительную раскладку, чтобы собственные относительные require() у node-pty разрешались без модификации. Две ошибки, найденные только при запуске скомпилированного бинарника: --external node-pty обязателен в самой команде bun build (иначе Bun молча упаковывает await import('node-pty') некомпилированного fallback, бросая вводящую в заблуждение ошибку в runtime вместо провала сборки), а защите автозапуска из ADR 0106 понадобилась вторая ветка для случая, когда process.argv[1] — виртуальный путь /$bunfs/root/... внутри скомпилированного бинарника. Матрица из пяти целей (linux-x64/linux-arm64/darwin-x64/darwin-arm64/win32-x64), каждая собирается на собственном нативном runner; реальным запуском в этом исследовании проверен только linux-x64 — остальные четыре, особенно Windows, обоснованы по исходному коду node-pty, и их первый реальный запуск — следующий тег версии |
| 0118 | Настройка runner через браузер: пара ключей Ed25519, сгенерированная в браузере (Web Crypto), регистрируется как новая строка runner_device_keys, и POST .../runner-ticket принимает её самоподписанный короткоживущий (≤60 с) JWT EdDSA как ВТОРЫЕ учётные данные рядом с PAT — дополнительные, никогда не заменяющие, и явно не двойная аутентификация с JWT сессии, которую RN-439 уже запрещает. Новый маршрут @Public() проксирует бинарник, уже опубликованный в GitHub Releases (кэш в памяти на 5 минут для разрешения asset, никогда для байтов), вместо сборки или хранения второй копии. File System Access API записывает настроенную папку прямо в Chromium (бинарник + конфиг + приватный ключ устройства, три файла, один клик); во всех остальных местах план Б — две простые загрузки. Принятое ограничение: браузер не может выставить бит исполнения Unix, так что chmod +x остаётся ручным. --project/--dir/--token становятся необязательными в CLI при наличии локального конфига. Явно отвергнуто в той же сессии: runner, самостоятельно обнаруживающий локальный контейнер Docker/Kubernetes и подключающийся по SSH, — это вновь открыло бы Фазу 25b, отрезанную намеренно |
| 0145 | runner направлял каждую одобренную команду терминала в подключённый CLI, как только workspace был проверен и runner подключён, — никогда не проверяя ЗАРЕГИСТРИРОВАННЫЙ контейнер running, тот же предикат, который container/mounted уже требовали с ADR 0143. Runner решал хост-или-контейнер внутри себя (ADR 0137), и без поднятого контейнера молча падал на голый ХОСТ — ровно то падение, которое ADR 0143 закрыл для двух других режимов. Второй дефект, объявленный открытым с RN-478: Workspace.ensure!/4 пытался выполнить ЛОКАЛЬНЫЕ File.mkdir_p!/git init на пути проекта runner, существующем только на ХОСТЕ, для которого у процесса engine никогда не было bind-mount, — предыдущее исправление лишь улучшило СООБЩЕНИЕ о сбое, а dev agent по-прежнему не имел, где работать. Поэтому три предусловия (проверен, подключён, контейнер running) уходят в Engine.Runners.RunnerReadiness, общий для TerminalExecutor и материализации worktree, которая теперь выполняется на РЕАЛЬНОЙ машине пользователя через ТОТ ЖЕ канал exec/exec_result, который уже выполняет одобренный терминал (Engine.Actions.Workspace.RunnerGit, новый), а не внутри процесса engine, — четыре операции Engine.Dev.WorktreeManager разветвляются так же, а периодическая задача очистки (Engine.Dev.WorktreeCleanup) ПРОПУСКАЕТ проект runner, который сейчас не готов, вместо того чтобы сломаться или счесть его сиротой. Протокол exec получает необязательное поле env (RN-507), чтобы учётные данные для git fetch (ADR 0056) могли путешествовать в окружении дочернего процесса на машине пользователя, — сливаясь поверх process.env, никогда не заменяя, никогда не отправляясь в docker exec (поддержки env в packages/docker-port нет по замыслу) и никогда не логируясь. Третья находка: container_start обслуживал и runner, но его payload (imagem/network/resources/rationale — Infra ВЫБИРАЕТ кандидата) там никогда не применялся — не из чего выбирать, поскольку broker никогда не дотягивается до папки проекта runner. Поэтому container_start становится исключительным для container/mounted (оба через broker, RN-503), а новое действие, container_start_via_runner (схема: только необязательный rationale), покрывает runner — его инструмент в Infra Lead проверяет execution_mode и подключённость runner ЛОКАЛЬНО (без HTTP) перед предложением, отклоняя по имени, а не предлагая вслепую, в отличие от предсуществующего, объявленного и всё ещё открытого пробела propose_container_start (RN-494) |
| 0147 | Запись в папку, которую пользователь выбрал ГДЕ УГОДНО на хосте, имеет ровно три возможных механизма — запускать api/engine нативно, держать живой процесс на хосте или заставить браузер писать через File System Access API, — и непрерывен только второй. brabo-runner уже является этим процессом, поэтому вместо поставки второго бинарника «зеркала» он становится ОДНИМ локальным агентом, который ОБЪЯВЛЯЕТ ВОЗМОЖНОСТИ (espelho, exec, pty) при подключении, а сервер предоставляет только то, что требует execution_mode проекта: одна установка, одна аутентификация, один отзыв. Объявляются в params join, потому что сегодня join НЕМОЙ с обеих сторон — runner отправляет {}, а сигнатура канала буквально игнорирует _params, с ролью, приходящей из kind тикета, — и предоставляются пересечением, хранясь в socket.assigns как состояние ПОДКЛЮЧЕНИЯ и никогда в таблице, — то же рассуждение, что держит containerAtivo на стороне клиента и уже позволяет записи :global в Registry умирать вместе с pid. Требуемая, но не объявленная возможность ОТКЛОНЯЕТ JOIN, и это правильная форма, потому что runner уже обрабатывает JoinRecusadoError как фатальную без повтора: неспособный runner не должен крутиться в цикле backoff. Возможность идёт в join, а не в push после join, как workspace_confirm, потому что тот описывает WORKSPACE, который меняется, а возможность описывает БИНАРНИК, неизменный на время подключения. Зеркало получает собственную защиту области, сестру guard.ts, переиспользуя двойной проход лексика+realpath, который уже ловит symlink посреди пути, и отклоняет три вещи: symlink, убегающий из назначения, назначение, СОДЕРЖАЩЕЕ workspace, и назначение ВНУТРИ него — два последних являются одной и той же петлёй с bind из ADR 0141, в противоположных направлениях. Одно направление и НИКОГДА --delete: файл, удалённый в workspace, остаётся в назначении, и накопление — объявленная цена. НЕ требует Docker и получает ОТДЕЛЬНЫЙ предикат вместо RunnerReadiness с флагом — общая функция с параметром «пропустить контейнер» — ровно то, как третье предусловие потом случайно отвалится для exec; зеркалу оно не нужно, потому что это предусловие существует, чтобы команда не упала на голый ХОСТ (ADR 0145), а копированию файла на машине пользователя некуда падать с хоста. Отзыв оказывается слепым так, как недооценивает даже docblock кода: он объявляет срез как «без видения maintainer», но у PAT пять маршрутов, а у ключей устройства два — developer не может перечислить даже СВОИ ключи, а то, чего не видно, не отозвать. Поэтому перечисление добавляется, и отзыв перестаёт означать лишь «без новых тикетов»: теперь он обрывает ЖИВОЙ канал, который Registry.whereis/1 уже делает достижимым. Синхронизация запускается ПО СОБЫТИЮ (конец хода, коммит) и никогда через fs.watch, что было бы неограниченной работой, запускаемой чем угодно — включая записи самого зеркала, что является петлёй. И строка, придающая всему этому форму: согласиться на конфигурацию — не значит одобрить действие. Запись зеркала не проходит через proposed_action, потому что это не агент, просящий действовать; это система, пишущая по пути, который объявил пользователь. Сделать её командой терминала было бы противоположностью — она попала бы в область путей ADR 0055, превратилась бы в очередь рутинных одобрений и разъела бы сам потолок, придающий клику смысл |
| 0151 | Runner получает согласованную БАЗУ, а папки начинают создаваться через канал, — намеренно узкий срез, с runner на МАШИНУ, отодвинутым в следующую фазу. Сегодня у runner нет базы: единственный корень — --dir, а без него корень — это cwd (index.ts:242-250); estado.dir — единичный путь, питающий защиту cwd, pty, cwdParaContainer и workspace_confirm. База существует только в api, и этот самый файл письменно запрещает (project-workspaces-root.ts:421-438) вносить правило базы в caminhoDeWorkspaceLocalValido, который выполняется при КАЖДОМ чтении. Решение: установщик согласует ОДНУ базу, хранимую локально; каждый проект — подпапка; база никогда не принимается по проводу — та же форма, что у broker (ADR 0144), где корень принадлежит исполняющему, а путешествует только СЕГМЕНТ. Защита ПЕРЕИСПОЛЬЗУЕТ dentroDoEscopo и отказ петли по СЕГМЕНТУ из espelho-guard.ts:208-233 (/base-outra НЕ находится внутри /base), никогда четвёртую копию правила — прецедент RN-515. Новая пара workspace_create/workspace_create_result по образцу exec/exec_result, после чего runner отправляет уже существующий workspace_confirm, так что никакой новый путь записи не рождается, engine по-прежнему не пишет в таблицу, и один-единственный маршрут продолжает ставить штамп workspace_verified_at. Фиксирует уточнение имени: workspace.verified не существует нигде — событие называется project.workspace_verified, — а workspace_confirm ОДНОНАПРАВЛЕН (terminal_channel.ex:356-373 отвечает {:noreply, socket}), так что пара, которой нужен ответ, не может быть на нём смоделирована. Возможность входит в словарь только в ту сессию, в которую входит код (RN-514). Собственный предикат с ДВУМЯ предусловиями, буквально перенимающий espelho.ex:19-24 о том, почему RunnerReadiness никогда не получает флаг «пропустить контейнер». Это не proposed_action. Объявляет ПЯТЬ независимых связей, которые runner на машину должен преодолеть, одна из них — миграция, и одна — breaking/ |
| 0154 | Идентичность локального агента становится идентичностью МАШИНЫ, а не проекта. Установщик уже подходит близко — с RN-531 он скачивает бинарник, сверяет его с подписанным checksums.txt и устанавливает через install -m 0755, без ручного chmod, — и затем ОСТАНАВЛИВАЕТСЯ, говоря это в собственной последней строке: он не спаривает агента, а ключ устройства всё ещё приходит с экрана проекта. Блокирует не бинарник, а ИДЕНТИЧНОСТЬ: service install нужны projectId и ключ, а в момент установки проекта не существует — ни учётной записи, ни workspace. ФАЗА 29 измерила «один runner на проект» как навязанное в пяти независимых местах; повторное измерение с arquivo:linha показывает, что три из них не требуют никаких изменений, и это находка данного ADR. RunnerSocket.connect/3 аутентифицирует по ТИКЕТУ, тикет запрашивается на проект, а атомарное потребление происходит в join против project_id топика — так что топик (terminal_channel.ex:204), id сокета (runner_socket.ex:73) и тикет описывают ПОДКЛЮЧЕНИЕ, а не процесс. Один процесс, открывающий N подключений, по одному на обслуживаемый проект, удовлетворяет всем трём побайтно и сохраняет отказ второму runner в том же проекте, что является реальной гарантией, а не случайностью. Остаётся то, что действительно принадлежит процессу: учётные данные, запрашивающие эти N тикетов, и unit, поддерживающий его жизнь. Поэтому runner_device_keys.project_id становится nullable, и NULL ОЗНАЧАЕТ машину — та же таблица, тот же kid внутри приватного JWK (RN-475), тот же отзыв; сестринская таблица рассматривалась и была отвергнута, потому что PatAuthGuard находит публичный ключ по kid и не должен знать его вид, а две таблицы означали бы два поиска, второй из которых забыли бы ровно один раз. Ключ машины не предоставляет ничего, чего у владеющего им пользователя уже не было: runner-ticket остаётся на проект, а авторизация по-прежнему разрешается против запрошенного проекта. Runner УЗНАЁТ, какие проекты обслуживает, через новый GET runner/projects, а не прочёсывая базу — угадывать по имени папки — тот класс ошибок, который отверг ADR 0141. Unit становится одним на машину, а проектные продолжают работать, при этом status ГОВОРИТ, что они существуют, вместо того чтобы позволить шести процессам молча спорить за три проекта. Отвергнуто: слияние топиков в runner:<userId>, ключ, пересекающий пользователей, обнаружение проектов по диску и любое исключение в decide.ts — этот ADR перемещает идентичность, никогда полномочия. Объявляет цену, которую предсказала ФАЗА 29: отзыв теперь обрывает агента для всех проектов этой машины |
| 0174 | Standalone-бинарник runner перестаёт обещать Mac Intel: четыре цели вместо пяти из ADR 0112. Измерено (AT-065): у macos-13 нет runner (образ выведен GitHub, 24h00m01s в очереди и отменён на трёх тегах), а замена macos-15-intel планируется и собирается, но проваливает --self-test-pty, потому что под Bun onData у node-pty никогда не срабатывает (oven-sh/bun#25822, открыт) — то же доказательство проходит под Node. Решено мейнтейнером: darwin-x64 уходит из матрицы и из ALVOS_ESPERADOS, прокси GET /runner-releases/binary отклоняет его собственным 400 со ссылкой на npm-пакет, install.sh на darwin-amd64 не скачивает и велит запустить npm install -g @brabo/runner, а браузер всё ещё его обнаруживает, но пропускает загрузку. scripts/ci/alvos-do-runner.spec.ts связывает четыре места, перечисляющих цели. Путь назад: новый ADR, когда Bun выпустит исправление |
| 0187 | Standalone-бинарник runner перестаёт обещать Windows: три цели вместо четырёх из ADR 0174, по той же схеме. Измерено в четырёх прогонах через workflow_dispatch (AT-343, запуски 36775746724, 36779817686, 36780804339, 36781729045): бинарник собирается, usage работает, и node-pty загружается, когда виртуальный путь Windows распознан, но под Bun net.Socket поверх выходного named pipe ConPTY завершается после первого фрагмента — даже cmd.exe /c echo умирает с 0xC000013A (семейство oven-sh/bun#25822). Решено мейнтейнером: win32-x64 уходит из матрицы и из ALVOS_ESPERADOS, прокси GET /runner-releases/binary отклоняет его собственным 400 со ссылкой на npm install -g @brabo/runner, отказ Windows в install.sh называет путь npm, а браузер всё ещё его обнаруживает, но пропускает загрузку. Путь назад: новый ADR, когда Bun выпустит исправление |
| 0193 | Git С УЧЁТНЫМИ ДАННЫМИ выполняется на ХОСТЕ runner, код — в контейнере (AT-116, решение владельца от 01.10): аутентифицированный git fetch из RunnerGit идёт ПОМЕЧЕННЫМ (gitCredenciado: true, выставляется только RunnerRouter.exec_git_credenciado/5), и runner выполняет его на хосте даже при активном контейнере, где env доходит до дочернего процесса, — та же папка, поскольку estado.dir — это bind-mount /work. Ни один порт ADR 0130 не меняется (DockerPort.exec по-прежнему без env), RunnerReadiness остаётся побайтно прежним, а отказ RN-558 сужается до env БЕЗ метки (на практике — runner старше этого ADR) |
| 0201 | Отзыв нацелен на КЛЮЧ, а не на пару {project, user} (AT-013, решение владельца от 01.10, пересматривающее RN-520 поверх ADR 0154 и ADR 0155). Тикет сокета runner записывает, КАКИЕ учётные данные его запросили (credential_kind/credential_id, nullable, миграция Ecto в собственной схеме engine), PatAuthGuard передаёт их дальше, сокет хранит их в assigns и в своём id, а POST /internal/runner/disconnect-credential аннулирует ожидающие тикеты этих учётных данных и спрашивает КАЖДЫЙ зарегистрированный runner, родился ли он от них, — так что другой runner того же пользователя, с PAT или другим ключом, остаётся стоять, а ключ МАШИНЫ — это один запрос вместо пары, применённой N раз. Отзыв PAT (RN-426/427) теперь тоже обрывает его подключения. Две переходные детали сохраняют падение того, что падало: УНАСЛЕДОВАННОЕ подключение (тикет без учётных данных) падает по паре в проектах, которые отправляет api, а если engine не знает маршрута, отзыв ключа возвращается к disconnectRunnerOfUser. Маршрут пары остаётся для удаления участника (RN-615). Диалоги подтверждения меняют только текст (RN-561); runner не меняется |
| 0203 | Настройка runner через браузер из ADR 0118 выводится из употребления (AT-014, решение владельца от 01.10, закрывает BRB-031): браузер больше не генерирует ключ, не скачивает бинарник и не пишет папку, а RunnerOnboardingPanel показывает вместо этого команду install.sh — установщик уже скачивает проверенный бинарник, устанавливает его исполняемым (install -m 0755, так что ручного chmod +x нет нигде) и спаривает машину через терминал. apps/web/src/lib/runner-bootstrap.ts и POST /projects/:projectId/runner-device-keys удалены (без других вызывающих; BREAKING), тогда как GET/DELETE и внутренний маршрут ключа машины остаются. Ранее зарегистрированные ключи проекта продолжают работать и отзываться. Объявлено: установки с командой теряют способ без PAT спарить вторую машину, а GET /runner-releases/binary остаётся без production-потребителя, сохраняемый отдельным решением |
Аутентификация, доступ и секреты
Собственная аутентификация, роли и их потолки, членство в workspace, усиление защиты между источниками и конверты секретов.
| # | решение |
|---|---|
| 0031 | Auth в домене api: argon2id с фиксированными параметрами, access token EdDSA с производным ключом, ротация refresh с отзывом семейства при повторном использовании, lockout с ключом по e-mail (а не по id) и единый инвариант, закрывающий перечисление |
| 0032 | Атомарный отказ от Keycloak: собственный эмитент в guard без прикосновения к RBAC, /internal/* вне JWT с общим секретом, web-сессия в httpOnly-cookie с CSRF, и мигрированный пользователь неотличим от несуществующего e-mail |
| 0037 | CORS, которого у engine не было (/health отвечал 200, а браузер отбрасывал ответ), собственный plug вместо Corsica с /internal и /metrics вне allowlist, WEB_ORIGIN, читаемый в одном месте, и strictPort, потому что порт является частью контракта CORS |
| 0050 | Учётные данные всегда шифруются и записываются; проверка становится отдельным действием. Шлагбаум ADR 0004 (тестировать ДО сохранения) казался благоразумным и давал худший исход: поле только для записи + отказ = пользователь без учётных данных И без текста, чтобы исправить, потому что экран никогда не показывает заново то, что он вставил. Сохранить и проверить — разные вопросы, и связывать их значило делать второй зависимым от того, есть ли ответ на первый сейчас. Действует для LLM и git вместе — та же таблица, то же обещание, и разное обращение дало бы два экрана с разными правилами для одного объекта. У вердикта ТРИ состояния, потому что тестер — NO-OP в ollama/anthropic/openai: в двоичном варианте они возвращали бы ok, и экран утверждал бы проверку, которую никто не делал (правило capability из ADR 0041, применённое здесь). Плохой ключ перестаёт быть HTTP-исключением и становится результатом 200, поэтому два @Catch, которые его отображали, ушли — фильтр, который не может сработать, — мёртвое правило |
| 0058 | Закрытый CSP в api и область проекта, удерживаемая в корне. Пересматривает пункт 7 из 0027, который решил contentSecurityPolicy: false, потому что «api отдаёт JSON, а CSP — дело web», — правда, которая не в этом суть: альтернатива ОБЩЕМУ CSP — не отсутствие заголовка, а КОНКРЕТНЫЙ CSP, и для того, кто отдаёт только JSON, конкретный — это default-src 'none', стоящий ноль поведения. Два пути выводят ответ api из-под CSP web: ПРЯМАЯ навигация к маршруту (вставленная ссылка, редирект), отрисованная на источнике api, и frame-ancestors, действующий только в документе, помещённом во фрейм. Вне production профиль ослабляет лишь то, что требует Swagger UI на /docs, при ТОМ ЖЕ условии, что монтирует Swagger, — если однажды он поднимется в production, CSP последует за ним, а не молча его заблокирует. Опции переезжают из литерала main.ts в security-headers.ts по причине, которая уже переместила cors-origins.ts: при boot они не были тестируемы, а доказательством должен быть настоящий запрос, потому что false и объект директив — оба «допустимая конфигурация» и различаются лишь в HTTP-ответе. Вместе с этим приходит сдерживание projectId: он приходит из параметра маршрута с уже декодированным Express percent-encoding, так что ..%2F..%2Fetc превращался в ../../etc и выходил за корень — затрагивая не только чтение и ЗАПИСЬ permissions.json, но и область, которая АВТОРИЗУЕТ терминал по 0055, то есть политику, указывающую не туда. Валидация остаётся там, где выводится корень, а не в каждом вызывающем, по той же причине, по которой появилась функция: продублированная проверка — проверка, которая однажды разойдётся. Также фиксирует семь исключений CodeQL с причиной каждого — среди них js/insufficient-password-hash над HMAC state OAuth, где нет пароля и медленный хэш был бы ошибкой |
| 0059 | У ключа, подписывающего state git OAuth, нет значения по умолчанию. Родилось из одного из исключений 0058: исключить js/insufficient-password-hash было правильно — пароля там нет, «пароль» — это GIT_OAUTH_STATE_SECRET, HMAC-ключ сервера, и HMAC-SHA256 — правильный примитив. Чего аргумент не учёл: серверный ключ с известным значением по умолчанию ничего не подписывает, а именно так и было: обе точки чтения делали process.env.GIT_OAUTH_STATE_SECRET ?? 'dev-oauth-state-secret-change-me'. Тот же режим отказа, что у WEB_ORIGIN, уже закрытый в cors-origins.ts, и хуже по двум причинам — значение ПУБЛИЧНО (оно в .env.example open source-репозитория; это не слабое значение по умолчанию, это опубликованный секрет), а защищает оно state, единственное препятствие к подделке callback маршрута, который должен быть публичным: с известным ключом любой подпишет нужный ему {projectId, userId, provider} и заставит callback записать в проект из этого payload git-токен, полученный от провайдера. Деталь, определяющая форму исправления: docker-compose.prod.yml подставлял литерал как fallback, так что на РЕАЛЬНОМ пути ошибки переменная БЫЛА определена — требование лишь «не пустая» ничего бы не поймало, и поэтому литерал из примера отклоняется явно, вместе с порогом 16 символов, превращающим вечное «слабый = CSRF» из таблицы конфигурации в настоящую проверку. Проверка выполняется при BOOT, а не при первом использовании: api, падающая только когда кто-то попытается подключить git, неделями стояла бы, принимая state, подписанный публичным ключом. Решено в одной функции по причине RN-092 — было две копии одного литерала, и их расхождение заставило бы callback отклонять каждый законный state. Намеренный слом: production без определённой переменной перестаёт подниматься, что является целью, а не побочным эффектом. Четыре сестринских секрета того же compose (AUTH_JWT_SECRET, BRABO_SERVICE_TOKEN, CREDENTIALS_MASTER_KEY, SECRET_KEY_BASE) объявленно остались за бортом, в бэклоге, — позже закрыты тем же правилом, без нового ADR, в RN-114 |
| 0084 | Социальный логин (GitHub/GitLab), пересматривающий бэклог ADR 0031/0032 — запрет отменён только для этого направления, по явному решению владельца продукта. SocialLoginCallbackUseCase завершается вызовом ТОГО ЖЕ EmitirSessaoUseCase, что вход по паролю, — никакого второго формата сессии. OAuth-клиент переиспользуется (GithubOauthClient/GitlabOauthClient получают buildLoginAuthorizeUrl/fetchIdentity), а state — НЕТ: domain/auth/social-oauth-state.ts подписывает ТЕМ ЖЕ ключом GIT_OAUTH_STATE_SECRET лишь потому, что payload структурно несовместим, — дискриминант purpose: 'social_login', проверяемый ДО любого другого поля, — то, что мешает принять здесь state из потока ПОДКЛЮЧЕНИЯ git (RN-273), что было бы эскалацией привилегий. Новая таблица social_identities (миграция 0047), ключ (provider, provider_user_id) — никогда e-mail/логин, которые могут сменить владельца (RN-276). Callback решает в три шага: идентичность уже известна → вход; e-mail совпадает с существующей учётной записью И подтверждён провайдером → привязка, и попутно подтверждается e-mail учётной записи, если ещё не был (RN-279); e-mail совпадает, но НЕ подтверждён → отказ, account takeover (RN-274); без соответствующей учётной записи → провижининг НОВОГО пользователя без пароля, переиспользуя то же состояние «ожидает» миграции Keycloak (RN-278) — здесь e-mail не обязан быть подтверждённым, потому что нет учётной записи, которую надо защищать (RN-275). Переиспользует ТО ЖЕ OAuth-приложение потока подключения git — ноль новых переменных окружения, — меняя только redirect_uri и scope (минимальный, без repo/api) на запрос; действие ОПЕРАТОРА по регистрации второго callback у провайдера — то, что оправдывает ветку breaking/. Access token никогда не путешествует в URL: cookie сессии записываются в callback, а boot web уже сам восстанавливает сессию |
| 0096 | Настоящий SMTP в MailSender, закрывающий пункт бэклога, открытый с 0032 («настоящий SMTP остаётся будущей настройкой»). nodemailer поверх чистого SMTP-транспорта — СТРОЧНЫЙ протокол с состоянием/MIME/STARTTLS/AUTH, отличный от HTTP JSON API, которые интегрирует остальной продукт, — за MAIL_TRANSPORT (log, значение по умолчанию в том числе в production, или smtp), выбираемый через useFactory в AuthUseCasesModule без прикосновения к какому-либо use case. SMTP_HOST/SMTP_USER/SMTP_PASSWORD/SMTP_FROM следуют шаблону RN-114 в production, когда режим smtp, но БЕЗ публичного значения по умолчанию, которое было у четырёх исходных RN-114, — мерка вступает лишь когда оператор выбрал smtp, проверяясь в конструкторе SmtpMailSender (вызываемом useFactory при сборке графа провайдеров, тот же дизайн, что CREDENTIALS_MASTER_KEY), а не жадным вызовом в main.ts. Тело — простой текст, никогда HTML — порт не несёт структуры для насыщенного тела. Исследование нашло реальный пробел до реализации: у email_verification не было никакого web-маршрута (verificarEmail существовал в apps/web/src/lib/auth.ts без вызывающего) — /verificar-email (VerifyEmailPage.tsx) закрывает это, повторяя SetPasswordPage.tsx |
| 0127 | ResolveEffectiveRoleUseCase.forProject остаётся projectRole ?? workspaceRole — строка проекта ПЕРЕКРЫВАЕТ строку workspace в ОБОИХ направлениях, и ограничение кого-то в чувствительном проекте (developer в workspace → viewer в проекте) — намеренная возможность, которую стёр бы «больший из двух». Вместо этого входят ДВА потолка на пути записи, отклоняемые с 403 и нигде не настраиваемые: никто не понижает owner workspace (так что maintainer больше не может запереть владельца вне собственного проекта), и никто не понижает САМОГО СЕБЯ (отмена требует той самой роли, от которой отказываются). owner читается из workspace_members.role, никогда из workspaces.created_by — created_by — исторический факт, не появляющийся ни в каком пути авторизации, и потолок, читающий его, защищал бы создателя, уже передавшего владение, оставляя открытым текущего владельца. Потолок 2 сформулирован БЕЗ порога («вы сами», а не «ниже maintainer»): версия с порогом копирует число из @RequireRole контроллера в домен и молча устаревает, если минимум маршрута изменится; объявленная цена — owner, понижающий себя до maintainer (обратимо), тоже отклоняется. Они живут в USE CASE с правилом в domain/iam/tetos-de-rebaixamento.ts, а не в RolesGuard, который авторизует ВЫЗЫВАЮЩЕГО против маршрута и не видит ни тела (dto.role), ни цели; ФОРМА следует domain/actions/decide.ts (RN-154/RN-418) без его исхода, поскольку синхронный вызов человека не имеет очереди одобрения, куда упасть. Три описания OpenAPI, которые RN-471 объявила ложными («больший из этого и того, что у человека уже есть», «включает тех, кто наследует доступ от workspace»), исправлены здесь. Объявлено всё ещё возможным: понизить другого maintainer, само-ПОВЫШЕНИЕ (без изменений, здесь не открыто), POST на уровне workspace, у которого нет никакого потолка, и самопонижение через УДАЛЕНИЕ — RemoveProjectMemberUseCase не получил потолка, и предпосылка, что удаление всегда безобидно, ЛОЖНА (безобидно, когда роль workspace удерживает падение; необратимое самопонижение, когда не удерживает). Оба случая удаления зафиксированы в тестах, включая открытый. Web намеренно оставлен в стороне, и по структурной причине: экран может вычислить потолок 2, но НЕ потолок 1, поскольку listProjectMembers возвращает роль строки проекта, а роль цели в workspace вне досягаемости клиента, — половина шлагбаума была бы вторым источником роли, от которого предостерегает RN-471; тем временем отказ виден через toast mensagemDaApi, добавленный #444 |
| 0156 | Потолок самопонижения достигает ДРУГОЙ двери: удаления собственной строки project_members. ADR 0127 поставил потолок только на пути записи и оставил это движение снаружи письменно, закрепив его в тесте, чьё имя документировало дыру, — «a remoção AINDA consegue rebaixar quem a chamou», — и предсказав цену её закрытия (сигнатура RemoveProjectMemberUseCase.execute). Это и есть тот следующий человек. Удаление не стирает роль, а МЕНЯЕТ эффективную: projectRole ?? workspaceRole (RN-471) начинает разрешаться по второму члену, так что maintainer, чьи полномочия шли от строки проекта, viewer в workspace, безвозвратно падал до viewer — восстановление — это POST :projectId/members, требующий того самого только что оставленного maintainer; без какой-либо роли в workspace падение — до ОТСУТСТВИЯ доступа. Правило — потолок 2, а не новый потолок: ehAutoRebaixamento сравнивает сегодняшнюю эффективную роль с завтрашней и никогда не должен был знать, каким маршрутом пришла вторая, поэтому remocaoEhAutoRebaixamento ДЕЛЕГИРУЕТ ей, передавая роль workspace как запрошенную, без повторной реализации какого-либо сравнения. Она существует как отдельная функция ради единственного случая, который сигнатура другой не может выразить, — актор без роли в workspace, где «после» — это ничто, не являющееся Role. Потолок 1 намеренно не получает пары: owner — вершина ROLE_ORDER, так что удаление строки проекта владельца workspace может лишь ПОВЫСИТЬ его эффективную роль — фактически это единственный способ отменить ограничение, которое потолок 1 запрещает создавать, и потолок там отклонял бы лишь безобидные движения, делая постоянным состояние, ради устранения которого родился ADR 0127. Сформулирован без порога, как потолок 2, и объявленная цена идёт вместе: самоудаление owner проекта до maintainer workspace обратимо и тоже отклоняется — единственное безобидное движение, которое проходило и теперь отклоняется. Сообщение СОБСТВЕННОЕ, потому что нажавший «удалить» не просил смены роли и заслуживает объяснения механизма, а не фразы add. Отвергнуто: потолок владельца при удалении, второе правило сравнения, прикосновение к forProject, распространение на область workspace (чей POST ещё без потолка, а @Delete участника не существует), блокировка удаления ДРУГИХ людей, которые падают, и шлагбаум на клиенте — MembersSection.handleRemove уже показывает mensagemDaApi в toast, а вычислять лишь ту половину, которую может вычислить экран, — это половина шлагбаума, которую отверг ADR 0127. Закрывает BRB-001 |
| 0157 | Потолок самодвижения достигает upsert WORKSPACE, а само-ПОВЫШЕНИЕ — которое ADR 0127 письменно оставил стоять — становится брешью и закрывается. AddWorkspaceMemberUseCase был двенадцатистрочным проксированием, которое никогда не получало актора, на маршруте, требующем owner: тот же класс дефекта, что у project_members, уровнем выше и хуже, потому что здесь нет уровня выше, чтобы удержать падение, и не существует никакого @Delete участника (измерено; ADR 0156 использовал тот же факт, чтобы это отложить). owner, записывающий себя viewer, терял весь workspace — каждый проект, расходы на LLM, которые принадлежат ему по RN-060, и саму возможность пригласить того, кто бы его восстановил, — а отмена была этим же маршрутом, требующим только что оставленного owner. Потолок не считает владельцев, по буквальному критерию ADR 0127 («сформулированный как клауза, он не имеет числа, которое могло бы устареть»): подсчёт добавляет чтение, которое может разойтись с записью, требует решать, считается ли второй владелец, ушедший из компании, и заставляет один и тот же запрос с тем же телом проходить сегодня и отклоняться завтра. Самый сильный аргумент — клауза уже производит инвариант, ради которого существовал бы подсчёт: у workspace никогда не бывает нуля владельцев, потому что удаление последнего потребовало бы, чтобы этот владелец сделал это сам, — ровно отклоняемое движение. Потолок 1 здесь не получает пары, и это было рассмотрено и не отражено: в отличие от удаления (ADR 0156), движение СУЩЕСТВУЕТ — владелец может понизить другого владельца. Оно отклонено, потому что потолок 1 — правило об инверсии иерархии, а @RequireRole('owner') уже делает инверсию невозможной в этой области: тот, кто может вызвать, никогда не ниже того, кого затрагивает. Добавить его вместе с отсутствующим маршрутом удаления сделало бы owner поглощающим состоянием, из которого никто не выходит по HTTP, — увольнение, ротация владения и исправление ошибочного приглашения потребовали бы прямой записи в базу, — класс состояния, ради устранения которого родился ADR 0127, восстановленный с обратным знаком. Поэтому понижение другого владельца ОСТАЁТСЯ, обратимое тем же маршрутом любым оставшимся владельцем. Самоповышение закрывается в обоих маршрутах членства. ADR 0127 записал его как остающуюся возможность («потолки — о спуске»); вновь открыв вопрос, ответ здесь таков: две половины — одно движение, человек, в одиночку решающий, какие у него полномочия, — и верхняя половина единственная, которая ЭСКАЛИРУЕТ привилегии, ровно то, о чём тот ADR мог утверждать, что его потолки никогда этого не делают. Переписать ТУ ЖЕ роль по-прежнему можно: идемпотентный upsert — не движение, и отказ сломал бы seed.ts. Сравнение не копируется: оно становится классификатором autoMovimentoDoProprioPapel, возвращающим НАПРАВЛЕНИЕ вместо булева, потому что вызывающему оно нужно, чтобы выбрать сообщение. ehAutoRebaixamento выживает как чтение этого классификатора — намеренно, а не ради совместимости: remocaoEhAutoRebaixamento делегирует ей, и её расширение заставило бы УДАЛЕНИЕ отклонять и самоповышение, ломая безобидное движение, которое ADR 0156 защитил по имени. Четыре сообщения, по одному на направление и на область — никто, кто пытался себя повысить, не читает фразу о понижении, и никого, остановленного в workspace, не отправляют просить maintainer. Отвергнуто: подсчёт владельцев, потолок владельца в этой области, создание @Delete участника workspace (новой двери понадобился бы этот же потолок плюс суждение о последнем владельце), расширение двери удаления, прикосновение к forProject третьим ADR подряд и шлагбаум на клиенте — строить нечего, поскольку у этого маршрута нет вызывающего в apps/web. Объявленная цена: законный maintainer, которому нужен owner, теперь зависит от другого человека. Закрывает BRB-002 |
| 0158 | Конверт начинает говорить, КАКОЙ мастер-ключ его обернул, и эта метка никогда не решает, откроется ли он. Сама страница runbook формулирует дыру («Хранимый в базе wrapped_dek не идентифицирует, каким ключом он был обёрнут»), и из неё вытекают два дефекта: средний шаг ротации не имеет прогресса — прервавший его не знает, где остановился, а у вопроса «осталось ли что-то на старом ключе?» нет ответа в SQL, только повторный запуск скрипта по напечатанному счётчику, — и неверный ключ и повреждённый blob попадают в один catch, два диагноза с противоположными действиями в одежде общего сбоя. Поэтому обе таблицы конвертов получают nullable key_id, всегда вместе, поскольку они делят конверт и скрипт, и изменение одной оставило бы массив, по которому запрос отвечает наполовину. Значение — отпечаток ключа, HMAC-SHA256(derived key, label), усечённый, — что отвечает на открытый вопрос, оставленный задачей («не хэш ключа, это даёт читающему базу оракул для офлайн-проверки»), измеряя, а не предполагая: конверт — AES-256-GCM, который АУТЕНТИФИЦИРУЕТ, так что проверка кандидата-пароля против wrapped_dek УЖЕ была идеальным оракулом стоимостью один scrypt; проверка против отпечатка стоит тот же scrypt. Никакой оракул не создаётся, и никакая переменная не добавляется — оператор нигде не вводит отпечаток, он — чистая функция уже опубликованного ключа, и это то, что делает старое возражение «ещё одни метаданные, которые надо синхронизировать» верным для произвольного id и ложным для отпечатка. Метка — это наблюдаемость, никогда не полномочие, и именно здесь это решение расходится с очевидным: decrypt остаётся побайтно прежним, а rewrap по-прежнему решает «уже на текущем ключе» ПОПЫТКОЙ. Использовать метку, чтобы пропустить попытку, рассматривалось и было отвергнуто из-за собственного режима отказа — строка, помеченная «текущая», чей конверт на старом ключе, была бы молча пропущена, выпала бы из счётчика переобёрнутых, и шаг 3 сделал бы её нечитаемой навсегда, превращая несогласованность метаданных в потерю секрета. Поэтому метка служит трём вещам вне криптографического решения: запросу прогресса (IS DISTINCT FROM, никогда <>, чтобы NULL считался ожидающим), диагнозу, когда НИ ОДИН ключ не открывает, и штампу, который rewrap записывает рядом с новым конвертом. Без ретроактивного заполнения: key_id рождается при следующей записи, существующие строки остаются NULL, означая «записано до этой колонки» и никогда «на текущем ключе», — с объявленной ценой, что на существующей установке запрос начинает что-то значить лишь после первой ротации. Api печатает текущий отпечаток при boot, потому что колонка, которую никто не может ни с чем сравнить, бесполезна. Отвергнуто: KMS/HSM/ключи на тенанта (ADR 0027 остаётся в силе), индекс по key_id и перенос диагноза на горячий путь чтения |
| 0173 | Рождается удаление участника workspace, с потолком по ТОЙ ЖЕ клаузе. DELETE /workspaces/:workspaceId/members/:userId (owner, 204) кладёт конец увольнению через запись в базу, которое ADR 0157 отказался открывать мимоходом. Потолок 2 наследуется без нового правила — remocaoEhAutoRebaixamento с ролью workspace как сегодняшней эффективной и никакой в качестве роли после, поскольку ничто выше не удерживает падение, — так что удаление САМОГО СЕБЯ — всегда 403, и эта же клауза защищает последнего владельца без подсчёта: только owner вызывает маршрут и никто не удаляет себя, так что каждое удаление оставляет как минимум вызывающего. Удаление ДРУГОГО владельца разрешено; потолок 1 по-прежнему без пары. Удаление каскадируется в транзакции (строки project_members цели в workspace, ключи устройства проекта и его PAT там), а затем обрывает его живой runner на каждом проекте путём RN-520. Объявлено открытым: workspaces.created_by не меняется, так что удаление создателя оставляет агентов расходовать LLM-ключ создателя (RN-058). |
LLM-провайдеры, модели и расходы
Контракт провайдера, каталог моделей и курирование, binding моделей, metering и бюджеты.
| # | решение |
|---|---|
| 0040 | Карточка дашборда и строка сводки показывают только USD (исходную валюту цен, без какой-либо конвертации) — расхождение, изолированное этой поверхностью, TokenMeter default/live остаются R$+US$; предпочтение валюты на workspace с ручным курсом зафиксировано и отложено |
| 0041 | OpenAI-совместимая база поверх node:http (не поверх SDK, чтобы иметь таймаут БЕЗДЕЙСТВИЯ), нормализованная ошибка с обязательным code в чанке, двухслойные capabilities с отклонением binding агента без нативного вызова инструментов и контрактный набор тестов, где контракт владеет утверждениями, а harness — диалектом |
| 0042 | Синхронизация каталога по capability, где обнаруженная модель входит выключенной, а исчезнувшая модель помечается, а не удаляется; две независимые оси доступности (курирование owner × удалённая реальность); каскад, перепроверяющий capability при падении уровня; и цена, замороженная в token_usage, чтобы старая стоимость оставалась воспроизводимой, с append-only аудитом вне outbox |
| 0043 | Шесть провайдеров 9b (NVIDIA NIM, Together, DeepInfra, Bitdeer, Vultr, OpenRouter) входят как конфигурация поверх базы, каждый исследован с нуля против официальной документации — capability объявляется только доказанной, с двумя реальными случаями решения, отменённого вживую во время реализации; единственный новый хук в базе — parseErrorFrame, который понадобился только OpenRouter (хабу) |
| 0049 | Курирование моделей на workspace: models.is_active была колонкой для ВСЕЙ установки — owner workspace A, включая модель, включал её для B, а расходы падали на бюджет того, кто не решал. Каталог остаётся глобальным, потому что имя, цена и capabilities — факт провайдера, и дублирование их на workspace создало бы N истин об одной модели, вдобавок разбив token_usage.model_id; мигрирует только решение — в workspace_models. Отсутствие строки И ЕСТЬ выключено — третьего состояния нет, и так RN-043 выживает без какой-либо колонки, которую мог бы затоптать sync; выключение — UPDATE, а не DELETE, чтобы не стереть вместе с ним и того, кто решил. Маршрут селектора зависит от ПРОЕКТА (у трёх экранов, которые его потребляют, уже был projectId, ни у одного не было workspace), и isActive уходит из сущности Model: кому он нужен, использует тип, существующий только при наличии workspace |
| 0051 | Грани capability читаются у провайдера, а «для чего подходит» — курирование workspace. Sync никогда не спрашивал удалённую сторону о модальности (supportsVision: local?.supportsVision ?? false), так что колонка рождалась false и умирала false — все 338 моделей первой реальной синхронизации OpenRouter остались без vision, включая 181, которые провайдер объявляет мультимодальными. Вход и выход изображения становятся РАЗНЫМИ осями: кто читает диаграмму и кто рисует — решают разные задачи. Другой половины запроса — «лучше для кода/документации» — нет ни в одном каталоге, и выводить её из имени модели было бы догадкой в одежде данных (ADR 0041); поэтому это мнение оператора, и живёт оно в workspace_models, как всё курирование с ADR 0049. Две оси не смешиваются: отметить применение не включает модель (у колонки is_active DEFAULT true, а новая строка рождается с явным false), и экран никогда не пишет «не читает изображение», потому что false там означает «провайдер не объявил». Видео осталось за бортом: ноль моделей в девяти провайдерах, фильтр, который никогда ничему бы не соответствовал |
| 0063 | Две аудитории для одних и тех же расходов: счёт owner и потребление участника. Запрос был о вкладке сводки расходов в духе activity OpenRouter, и данные никогда не были проблемой — у token_usage есть провайдер, модель, актор, токены и ЗАМОРОЖЕННАЯ цена с 0042; не хватало АГРЕГАЦИЙ (не было ни одной по модели, по проекту внутри workspace, по сессии или по человеку). Трудность — столкновение двух существующих правил: по RN-058 ключ, который тратит агент, — ключ OWNER, а по RN-060 счёт по этому ключу — его и только его, так что «сколько я потратил» — буквально просьба о куске чужого счёта. Оба лёгких выхода неверны: открыть отфильтрованный отчёт по учётным данным — значит отменить RN-060 с чёрного хода (ответ всё равно говорил бы о провайдере и ключе, а сумма строк восстанавливает счёт), а не показывать ничего — значит оставить без права видеть потребление именно того, кто потребляет. Решение разделяет ВОПРОСЫ: вопрос owner — по УЧЁТНЫМ ДАННЫМ (провайдер, ключ, счёт) и по-прежнему требует owner, переиспользуемый целиком, а не переписанный; вопрос участника — по АКТОРУ, в токенах и оценочной стоимости, БЕЗ разбиения по провайдеру или учётным данным. Актор берётся из аутентифицированного токена, и нет параметра, куда вписать id другого человека, — «участник не видит строку другого актора» становится свойством сигнатуры, а не проверкой, которую можно забыть; доказано тестом, и мутация, удаляющая фильтр, его убивает. Оси провайдера НЕ СУЩЕСТВУЕТ в новой агрегации (sumGroupedBy имеет model/project/actor/session/day и ничего больше): отсутствие — то, что не даёт виду участника получить её случайно, и по той же причине два провайдера, отдающие модель с одинаковым именем, попадают в одну строку. Агент не входит в счёт участника, потому что token_usage фиксирует, кто потратил, а не кто велел потратить. Без библиотеки графиков — две формы, по одной серии, инлайн <rect> справляется, — а дневная серия приходит из api ПЛОТНОЙ, иначе три вызова, разбросанные по трём неделям, превращаются в три слипшихся столбца. Без миграции: измерено на 525 тыс. строк, запросы выполняются за 55 мс и 38 мс через seq scan, а индекс по created_at довёл бы их до 32 мс и 19 мс — выигрыш зафиксирован, индекс придёт позже |
| 0064 | Область «область» в каскаде моделей и глобальный binding агента. Запрос: leads областей и их подгруппа разделяют действующую модель, а агент может расходиться — наследуемое значение по умолчанию, решение, уже принятое пользователем. Каскад (сессия > агент > проект > workspace) получил area между агентом и проектом, и ответ на вопрос ГДЕ поднял несогласованность, потребовавшую решения до написания кода: binding agent был ГЛОБАЛЬНЫМ (scope_id = чистый slug, :projectId маршрута намеренно ИГНОРИРУЕТСЯ с Фазы 9a), а area — НА ПРОЕКТ с ADR 0053, — проектная область НАД глобальной заставила бы один и тот же агент разрешать разные модели только там, где существует область, — поведение, зависящее от случайности данных. Два взаимоисключающих выхода: binding агента становится проектным (меняет существующее поведение) или область остаётся НИЖЕ агента (противоречит «наследуемому значению по умолчанию», потому что почти каждый binding сегодня И ЕСТЬ агентский). Выбран первый: scope_id у agent и area стал СОСТАВНЫМ — ` |
| 0075 | Embeddings в контракте LLMProvider. Будущему RAG чата нужны векторы, а продукт не умел их делать: слово «embedding» встречалось в ОДНОМ комментарии, в прозе bitdeer-provider.ts. Операция входит как embed?, необязательная, под тем же двусторонним контрактом, что listModels (кто объявляет capability, тот её реализует; кто не объявляет, тот её не раскрывает), ПАКЕТОМ, потому что индекс получает N чанков за раз, — и отсюда самая важная гарантия: один вектор на вход или ошибка, никогда не более короткий список, потому что порядок — единственная связь между входом и вектором, а частичный ответ потом необнаружим. Ошибка БРОСАЕТСЯ нормализованной по code вместо того, чтобы стать чанком, с нетронутой таксономией 0041: смысл чанка — сохранить расходы идущего хода, а здесь сохранять нечего. Возврат объявляет dimensions, сверенные с тем, что ВЕРНУЛОСЬ (а не скопированные из каталога — у векторного индекса фиксированная размерность), model, который провайдер назвал использованным (alias разрешается в датированную версию, и именно она идёт в metering по причине замороженной цены, RN-044), и inputTokens/estimated с различением из RN-041. Capability двухслойная, как требовал 0041, но с отличием, которое пришлось назвать: вызов инструментов — градиент (модель без инструментов всё ещё разговаривает), а embedding — ИСКЛЮЧЕНИЕ: nomic-embed-text не отвечает на вопросы, а llama3.2 не возвращает векторы, два непересекающихся множества, и поэтому вопрос не помещался ни в одну существующую колонку supports_*. Провайдер: capabilities.embeddings, ОБЯЗАТЕЛЬНАЯ по тому же аргументу, что сделал обязательным code. Модель: supportsEmbeddings/embeddingDimensions в каталоге, необязательные, потому что отсутствие означает «провайдер не сказал», — Ollama единственный из девяти, кто публикует это на модель (capabilities: ["embedding"] в /api/tags). Только ollama объявляет true, доказано ИСПОЛНЕНИЕМ против реального демона 0.32.1 (2 входа → 2 вектора по 768, prompt_eval_count: 10), исполнением, которое также породило тест на 501, возвращаемый чат-моделью. Остальные восемь остаются false: семь из-за отсутствия доказательства (ни одного ключа в окружении, а единственный оплаченный smoke, который когда-либо запускался, был ЧАТОВЫМ — в хабе embedding маршрутизируется к другому провайдеру), а Anthropic — потому что у неё нет этой операции (её документация советует использовать третью сторону). Диалект базы доказан отдельно, контрактным набором, запущенным второй раз против него с включённой capability, и именно это делает дешёвым включение провайдера, когда появится ключ. Два объявленных среза: metering для embedding ещё не существует, потому что token_usage.session_id NOT NULL, а индексация репозитория происходит не внутри сессии, и колонка models.supports_embeddings ждала слота миграции другой волны |
| 0076 | provider снова становится измерением расходов, а сдерживание переходит к ТИПУ. Пересматривает 0063, исключивший ось фразой без полутонов («отсутствие структурно… нет аргумента, который можно передать»), — аргумент остаётся верным, и именно поэтому повторное открытие, решённое владельцем продукта, приходит с прописанным сдерживанием. Чего 0063 не разделял и что потребовало использование: owner не мог увидеть ВНУТРИ скользящего окна, куда ушли деньги (credential-spend отвечает по провайдеру, но в календарных месяцах и привязано к ключу, существующему сегодня), а человек и агент шли в одном рейтинге. sumGroupedBy получает provider, отчёт owner получает porProvider (RN-186), а участник не получает никакого поля. Сдерживание меняет ФОРМУ, а не силу: это две перегрузки, и область, несущая actor, — единственная, что есть у вида участника, — принимает только SpendDimensionDoAtor, то есть Exclude<SpendDimension, 'provider'>; запросить provider с актором НЕ КОМПИЛИРУЕТСЯ (RN-187). Никакого if над этой комбинацией, намеренно, по той же причине, по которой разрешение auto mode живёт в репозитории, а не в decide.ts (RN-153/154): динамическая проверка подсказывает динамическую гарантию, и именно её следующий рефакторинг удалит, не оставив красного теста. Exclude вместо второго списка, написанного вручную, потому что параллельный ограниченный список молча устаревает. Человек и агент становятся porOwner/porAgente, РАЗБИЕНИЕМ porAtor в use case, а не двумя запросами (RN-188) — 0063 измерил, что стоимость растёт с размером token_usage, а не запроса. Индекс по token_usage(created_at), который 0063 измерил и отложил, входит здесь (миграция 0044): 55 мс → 32 мс и 38 мс → 19 мс на 525 тыс. строк. Объявленное последствие без смягчения: гарантия перешла от «невозможно выразить» к «невозможно скомпилировать», что слабее, — цену удерживают два независимых барьера, второй из которых — отсутствие у маршрута участника какого-либо параметра измерения, с @ts-expect-error, покрывающим первый (если барьер падает, tsc отклоняет НЕИСПОЛЬЗУЕМУЮ директиву, вместо того чтобы тест начал ничего не проверять). ЭКРАН остаётся снаружи, объявлено |
| 0077 | Рейтинг моделей по возможностям, без выдуманной оценки. Handoff просил таблицу «Лучшие модели по возможностям» с ОЦЕНКОЙ по возможности (код 9.4, изображение 9.1…) и бейдж ideal в ModelPicker, «когда модель покрывает все возможности, требуемые агентом». Исследование нашло, что оба просят одни и те же данные, которых у продукта нет: оценка — ВЫМЫСЕЛ макета (ни один провайдер не публикует «качество кода», ни одна метрика продукта это не измеряет), а «требуемых агентом возможностей» в домене не существует — сам ModelsSection уже переименовал колонку «Агент · возможности» просто в «Агент» по этой причине, и вновь пообещать то, что было отвергнуто, вновь открыло бы пробел. Второй, структурный барьер закрывает бейдж сам по себе: ModelPicker binding агента читает GET /projects/:id/models (роль viewer), а курирование (uses, ADR 0051) существует только в ModelComCuradoria, из маршрута только для maintainer, — связать их значило бы расширить RBAC, отдельное продуктовое решение. Бейдж НЕ построен (задокументировано в самом ModelPicker.tsx); таблица рейтинга ПОСТРОЕНА с двумя РЕАЛЬНЫМИ сигналами: «рекомендовано»/«альтернатива» берутся из курирования (uses), которое workspace уже отметил, упорядоченные по тому, сколько агентов ЭТОГО проекта уже разрешаются в эту модель по каскаду (самый честный сигнал без выдумывания оценки), со стоимостью как тай-брейком; возможность без курированного покрытия показывает пробел, никогда не скрывая строку |
| 0110 | Закрывает пункт бэклога «бюджет на область», открытый с Фазы 8. agent_areas получает budget_micros/spent_micros, повторяя max_parallel в ТОЙ ЖЕ строке — без новой таблицы и не общая таблица budgets (её CHECK требует ровно одного из проекта/сессии, построенная для двустороннего исключения, а не для третьей области). Это ТРЕТИЙ ДОБАВОЧНЫЙ потолок к бюджетам проекта/сессии, проверяемый и увеличиваемый независимо CheckBudgetGateUseCase/RecordLlmUsageUseCase, — явно НЕ каскад binding моделей из ADR 0064, с которым два механизма делят только имя. spent_micros накапливается всякий раз, когда актор принадлежит области (разрешается чистой areaDo, без похода в базу для актора без области), с потолком или без; SetAreaBudgetUseCase требует maintainer и принимает null для снятия потолка |
| 0114 | Обнаружение нативного Ollama при bootstrap pnpm dev: preflight.mjs подтверждает по HTTP, что занявший OLLAMA_PORT (по умолчанию 11434, тот же порт, что у нативной установки) — действительно демон Ollama, ОДИН раз спрашивает, переиспользовать ли его (по умолчанию «да» без TTY, поскольку меню намеренно запускает команды со stdin из /dev/null), и сохраняет ответ в .env (OLLAMA_MODE=host|container) через escreverEnv — первое в репозитории, что ПИШЕТ .env на месте, а не только читает. ollama/ollama-model-loader уходят за profiles: ["local-llm"], переключаемые командой scripts/dev/perfil-ollama.sh; сервис с profile, по умолчанию невидимый для docker compose config, вынудил detectarOllamaNativo получить собственный путь сообщения о конфликте (общий цикл по портам перестал бы видеть настоящего занявшего, не являющегося Ollama) и вынудил убрать depends_on: ollama у api (Compose отклоняет весь файл, когда сервис вне неактивного profile зависит от сервиса внутри). Новый пункт меню «Docker › Reconfigurar Ollama» забывает решение по требованию |
| 0115 | Pull модели из Hugging Face получает СОБСТВЕННУЮ МАЛЕНЬКУЮ таблицу, huggingface_model_pull_requests, никогда proposed_actions — прослежено и отвергнуто, потому что session_id NOT NULL этого конвейера, разрешение политики decide()/agent_autonomy и закрытый словарь ActionType построены вокруг АГЕНТА под надзором человека, а здесь актор уже ЯВЛЯЕТСЯ человеком, со шлагбаумом роли на маршруте, без сессии и без агента. Двухшаговое подтверждение И ЕСТЬ переход pending_confirmation → confirmed, а не дополнительный флаг — ConfirmModelPullUseCase отказывается (409) выполнять его дважды, и только успешное подтверждение вызывает OllamaProvider.pullModel СИНХРОННО внутри запроса (у api нет собственного фонового исполнителя задач; GET .../pull-requests/:id существует для тех, кто не хочет держать это соединение открытым). repoId путешествует в теле запроса, а не в маршруте, потому что форма Hub <publisher>/<model> ломает сопоставление пути с единственным сегментом (то же исправление, что уже применялось для путей файлов в code.controller.ts). Вручную курируемый, чувствительный к регистру allowlist официальных publishers Hub («всегда ручное курирование» из ADR 0042, применённое к новой печати) фильтрует результаты поиска по умолчанию; результаты сообщества требуют явного opt-in, выключенного по умолчанию, сопровождаемого постоянным предупреждением безопасности |
| 0166 | Предпочтение маршрутизации хаба живёт в binding модели, путешествует вместе с ним и замораживается в metering. Измерено на установке v6.1.0 (AT-090): 23 из 24 вызовов модели через OpenRouter обслужил upstream примерно в четыре раза медленнее, потому что тело запроса никогда не говорило, как выбирать (provider.sort). Решено мейнтейнером: model_bindings.routing_preference (`price |
| 0188 | РЕАЛЬНАЯ стоимость, которую возвращает провайдер, становится числом metering (AT-270, решение владельца от 30.09): когда ответ её несёт (usage.cost OpenRouter), она становится token_usage.cost_micros, тем, что ход возвращает engine, тем, что суммируют бюджеты, и тем, что показывает вкладка Расходы; замороженная цена каталога (ADR 0042) остаётся числом только там, где ответ ничего не говорит. Цена строки становится НЕЯВНОЙ (cost ÷ tokens, помеченная price_implicit, та же колонка и функция, что у Jev из ADR 0179), то, что взял бы каталог, идёт рядом в catalog_cost_micros, и записываются разрешённая модель и id gen-…. estimated по-прежнему означает ТОКЕНЫ; BYOK падает на каталог. Тот же раунд записывает ЧАСТИ, которые сообщает диалект, — cached_input_tokens и reasoning_tokens (AT-272), никогда не суммируемые с итогами, где null означает «не сказано», — и medir-execucao.ts сообщает, какая часть входа была чтением из кэша |
| 0198 | СЕМАНТИЧЕСКИЙ дубликат истории или бизнес-правила ПРЕДУПРЕЖДАЕТСЯ через embedding с порогом, никогда не отклоняется (AT-171, решение владельца от 01.10): после записи элемента его заголовок сравнивается с заголовками проекта того же типа через RagEmbeddingService из RAG, и косинус, равный или больше 0,80, — ОТПРАВНАЯ ТОЧКА, не откалиброванная, потому что окружение не смогло записать реальные векторы, — возвращает агенту предупреждение в результате инструмента и backlog.semantic_duplicate_warned в лог. Без провайдера embedding проверка ПРОПУСКАЕТСЯ и говорит почему. Её расходы измеряются отдельной строкой token_usage (актор system/duplicata-semantica) — ОДНО исключение из среза ADR 0075, который продолжает действовать для индексации и поиска. Точный дубликат по-прежнему отклоняется (RN-080/081) |
RAG, граф знаний и артефакты
Чанки и гибридный поиск, их измерение, граф Neo4j и артефакты, проецируемые из event log.
| # | решение |
|---|---|
| 0079 | Таблица chunks для Chat RAG: vector(768) и tsvector в ОДНОЙ строке (а не две таблицы с JOIN), индекс HNSW (vector_cosine_ops, не IVFFlat — таблица рождается пустой) и GIN, три честные области (docs/adr/session), взаимоисключающие через CHECK. Миграция 0045 сама несёт CREATE EXTENSION IF NOT EXISTS vector, потому что docker/postgres/init.sql выполняется только при первой инициализации тома, — и поэтому рождается в ветке breaking/: создание расширения может требовать привилегии, которой у роли приложения в production может не быть |
| 0080 | Гибридный поиск: веса, порог и что такое цитата. docs/adr индексируются из GIT-репозитория САМОГО проекта (через ReadProjectCodeUseCase, те же учётные данные/шлагбаум/проверка пути, что у вкладки Code); session индексирует только chat.message/agent.response (остальная часть event log — механизм, а не цитируемое знание). Разбиение по абзацу/заголовку, 1200 символов с перекрытием 150 — задокументированная отправная точка, не откалиброванная на реальных данных. Когда провайдер embedding (фиксированный: ollama/nomic-embed-text, та же размерность, что в RN-222) не отвечает, конвейер записывает чанки БЕЗ вектора вместо полного сбоя — search_vector — это GENERATED ALWAYS AS и не зависит ни от какого провайдера, — и объявляет пробел в отчёте, никогда не притворяясь полной индексацией. Гибридный поиск — это ДВА независимых запроса (searchByVector/searchByLexicalQuery, каждый в созданном для него индексе), слитых взвешенной суммой (0.6 векторный, 0.4 лексический — шкалы нормализованного ts_rank и косинуса по природе несопоставимы) и обрезанных по порогу (0.2); веса и порог — настраиваемая отправная точка, а не наука. Цитата — это chunkId/content/раздельные scores (null, а не ноль, когда сигнал ничего не нашёл)/origin как размеченное объединение по kind (file с sourcePath/headingPath или session с sessionId/eventId). Переиндексация всегда РУЧНАЯ (POST .../rag/reindex, идемпотентная полная перестройка) — без watcher по push или по закрытию сессии |
| 0082 | Chat RAG становится СОБСТВЕННОЙ вкладкой (key: 'rag'), без переименования sessions. 0080 оставил готовыми конвейер и три маршрута; 0078/RN-202 отложил метку «Chat RAG», потому что возможности не существовало — теперь она есть, и вопрос был только в том, ГДЕ она живёт. sessions — разговор с АКТИВИРОВАННЫМ агентом (тратит ключ owner за ход, RN-058); RAG — поиск по уже построенному индексу, без какого-либо агента посередине и read-only по природе — слияние двух заставило бы одну ленту выражать структурно разные вещи, поэтому ответом стала новая вкладка (ordem: 28, между Кодом и Backlog), а не переименование существующей. Экран потребляет три контракта 0080, не угадывая форму (searchRag/getRagCoverage/reindexRag, типы отражены 1:1), и показывает две честные деградации, которые backend уже объявлял, а ни один экран не читал: vectorAvailable: false становится видимым предупреждением (RN-252), а покрытие никогда не выдумывает «переиндексировано X мин назад» — ответ этих данных не несёт. Цитата с источником session ведёт к точному СОБЫТИЮ, переиспользуя useNavigate + highlightEvent, ТОТ ЖЕ механизм, что уже используют чипы доказательств Psicólogo (RN-253); источник file показывает путь/заголовок как текст, без ссылки — у вкладки Код сегодня нет deep-link по пути, а построение такой ссылки вне объёма этого направления. Кнопка переиндексации появляется только для owner/maintainer на клиенте, повторяя мерку maintainer, которую уже применяет маршрут (RN-238/RN-254), — гарантирует по-прежнему api |
| 0099 | Neo4j входит как граф ЗНАНИЙ — ВЫВЕДЕННАЯ из event log память, никогда не источник истины — для версионируемых шаблонов промптов (PromptTemplate/PromptVersion) и реляционной памяти (взаимодействия, гипотезы Psicólogo с доказательствами, профили Anamnese, handoff). pgvector ОСТАЁТСЯ векторным индексом чанков — граф не хранит embedding. Драйвер neo4j-driver в API (не в engine): API уже владеет всем хранением и существующим RAG, engine потребляет через внутренний HTTP с service token, как всегда. GraphStore.onModuleInit никогда не бросает — три переменные окружения обязательны в production и необязательны вне его, недоступность становится GraphUnavailableError/503, никогда сырым 500. Шаблон явно вдохновлён репозиторием ErickWendel/neo4j-ai-experiments (граф как память агентов + промпты как файлы, отделённые от кода) |
| 0100 | Новый инструмент rag_search для агентов engine — закрывает самый большой пробел существующего RAG (pgvector, гибридный поиск): ни один агент его никогда не запрашивал, только web-вкладка «Chat RAG». Вызывает POST /internal/rag/search (переиспользует HybridSearchUseCase без дублирования логики), форматирует попадания с цитатой (path + фрагмент), деградация (degraded: true) всегда видна в НАЧАЛЕ текста. Собственные потолки (RN-150): top_k ограничен 10, потолок 16 KiB по байтам. ollama-model-loader (новый одноразовый сервис, dev и prod) закрывает отдельный реальный баг: nomic-embed-text никогда не скачивался автоматически, и RAG молча деградировал до только лексического в любом чистом окружении |
| 0101 | Потребление фундамента графа: InstructionFiles получает источник :graph (приоритет db > graph > dir > root, переиспользуемый кэш ETS), без изменения контракта какого-либо GenServer — ux-designer, Psicólogo и Anamnese начинают ПЫТАТЬСЯ разрешить шаблон из графа до инлайн-текста, который становится fallback. Два отдельных флага (graph_templates_enabled?, graph_instruction_templates_enabled?) намеренно — один ключ столкнулся бы с ПРОТИВОПОЛОЖНЫМИ значениями по умолчанию у параллельных направлений. Psicólogo/Anamnese дополняют «недавние»/«временное окно» РЕЛЕВАНТНЫМИ фрагментами через rag_search (никогда замена, всегда добавление — без попаданий поведение идентично прежнему). Граф пишется сам: GraphProjector дренирует ВТОРУЮ линию outbox (aggregateType: 'graph_projection', никогда не совпадающую с фильтром engine) и вызывает уже существующие use case записи — engine НИКОГДА не пишет в граф напрямую, сохраняя event log единственным источником истины |
| 0113 | Прикрепить локальную папку СОБСТВЕННОЙ машины пользователя как справочный материал только для чтения — БЕЗ CLI brabo-runner, явно отличное от execution_mode: runner (ADR 0104), которому нужен реальный путь хоста. Браузер читает СОДЕРЖИМОЕ файлов (File.text()) и ОТНОСИТЕЛЬНЫЕ пути (File.webkitRelativePath) — никогда абсолютный путь хоста, потому что File API ни одному сайту его не раскрывает; нет пути, который нужно маршрутизировать через Runner (ADR 0107) или валидировать, как защита ADR 0072, потому что пути нет вовсе. chunks.scope получает четвёртое значение, 'local' (миграция 0052, добавочный ALTER TYPE, ноль новых CHECK — существующие ограничения уже делятся на «это session или нет»), переиспользуя весь конвейер RAG (разбиение, embedding, гибридный поиск, цитирование) вместо параллельного механизма. Загрузка за один раз: агрегированные потолки (число файлов, сумма байтов) ОТКЛОНЯЮТ (400) весь пакет, а не молча обрезают, отдельный слишком большой или нераспознанный файл просто пропускается; повторная отправка — ЕДИНСТВЕННЫЙ механизм ресинхронизации, намеренно никогда не связанный с общей «Reindexar agora» (которая перечитывает из источника, к которому сервер может вернуться, — у этой области такого нет) |
| 0129 | Гибридный поиск RAG наконец оставляет след — но как ТАБЛИЦЫ (rag_searches/rag_feedback), а не события сессии: session_events.session_id — NOT NULL, а поиск из вкладки RAG — поиск проекта без сессии, так что event log молча отбросил бы ровно те поиски, которые оценил человек (тот же класс проблемы, что отрезал metering embedding в ADR 0075). Веса ЗАМОРАЖИВАЮТСЯ в каждой строке, как замороженная цена в metering (ADR 0042), чтобы последующая калибровка молча не переписала смысл каждого предыдущего измерения. rag.search/rag.feedback существуют, только когда ЕСТЬ сессия, и являются повествованием, никогда источником измерения. Запись телеметрии никогда не роняет поиск — и никогда не падает молча: источник infra в логе и явный searchId: null. Здесь ничего не калибруется; этот шаг лишь инструментирует. |
| 0132 | Очередь RAG получить golden-set: та же форма, что у агента QA Automação (ADR 0123), — постоянно исключённый тег ExUnit (:golden_set_rag), пол храповика с ключом по МОДЕЛИ, seed-скрипт, вызываемый через System.cmd, — скопированная намеренно, а не выведенная заново, с единственной адаптацией, которую навязывает домен: гибридный поиск RAG целиком выполняется внутри api (HybridSearchUseCase), без суждения чат-LLM в какой-либо точке пути, поэтому seed-golden-set-rag.ts и провижинит, И выполняет сам поиск, а сторона Elixir лишь его вызывает и применяет пол — переписать поиск на Elixir значило бы тестировать вторую реализацию, никогда настоящую. 17 вопросов СОСТАВЛЕНЫ на основе реальных RN/ADR, а не извлечены — проверено заранее: gh issue list в этом соло-репозитории разработки возвращает «No Issues», так что нет подлинного корпуса вопросов для добычи, и это сказано, а не подделано. Индексируемый корпус, однако, реален: 22 курированных файла из собственного docs/ этого репозитория, скопированные буквально и проиндексированные тем же IndexProjectDocsUseCase, которым пользуется любой реальный проект, — курированный, а не всё дерево из 130+ ADR, из-за стоимости embedding при ручном запуске. Критерий попадания — путь файла, никогда точный чанк, в пределах top 5, никогда позиция 1 — чистая и протестированная функция (golden-set-criterio.ts), потому что разбиение — ровно тот параметр, ради свободы пересмотра которого существует вся эта программа, а Chat RAG цитирует несколько чанков на ответ, а не только первый. Новый gate rag-acertivo в docs/gates.yml, warn (нет CI с реальным LLM), доказательство tipo: teste — этот gate доказывает сам golden-set, а не событие. Измерено по-настоящему, дважды, детерминированно: 17/17 вопросов нашли файл в пределах top 5 в обоих запусках против реального Ollama с nomic-embed-text и изолированного Postgres |
| 0138 | Этап 3 программы измеримого RAG: golden-set RAG (ADR 0132) начинает по-настоящему выполняться в CI, в новом .github/workflows/golden-set-rag.yml, — но ПО РАСПИСАНИЮ (ночной cron + workflow_dispatch), никогда в pull_request, — периодичность, которую выбрал владелец продукта с учётом ~3–6 мин, которые стоит один запуск (скачать nomic-embed-text плюс индексация и 17 поисков). severidade у rag-acertivo ОСТАЁТСЯ warn — намеренно, теперь по причине периодичности, а не технической, поскольку задача по расписанию не блокирует merge. Что делает это осуществимым там, где golden-set QA (ADR 0123) ещё нет: этот вызывает лишь модель EMBEDDING (CPU, детерминированно, без сэмплирования), никогда чат-модель, выносящую суждение, — хватает ubuntu-latest без GPU. Находка, упрощающая всю задачу: seed-golden-set-rag.ts никогда не задаёт NODE_ENV, так что четыре секрета, которые api отклонила бы в production (RN-114), без возражений берут литеральный dev-fallback — в отличие от docker/smoke.sh, здесь ничему не нужен openssl rand. Health-check сервиса Ollama выполняет ollama list, а не curl (образ гарантирует первое, но не второе), переиспользуя ровно тот шаблон ожидания, который docker/ollama/pull-models.sh уже проверил реальной загрузкой. Golden-set QA не получает от этого ADR никаких изменений — он остаётся полностью ручным, остаётся объявленным TODO(humano) |
| 0148 | Каждый артефакт, который производят агенты, — brief Criativo, бизнес-правила PO, диаграмма C4 и маршрутизация модулей Arquiteto, решения шести разговорных агентов — живёт ИСКЛЮЧИТЕЛЬНО в event log; emit_artifact.ex говорит это прямо: «таблицы артефактов нет». Это правильное решение для ИСТОЧНИКА, и здесь оно не трогается. Проблема в том, что эта память не доходит ни до какого места, которое открывает человек: она читается построчно внутри таймлайна сессии продукта, и тот, кто открывает папку проекта в редакторе — где будет код, — не находит ничего из рассуждений, которые его породили. Откладывание git (отложенный провижининг (собственный PR)) расширило дыру: между созданием и handoff к Arquiteto у проекта нет никакого репозитория, так что всё, что продукт накопил, остаётся строками в базе, пока папка пользователя пуста. Поэтому docs/ становится ПРОЕКЦИЕЙ event log в строгом смысле, который ADR 0101 придал графу, — источником остаётся событие, папку можно удалить целиком и перестроить, и НИЧТО в продукте не читает из неё, чтобы что-либо решить; она существует, чтобы её открыл человек, и всё остальное следует из этого. Механизм копирует GraphProjector деталь за деталью (собственный poller с .unref(), публичный drainOnce() для тестов, флаг draining, пакет 50, markProcessed только после успеха) — не переиспользование кода, а переиспользование ДИЗАЙНА, поскольку они пишут в назначения, не имеющие ничего общего, и копирование формы позволяет тому, кто знает одно, понять другое без перечитывания. Собственный aggregate_type (artifact_projection) по причине, которую уже документирует граф: Engine.Outbox.Drain дренирует только session/task/container и помечает строку 'session' каждые ~2 с, так что делить его значило бы соревноваться с engine и почти всегда проигрывать, — с одним намеренным отличием: aggregateId — это ПРОЕКТ, а не сессия, избавляя проектор от запроса на каждый артефакт ради того, что у use case уже было на руках. Список РАЗРЕШЁННЫХ, а не запрещённых, чтобы новый тип артефакта рождался вне проекции, а не появлялся без предупреждения в папке, которую читает пользователь; qa_verdict, secops_verdict, task_blocked и infra_delegation_files остаются снаружи как операционные ИСХОДЫ, а не продуктовые документы, — вердикт gate на каждую задачу наполнил бы docs/qa-lead/ файлами, которые никто не открывает, и стоил бы папке того самого свойства, которое делает её полезной: помещаться в один взгляд. Раскладка — docs/<agent>/, так что навигация отвечает на «что произвёл Arquiteto?», ничего не открывая; четыре ВЕРСИОНИРУЕМЫХ типа перезаписывают один файл (папка показывает ДЕЙСТВУЮЩЕЕ, история целиком остаётся в event log — проецировать их по версиям породило бы c4-diagram-v1.md, c4-diagram-v2.md и заставило бы читателя выяснять, какой действует, ровно то, от чего избавляет сведение по version), тогда как append-only типы получают каждый свой файл с seq события, потому что без него два артефакта одного типа и заголовка столкнулись бы и второй стёр бы первый — единственный способ, которым эта проекция могла бы потерять информацию, имеющуюся в источнике. Между заголовком, написанным LLM, и файловой системой стоят два барьера, и второй не избыточен: slugDeArquivo ВЫВОДИТ имя из закрытого алфавита (ни один путь возврата не оставляет выжить разделителю, а пустое даёт null, поскольку имя, выведенное из ничего, сталкивается со всеми остальными), а FsArtifactFileStore снова проверяет через relative() перед открытием — они отвечают на разные вопросы, «какое имя я вывожу» и «что я собираюсь открыть», и доверие только первому сделало бы безопасность этой записи зависимой от того, что ни одно будущее изменение slug никогда не пропустит разделитель, — ровно то сдерживание, которое ADR 0130 отвергает из принципа. В режиме runner папка отводится в управляемый корень ровно как permissions.json и по той же физической причине (api пишет изнутри своего контейнера, проект runner намеренно остаётся без bind-mount, а канал runner несёт КОМАНДЫ, без примитива для записи содержимого файла) — объявленная цена: там docs/ не лежит рядом с кодом. И запись НЕ проходит через proposed_action, что не упущение: этот конвейер — источник каждого внешнего эффекта АГЕНТА, ПРОСЯЩЕГО ДЕЙСТВОВАТЬ, а здесь ни один агент ничего не просит — артефакт уже эмитирован и уже в event log; маршрутизировать это через одобрение заполнило бы очередь рутинным решением на каждый артефакт и разъело бы сам потолок, придающий смысл клику в действиях, которые действительно являются внешним эффектом |
| 0196 | Anamnese ВКЛЮЧЕНА СНОВА с исправлениями (AT-277, решение владельца от 01.10). Раунд не выполняется без допустимого СУБЪЕКТА — ЭФФЕКТИВНОГО участника проекта (projectRole ?? workspaceRole, RN-471; создание проекта не записывает строку в project_members, и именно поэтому каждый раунд реального использования 29.09 платил модели за вывод «нет допустимого участника»), вне opt-out, с СОБСТВЕННЫМ взаимодействием в окне: ни вызова LLM, ни поиска RAG, причина названа в логе engine, а anamnese.run_skipped — только в раунде, запрошенном вручную. Гипотеза Psicólogo, ПРИНЯТАЯ человеком, которому принадлежит сессия, становится ФАКТОМ ПРОФИЛЯ в графе — переводится из psychologist.hypothesis_accepted тем же GraphEventTranslator, так что grafo:reprojetar её восстанавливает, — и доходит до агентов, разговаривающих с этим человеком: api читает её через QueryUserContextUseCase (в области проекта, 5 самых свежих, итог рядом), а engine добавляет её как эфемерное системное сообщение в ход автора тем же путём, что язык ответа. Принятие третьим лицом и отказ лишь фиксируются. ANAMNESE_ENABLED возвращается к значению по умолчанию true; Psicólogo остаётся на паузе |
| 0200 | Контракт между модулями становится версионируемым АРТЕФАКТОМ Arquiteto, и dev agents читают его вместо worktree другого модуля (AT-276, решение владельца от 01.10). artifact.module_contracts, без таблицы, действующая версия заменяет предыдущую: на модуль — только то, что он РАСКРЫВАЕТ (funcao/rota/evento/dado + сигнатура, 1–40 элементов); то, что он потребляет, выводится при чтении из dependsOn действующего module_map. ОТДЕЛЬНЫЙ артефакт, никогда не поле module_map. Arquiteto получает declare_module_contracts; dev agents получают listar_contratos_de_modulos (без параметров, в области проекта, собственный модуль и его зависимости целиком, потолок 120 элементов), и их kickoff говорит читать интерфейсы там; без контракта — report_blocked с названием недостающего |
| 0205 | То, что Anamnese видит как отклонение АГЕНТА — цикл, ход, отменённый пользователем, сообщение пользователя, исправляющее сбой агента, — становится сигналом улучшения ПРОДУКТА (AT-356, часть 2, решение владельца от 02.10): долговечное событие anamnese.agent_deviation, записываемое новым инструментом report_agent_deviation, отдельное от профиля пользователя и БЕЗ персональных данных (только агент, тип отклонения из закрытого словаря и доказательство по eventId; без свободного текста). Не проецируется в граф пользователя; читается из event log, без нового экрана |
Web-интерфейс и дизайн-система
Экраны, навигация, темы и структура самых больших файлов web-приложения.
| # | решение |
|---|---|
| 0036 | Экраны auth, верные утверждённому макету: три шрифта дизайн-системы размещены у себя (CSP блокировал CDN, и типографика в production падала на системный шрифт), версия тега, запечённая в артефакт, контраст токенов, вычисленный, а не измеренный axe, и шесть намеренных расхождений с макетом |
| 0060 | Поверхность чтения кода вкладки Код, сдержанная и с бюджетом. 26a внесла listTree и getPullRequestDiff в контракт и установила проверку, отклоняющую операцию без потребителя; этот ADR — потребитель, и решение его написать вынудило решить три вещи, с которыми не сталкивался ни один предыдущий маршрут, потому что ни один не получал ПУТЬ ФАЙЛА от клиента. Где живёт поиск: вне контракта. Ни у одного из трёх провайдеров его нет — у GitHub и GitLab есть поиск кода на уровне платформы с собственной семантикой, а LocalGitProvider — bare-репозиторий, — так что объявить его значило бы либо 13-ю операцию с capability false в local (вкладка, исчезающая у одного провайдера), либо утечку словаря одной платформы в нормализованный контракт, который существует именно чтобы этого не допустить; он остаётся составным на уровне приложения, поверх listTree + getFileContent. Сколько он может тратить: три бюджета (пройденные каталоги, открытые файлы, возвращённые вхождения) плюс кэш с коротким TTL, потому что это единственное чтение, стоимость которого растёт с РАЗМЕРОМ репозитория, а не запроса, а платят учётные данные OWNER (RN-058/RN-082) — без потолка это то же семейство сбоев, что 3.824 req/мин дашборда, с 429, превращающимся в пустой экран. Обход в ширину: обрезанный посередине, он отдаёт неглубокие файлы, где обычно и ищут. Как сдерживается путь: одна функция, в файле RN-092, переиспользующая примитивы области из 0055 — и возвращающая НОРМАЛИЗОВАННЫЙ путь, потому что возврат исходного позволил бы проверить одну строку и отправить другую. Вектор — не чтение не того файла: у удалённого провайдера путь становится сегментом URL API, и .. МЕНЯЕТ ENDPOINT с токеном owner на руках. CodeQL будет продолжать указывать, и цена — уже принятая в ПОСТ-ФАЗЕ 15: дублирование проверки заглушило бы предупреждение и создало бы четыре копии, которые однажды разойдутся. Четыре маршрута — GET/role:viewer, и у контроллера НЕТ глагола записи: чтение — не внешний эффект и не становится proposed_action (заполнило бы очередь шумом), а когда запись появится, она родится как таковая — отсутствие глагола делает эту границу проверяемой, а не намерением |
| 0074 | Достижимая тема и boot под CSP. Светлая тема существовала в design/tokens.css, измерялась тестом паритета и упоминалась в design/README.md — и ничто в apps/web не писало data-theme: единственный способ её увидеть — ввести атрибут в DevTools. Оба теста контраста знали об этом и были правы, пока это длилось (один утверждал через expect, что три пары светлой темы ПРОВАЛИВАЮТСЯ, «запись того, что наследуется, а не гарантия того, что отрисовывается»); с кнопкой темы измерение только основной темы стало бы скрывать половину видимой поверхности. Boot — это ФАЙЛ (public/theme-boot.js), а не инлайн-скрипт: образ отдаётся под script-src 'self' (ADR 0058), и инлайн работал бы в pnpm dev:web и блокировался бы в production — сбой 0036 с другим подлежащим, поскольку handoff просит именно инлайн. Синхронный и до бандла, иначе пользователь светлой темы видит тёмную вспышку при каждой загрузке; чистый ES5, потому что public/ у vite не проходит сборку, закреплено тестом; localStorage, который бросает исключение, деградирует до «красит и не сохраняет», а неизвестное значение падает на значение по умолчанию, а не становится data-theme, которого CSS не знает. Шесть токенов светлой темы изменили значение, потому что самый требовательный фон темы — бумажный --code-bg: --accent 3,56 → 4,81, --warning 3,15 → 4,98, --success 3,89 → 5,12, --violet 4,16 → 4,95, --text-muted 2,76 → 5,17 (при 2,40:1 на --surface-2 он проваливал даже пол 3:1 — это был дефект, а не долг), а --accent-hover последовал на ступень ниже. Зафиксированный побочный эффект: пять пар, которые в ТЁМНОЙ теме являются известным долгом, проходят 4,5:1 в светлой, а «известное исключение дизайн-системы» (--on-accent на --accent, 3,20:1) исчезает в светлой, потому что исправление, описанное комментарием теста, — затемнить до --terracota-500, — и есть то, что стала делать светлая. Тёмная не изменила ни одного значения. Палитра синтаксиса становится ВОСЕМЬЮ ролями handoff с префиксом --syntax-*, и пять из восьми значений handoff отвергнуты измерением (--syn-cm 2,32:1 в светлой, --syn-kw 4,34, --syn-str 4,20, --syn-fn 4,14, --syn-op 4,00): где handoff проваливается, действует измеренное число, та же мерка, что в 0036. --font-display/--shadow-modal и --r-md/--r-lg/--r-pill входят как ALIAS, никогда не переименование, — а --r-sm (7px) НЕ синоним --radius-sm (4px) |
| 0078 | Рамка экрана, а реестр вкладок расходится с handoff намеренно. Чек-лист «рамка экрана» из handoff (header 60px, линейка вкладок с box-shadow: inset 0 -2px 0 var(--accent), горизонтальная прокрутка, контейнер содержимого с максимальной шириной 960–1040px) имел все четыре дефекта в ProjectPage.tsx, и все четыре исправлены — --header-h стал min-height, а не height, потому что заголовок ПРОЕКТА (идентичность + компактный TokenMeter) богаче общего заголовка 6 внутренних экранов handoff, и навязать 60px значило бы обрезать предупреждение о бюджете, тот же класс ошибки, который запрещает RN-088. Часть, требовавшая решения, а не только CSS: handoff перечисляет 7 вкладок, а реестр (project-tabs.ts) имеет 10. executores (RN-121), backlog (RN-048) и insights родились ПОСЛЕ handoff, с реальными данными и собственной RN, и ОСТАЮТСЯ — handoff является эталоном ВИЗУАЛЬНОЙ точности, а не потолком того, сколько вкладок у продукта (RN-203). А ключ sessions остаётся с меткой «Chat», никогда «Chat RAG», как просит более свежий handoff (RN-202): «Chat RAG» описывает несуществующую функциональность — без конвейера индексации, без UI цитирования, лишь фундамент контракта (embeddings, 0075), — и переименование вкладки сегодня анонсировало бы возможность до её появления, ту же ошибку, которую 0042 уже отвергает для модели каталога. Значения отступов линейки, жившие как CSS-override потомка в ProjectPage.module.css — долг, объявленный с ФАЗЫ 16, — переехали в Tabs.module.css, у которого теперь тот же владелец. Ни одна ФОРМА экспорта не изменилась (AbaDoProjeto, ABAS_DO_PROJETO); лишь метка code сменилась с «Code» на «Código» |
| 0097 | «N агентов онлайн» дашборда (RN-409) читает engine.dev_agent_states НАПРЯМУЮ — сырой SQL, пакетный по workspace, внутри ТОГО ЖЕ Promise.all, что уже суммирует одиннадцать запросов read model RN-090, — вместо раскрытия нового внутреннего HTTP-маршрута в engine. api и engine делят ОДНУ физическую базу, разделённые лишь схемой Postgres ("engine" против public), и прецедент межсхемного чтения уже есть (apps/api/scripts/medir-execucao.ts уже читает engine.oban_peers) — это решение поднимает шаблон из ручного скрипта до ПРОТЕСТИРОВАННОГО production-кода, с минимальной и объявленной фикстурой таблицы engine в test/support/global-setup.ts. Принятое следствие: запрос предполагает, что эксплуатирующий продукт мигрирует обе стороны вместе (db:migrate И engine:migrate), и падает громко/видимо, если нет, — без try/catch, скрывающего ошибку |
| 0116 | ActionType в apps/web перестаёт копироваться вручную — теперь он генерируется openapi-typescript из docs/reference/openapi.json (уже артефакт с проверкой дрейфа), переиспользуя тот же шлагбаум свежести вместо его дублирования. Исправляет копию, которая уже ДВАЖДЫ расходилась с apps/api/src/domain/actions/decide.ts в production (три типа bootstrap Gitflow, ломающие ApprovalCard; затем parallelize/raise_max_parallel), оба раза без замечания компилятора, потому что apps/api — не зависимость apps/web. Мигрирует только ActionType — остальные ~1600 строк api-types.ts остаются написанными вручную, по одному типу за раз, а не массовой регенерацией. aprovacoes.test.ts (ФАЗА 19) остаётся: исчерпывающесть теперь проверяет и компилятор через Record<ActionType, ...>, но утверждения теста о содержимом (качество фразы, а не только наличие) — не свойство типа |
| 0122 | apps/web/src/routes/SessionPage.tsx — 3 807 строк, 169 KiB, 25 тестовых файлов, его импортирующих, в активном изменении (50 коммитов, ноль прежних попыток извлечения) — заранее объявляет ВСЮ декомпозицию в 5 PR, чтобы каждый PR её исполнял, а не решал заново: чистые хелперы timeline/хода → lib/session-timeline.ts (PR 1), StorySlide (PR 2), StructuredQuestionCard + permiteOutra (PR 3), хелперы дерева бэклога + ItemDeBacklog + ContextAside (PR 4), хук useSessionReadiness — единственный, кто не является переносом файла, упорядоченный последним (PR 5). У SessionPage.module.css сегодня ровно один импортёр, так что новые соседние файлы PR 2–4 могут безопасно импортировать его напрямую — никакого вынужденного разделения CSS. Явно ВНЕ пяти PR: набор состояния канала хода (turnoViaCanal/statusAgent/pensandoVisivel/atividadeDoTurno, читаемый и записываемый четырьмя разными обработчиками входа в ход, «ОДНО место, которое завершает ход» из комментария в коде) и ProjectSettingsTab.tsx (отдельный объём). Мерка приёмки каждого PR — те же 25 файлов SessionPage.*.test.tsx, проходящие без редактирования |
| 0124 | Набор состояния канала хода, который ADR 0122 отложил («собственный ADR, позже, с отдельной нумерацией»), уходит в useTurnoDoAgente (apps/web/src/lib/session-turno.ts): streaming/streamingText/streamingAgent/turnoViaCanal/statusAgent/pensandoVisivel/atividadeDoTurno/optimisticUser, эффект канала connectSessionHeartbeat (проверено: на 100% машинерия жизненного цикла хода) и iniciarTurnoDoAgente/finalizarTurnoDoAgente/cancelarTurnoOtimista. Построчное перечтение обнаружило, что собственный подсчёт ADR 0122 устарел — названы четыре обработчика входа в ход, а реальных точек входа записи семь, включая handleReturnStory/RN-174, нигде там не упомянутый, — и опиралось на предыдущий PR механической дедупликации (сам без ADR), который сначала заставил три продублированные инлайн-ветки вызывать уже существовавший iniciarTurnoDoAgente (получивший там { comStatus }) и ввёл cancelarTurnoOtimista. Эта функция построена так, чтобы покрывать лишь два из пяти найденных в файле блоков сброса-при-сбое разной формы (идентичные 4-полевые блоки handleReadiness/handleArchitectureReadiness) — НЕ 2-полевой блок handleAcceptHandoff (его kickoff — асинхронный GenServer.cast, так что он никогда не взводил остальные два поля; связать его с cancelarTurnoOtimista повторило бы ровно ловушку молчаливой связанности, которую ADR 0122 уже назвал) и не две разные формы handleSend (optimisticUser принадлежит собственному жизненному циклу handleSend, а не взведению). SessionPage.tsx уменьшается с 2 681 до 2 479 строк; возврат хука в виде объекта (13 полей, позиционная деструктуризация отвергнута как нечитаемая) сохраняет неизменным имя переменной в каждой точке вызова. Закрывает именно пункт канала хода из раздела «Явно вне объёма» ADR 0122 — ProjectSettingsTab.tsx, другой файл этой линии долга, остаётся нетронутым и с отдельным объёмом |
| 0125 | apps/web/src/routes/ProjectSettingsTab.tsx — 2 532 строки, 89,9 KiB, ПОСЛЕДНЯЯ открытая половина линии технического долга, которую ADR 0122 и ADR 0124 назвали и намеренно отложили, — делится на файл на раздел в apps/web/src/routes/settings/: 17 разделов плюс settings/shared.ts из двух символов. ОДИН PR, а не пять, как в 0122, и причина структурная, а не вкусовая: родитель не держал НИЧЕГО (17 JSX-потомков, ни одного хука, ни одного query, ни одного t, ни одного queryClient, ни одной проверки роли), ни один раздел не получал больше, чем {projectId} (MatrixSection/CredentialsSection не получают никаких props), и 11 из 17 уже экспортировались — так что каждый файл — непрерывный блок, побайтно идентичный, и аргумент активного изменения, вынудивший форму пяти PR в 0122, здесь неприменим. ProjectSettingsTab.tsx ОСТАЁТСЯ на своём пути как точка входа + barrel (2 532 → 77 строк), и это несёт нагрузку, а не косметика: ProjectSettingsTab.test.tsx импортирует 11 имён по этому пути, ProjectPage.test.tsx и project-tabs.test.tsx мокают его через vi.mock ПО ПУТИ, а ProficiencySection.test.tsx рендерит сам barrel — единственный тест, монтирующий все 17 разом. Та же форма barrel, что в ADR 0121, но с ИМЕНОВАННЫМИ экспортами вместо export *: раздел — это компонент с формой props, и явный список — то, что позволяет компилятору поймать раздел, переставший совпадать с точкой вызова. Размещение хелперов механическое и проверено grep — один вызывающий означает файл вызывающего (десять из двенадцати), два и больше — shared.ts (ORIGIN_TONE, formatarCustoMicros), — что попутно исправило ожидание: iniciaisDe/gradienteDe принадлежат только MembersSection, а не ProficiencySection. 6 приватных разделов получают export в собственном модуле (механически; barrel должен их импортировать), но НЕ реэкспортируются barrel, так что публичная поверхность остаётся побайтно идентичной по форме — её расширение было бы наблюдаемым изменением в PR, утверждающем, что изменений нет. ProjectSettingsTab.module.css остаётся ОДНОЙ общей таблицей стилей, теперь с 15 импортёрами, — тот же ответ, что 0122 дал для SessionPage.module.css. Намеренно сохранены: useTranslation('settings') ~14×, useQueryClient() 11×, useToast() 10×, useQuery(['project', projectId]) 7× — React Query их дедуплицирует, а поднятие их в родителя или контекст было бы изменением дизайна, замаскированным под уборку. Закрывает ПОСЛЕДНИЙ пункт открытого долга, объявленный в docs/architecture.md; четыре улучшения UI, приходящиеся на эти же файлы, — последующие PR того же плана, явно вне этого |
| 0126 | Горизонтальная двухуровневая полоса вкладок страницы проекта (components/ui/GroupedTabs.tsx) становится ВЕРТИКАЛЬНОЙ ДОРОЖКОЙ (apps/web/src/routes/ProjectRail.tsx), с тремя группами, открытыми одновременно. Из трёх аргументов дизайн-ревью проверку пережил только один, и это единственное записанное обоснование: 12 вкладок на панели, сделанной под ~6, не масштабируются. Два других были ложными и записаны, чтобы никто их не оспаривал заново, — второй уровень был инлайн-строкой, никогда не наложением (GroupedTabs.tsx:143), а закрытая группа ДЕЙСТВИТЕЛЬНО суммировала счётчики потомков (somarContagens, :33, протестировано в :141). Геометрия заимствована из routes/code/CodeShell.module.css — единственной дорожки в репозитории с нарисованным активным состоянием (полоса --surface-1, разделитель справа, элемент 32px в --radius-sm, активный тонированный color-mix(… 12%)), — никогда из .trilha сайдбара, у которой нет ни активного элемента, ни клавиатуры. Одно объявленное расхождение: ширина 180px вместо 48px, потому что эта дорожка несёт метки и ЗАГОЛОВКИ групп — механизм, держащий три группы открытыми. Пять счётчиков остаются РАЗДЕЛЬНЫМИ, и ни одна группа их не суммирует; бейдж остаётся сплошным --accent/--on-accent против 18%-го тонирования handoff, из-за долга контраста ADR 0074. Протестированный клавиатурный контракт onKeyDownDaLinha ПЕРЕНЕСЁН со сменой оси (ArrowDown/ArrowUp/Home/End, с зацикливанием) и улучшен — ref по ключу вместо позиционных запросов [role="tab"] к DOM, существовавших лишь потому, что примитив Tabs их не раскрывает. GroupedTabs удаляется вместе со своим тестом; Tabs ОСТАЁТСЯ, несмотря на потерю единственного вызывающего, потому что ds-bundle/_ds_sync.json публикует его как components/primitivas/Tabs, а примитив DS без потребителя — это запас, а не мёртвый код. Пересматривает RN-201: автосворачивание вкладки Код (AutoCollapseContext/useAutoCollapseSidebar) УДАЛЕНО, потому что постоянная дорожка проекта рядом с дорожкой иконок Shell — это две соседние вертикальные дорожки на самой тяжёлой вкладке. Принятая цена, измеренная: 492px рамки до первого символа кода (264 сайдбар + 180 дорожка + 48 дорожка кода) против ~110px ранее. Покупается то, что сворачивание снова становится решением пользователя — по-прежнему ручным, по-прежнему сохраняемым; двойная дорожка выживает при ручном сворачивании, что сказано вслух, а не притворно отрицается |
| 0176 | apps/web/src/routes/SessionPage.tsx уходит ниже 1.000 строк в десяти стековых и МЕХАНИЧЕСКИХ PR и только тогда получает потолок в CI (AT-138, BRB-015, решение мейнтейнера: «да, и потом заблокировать»). Линия долга, закрытая ADR 0122/0124 на 2.479 строках, закрылась ЧИСЛОМ, которое никто не отслеживал: в origin/dev на 018b8cd24c в файле было 2.637 строк, обратно к прежнему размеру за счёт законных фич. Программа переносит, не редактируя ни одного из 28 файлов SessionPage.*.test.tsx: проходы ленты (session-fio.tsx), 762-строчное тело useMemo timeline (session-timeline-montagem.tsx, вызываемое изнутри ТОГО ЖЕ useMemo с ТЕМ ЖЕ списком зависимостей, побайтно), три полосы JSX (SessionTopbar, SessionFio, SessionComposer), выведения handoff (lib/session-handoffs.ts), refs и эффекты прокрутки (useRolagemDoFio), продвижение историй (usePromocaoDeHistorias + DevolverHistoriaModal) и действия handoff/исполнения, не относящиеся к ходу (useAcoesDeHandoff), — 2.637 → 834. Обработчики входа в ход остаются: они — дверь набора ADR 0124. Последний PR добавляет SessionPage.teto.test.ts, отклоняющий файл с 1.000 строк и более; поднятие потолка требует нового ADR |
| 0181 | Тёмная тема становится НЕЙТРАЛЬНО-ЧЁРНОЙ с МЯГКИМ терракотовым акцентом, а светлая — нейтральной из того же семейства (AT-283/AT-284). СЕМАНТИЧЕСКИЕ значения меняются, имена — нет; входит сырая шкала --neutro-*. Итоговые числа получены ИЗМЕРЕНИЕМ, а не из черновика (--text-muted #86868f в тёмной / #696972 в светлой, --on-accent тёмной становится нейтрально-чёрным), и пять пар долга тёмной темы, которые 0074 закрепил числом, теперь проходят 4,5:1 и утверждаются как ПОЛЫ; исключение основной кнопки ушло без сдвига тона бренда. Цвет вне токенов уходит в токены: сверенная копия fallback для Mermaid/xterm/миникарты, три токена агента на тему, --overlay и тест, отклоняющий любой var(--x), который никто не объявляет |
| 0210 | Анимация текста с motion (AT-401): Shimmer из AI Elements, адаптированный к CSS Modules и токенам (основа --text-secondary, развёртка --text-primary), живёт в components/ui/Shimmer.tsx — единственном импортёре motion, загружаемом через LazyMotion + domAnimation в собственном чанке vendor-motion (26,5 kB gzip, вне начальной загрузки); уменьшенное движение оставляет текст статичным. |
| 0211 | Одна навигация (AT-404): вертикальная дорожка проекта (ADR 0126, ProjectRail.tsx) удалена, и список по проекту в сайдбаре (AbasDoProjeto) поглощает всё, что у неё было, — группы, активную вкладку, пять РАЗДЕЛЬНЫХ счётчиков (тот же хук useContagensDoProjeto рамки, без нового запроса), клавиатуру, flyout в свёрнутом виде и выдвижную панель на телефоне. Рамка вкладки Код уменьшается с 492px до 312px (110px в свёрнутом виде). |
Эксплуатация, deploy и установка
Production-образы, Kubernetes, наблюдаемость, бэкап, dev-compose и установщик в одну строку.
| # | решение |
|---|---|
| 0024 | Многоэтапные non-root production-образы, compose.prod, CI со сканированием и дымовым тестом |
| 0025 | Kustomize base+overlays (не Helm), HPA engine по глубине очереди Oban, локальный overlay |
| 0026 | Graceful shutdown с дренированием, регистрация сессии в :global, OpenTelemetry с trace на сессию, метрики и дашборды как код |
| 0027 | Протестированный backup/restore, rate limit в Postgres (без Redis), классифицированная HTTP-поверхность и версионируемый релиз |
| 0035 | Раздельные инструментирование и экспорт (trace с реальным trace_id без коллектора), путь между слоями через AsyncLocalStorage + @Traced, читаемый лог в dev и одна строка JSON в production, и три мёртвые дыры корреляции |
| 0070 | Наблюдаемость в локальном Compose: метрика и лог, без trace. 0026 собрал Prometheus, Loki, Tempo, Alloy, Collector и Grafana только для Kubernetes — критерий приёмки говорил «в локальной Grafana», и «локальный» там был кластер k3d. Практическое следствие: у разработчика с pnpm dev вся инструментация работает (api раскрывает 102 серии, engine раскрывает oban_queue_depth, все три ставят trace_id в лог), и НИЧЕГО из этого не наблюдаемо, потому что для просмотра панели нужно было поднять кластер, который по замыслу не сосуществует с pnpm dev (те же порты, ADR 0025). Overlay docker/docker-compose.observability.yml — opt-in, не часть pnpm dev: это четыре дополнительных контейнера на машине, где уже работают Postgres, Ollama и три приложения, и тот, кто не расследует, не должен за них платить. Поставляет две телеметрии, которым НЕ нужен коллектор, — метрика это scrape, лог читается из stdout, ровно разделение из решения 9 в 0026, — и оставляет trace снаружи, не задавая OTEL_EXPORTER_OTLP_ENDPOINT, потому что 0035 уже установил, что не экспортировать — нормальное состояние в разработке, а указание на несуществующий адрес лишь порождает ошибку экспорта на каждом ходе. Артефакты — ТЕ ЖЕ, что в кластере, а не копия: Compose монтирует deploy/k8s/observability/dashboards/ напрямую, и UID источников данных идентичны, потому что дашборд ссылается на источник данных по UID, а копия дашборда расходится на первой исправленной лишь с одной стороны панели; по той же причине scrape выдаёт метки app/pod, по которым группируют панели, а коллектор выдаёт app, который Alloy кластера выводит из app.kubernetes.io/name. Разбор лога — через regex, а не stage.json, потому что в dev pino-pretty рисует читаемое дерево, а engine использует PrettyLogFormatter — хорошее решение, которое не меняют в угоду коллектору; адаптируется коллектор. В Loki идут только api, engine и web: Postgres и Ollama — шум, а сам стек наблюдаемости остаётся снаружи, потому что работает в ТОМ ЖЕ проекте Compose и поглощал бы собственный лог в самоподпитывающемся цикле. trace_id — СТРУКТУРИРОВАННЫЕ метаданные и никогда не метка (кардинальность), что потребовало allow_structured_metadata и схемы v13 в собственной конфигурации Loki. Фиксирует три режима молчаливого сбоя, стоившие времени, все с одной подписью — валидная конфигурация, ноль ошибок в логе, неверные данные на панели: stage.replace заменяет ГРУППЫ ЗАХВАТА, и без скобок не заменяет ничего; экранирование \x1b в строке River обрабатывается до компиляции regex, а [[:cntrl:]] от этого слоя не зависит; а шаблон Go с отсутствующим ключом отрисовывает литерал <NO VALUE>, который стал значением уровня в селекторе |
| 0150 | Установщик в одну строку и три дисковых факта, придающих ему форму. docker-compose.prod.yml — не манифест установки: каждый сервис указывает на brabo-*:prod с локальным блоком build:, под docker/ нет ни одной ссылки на ghcr.io, а файл говорит о себе, что он — «compose ВАЛИДАЦИИ» и «чем этот файл НЕ является: усиленным production-манифестом». Единственный потребитель .release/images.json — overlay kustomize. Решение: вызов — sh -c "$(curl -fsSL …)" и **никогда `curl |
| 0152 | Бэкап и восстановление, работающие против compose, — родились потому, что ФАЗЕ 29 нужно мигрировать существующую установку, а задокументированный путь не годится: make test-restore — путь Kubernetes (нужен kubectl, кластер и CronJob brabo-backup), у docker-compose.prod.yml нет сервиса backup, а backup.sh покрывает только Postgres, требуя пять переменных S3. Решение начинается с ОТКАЗА от лёгкого хода («бэкапить каждый том») и классификации всех семи: pgdata и git_local_repos — источник истины; neo4j_data и project_workspaces — производные; ollama_data можно получить заново; brabo_projects_base принадлежит пользователю. git_local_repos — реальная дыра и находка этого ADR: проект провайдера local хранит там bare-репозиторий, потеря этого тома теряет код, который произвели агенты, ни один бэкап сегодня его не покрывает, и он НЕ восстановим из Postgres: event log хранит повествование, а не объекты git. Neo4j получает перепроецирование, а не бэкап — восстановить, возможно, устаревшую проекцию рядом с Postgres, восстановленным в другой момент, значит получить два производных состояния из разных моментов без ничего, что их примирит; эта фаза объявляет путь и принимает, что мигрированная установка начинает с ПУСТЫМ графом, что является известной деградацией (RAG живёт в pgvector, внутри дампа), а не потерей данных. Диск становится допустимым назначением, чтобы миграция не требовала bucket. Восстановление compose переиспользует ТЕ ЖЕ три проверки restore.sh:139-224 — обёртка меняется, суждение нет, — а deploy/k8s/test-restore.sh остаётся нетронутым. Установщик получает право удалять тома лишь потому, что доказал, что может их вернуть |
| 0153 | DEPLOY_ENABLED упоминается как триггер активации десятью документами — ADR 0089/0091/0092 в самом поле Status, fluxo.yml в трёх местах (platform.ativacao, area-infra.gate_saida и ОБЕ поставки пробелов secops-runtime), gates.yml в gate operavel, плюс business-rules.md, backlog.md, modelo-de-time.md, auditoria-fluxo-vs-codigo.md, historico-de-fases.md и CLAUDE.md — и не существует нигде в коде: два единственных вхождения — КОММЕНТАРИИ (external-effect.ts:144, listar_metricas_de_produto.ex:185). Десять документов указывают на выключатель, за которым нет стены. Находка, не относящаяся к deploy: gate deployavel объявлен как gate_saida у area-infra в fluxo.yml:247 и отсутствует в реестре gates.yml, — ровно та несогласованность, которую поймала бы перекрёстная проверка (всё ещё в бэклоге) между тремя частями модели команды, — вторая находка такого рода. Решение делится на две части с жёсткой границей: Этап A, решаемый сегодня, добавляет deployavel в реестр как planned/warn (RN-070/071 запрещают повышать до block без скрипта проверки), оставляет нетронутыми два досрочно введённых скрипта и заставляет прозу перестать обращаться с DEPLOY_ENABLED как с переменной, называя вместо этого ВЕХУ, которую он всегда означал; Этап B блокирован оператором, а не кодом, — production-окружение с реальным трафиком, после которого platform выходит из planned и пробелы становятся возможными. Инфраструктура — НЕ узкое место (overlays, образы в GHCR по digest с 0119, скрипты smoke/rollout/restore — всё существует) — не хватает кого-то, кто будет это запускать и поддерживать. РАССМОТРЕННАЯ И ОТВЕРГНУТАЯ альтернатива — создать переменную сейчас со значением по умолчанию false: переменная, которую не читает никакой код, — то же пустое обещание, которое разоблачает этот ADR, теперь подпёртое строкой конфигурации; флаг рождается в PR, у которого будет первый реальный потребитель. Явно отвергается: создание агента platform/secops-runtime в engine (повторяя отказ 0091/0092, который остаётся в силе) и пометка MTTR/change failure rate как навсегда неизмеримых, когда они зависят лишь от окружения |
| 0155 | Установщик создаёт первую учётную запись в терминале и поднимает локального агента уже спаренным. Находка, которая к этому вынуждает: новая установка в одну строку никого не пускает внутрь. Сгенерированный .env не несёт ни одной переменной e-mail, MAIL_TRANSPORT падает на log (docker-compose.install.yml:200, чей собственный комментарий говорит, что поведение «до сих пор остаётся только-лог»), а регистрация требует подтвердить e-mail — что, как уже фиксировал docblock provisionar-usuario.ts, «с MailSender только-лог само не закрывается». Так что установка заканчивается словами «Готово», и единственный путь дальше — выудить ссылку подтверждения из docker compose logs api. provisionarUsuario существует ровно для этой дыры — создаёт пользователя с УЖЕ подтверждённым паролем, — и его два вызывающих — демонстрационный seed и smoke, ни один из которых не является реальной установкой. Решение: шаг выполняется только когда пользователей нет вовсе, через внутренний маршрут, аутентифицированный BRABO_SERVICE_TOKEN, который установщик только что сгенерировал и записал в режиме 600; публичный маршрут «создать первого owner» не рождается, потому что публичный маршрут, создающий owner, — гонка между тем, кто установил, и тем, кто просканировал порт. Учётная запись рождается подтверждённой, и причина — то, что это разрешает: подтверждение e-mail доказывает «этот человек контролирует этот почтовый ящик», а тот, кто запускает установщик, уже доказал нечто более сильное — он контролирует МАШИНУ, .env с пятью секретами и демон Docker. Требовать более слабое доказательство по каналу, о котором установка знает, что он выключен, — театр, — и установщик ГОВОРИТ это в выводе, вместо того чтобы человек обнаружил, что его учётная запись отличается от всех остальных. Согласие явное, и без TTY шаг не происходит (правило RN-511, форма consentir-base.mjs): не существует пути, где учётная запись рождается с паролем, выбранным скриптом, ни сгенерированным, ни по умолчанию. Пароль читается без эха, подтверждается, используется и отбрасывается — никогда в .env, никогда в маркере, никогда в логе; маркер получает e-mail owner, что является идентификацией, а не секретом. После создания учётной записи регистрируется ключ устройства машины и устанавливается unit на машину; пара генерируется НА машине, и приватная половина никогда не путешествует, сохраняя верной фразу, которой RN-519 отвергла видение maintainer. Затем агент поднимается БЕЗ проекта, и это нормальное состояние — он ждёт, не выдаёт сбоя, а status говорит «работает, нет проектов в режиме runner», одно из четырёх состояний, которые RN-518 уже различает, а не пятое. Отвергнуто: создание учётной записи, когда она уже есть, публичный маршрут первого owner, пароль, выбранный скриптом, ослабление обычной регистрации, самовольное включение SMTP и запрос учётных данных LLM |
| 0160 | Compose установщика путешествует вместе с ним, как подписанный asset Release. Первый запуск установщика на чистой машине (RN-549) обнаружил, что опубликованный install.sh сам ничего не поднимал: он выполнял docker compose -f docker/docker-compose.install.yml, путь относительно того места, где бы он ни выполнялся, а этот файл не был asset Release, не был в подписанном checksums.txt и нигде не скачивался — так что тот, кто следовал единственной строке runbook, умирал с «no such file or directory» после того, как .env с пятью секретами уже был записан. Решено мейнтейнером: четыре файла становятся assets в том же подписанном манифесте — compose, два файла, которые он монтирует через bind-mount (postgres/init.sql, ollama/pull-models.sh), и, как измерено во время реализации, backup/test-restore-compose.sh, который выполняет путь миграции и который ручной обход E2E никогда не брал, потому что на чистой машине нечего мигрировать. Имена несут префикс brabo-install-, потому что Release плоский, и общие имена столкнулись бы. Таблица живёт с двух сторон — scripts/ci/assets-do-instalador.ts для публикующего, case в install.sh для скачивающего (bash 3.2, без Node), — и spec отклоняет их расхождение и выводит каждый относительный bind-mount compose, ровно так, как дефект и родился. Job checksums публикует их из checkout тега и теперь выполняет sha256sum -c --strict над манифестом перед прикреплением. Установщик скачивает и сверяет все четыре сразу после самопроверки, против ТОГО ЖЕ манифеста, по точному имени, с тремя различными именованными отказами, все при нетронутой машине; COMPOSE_DE_INSTALACAO рождается пустым и становится абсолютным путём лишь когда проверенные копии кладутся в папку установки, заменяя — никогда не читая — то, что там было. E2E отказывается от ручного шага и утверждает его отсутствие. Отвергнуто: клонировать репозиторий на теге (меняет цепочку подписи на доверие к транспорту git, плюс ещё одна зависимость, которой на чистой машине может не быть), tarball (одна строка манифеста на четыре безымянных файла), встраивание их в install.sh, флаг, указывающий на URL другого Release, и запуск E2E в pull request. Объявлено: доказано сквозным образом только на следующем финальном теге; ни один уже опубликованный Release не получает assets; и, измерено и не исправлено, test-restore-compose.sh вызывает Compose без --env-file, так что доказательство восстановления миграции склонно падать безопасным образом |
| 0161 | Экраны предлагают только тот режим исполнения, который эта установка может запустить, а установка из Release не имеет broker контейнеров. Реальная установка v6.1.0 (AT-085) создала проект в mounted — ПРЕДВЫБРАННОМ, потому что install.sh всегда записывает BRABO_PROJECTS_BASE, а RN-513 предвыбирает этот режим, когда база известна, — и получила шесть dev.blocked_by_container плюс два container.start_failed: container и mounted поднимают контейнер только через broker (ADR 0144), у compose установки нет никакого сервиса broker (не выключенный profile — решение 7 ADR 0150), а без работающего контейнера ни один dev agent не работает (ADR 0143). Мейнтейнер решил «оба»: установщик спрашивает, включать ли broker, а экраны перестают предлагать то, что не работает. Половина установщика была измерена и остановлена: образ broker недоступен для установки из Release — четыре цели bake, ни одного broker в images-manifest.ts или в images.json v6.1.0, docker manifest inspect ghcr.io/daneiel/brabo-broker:6.1.0 отвечает denied, тогда как тот же анонимный вызов для brabo-api разрешается, а единственная строка выполняется в папке без исходного кода, из которого можно собрать. Вопрос, у «да» которого нет образа, хуже никакого; публикация broker как пятого образа идёт первой, отдельным направлением. Половина экрана входит: brokerConfigurado (из ContainerBrokerPort.configurado(), того же источника, что использует чтение наблюдаемого состояния) входит в GET .../projects-base и в каждую строку GET .../containers — без нового маршрута. У мастера три состояния, которые никогда не схлопываются: подтверждённый broker сохраняет RN-513; подтверждённое отсутствие оставляет Container и Смонтированный на экране, но неактивными, один раз текстом говорит почему и предвыбирает Runner; неизвестное не предвыбирает Смонтированный и ничего не блокирует. /containers получает четвёртый локальный отказ, sem_broker_na_instalacao, для container/mounted, до проверки образа. RN-512 и пять слоёв сдерживания остаются нетронутыми. Вне объёма, объявлено: перевод режима, остановка/удаление, незнание Infra Lead об отсутствии broker и предложение параллелизации, игнорирующее заблокированных агентов |
| 0162 | Broker контейнеров становится пятым публикуемым образом, и установщик спрашивает, включать ли его. Половина установщика из «оба» ADR 0161: в установке из Release проекты container и mounted никогда не запускались, потому что их контейнеры поднимает broker (ADR 0144), а у установки не было сервиса broker — измерено, ghcr.io/daneiel/brabo-broker отвечал denied, у bake было четыре цели. Dockerfile.prod уже существовал и проходил hadolint; ничто его не собирало. Теперь цель bake broker проходит через каждый шлагбаум, через который проходят остальные четыре, — собирается, сканируется Trivy и отклоняется как root на каждом PR, доказывается здоровым в read-only и без сети, публикуется, записывается в images.json, подписывается и проверяется по digest на каждом теге. Compose установки получает сервис выключенным, под profile container-broker, с нетронутыми пятью слоями ADR 0130; overlay Kubernetes о нём не знает. Установщик спрашивает после базы, говорит текстом, что включение отдаёт сервису сокет Docker машины, включает его только по набранному «s», измеряет gid сокета изнутри контейнера (иначе — именованный отказ, никогда угаданный 999), записывает COMPOSE_PROFILES/BROKER_URL/DOCKER_GID/управляемый корень вместе и проверяет после подъёма, что api достигает broker и что вычисленный корень — корень тома. Аргумент RN-512 (тот, кто устанавливает на свою машину, уже имеет сокет) — причина, по которой его можно предложить; то, что читатель установщика никогда не открывал этот репозиторий, — причина, по которой о нём спрашивают, а не предполагают. Цена, объявлена: образ, равноценный root, публичен, и пользовательская установка монтирует сокет Docker, когда соглашается |
| 0169 | S3-назначение бэкапа локального overlay перестаёт быть MinIO и становится SeaweedFS (docker.io/chrislusf/seaweedfs 4.47, закреплённый по digest ИНДЕКСА, server -s3 -s3.port=9000). MinIO перестал публиковать образ сообщества — закреплённый digest отвечает 401 на quay.io, а репозиторий Docker Hub — 404, — так что bootstrap умирал на rollout status, и ни одно доказательство свойств не выполнялось (issue #633). Измерено против критериев мейнтейнера, по порядку: SeaweedFS проходит все четыре (официальный, анонимный, индекс amd64+arm64; реальный поток lib.sh через aws-cli — аутентификация, mb, multipart-загрузка 30 MiB через stdin, head-object, скачивание со сверкой sha256, копирование на стороне сервера, list-objects-v2, rm; меняется только сервер; Apache-2.0); Garage отброшен на третьем (файл конфигурации плюс раскладка после boot и импорт ключа); Bitnami, его замороженное устаревшее зеркало и MinIO только с :latest от Chainguard — на первом. Ресурсы получают нейтральное имя (s3-local); клиент, скрипты бэкапа, Secret и порт не меняются. Объявлено: digest гарантирует неизменяемость, а не доступность; совместимость с S3 — по операциям, так что новая операция в lib.sh сначала измеряется против него |
| 0170 | Dev-compose становится Docker-проектом brabo-dev, и dev отказывается подниматься рядом с установкой. Измерено 2026-09-19: dev-compose и compose установки оба были name: brabo — ОДИН Docker-проект на машине с обоими, делящий контейнеры, сеть и тома (brabo_pgdata…); база установки получила миграцию от dev, а reset-total.sh стёр бы её схемы. Установка остаётся brabo (её переименование отключило бы каждую существующую установку от собственных томов); имя меняет dev, а overlay наблюдаемости повторяет имя, потому что побеждает последний -f. preflight.mjs и reset-total.sh ОТКАЗЫВАЮТ, называя контейнеры и down (без -v) каждого файла, пока существует любой контейнер, чья метка config_files указывает на compose установки (в любом состоянии, под любым -p), или пока dev-compose разрешался бы в brabo; защита reset выполняется прежде всего. Старые тома brabo_* dev дают лишь ПРЕДУПРЕЖДЕНИЕ — их доказывают ключи, которые есть только у dev, выведенные из двух файлов compose, общие ключи остаются двусмысленными, и предупреждение это говорит, — а runbook несёт процедуру копирования или начала заново. Объявленная цена: разработка на машине с установкой требует сначала её остановить |
| 0209 | Engine переходит на Elixir 1.20.4 / OTP 29.1.1 поверх Alpine 3.24.2 в dev, production и CI (AT-398): OTP 27 аварийно завершался при boot с sys_sigaltstack(): Internal error под musl 1.2.6 из Alpine 3.24 на хостах с AMX (erlang/otp#11349), исправлено только начиная с OTP 29.1; цена — пересборка dev-томов _build/deps. |
CI, релиз и цепочка поставок
Конвейер, публикуемые образы, подпись, сканирование, закрепление версий, слои тестов и структура самого репозитория.
| # | решение |
|---|---|
| 0039 | actionlint, закреплённый в Dockerfile engine (тот же шаблон, что gitleaks/hadolint), валидация конвейера CI при ГЕНЕРАЦИИ вместо нового gate после PR и задокументированный пробел .gitlab-ci.yml без эквивалентного офлайн-статического линтера |
| 0119 | release.yml перестаёт собирать четыре production-образа только для того, чтобы доказать, что тег собирается, и ПУБЛИКУЕТ их в GHCR на каждом финальном теге, публичными, входя с собственным GITHUB_TOKEN job (packages: write) — ни одного нового секрета. Префикс registry и вывод сборки становятся переменными bake (REGISTRY/OUTPUT), обе по умолчанию пустые, чтобы ci.yml продолжал собирать короткие локальные имена, которые он сканирует; type=registry — не оптимизация, а единственный режим, в котором bake возвращает containerimage.digest, потому что digest рождается из push. То, что опубликовал каждый тег, записывается ПО DIGEST в .release/images.json — asset Release, который входит в репозиторий в PR CHANGELOG, БЕЗ третьего исключения push. Overlay сохраняет REPLACE_WITH_DIGEST как маркер, а make imagens-do-release применяет digest через kustomize edit set image; репозиторий никогда не объявляет, какой релиз в production. Находка по пути: overlays перечисляли три образа, а не четыре — CronJob бэкапа наследовал brabo-backup:prod, имя, которое не разрешается ни в каком registry |
| 0120 | Слой браузерного E2E (e2e/, Playwright, только chromium) закрывает единственную дыру, оставшуюся на вершине пирамиды: ничто не испытывало настоящий браузер, а механизмы, несущие сессию, — механизмы браузера: refresh-cookie httpOnly в паре с читаемым brabo_csrf, межисточниковые вызовы с :8088 на :3000 (preflight, SameSite, credentials: 'include') и одноразовый тикет сокета RN-108, выполняющий handshake с engine на ТРЕТЬЕМ источнике. jsdom не навязывает ни одного из них, так что тест, утверждающий их там, прошёл бы, ничего не доказав (main.ts уже говорит: «тест не делает preflight»). Он выполняется против production-compose, который уже поднимает docker/smoke.sh (SMOKE_KEEP_UP=1), и в ТОМ ЖЕ job images, потому что четыре образа — большая часть времени этого job, а отдельный job пересобирал бы их, чтобы прийти к идентичному стеку. e2e/ НЕ является членом workspace — собственные pnpm-workspace.yaml и lockfile, та же форма, что у website/ (ADR 0117), по той же причине: дерево Playwright не попадает ни в какой образ и нечего ему делать в pnpm audit продукта. Spec утверждают МЕХАНИЗМ (флаг httpOnly, как его сообщает браузер, document.cookie, как его видит страница, переживание reload, ticket= в URL WebSocket) структурными селекторами, никогда текстом — язык интерфейса решает сервер. Seed идёт по HTTP, повторяя smoke. Объявлено как не покрытое: различия между браузерами, инлайн-одобрение и потоковая передача |
| 0121 | apps/api/src/db/schema.ts — 2 452 строки, 51 таблица, 34 enum, самый изменяемый файл репозитория — делится на файл на ДОМЕННЫЙ АГРЕГАТ в db/schema/, отражая src/domain/* вместо изобретения таксономии, которую файл уже вручную поддерживал в комментариях // --- section ---. Старый путь становится 25-строчным barrel из export *, так что 144 модуля, импортирующие …/db/schema, ничего не меняют, import * as schema в drizzle-client.ts видит идентичный набор экспортов, а drizzle.config.ts продолжает указывать на него (проверено: drizzle-kit следует по цепочке export * и сообщает «No schema changes, nothing to migrate»). Чисто механически — ни одна таблица, колонка, значение enum, имя индекса или выражение ограничения не изменились, и мерка приёмки — миграция с НУЛЕВЫМ diff, строже набора тестов, потому что Drizzle сравнивает выведенный SQL. Enum живут с таблицей, которая их вызывает, а не с темой: FK между файлами безопасен в цикле (.references() — ленивый callback), ENUM между файлами — нет (выполняется при вычислении модуля), так что project_execution_mode/story_promotion остаются в iam.ts с единственным потребителем, а failure_origin — в backlog.ts, сохраняя граф импортов DAG. backup.ts — единственный файл без пары в domain/ — инфраструктура без бизнес-правила, и неверная граница стоила бы больше, чем маленький файл. docs/.docmap.yml получает schema/** рядом с barrel: без этого правило было бы слепо к ограничениям, за которыми оно и существует следить |
| 0140 | 2026-09-04 endpoint advisories npm (POST .../security/advisories/bulk) часами работал с перебоями и провалил job аудита зависимостей в ЧЕТЫРЁХ отдельных PR с идентичной подписью (error (503), затем TimeoutError) — воспроизведено вне CI, с другой машины, где простой GET к registry отвечал за 130 мс, а пакетный POST не отвечал никогда. Цена — не потерянные минуты, а привычка, которой это учит: каждое такое красное очищается перезапуском до зелёного, и кто этому научился, перезапускает и поверх настоящего красного. Решение владельца продукта: три попытки без ответа registry — это ПРИНЯТЫЙ РИСК — job становится зелёным с громким предупреждением, говорящим, что дерево НЕ было проверено, никогда что оно чистое. Чего решение не ослабляет, то и делает его защитимым: сообщённая уязвимость по-прежнему проваливает на ПЕРВОЙ попытке без повтора, и нераспознанный сбой тоже проваливает (fail closed). Приоритет — сердце этого: classificarAudit проверяет подписи НАХОДКИ до сетевых, так что отчёт, случайно упоминающий timeout (пакет с таким именем, CVE о таймауте), никогда не прощается как недоступность, и есть тест ровно на это. Логика живёт в тестируемом скрипте (11 случаев, на РЕАЛЬНОМ выводе запуска 33838507158 вместо пересказов), а не в bash внутри YAML. Меняется только сторона pnpm; mix hex.audit/mix deps.audit остаются как были, поскольку расширение исключения на тех, кто о нём не просил, превратило бы точечное решение в общую политику. Объявлено и не решено: ничто не предупреждает, когда риск принимается повторно, — три таймаута в одном PR это сторонний шум, три в каждом PR неделю — это шлагбаум, фактически выключенный, и сегодня ничто их не различает |
| 0149 | Продукт не подписывает ничего из того, что публикует: поиск cosign, sigstore, slsa и attestation в .github/, scripts/, docker/ и Makefile возвращает одно реальное вхождение, и оно не покрывает ни образ, ни бинарник (npm publish --provenance для npm-пакета, publish-runner.yml:188). release.yml собирает и пушит четыре образа (:286-303) и записывает их digests в .release/images.json (:308-319), объявляя только три разрешения (:40-43) — без id-token: write, которого требует keyless-подпись. build-runner-binaries.yml загружает пять бинарников через gh release upload (:227) и ни одного checksums.txt — тогда как СУЩЕСТВУЮЩИЕ вызовы sha256sum -c (ci.yml:201, Dockerfile.prod:95) проверяют сторонние бинарники, ровно тот шаблон, который репозиторий знает и никогда не применял к себе. А GET /runner-releases/binary — @Public() и передаёт байты GitHub напрямую, без какой-либо проверки целостности. Это BRB-005, и это то, что заблокировало установщик в одну строку в ФАЗЕ 28. Решение: keyless cosign (OIDC Actions), подписывающий четыре образа по digest — никогда по тегу, подвижному указателю, — плюс ОДИН подписанный checksums.txt, покрывающий пять бинарников, вместо пяти отдельных подписей (пять артефактов для проверки — пять способов проверке остаться наполовину). Три названных потребителя, у каждого — отказ, никогда не предупреждение: установщик, прокси скачивания и локальная сборка. Keyless, а не управляемая пара ключей, по причине, которую репозиторий уже выучил на горьком опыте: приватный ключ означает хранение, ротацию и ЕЩЁ один секрет CI, а истёкший незамеченным PAT уже провалил два запуска tag-release (0139). Сам проверяющий бинарник cosign закреплён и сверяется через sha256sum -c против хэша, записанного в версионируемом скрипте. Отвергнуто: управляемые ключи, «подписать сейчас, проверить потом», подпись по тегу и полная аттестация SLSA/SBOM как отдельная ось. Объявляет, что broker остаётся без подписи, потому что остаётся без публикации — у bake четыре цели. Дополнено в сессии 3, которая разделила надвое строку прокси скачивания: он проверяет целостность (sha256 против checksums.txt релиза, отказывая — никогда не предупреждая — когда манифеста нет) и не происхождение, потому что оба способа, которыми api могла бы проверить подпись, были измерены и отвергнуты — cosign в образе весит 155 MB против runtime Alpine+Node, а @sigstore/verify дёшев (2,5 MB), но заставил бы маршрут @Public() зависеть от второго стороннего хоста, чтобы проверять то, чего пока не несёт ни один Release, что ADR 0041/0042 запрещают объявлять без доказательства. Так что BRB-005 закрывается в части производства подписей и остаётся открытым для этой половины прокси (RN-525) |
| 0159 | Сторонние образы закрепляются по digest, с тегом в комментарии в конце, а линт отклоняет следующую регрессию. Это отменяет решение, которое этот репозиторий записал: страница цепочки поставок называла закрепление по тегу «сознательной остановкой, а не небрежностью», покупающей воспроизводимость в повседневности «без затрат на поддержку digests в образах, которые мы не публикуем». Обе половины этой фразы падают перед измерением. Сторонних ссылок 37, и три места, где они выполняются, не являются повседневностью: тринадцать — строки FROM в Dockerfile, являющиеся основой четырёх образов, которые мы публикуем в GHCR и подписываем cosign (RN-524); четыре — в docker-compose.install.yml, который работает рядом с Postgres того, кто установил продукт, в том же файле, где четыре собственных образа уже приходят по digest через переменную; и четыре — services: в ci.yml/golden-set-rag.yml, что буквально и есть runner, ради защиты которого существует правило для actions, достигнутый через другую дверь, — ни одно из трёх не было в собственных доказательствах BRB-004. Затраты на поддержку реальны и ТЕ ЖЕ, что уже приняты для SHA actions. Проверка — сестра actions-pinadas.ts, а не расширение: uses: — один синтаксис в YAML workflow, образы — ещё три между compose, манифестами kustomize и Dockerfile, а функция, отвечающая на оба вопроса, отвечает на оба плохо. Она отклоняет изменяемую ссылку, digest без комментария тега и один и тот же тег с двумя разными digests — это третье превращает обещание, записанное в комментарии (golden-set-rag.yml, утверждающий, что запускает «ТУ ЖЕ закреплённую версию» dev-compose, потому что пол golden-set ключуется по модели, а не по окружению), в нечто проверяемое. Digest — это digest индекса, никогда одной платформы: закрепление linux/amd64 без предупреждения сломало бы linux-arm64. В Dockerfile комментарий тега идёт на строке выше, и это выучено на ошибке: парсер Docker принимает # только в начале строки, так что комментарий в конце — это FROM с тремя аргументами, и сборка умирает, — тогда как hadolint пропускал тот же файл, потому что согласие линтера — не согласие сборки. Проверка распознаёт форму в конце лишь для того, чтобы её отклонить, поскольку шаблон, который просто не совпадал бы, заставил бы ссылку исчезнуть из прочёсывания, а не покраснеть, — обменяв ошибку на молчание, тот дефект, который она существует предотвращать. Комментарий — один токен в обеих формах, и это то, что отделяет тег от прозы, которая уже стоит над почти каждым FROM. Проверка намеренно не покрывает четыре образа, которые публикует продукт, по трём независимым причинам — никакая третья сторона не может их сдвинуть; brabo-api:prod — локальный тег, чей digest не существует до сборки и меняется с каждой сборкой; а там, где они проходят через registry, ADR 0119 уже разрешает их по digest, при этом overlay намеренно хранит МАРКЕР, а не замороженный релиз, так что требование литерала там отклоняло бы правильное состояние. То же для интерполированных ссылок и стадий multi-stage. Список исключений — по имени и закрывается при сбое. Compose наблюдаемости был открытым TODO(humano) и входит: его исключение стоило бы именованного освобождения внутри docker/, ровно та форма дыры, которую диагностирует docblock сестры («новый workflow пишется копированием соседа, а у соседа был тег»). Объявлено и не исправлено: digest не получает обновлений безопасности, пока кто-то не поднимет его вручную, — снова долг SHA actions, — а экосистема docker у Dependabot не включена, отдельное решение; dev-базы (node:24-alpine) замораживаются сильнее production-баз, что является менее комфортным следствием и принято; а otel-collector-values.yaml, где image: открывает словарь, тег в котором выбирает chart, остаётся без покрытия. RN не рождается: у сестринского правила её тоже нет, и оба живут в CLAUDE.md и на странице цепочки поставок. BRB-004 перестаёт воспроизводиться |
| 0172 | Релиз запускает Trivy на том, что ПУБЛИКУЕТ, по digest и до подписи (AT-179): до этого сканировал только ci.yml, и он сканирует ЛОКАЛЬНУЮ сборку PR, тогда как тег собирается с нуля в другом запуске (AT-110), — образ, который скачивает установщик, был тем, который никто не сканировал. Шлагбаум стоит между записью .release/images.json и cosign sign, так что непрошедший образ никогда не подписывается; он использует флаги ci.yml побайтно (--severity HIGH,CRITICAL --ignore-unfixed --exit-code 1, тот же .trivyignore.yaml, никакого нового allowlist), а код выхода Trivy — вердикт. HIGH/CRITICAL без исправления СООБЩАЕТСЯ — сводка job и asset Release trivy-sem-correcao.md, собираемый scripts/ci/trivy-do-release.ts, — и не блокирует. Объявленная цена: push уже произошёл, так что проваленный тег оставляет неподписанные образы в GHCR без Release; а версия, хэш и флаги живут в двух workflows, охраняемых spec. Измерено против digests v6.1.0: api и web сегодня провалились бы на CVE-2026-45447 (openssl, исправлена в 3.3.7-r1). |
| 0178 | Ссылка на сторонний образ становится image:tag@sha256:<index> — тег выходит из комментария и идёт ВНУТРЬ ссылки, перед digest (AT-139, поверх ADR 0159; решение мейнтейнера назвало его «ADR 0171», номером, который так и не был написан и остаётся свободным). Измерено 2026-09-27: Dependabot не читает комментарий тега ни в каком формате и сдвигает закрепление только по digest на digest latest, что увело бы pgvector, neo4j и node на другую мажорную версию, при этом комментарий продолжал бы лгать, а линт был бы зелёным. 39 ссылок мигрируют с ТЕМИ ЖЕ digests (29 в docker/, 4 в deploy/k8s/, 6 в workflows), комментарии удалены; imagens-pinadas.ts выводит тег из ссылки и отклоняет digest без инлайн-тега (включая форму ADR 0159), несогласный комментарий тега, комментарий в конце FROM и один инлайн-тег с двумя digests — каждое правило доказано мутацией. НЕ сделано, и объявлено: включение docker/docker-compose в Dependabot, потому что решённый шаг, выравнивающий services: workflows в PR бота, должен был бы пушить в .github/workflows/, чего GITHUB_TOKEN никогда не может, — выход за мейнтейнером. imageName у CNPG и образ golden-set QA в любом случае остаются ручными |
| 0197 | services: в workflows читают образ из dev-compose, а экосистемы docker/docker-compose в Dependabot включены (AT-246, решение владельца от 01.10, поверх ADR 0159 и ADR 0178). Шесть литералов (pgvector и ollama в ci.yml и в двух golden-set) уступают место ${{ needs.imagens.outputs.<image> }}, приходящему из переиспользуемого imagens-do-compose.yml, который запускает scripts/ci/imagens-do-compose.ts, — один источник digest, так что PR бота никогда не трогает .github/workflows/, а «тот же Ollama, что в dev» у golden-set-rag.yml переходит из обещания в конструкцию. imagens-pinadas.ts теперь отклоняет в workflow любой литерал образа, любое выражение, не являющееся ровно этим (литерал, ` |
Документация
Как документация генерируется, проверяется и публикуется.
| # | решение |
|---|---|
| 0029 | Непрерывная синхронизация документации: генерировать > проверять > помнить, карта ответственности, проверяемая в CI, и обязательный запасной выход |
| 0033 | Справочник API выходит из кода: DTO ответов, отражающие сущность по типу, табличный тест, требующий summary и настоящий ответ (синтезированный Swagger не считается), и манифест хэшей вместо регенерации в --check |
| 0034 | Документация, публикуемая по ступеням в подкаталоге Pages: baseUrl через переменную, noIndex вне main с forceIgnoreNoIndex в локальном поиске и третье исключение прямого push |
| 0071 | Симметричная публикация по ступеням и корень, который выбирает. 0034 заставил каждую постоянную ветку публиковать собственную документацию, но оставил АДРЕС асимметричным: main в корне, qa и dev в подкаталоге. Асимметрия взимала три цены — три сайта не видели друг друга (попавший на /brabo/ не мог добраться до qa), особый случай корня требовал шага workflow, выполнявшегося лишь в трети публикаций, и негде было объявить зрелость. Все три переходят на /brabo/<branch>/, а корень становится сгенерированным ИНДЕКСОМ, а не перенаправлением: перенаправление было бы проще и проглотило бы вопрос, который заставляет кого-то смотреть больше одной ступени («есть ли уже в qa то, что мне нужно?»). Страница предлагает то, что СУЩЕСТВУЕТ в дереве, никогда фиксированный список, и показывает проставленную версию, прочитанную из тегов. 404.html в корне удерживает каждую старую глубокую ссылку (/brabo/architecture → /brabo/main/architecture), с защитой от петли для 404, происходящего ВНУТРИ ступени. Самое важное изменение невидимо: сайт знал, что он production, сравнивая BASE_URL === '/brabo/', и при всех трёх в подкаталоге это становится ложным и для main — noIndex на НАСТОЯЩЕЙ документации, вне Google, молча и при зелёном CI, потому что ничто в сборке не проваливается из-за недостаточной индексации; ступень становится ОБЪЯВЛЕННОЙ в DOCS_BRANCH. Дерево собирается для всех трёх (не keep_files: true, который никогда не удаляет сирот), что даёт миграцию бесплатно: мусор, оставленный main в корне, отсутствует в собранном дереве и уходит при первом push. И переход засевает /main/ из старого корня — без этого первый push dev оставил бы /brabo/main/ несуществующим ровно тогда, когда 404.html перенаправляет туда |
| 0073 | Публикуемый путь называет ОКРУЖЕНИЕ, а не ветку. Пересматривает 0071: он поместил три ступени в /brabo/<branch>/ интерполяцией $GITHUB_REF_NAME, и из-за этого стабильная документация оказалась в /brabo/main/ — main — слово того, кто коммитит, а читающий выбирает зрелость, а не ветку. main начинает публиковать в /brabo/prd/; qa и dev совпадают случайно, а не по правилу. Карта ветка→путь начинает СУЩЕСТВОВАТЬ в одной точке на процесс (шаг workflow, таблица DEGRAUS в docusaurus.config.ts и в landing.mjs, последняя отделяет каталог дерева от ветки, из которой берётся ТЕГ версии). E_PRODUCAO по-прежнему DOCS_BRANCH === 'main' — пункт 4 из 0071 существует как раз на день, когда меняется путь, и этот день настал. Две ловушки обработаны: /brabo/main/ опубликован и исчезает с keep_files: false, поэтому 404.html ПЕРЕПИСЫВАЕТ префикс (собственный случай, а не общее перенаправление, которое произвело бы /brabo/prd/main/…), а защита от петли начинает покрывать только существующие пути; и переход засевает /prd/ из gh-pages:main, иначе /brabo/prd/ отвечал бы 404 днями между первым push dev и следующим продвижением |
| 0117 | website/ выходит из корневого pnpm-workspace.yaml и получает собственный pnpm-lock.yaml, чтобы корневой pnpm audit перестал сообщать о дереве зависимостей Docusaurus, которое никогда не поставляется. Аудит 13 корневых overrides безопасности через pnpm why <pkg> -r (а не перечитывание их комментариев) обнаружил, что большинство приписанных только website/ на деле СМЕШАННЫЕ — mermaid/dompurify/uuid также разрешаются через runtime-mermaid самого apps/web (ADR 0068), postcss/nanoid — через vite из apps/web, js-yaml/fast-uri/lodash — через цепочки eslint/ajv/@nestjs/swagger из apps/api, — поэтому они остаются в корне И дублируются в новом блоке pnpm.overrides в website/package.json; только serialize-javascript и диапазон yaml@1.x действительно были исключительными для website и ушли окончательно; esbuild остался только в корне, отсутствуя в дереве website/. pnpm --filter website (только из корня, требует членства в workspace) становится pnpm --dir website в каждом скрипте docs:* и в docs-deploy.yml/docs-check.yml, каждый из которых получает второй, отдельный шаг pnpm install с областью в website/ |
| 0202 | Индекс ADR группируется по ТЕМЕ, а тема — ВНЕ ADR (AT-137, решение владельца от 01.10, находка BRB-026): docs/adr/temas.yml хранит закрытый список из 15 тем и одну основную тему на ADR, чтобы ни один принятый ADR не редактировался, даже ради получения frontmatter. Индекс СВЕРЯЕТСЯ, а не генерируется — однострочные описания остаются курированными, — а docs:check (scripts/docs/temas-de-adr.mjs, каждое правило доказано мутацией) отклоняет ADR без темы, тему вне списка, тему без ADR и строку индекса не в том разделе, вне какого-либо раздела или вне числового порядка. Таксономия берётся из направлений продукта, а не из фаз или папок кода, и является предложением lane на ревью владельца; автор нового ADR выбирает его тему в том же PR, а превышение 15 тем требует ADR |
| 0206 | Справочник API становится ОДНОЙ страницей Redoc, отрисованной на сервере (redocusaurus), по адресу /reference/api/, пересматривая половину публикации из 0033: плагин и тема OpenAPI тянули postman-collection → @faker-js/faker@5.5.3 (алерт High #43) без пути исправления. Страницы по операциям, сайдбар по тегам, URL по операциям, playground и фрагменты cURL уходят по решению владельца; api-render-check.mjs теперь утверждает заголовок и одну SSR-секцию на операцию |
| 0216 | Четыре языка для документации и интерфейса — pt-BR становится ИСТОЧНИКОМ и значением по умолчанию, с английским, русским и упрощённым китайским как поэтапными переводами, измеряемым паритетом и предупреждением на каждой странице для непереведённых или устаревших страниц (AT-545, решения владельца от 15.10) |
| 0218 | Каждый ADR называется «ADR NNNN — заголовок»: H1 получает префикс ADR, 4-значный номер и длинное тире; имя файла (URL) не меняется, а нормализация метки заголовка — не правка решения (AT-559, решение владельца от 15.10) |
| 0219 | Справочник входит в перевод: docs/reference/** становится источником на pt-BR, переводится и учитывается в паритете, включая сгенерированный блок, кроме самого отчёта о паритете и openapi.json; страница Redoc отложена до AT-565 (AT-564, решения владельца от 15.10) |
| 0220 | Страница API переводится по каталогу: источником остаётся текст декораторов api (исключение из источника pt-BR), по каталогу на локаль с ключом-хешем текста, spec выводится при сборке и не попадает в git, осиротевший ключ отклоняется, отсутствующий предупреждает, фиксированные подписи Redoc на английском (AT-565, решения владельца от 16.10) |
Соглашение
- Один файл на решение, в
docs/adr/NNNN-short-title.md, с последовательной 4-значной нумерацией, и заголовком# ADR NNNN — <заголовок>(0218). Номера не переиспользуются, даже когда ADR заменён, — следующий 0220. - Одна тема на решение, выбираемая автором ADR, в том же
PR: строка в
docs/adr/temas.ymlи строка индекса под разделом этой темы, в числовом порядке. Ревьюер подтверждает. Новая тема — это изменение списка вtemas.yml, обоснованное в PR, который её добавляет; превышение 15 требует ADR, пересматривающего 0202. - Три раздела, и только они: Контекст (проблема или сила, которая его вызвала), Решение (что было решено), Последствия (принятые компромиссы и то, что осталось на потом).
- Без сторонних шаблонов — никаких Nygard, MADR или вариантов. Трёх разделов достаточно.
- На португальском, как и остальной проект.
- Принятый ADR не редактируется. Заменённый более поздним решением остаётся как есть; новый ADR ссылается на старый и объясняет, что изменилось.
Ценность этих ADR — в фиксации того, что не сработало: 0020 документирует девять неудачных исполнений подряд, а 0027 фиксирует обоснование, пересмотренное в той же сессии, которая его породила. Опущенное плохое последствие — та часть, которой не хватает полгода спустя.