ADR 0044 — Усыновление существующего репозитория и план как шлюз
Контекст
Первый dogfooding (Фаза 10) запустился только потому, что кто-то вставил строки
вручную. Находка P1 #1, дословно из
docs/missions/dogfooding-mission.md:638:
Продукт не умеет направить проект на существующий репозиторий.
createRepoбезусловен;getRepoсуществует и не вызывается ни одним сценарием использования; у DTO нет поля дляexternalId.
Процедура миссии (:102-134) описывает обходной путь: INSERT в
project_repositories и в repo_bootstraps, последняя «помечена как
сошедшаяся — чтобы продукт не пытался возобновить никакой bootstrap». То
есть, чтобы использовать Brabo в уже существовавшем репозитории, приходилось
лгать базе о bootstrap, который никогда не выполнялся.
Три вещи из уже существовавшего сформировали решение:
- Dry-run уже был написан, только никто его так не называл.
BootstrapStep.check(ctx)(Фаза 2, ADR 0005) перечитывает УДАЛЁННОЕ состояние и возвращает ещё ожидающие мутации. Он вызывается при каждом запуске, и именно это даёт идемпотентность и возобновление. План — это тот же список без вызоваrun(). - Защита ветки в контракте — это boolean.
ADR 0028
намеренно отказался давать конфигурацию
ProtectBranchInput(«это создало бы словарь, который умеет соблюдать лишь один из providers, а другому пришлось бы молча его игнорировать»), оставив нормализованнуюProtectionPolicyна случай реальной необходимости. Контракт обещает лишь наблюдаемое:listBranchesвозвращаетprotected: true. - Bootstrap уже не перезаписывал защиту.
bootstrap-steps.ts:112пропускает ветку сprotected: trueсо времён Фазы 2. Не хватало не защиты, а того, чтобы сделать видимым и явно одобренным то, что он сделает.
Стоит зафиксировать, что ADR 0028 не говорит «никогда не перезаписывать существующую защиту». Это правило рождается здесь; 0028 — причина того, что оно может работать лишь на уровне boolean.
Решение
origin как ось, в обеих таблицах
project_repositories.origin и repo_bootstraps.origin (created |
adopted), записываемые явно тем, кто пишет, — а не значением столбца по умолчанию,
чтобы усыновление было видимым выбором в коде, а не отсутствием
(RN-046). Backfill миграции 0031
намеренно слепой: усыновления до неё не существовало, поэтому каждая
предсуществующая строка по определению создана Brabo, и нет случая,
который можно классифицировать неправильно, — в отличие от направленного backfill 0026.
План живёт в курсоре, а не в новой таблице
repo_bootstraps получает plan (jsonb), plan_generated_at,
plan_decision, plan_decided_at, plan_decided_by.
План — это snapshot, а не журнал: тот же владелец, тот же ключ и то же время
жизни, что у курсора, уже определённого ADR 0005. Собственная таблица
подразумевала бы запрашиваемую историю старых планов — а история уже
живёт в session_events и proposed_actions, в двух повествованиях, которым
не нужно третье.
Шлюз стоит ПЕРЕД runner
Нулевой plan_decision — важное состояние: план сгенерирован, ничего
не решено, ничего не выполняется. Внутри исполнителя нет фильтра —
BootstrapRunner — это runner Фазы 2, вынесенный дословно (128 строк,
сверенных байт в байт), чтобы его можно было разделять, и он просто
не вызывается. Вместе с guard в :112 не существует пути кода,
который защитил бы ветку вне одобренного плана
(RN-045).
Одобрение — всё или ничего: выборочное одобрение сломало бы каскад
dev←main, qa←dev, rc←qa (одобрить qa без dev невыполнимо) и
потребовало бы переписать runner. Выполняется план, заново выведенный
check() в момент выполнения, — равный показанному или меньше.
Исправление в ходе реализации: первая версия обещала МЕНЬШЕ,
чем выполнила бы. protect_branches.check() читает текущее состояние, и
ветка, которую план сам создаст (rc, в форке Фазы 10),
ещё не существует, чтобы её можно было указать как незащищённую, — но она существовала бы при
выполнении и была бы защищена. План получил проход, проецирующий
защиты запланированных веток. Обещать больше допустимо (runner
пропускает то, что уже защищено); обещать меньше обнулило бы правило
как раз в самом частом случае усыновления.
«Усыновить как есть» не подделывает курсор
Отказ от bootstrap записывает plan_decision = 'as_is' и событие —
и оставляет курсор там, где он есть. Передвинуть курсор на «последний шаг, done»
значило бы превратить ручной seed Фазы 10 в официальное поведение:
курсор говорил бы, что шесть шагов выполнены, когда не выполнен ни один. Проект делает
работоспособным записанное решение, которое deriveProvisioningStatus
соблюдает. План сохраняется как доказательство того, что намеренно не
было применено.
Отсюда и новый статус awaiting_plan_decision: без него усыновлённый
проект навсегда остался бы provisioning, а UI опрашивал бы
работу, которой не существует.
Отдельный маршрут, а не mode в DTO
POST .../repository/adopt вместо дискриминированного DTO:
@RequireRole и OpenAPI задаются по маршруту, route-surface.spec.ts классифицирует
по маршруту, а ответы действительно различаются (создание возвращает курсор
bootstrap; усыновление возвращает план). DTO с @ValidateIf дал бы
слабую схему в сгенерированном документе — ровно то, что эта spec существует,
чтобы поймать.
Почему НЕ проходит через decide()/ProposeActionUseCase
Общий pipeline одобрения решает по мутации. Здесь решение
по плану: пользователь одобряет связный набор, а не четырнадцать
действий по одному. Кроме того, bootstrap со времён Фазы 2 рождается
auto_approved и ведёт повествование в выделенной сессии, вне decide(), — а
каждая одобренная мутация по-прежнему становится proposed_action, когда runner
выполняется, так что прослеживаемость не меняет места.
Последствия
getRepoиз сироты становится нагрузкой: он существовал с Фазы 2, покрыт набором контрактных тестов и никогда не вызывался ни одним сценарием использования.- Ошибки provider (404 vs 403) уже приходили различимыми; не хватало того, чтобы сообщение говорило, что делать, а это противоположное в каждом случае — сверить идентификатор против сменить учётные данные. Свернуть оба в «не удалось усыновить» было бы диагностикой методом исключения, повторять которую ADR 0020 запрещает.
- Набор контрактных тестов GitProviders остался нетронутым, и ни один новый метод не вошёл в контракт. Это был явный критерий приёмки, и это то, что удерживает расхождение между providers там, где ADR 0028 его оставил.
- Дыра, закрытая мимоходом:
ProvisionRepositoryUseCase.executeв усыновлённом проекте попадал в ветку «оба уже существуют» и запустил бы bootstrap в чужом репозитории без плана. Теперь отказывает с 409. - Мастер получает шаг перед всем остальным, а усыновление пропускает шаг политики веток: обещать шаблон для репозитория, у которого уже есть собственная политика, было бы ложью. Никакого нового компонента UI, кроме экрана плана.
- Экран плана сам отрисовывает
BootstrapSteps, а не переходит наProvisioningPage— та при монтировании запускаетprovisionRepository, что создало бы репозиторий.
Что остаётся на потом
- Нормализованная
ProtectionPolicy(отложена ADR 0028). Пока её нет, расхождение КОНФИГУРАЦИИ защиты невидимо: ветка с частичной защитой считается незащищённой и может быть перезаписана — внутри одобренного плана. Это находка P1 #2 dogfooding, здесь не исправленная. rc, который политика Фазы 6 не использует (находка #3, P2): шаблон продолжает создавать и защищатьrc. Вне охвата 12a.- Приёмка против реального форка:
adopt-repository.smoke.spec.tsсуществует, ТОЛЬКО ДЛЯ ЧТЕНИЯ и запускается под условиемADOPT_TEST_REPO+GITHUB_TEST_TOKEN. Никогда не одобряет — одобрение изменило бы настоящий репозиторий, а это решение — человеческий шлюз, а не тест. «Проект работоспособен после» остаётся ручным чек-листом. - Сбора урожая dogfooding не существует.
docs/explanation/primeiro-dogfooding.mdупоминается в CLAUDE.md и никогда не был написан; существует скелетdocs/missions/colheita-esqueleto.md. Источник, цитируемый здесь, —docs/missions/dogfooding-mission.md. ADR Фазы 10 тоже остаётся открытым. - Усыновление не мигрирует никаких данных (issues, исторические PR) — это доступ и политика, и только (CLAUDE.md, Фаза 12).
Ссылается на ADR 0005, откуда
происходит check(), ставший dry-run, и
ADR 0028, который
определяет, почему «расходящаяся защита» здесь может быть только boolean.