ADR 0005 — Bootstrap de Gitflow idempotente e retomável
Contexto
Fase 2, sessão 3 ("o coração"): ProvisionRepositoryUseCase passa a, além
de criar o repositório, executar o bootstrap de Gitflow completo (branches
dev/qa/rc, proteções conforme capabilities, template de PR,
docs/branching-policy.md) como uma sequência de passos que precisa:
convergir sempre pro mesmo estado rodando N vezes (idempotência), retomar
de onde parou depois de um crash no meio (persistência de progresso), e
narrar cada mutação como proposed_action auto_approved no event log da
sessão — tudo isso sem nenhum precedente no código (primeira feature de
"matar o processo no meio e retomar" neste repositório).
Duas decisões foram explicitamente confirmadas com o usuário antes da
implementação; o resto foi resolvido durante a implementação, à medida
que a integração com o código existente (sessões, proposed_actions,
GitProviderContract) revelou restrições reais.
Decisões
1. getFileContent como 9ª operação do GitProviderContract.
A checagem "arquivo já commitado com mesmo conteúdo" (idempotência dos
passos de commit) não tem como ser feita com as 8 operações originais —
nenhuma lê conteúdo de arquivo. Confirmado com o usuário: adicionar
getFileContent(externalId, branch, path): Promise<string | null>,
implementada nos 3 providers (LocalGitProvider via git show,
GithubProvider via Octokit repos.getContent, GitlabProvider via
Gitbeaker RepositoryFiles.show), com cobertura na suite de contrato
única. Os backends fake do GitHub/GitLab (msw) precisaram passar a
rastrear conteúdo de verdade (antes só metadados de branch/PR) — grafo
blob→tree→commit pro GitHub (espelha a API real), mapa path→conteúdo por
branch pro GitLab (a API de commit manda o estado completo por vez).
2. Sessão dedicada, criada uma vez e reaproveitada em toda retomada.
proposed_actions/session_events exigem um sessionId existente — não
há suporte a ação/evento "sem sessão". Confirmado com o usuário:
ProvisionRepositoryUseCase cria UMA sessão por projeto na primeira
tentativa (mesmo mecanismo barato de CreateSessionUseCase — só INSERT +
evento de outbox, nunca chama o engine), guarda o id em
repo_bootstraps.session_id, e a REAPROVEITA em toda retomada — a
história do bootstrap (sucesso, falha, retomada, skips) fica narrada
numa timeline contínua, não fragmentada entre sessões. A sessão nunca
transiciona pra active via TransitionSessionUseCase (que chamaria o
engine via ApiToEngineClient.startSession — errado pra uma sessão que
nunca roda comando nenhum): a transição created→active é feita direto
via SessionRepository.updateStatus, guardada por assertTransition.
Pela mesma razão, a sessão NUNCA vai pra closed_abnormally numa falha
de passo — esse é um estado terminal (sem transições de saída,
session-state-machine.ts) e quebraria a reutilização em retomadas
futuras; ela fica active durante qualquer número de tentativas
falhas/retomadas, só fechando (closing→closed, sem tocar o engine
nesses dois hops) quando os 6 passos convergem na mesma execução.
3. Cursor único por projeto, revalidação total em toda execução.
repo_bootstraps é uma linha por projeto (não um log por passo) —
{project_id, session_id, step, status, attempts, last_error}. A
idempotência NÃO vem de pular passos com base nesse cursor: TODA
execução (fresca ou retomada) percorre os 6 passos desde o início,
chamando check() de cada um contra o estado real no provider — só
quando check() reporta "não satisfeito" é que uma mutação de verdade
acontece. O cursor é só um diagnóstico (último passo tocado + resultado),
nunca um gate. Essa única regra dá de graça: idempotência (rodar N vezes
= todos os check() retornam satisfeito, zero mutação), retomada após
falha (os passos já feitos continuam satisfeitos, só o que faltou
executa), e retomada após "matar o processo no meio de uma mutação" (a
linha fica com status=running; na próxima execução o check()
descobre que a mutação na verdade já aconteceu e pula) — os 3 cenários
do critério de teste (item 6) são o MESMO mecanismo, não três
implementações separadas.
4. Sem decide() no caminho do bootstrap — status nasce hardcoded
auto_approved. O pedido diz "decisão auto_approved para o bootstrap".
Chamar decide() de verdade (como ProposeActionUseCase faz pra ações
de usuário/agente) NÃO produziria isso por padrão: o fallback de
decide() sem regra aplicável em permissions.json é require_approval,
nunca auto_approve. Ou seja, bootstrap-sempre-auto-aprovado só faz
sentido como uma categoria estrutural separada do pipeline discricionário,
não como "o resultado típico de decide() pra um projeto novo".
Estruturalmente seguro: auto_approved nunca é destino de transição em
action-state-machine.ts (só estado inicial), então hardcodar na criação
não viola a máquina de estados. Consequência aceita: um deny em
permissions.json mirando git_branch_create etc. não bloqueia o
bootstrap — é infraestrutura de projeto novo, não uma ação discricionária
de agente/usuário.
5. Ordem de EXECUÇÃO dos passos difere da ordem listada no pedido.
O pedido lista "criar dev a partir de main" primeiro, commits por último.
Impossível como está: createRepo cria um repo bare vazio, sem commit
inicial, nos 3 providers (auto_init: false — "provisionado" precisa
significar a mesma coisa em todos), e uma ref sem nenhum commit não pode
ser origem de createBranch. Os dois commits em main (template de PR,
branching-policy.md) precisam vir PRIMEIRO — são eles que dão a main
seu primeiro commit. Ordem de execução real
(BOOTSTRAP_STEP_SEQUENCE/BOOTSTRAP_STEPS, mantidas em sincronia):
commit_pr_template → commit_branching_policy → create_dev_branch → create_qa_branch → create_rc_branch → protect_branches. Cascata de
branch é dev←main, qa←dev, rc←qa (default meu — o pedido não especifica a
origem de qa/rc; segue o pipeline de promoção dev→qa→rc→main descrito no
CLAUDE.md). O enum bootstrap_step no schema (ordem de declaração) não
precisou mudar — nunca é comparado por ordem, só por igualdade.
6. Sem 409 pra reprovisionar um projeto já convergido. A versão
anterior de ProvisionRepositoryUseCase lançava ConflictException se
já havia um repo pro projeto. Mantido esse guard teria quebrado a
idempotência: "rodar o use-case N vezes converge sem erro" (item 2 do
pedido) é incompatível com "a 2ª chamada lança 409". Resolvido: nenhum
guard de conflito nesse sentido — um projeto já convergido só faz todos
os 6 check()s reportarem satisfeito (puro skip), nunca erro.
7. Credencial de github/gitlab por usuário (PAT), não mais por projeto
(OAuth). A versão anterior resolvia o token via project_git_connections
(conexão OAuth por projeto, usada pelo createRepository legado). O novo
contrato (createRepo etc.) espera um PAT (token: no construtor do
Gitbeaker — PRIVATE-TOKEN), não um token OAuth (oauthToken: —
Authorization: Bearer); os dois não são intercambiáveis (docs/adr/0004).
Resolvido: credencial agora vem de UserCredentialRepository (PAT
registrado pelo usuário, ver docs/adr/0004), decriptado direto (string
crua, sem o wrapper JSON {accessToken,refreshToken} do fluxo OAuth).
8. Aposentado o contrato legado GitProvider/createRepository.
Confirmado por grep que ProvisionRepositoryUseCase era o único
consumidor de .createRepository() em todo src/ — GitProviderRegistry
agora retorna GitProviderContract diretamente. Fecha a "dívida
explícita" registrada em docs/adr/0001.
Consequências
- Nenhuma coluna de status em
projects—provisioning|provisioned| provision_failedé derivado puramente derepo_bootstraps(repo-bootstrap-status.ts, mesma filosofia framework-free deaction-state-machine.ts), evitando duas fontes de verdade. repo_bootstrapsnão usaSELECT ... FOR UPDATE— provisionamento concorrente pro MESMO projeto não é serializado nesta sessão (mesma simplificação implícita que o guard antigo defindByProjectIdjá fazia). Se isso virar problema real, a decisão futura é sobre lock otimista/pessimista na linha, não sobre redesenhar o cursor.- Risco real, não implementado nesta sessão:
GithubProvider.protectBranchhardcodaenforce_admins: true, o que bloquearia os commits emmainse a ordem fosse invertida (proteger antes de commitar) — não é o caso aqui (commits vêm primeiro), mas um GitHub real commainjá protegida por fora do bootstrap poderia rejeitar um push administrativo depois. Fora do escopo desta sessão (critério de aceite testado contra LocalGitProvider, sem conceito de proteção). apps/api/scripts/demo-repo-bootstrap.tsdemonstra o critério de aceite fim a fim: provisiona com LocalGitProvider, injeta uma falha na 2ª chamada decreateBranch(equivalente observável a matar o processo no passo 4 de 6), roda de novo, converge, e imprime o event log completo mostrando osbootstrap.step_skippeddos passos 1-3 na retomada.