ADR 0044 — Adoption of an existing repository, and the plan as a gate
Context
The first dogfooding (Phase 10) only ran because someone inserted rows
by hand. P1 finding #1, verbatim from
docs/missions/dogfooding-mission.md:638:
The product doesn't know how to point a project at an existing repository.
createRepois unconditional;getRepoexists and is called by no use case; the DTO has no field forexternalId.
The mission procedure (:102-134) describes the workaround: an
INSERT into project_repositories and into repo_bootstraps, the
latter "marked as converged — so the product doesn't try to resume a
bootstrap that never ran." In other words: using Brabo on a repository
that already existed required lying to the database about a bootstrap
that never happened.
Three things that already existed shaped the solution:
- The dry-run was already written, nobody had just called it that
way.
BootstrapStep.check(ctx)(Phase 2, ADR 0005) re-reads the REMOTE state and returns the mutations still pending. It's called on every run, and that's what gives idempotency and resumability. A plan is that same list without callingrun(). - Branch protection, in the contract, is a boolean.
ADR 0028
deliberately refused to give
ProtectBranchInputconfiguration ("it would create a vocabulary that only one of the providers knows how to honor, and the other would have to silently ignore"), leaving a normalizedProtectionPolicyfor when there was real need. The contract only promises the observable:listBranchesreturnsprotected: true. - The bootstrap already didn't overwrite protection.
bootstrap-steps.ts:112skips a branch withprotected: truesince Phase 2. What was missing wasn't the guard — it was making what it would do visible and explicitly approved.
It's worth noting that ADR 0028 does not say "never overwrite existing protection." That rule is born here; 0028 is the reason it can only operate at the boolean level.
Decision
origin as an axis, on both tables
project_repositories.origin and repo_bootstraps.origin (created |
adopted), written explicitly by whoever writes them — not by the
column default, so adoption is a visible choice in the code and not an
absence (RN-046). Migration 0031's
backfill is deliberately blind: adoption didn't exist before it, so
every pre-existing row was created by Brabo by definition, and there's
no case to misclassify — unlike 0026's directed backfill.
The plan lives on the cursor, not on a new table
repo_bootstraps gains plan (jsonb), plan_generated_at,
plan_decision, plan_decided_at, plan_decided_by.
The plan is a snapshot, not a log: same owner, same key and same
lifetime as the cursor ADR 0005 already defined. A dedicated table
would suggest a queryable history of old plans — and the history
already lives in session_events and proposed_actions, in two
narratives that don't need a third.
The gate sits BEFORE the runner
A null plan_decision is the state that matters: plan generated,
nothing decided, nothing runs. There's no filter inside the
executor — the BootstrapRunner is the one from Phase 2, extracted
verbatim (128 lines checked byte by byte) to be shareable, and simply
isn't called. Combined with the :112 guard, there is no code path
that protects a branch outside an approved plan
(RN-045).
Approval is all-or-nothing: selective approval would break the
dev←main, qa←dev, rc←qa cascade (approving qa without dev is
unsatisfiable) and would require rewriting the runner. What runs is
the plan re-derived by check() at execution time — equal to or
smaller than what was displayed.
Correction made during implementation: the first version promised
LESS than it would execute. protect_branches.check() reads the
current state, and a branch the plan itself is about to create (rc,
in the Phase 10 fork) doesn't exist yet to be listed as unprotected —
but it would exist at execution time, and would get protected. The
plan gained a pass that projects the protections of the planned
branches. Promising more is acceptable (the runner skips whatever is
already protected); promising less would nullify the rule precisely in
the most common adoption case.
"Adopt as-is" doesn't tamper with the cursor
Dismissing the bootstrap records plan_decision = 'as_is' and an
event — and leaves the cursor where it is. Moving the cursor to "last
step, done" would turn Phase 10's manual seed into official behavior:
the cursor would say six steps ran when none did. What makes the
project operable is the recorded decision, which
deriveProvisioningStatus respects. The plan stays stored as evidence
of what was deliberately not applied.
Hence also the new awaiting_plan_decision status: without it, an
adopted project would stay provisioning forever, with the UI polling
for work that doesn't exist.
A separate route, not mode in the DTO
POST .../repository/adopt instead of a discriminated DTO:
@RequireRole and OpenAPI are per route, route-surface.spec.ts
classifies by route, and the responses actually differ (creating
returns the bootstrap's cursor; adopting returns the plan). A DTO with
@ValidateIf would produce a weak schema in the generated document —
exactly what that spec exists to catch.
Why it does NOT go through decide()/ProposeActionUseCase
The generic approval pipeline decides per mutation. Here the
decision is per plan: the user approves a coherent set, not
fourteen actions one by one. Besides, the bootstrap, since Phase 2, is
born auto_approved and narrates in a dedicated session, outside
decide() — and each approved mutation still becomes a
proposed_action when the runner runs, so traceability doesn't move
elsewhere.
Consequences
getRepostops being an unused import: it existed since Phase 2, covered by the contract suite, and had never been called by any use case.- Provider errors (404 vs 403) already arrived distinct; what was missing was for the message to say what to do, which is opposite in each case — checking the identifier vs. swapping the credential. Collapsing both into "adoption failed" would be the diagnosis-by-elimination ADR 0020 forbids repeating.
- The GitProvider contract suite stayed untouched, and no new method entered the contract. It was an explicit acceptance criterion, and it's what keeps the divergence between providers where ADR 0028 left it.
- A hole closed along the way:
ProvisionRepositoryUseCase.executeon an adopted project used to fall into the "both already exist" branch and would run the bootstrap on a third party's repository without a plan. Now it refuses with 409. - The wizard gains a step before everything, and adoption skips the branch-policy step: promising the template for a repository that already has its own policy would be a lie. No UI component beyond the plan screen.
- The plan screen renders
BootstrapStepsitself instead of navigating toProvisioningPage— that one dispatchesprovisionRepositoryon mount, which would create a repository.
What's left for later
- Normalized
ProtectionPolicy(deferred by ADR 0028). While it doesn't exist, divergence in protection CONFIGURATION is invisible: a branch with partial protection counts as unprotected and can be overwritten — within an approved plan. It's dogfooding's P1 finding #2, not fixed here. - The
rcthat Phase 6's policy doesn't use (finding #3, P2): the template still creates and protectsrc. Out of scope for 12a. - Acceptance against the real fork:
adopt-repository.smoke.spec.tsexists, is READ-ONLY and runs gated byADOPT_TEST_REPO+GITHUB_TEST_TOKEN. It never approves — approving would mutate a real repository, and that decision is the human gate, not a test. "Project operable afterward" remains a manual checklist. - The dogfooding harvest doesn't exist.
docs/explanation/primeiro-dogfooding.mdis cited by CLAUDE.md and was never written; what exists is the skeletondocs/missions/colheita-esqueleto.md. The source cited here isdocs/missions/dogfooding-mission.md. Phase 10's ADR is also still open. - Adoption doesn't migrate any data (issues, historical PRs) — it's access and policy, only (CLAUDE.md, Phase 12).
References ADR 0005, where
the check() that became a dry-run comes from, and
ADR 0028,
which defines why "divergent protection" here can only be boolean.