ADR-009: Port Migration Recipes: How a Registered Repository Stops Hardcoding Ports
Status: Proposed 2026-09-14 (becomes Accepted when the tracking issues land) Date: 2026-09-14 Decision Makers: Chris (@amiable-dev), LLM Council Council Review: 2026-09-14 — quick tier, two of four reviewers responded (two timed out; an earlier balanced-tier attempt timed out entirely), chairman synthesis used, see "Council Review" below Related: ADR-001 (advisory, never kills), ADR-002 (the decodable port scheme), ADR-006 (self-configuring CLI), ADR-007 (plugin, skills, MCP), ADR-008 (agent guardrails)
Context
Registering a repository (berth project add, ADR-006) gives it a permanent block and records the ports its configs already hardcode as declared. That makes collisions visible; it does not remove them. On the machine this was designed on, 21 projects are registered and the legacy declarations still collide: four sites declare 3000, four Vite apps declare 5173, four projects declare 5432, two Wrangler workers declare 8787. Every one of those is a collision the moment two of them run at once, and the dashboard says so, but nothing changes until each repository's own run configuration reads berth's numbers instead of a literal.
The berth-onboard skill (ADR-007) already has a step for this: "point the code at the numbers, with the human's approval; propose the diff, apply it only when asked". It gives no per-tool guidance, so each session re-derives the recipe, and the results vary: some fall back to the old literal, some forget strict mode, some rewrite Compose files by hand, some leave the CORS allowlist on the old number. A migration that is re-invented per session is not a migration.
Constraints the recipes inherit:
- berth is advisory and language-agnostic (ADR-001). It does not rewrite application code; it tells sessions the numbers.
- The policy is edited additively by the CLI and never by hand from an agent session (ADR-006). Anything that reduces what berth can see is a human decision (ADR-008).
Pis permanent (ADR-002): a project's W0 numbers never change. Worktrees W1–W9 get their own hundred, and dynamic claims live in 40000–41999. A migrated repository must work in all three cases.- Many of these repositories are public. Contributors and CI run them without berth, so a migrated config must still start with no environment at all, and the migration diff is a pull request the upstream maintainer may decline.
Decision
Target state per config: environment first, project default second, strict bind. A migrated setting reads its role variable (
PORT,API_PORT,DB_PORT, …); when the variable is absent it falls back to the project's own W0 canonical port, never to the legacy literal; and the server binds strictly (VitestrictPort, or the equivalent) so a taken port stops the process instead of incrementing. Environment first is what makes W1–W9 and dynamic claims work; the W0 default is decodable and collision-free on the owner's machine and is simply an unusual free port anywhere else; strict bind keeps the rule "if the port is taken, stop and report". Four conditions travel with the default:Pis never reassigned. The committed default is only honest because ADR-002 makes the number permanent; this ADR restates that as an invariant the policy tooling must keep.- The number is documented as arbitrary but stable, with its provenance: the README line reads like "local ports:
PORT, default 13000 (berth project 3, web; any free port works, setPORTto override)". - Upstream may decline. For a repository the human does not own, the diff is a pull request like any other; if it is not merged, the local checkout still works through
berth env, never through a local edit that is kept out of git. - Collateral moves with the port. Everything that names the old number is in scope: CORS allowlists, OAuth callback URLs, proxy targets, Playwright
baseURLandwebServer.url, devcontainerforwardPorts,.vscode/launch.json, and links in docs. A recipe that changes the default and leaves one of these behind has produced a broken repository.
A separate skill,
berth-migrate, with one recipe per tool. The skill carriesrecipes/<tool>.mdfiles loaded on demand:vite,next,docusaurus,wrangler,compose,dotenv,procfile,python(uvicorn, Django, Flask),storybook,playwright(which also covers Cypress and Vitest UI as test-runner web servers),monorepo,shared-stackandgeneric-clifor anything that takes--port. The role map that decision 5 describes lives once, inrecipes/_roles.md, andberth-onboardstep 4 refers to it and hands off to this skill. The trigger is distinct from onboarding: "migrate this repo's ports", "stop hardcoding ports", or a session context that reports declared legacy ports for the current project. Onboarding is additive registration; migration is a code change that wants review. Separate descriptions keep both triggers precise, and the shared role map keeps the two skills from forking.Each recipe has the same shape. Eleven sections: applies-to (tool and the version range the recipe was validated against), detect (which files and patterns mark this tool, and which generated or vendored paths it must never touch), map (which hardcoded port becomes which role, with the ambiguous cases named), edit (the exact before/after for config files and for
package.jsonscripts, in a form that re-running leaves unchanged, and in a cross-platform form where the repository has Windows contributors: reads inside the config file, orcross-env/dotenv-cli, never${PORT:-13000}in an npm script), default (the W0 form for that tool), strict (how to fail on a taken port), side-channels (HMR/websocket ports,hmr.clientPortbehind a proxy, preview servers, inspector ports; allocated fromextras, and never asserting a universal HMR number), collateral (the references from decision 1 that must move with the port), host-binding (localhostversus127.0.0.1versus0.0.0.0, which decides whether a strict bind that says "free" is reachable from the browser, WSL or a container), verify (the command that proves the expected process, not merely something, is listening on the berth port:berth who <port>), and rollback (what to revert). A recipe that cannot fill a section writesN/A: <reason>; a recipe with a section missing is not shipped.Compose is migrated in the file, with the override as the fallback. The in-file form
"${DB_PORT:-15002}:5432"is the default recipe: Compose reads interpolation from the shell and from.env, the form is idiomatic, and a plaingit clone && docker compose uplands on the project's own number instead of reproducing the four-Postgres collision. The override file printed byberth env --compose-overrideremains the migration for Compose files that cannot take interpolation (secrets, runtime templating, files the human does not want touched). The recipe states the hand-off: in a berth session the variables are already in the environment; in a plain terminalberth env --shellor a.envline supplies them; otherwise the default applies.Role mapping is by tool semantics, confirmed by the human. A Vite or Next dev server is
web; Wrangler isapiunless it is the only server, in which caseweb; Postgres isdb, Redis iscache, an SMTP sink issmtpand its UImail-ui, an OTLP collectorotlp-grpc/otlp-http; Storybook, a docs site or a test-runner web server is anextrasslot named after the tool. Anything else becomes anextrasslot named after the service. A monorepo with several dev servers maps them to distinct roles and extras within the sameP; it never borrows the worktree dimension, and a package that is a separate product registers as its own project. The skill states its mapping and the human corrects it before any edit.The agent proposes, the human applies, berth verifies; pruning is a rail-guarded, audited exception to "additive only". The skill produces one diff per file with its evidence (file and line of the literal, the role it maps to, the new default), including the README and
.env.examplelines of decision 9, applies them only on explicit approval, then runsberth checkand a re-scan. It never edits the policy. Removing entries fromdeclaredis the one subtractive policy change the CLI performs, and ADR-009 records it as a narrow carve-out from ADR-006 with this protocol:berth project declared [path]lists each entry with the current scan's evidence (file and line) or marks itstale. It is read-only and agent-available;--add <port>is additive and agent-available.berth project declared --pruneremoves only entries that are absent from the same fresh scan it prints; it never prunes from a cached result, and--dry-runshows the outcome without writing.--pruneand--remove <port>are human-only under the ADR-008 rail: they run for a human at a terminal without Claude markers, or whenBERTH_ALLOW_DESTRUCTIVE=1is set deliberately for that invocation. The rail is the approval mechanism: an agent that has been told "yes" still cannot run it without the human setting the variable.- Every prune appends what was removed and why to
~/.local/state/berth/audit.log, and the policy's.bakis written first, so the file's history stays additive even when its content shrinks.
Scanner zones: three, stated exactly. A project's block is
[10000 + 1000·P, 10000 + 1000·P + 999]; the W0 slice is its first hundred.berth project addandberth scan --writeclassify every literal they find:- outside the block, including the dynamic pool 40000–41999: legacy; recorded in
declared, takes part in collision checks. A hardcoded pool number is a stale artefact, not a claim, and stays visible. - inside the W0 slice, read environment-first: canonical; silent, never written to
declared. - inside the block but not environment-first (a bare
13000, or any W1–W9 number in a committed file): reported byberth scanas "canonical literal, needs the environment-first form", never written todeclared. A bare W0 literal is right for one worktree and wrong for the other nine; the report keeps that visible without turning the project's own number into a legacy cell. Without this, a migrated default would be re-declared on the next scan and the map would show a legacy cell forever.berth scan --helpstates the zones. What counts as "inside" for a monorepo sub-package is decided by the recipe's map section, not by the scanner.
- outside the block, including the dynamic pool 40000–41999: legacy; recorded in
Shared stacks: point at the exports, gate the duplicate, defer the lifecycle. A repository that runs its own copy of a shared service (Grafana, Prometheus, an OTLP collector) gets the
shared-stackrecipe, limited to what berth provides today: its exporter or OTLP endpoint readsBERTH_SHARED_<STACK>_<SERVICE>(exported byberth envfrom the policy's[shared.<name>]table), and the duplicate service moves behind a Compose profile so a plaindocker compose upno longer starts it. The owner project is exempt. What happens when an owner project is removed, who validates a shared port, and what a contributor without berth should see are open questions for a later ADR; the recipe does not answer them.The numbers are written down where contributors look. The migration adds one line to the repository's README (the provenance sentence of decision 1) and the defaults to
.env.example. These are code changes and ride in the same one-diff-per-file approval as the config edits; they are never written silently after the config diff is approved.
Consequences
- Each repository is reviewed once, by its owner, with a diff that names every literal it replaces and every reference that moves with it. After that, sessions, worktrees and dynamic claims all work without further edits, and
berth checkfor that block readsokoridle. - The W0 default puts a number chosen by one machine's policy into a repository other people clone. That number is arbitrary to them, the same way 3000 was, less likely to be occupied on their machine than 3000 or 5432, and stable because
Pis permanent. - Compose files change in place. The
${VAR:-default}form is harmless without berth and idiomatic with it; the override file becomes the exception rather than the rule, which reverses the onboard skill's current wording. - The scanner change (decision 7) alters
scan --writeandproject add: literals inside the block are no longerdeclaredentries. Existing entries are not removed automatically;declared --prunedoes that with evidence, and only for a human. declared --pruneis the first subtractive policy command. The rail, the dry run, the same-scan rule and the audit line are what make it acceptable under ADR-006 and ADR-008; a future subtractive command needs the same four.- The recipe list is longer than the scanner's file patterns today (Storybook, Playwright, devcontainers, VS Code launch files are not scanned). The scanner grows to match as recipes ship, so that verify and detect agree.
Alternatives Considered
- Fall back to the legacy literal. Rejected. The collisions return the moment a terminal has not run
berth env, which is exactly the situation the registry was built to make visible. A migration that keeps the old number as the default has not migrated. One council reviewer proposed the variant "public defaults in the repository, W0 as a local override"; declined for the same reason, and because a local override kept out of git is invisible to the next session. - No fallback; fail when the variable is unset. Rejected. It is hostile to contributors and CI, and strict bind already gives the fail-fast that matters (a taken port). A missing variable is an environment gap, not a collision.
- Keep the recipes inline in
berth-onboard. Rejected. The skill would carry a dozen tools' worth of before/after and its description would have to trigger on two different intents. Skills are cheapest when their descriptions are precise and their bodies short; recipe files loaded on demand keep both. berth migrate --write: berth rewrites the files itself. Rejected. berth would need a codemod per framework, per language, kept current with each tool's config format, for an advisory tool that otherwise never touches application code. The agent already has the editing tools and the human already has the review loop; the recipe is what was missing.- Override file as the Compose default. Rejected after council review. A fresh clone would reproduce the collision until the cloner learned about the override, which contradicts decisions 1 and 9.
- "Inside the block means canonical" as a single rule. Rejected after council review. It would hide a bare W0 literal that breaks every other worktree, and it would bless a hardcoded pool number that is a stale artefact. Three zones keep both visible.
- Agent-run prune after a spoken "yes". Rejected. The CLI cannot see the conversation; the rail can see the terminal and the override variable. ADR-008's mechanism already expresses "a human decided this" in a way the tool can check.
- A scanner-version stamp on each
declaredentry, with a--recheck. Declined. Prune always re-runs the scan it acts on, so a stamp adds nothing the same-scan rule does not already give. - A local proxy or alias layer so repositories need no change. Deferred, not rejected. Names over ports remain the v1 answer to discoverability (portless in alias mode,
docs/DESIGN.md); hiding the numbers behind a proxy does not stop two dev servers from wanting 5173.
Compliance / Validation
tests/plugin.test.ts: theberth-migrateskill exists; every recipe file has the eleven sections of decision 3 (anN/Acarries a reason); every recipe names its role variable, its W0 default form and its strict-mode setting;recipes/_roles.mdexists andberth-onboardrefers to it; no recipe or skill mentions a human-only command (free,--force,hooks,declared --prune,declared --remove);claude plugin validate --strictpasses.tests/scan.test.ts: a classification table for decision 7: a literal outside the block is declared; a pool literal is declared; a W0 literal in environment-first form is silent; a bare W0 literal and a W3 literal are reported as canonical literals and not declared; the same holds forproject addand forscan --write.tests/project.test.ts:project declaredlists each entry with evidence orstale;--addis additive and idempotent under the policy lock;--pruneremoves only stale entries, is idempotent, writes the.bakand the audit line, and is refused withCLAUDECODE=1unlessBERTH_ALLOW_DESTRUCTIVE=1;--removefollows the same rail;isDestructivecovers both.- Manual: migrating one Vite repository and one Docusaurus repository on this machine with the recipes, then
berth checkreadsok/idlefor both blocks and the map rows show no legacy cells.
Council Review
Two reviewers responded on the quick tier (two timed out) and the chairman synthesised; the balanced-tier attempt earlier the same day timed out before synthesis. Findings, and what this ADR did with them:
Accepted as proposed: the environment-first, W0-default, strict-bind target state (decision 1), with the reviewers noting a contributor's machine is likelier to have 3000 or 5432 occupied than 13000; the separate skill (decision 2, unanimous); human-confirmed role mapping (decision 5); README and .env.example lines (decision 9).
Adopted: the four conditions on the default (P permanence as an invariant, documented provenance, upstream may decline, collateral in scope). Adopted: the Compose default inverted to the in-file ${VAR:-default} form, the override kept as the fallback. Adopted: decision 7 rewritten as three exact zones, with a bare in-block literal reported rather than silenced and pool literals kept visible; the block range stated as [10000 + 1000·P, +999]. Adopted: decision 6 rewritten as an explicit carve-out from ADR-006: dry run, same-scan rule, audit line, .bak; and, going one step further than the synthesis, --prune placed under the ADR-008 rail rather than trusting a conversational approval, since one reviewer wanted prune restricted to humans and the rail is how berth already expresses that. Adopted: the recipe sections applies-to, side-channels, collateral, host-binding, idempotent edit, exclusions inside detect, ownership-checking verify, and N/A: <reason>. Adopted: Storybook, Playwright and test runners, monorepos (distinct roles within one P, never the worktree dimension), devcontainer and VS Code launch files as collateral, and the Windows guidance (cross-env/dotenv-cli forms, host binding for WSL). Adopted in part: decision 8 narrowed to what berth already provides (BERTH_SHARED_* exports, the duplicate behind a Compose profile); owner lifecycle and validation deferred to a later ADR as the council asked, but not removed, because the shared table and its exports exist today.
Declined: "public defaults in the repository, W0 as a local override" (see Alternatives); a scanner-version stamp with --recheck (the same-scan rule covers it); making the dynamic pool configurable for Hyper-V excluded port ranges (noted for a Windows host, no Windows host exists yet).
Dissent recorded: one reviewer held that a machine-derived number does not belong in a public repository's defaults. The ADR keeps the number and answers with provenance in the README line and the observation that every default is machine-derived somewhere; the reviewer's concern is the reason decision 1 now carries conditions.