| new file mode 100644 |
| index 000000000..95ac0d4f3 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-02-plan-environments.md |
| @@ -0,0 +1,110 @@ |
| +# Environments Implementation Plan — pipelines slice 2 |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. |
| + |
| +**Goal:** Named environments as overlays on the shipped app-config feature — |
| +`apps/<acct>/<app>/environments/<name>.json` bundles, a route binding |
| +(`Route.Environment`), merged materialization in the adopt pass, and the |
| +console showing which environment each route is wired to. |
| + |
| +**Spec:** `2026-08-02-pipelines-design.md` §4/§4a (the reconciliation is §4a); |
| +`2026-08-02-app-config-secrets-design.md` (the layer this builds on — read it). |
| + |
| +## Global Constraints |
| + |
| +- `config.json` (schema 1) is untouched in shape and meaning: it is the |
| + default bundle. Nothing existing re-keys, re-encrypts or migrates. |
| +- Environment names: `[a-z0-9-]{1,32}`; `default` reserved (refused as a |
| + bundle name — it names the implicit base). |
| +- Resolution: default ∪ environment, environment wins per key. One overlay, |
| + never a chain. |
| +- All registry migrations additive. All bucket shapes additive. |
| +- `carlos route -channel` and `carlos promote` must never write |
| + `Route.Environment`. Binding is only via `carlos route -environment`. |
| +- Exec-backed routes (`Backing == ""`) stay skipped by Sync, same log-line |
| + convention as PR #9. |
| +- Full suite green (16 packages), gofmt clean on touched files, per CLAUDE.md. |
| + |
| +--- |
| + |
| +### Task 1: Environment manifests + merged resolution (internal/appconfig) |
| + |
| +- `EnvironmentKey(acct, app, name string) string` → |
| + `apps/<acct>/<app>/environments/<name>.json` |
| +- `ValidEnvironmentName(name string) bool` (regexp above; `default` and `""` |
| + refused) |
| +- `Load`/`Save` for a named bundle: reuse the existing manifest read/write + |
| + CAS path — same schema-1 checks, same refusal of `schema > 1`. |
| +- `LoadMerged(ctx, s, acct, app, envName)` → the resolved manifest: default's |
| + vars overlaid by the environment's. `envName == ""` → default only. A |
| + MISSING default with a present environment is fine (environment only); a |
| + missing environment named explicitly is an error (the caller bound it, so |
| + its absence is a defect, not an empty set). |
| +- `ListEnvironments(ctx, s, acct, app)` → names, from a prefix listing. |
| +- Tests: overlay wins per key; env-only and default-only both resolve; |
| + missing-named-environment errors; name validation table; schema>1 refused |
| + on a named bundle. |
| + |
| +### Task 2: `Route.Environment` + `carlos route -environment` binding |
| + |
| +- Additive registry column `environment TEXT NOT NULL DEFAULT ''` (follow the |
| + existing migrate* pattern); `SetEnvironment(host, env)` narrow UPDATE |
| + mirroring `SetChannel` (zero rows = error). |
| +- `carlos route -host <h> -environment <name>`: binds after an existence |
| + rail — the bundle must exist in the bucket for that route's app+account |
| + (via `ListEnvironments` or a direct Get), else refuse naming |
| + `carlos env set -app <a> -environment <name> …` as the way to create it. |
| + `-environment -` (a single dash) clears the binding back to default. |
| +- `-app` bulk form works like `-channel`'s: all checks first, one |
| + transaction. `-channel` and `-environment` may be combined in one |
| + invocation but are validated independently. |
| +- Platform-minted routes: environment binding IS allowed (reconcile does not |
| + derive Environment) — but verify that claim against reconcile.go and say |
| + so in a comment, per the Platform+Canary lesson. |
| +- Tests: bind/clear round-trip; nonexistent bundle refused; bulk atomicity; |
| + `-channel` alone never touches environment (regression pin). |
| + |
| +### Task 3: Sync renders the merged view |
| + |
| +- In `internal/appconfig/sync.go`: per route, resolve |
| + `LoadMerged(..., rt.Environment)` instead of the bare default. A bound-but- |
| + missing environment is a loud per-route error that skips THAT route |
| + (leaving its current env file untouched) and never blocks other routes. |
| +- Restart-on-change semantics unchanged. Reaping unchanged. |
| +- Tests: two routes of one app in different environments get different env |
| + files; a route whose bound environment vanishes keeps its last-good file |
| + and logs; unchanged merged config = no write, no restart. |
| + |
| +### Task 4: CLI — `-environment` on env/secrets, plus listing |
| + |
| +- `carlos env set/unset/list -app <a> [-environment <name>]` and the same on |
| + `carlos secrets` — operating on the named bundle; `list` says which bundle |
| + it is showing. `carlos env environments -app <a>` lists bundle names. |
| + Creating a bundle is implicit on first `set` (like `config.json` today). |
| +- Tests: round-trip on a named bundle; default untouched by named writes; |
| + `environments` listing; reserved-name refusal. |
| + |
| +### Task 5: Console — environment beside every route, honest prod-wired |
| + |
| +- App page: each route row shows its environment (or "default"), next to the |
| + channel/rung placement from slice 1. |
| +- Replace `prodWiredCanaryHosts` (the apex-host placeholder, marked |
| + delete-me-when-environments-land) with the spec §4a definition: a |
| + `canary/*` route is prod-wired iff it binds the same environment as the |
| + app's stable route (both-default counts). Keep it one function. |
| +- Tests: environment renders; prod-wired badge follows the new definition |
| + (same-env canary badged, different-env canary not). |
| + |
| +### Task 6: Whole-branch Opus review, fix rounds, merge |
| + |
| +Adversarial, against both specs. Explicit attention: additive-ness of the |
| +registry migration against a live DB; Sync failure isolation (one bad |
| +environment must not starve the fleet's config); the combined |
| +`-channel -environment` invocation's atomicity. |
| + |
| +### Task 7 (operational): deploy + first real environments |
| + |
| +Roll both deployments. On Tito, express the original motivating case |
| +minimally: create a `demo` environment for titogo, bind demo.go.tito.io to |
| +it, verify the env file merge on-box and that go.tito.io (default) is |
| +untouched. Document in the pinfra README. |