carlos / platform Public

spec+plan: environments layer on app-config — pipelines slice 2

The rastrillo session's env/secrets feature (PR #9) shipped the storage,
crypto and materialization the environments sketch wanted, and its spec
§8 deferred the reconciliation to this slice. §4a records it: config.json
IS the default bundle, a named environment is the same schema-1 manifest
at environments/<name>.json, resolution is one overlay (never a chain), a
route binds at most one via a new additive Environment column, and
"prod-wired canary" finally gets its honest definition — same environment
as the stable route — replacing the console's apex-host placeholder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell c0823de3cc80251bf4722c7a5edc883a3e9dabc0 parent 41d99f4
2 files changed, +145 −0
  • docs/superpowers/plans/2026-08-02-plan-environments.md +110 −0
  • docs/superpowers/specs/2026-08-02-pipelines-design.md +35 −0
diff --git a/docs/superpowers/plans/2026-08-02-plan-environments.md b/docs/superpowers/plans/2026-08-02-plan-environments.md
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.
diff --git a/docs/superpowers/specs/2026-08-02-pipelines-design.md b/docs/superpowers/specs/2026-08-02-pipelines-design.md
index 53ae4acab..6ef5b5f36 100644
--- a/docs/superpowers/specs/2026-08-02-pipelines-design.md
+++ b/docs/superpowers/specs/2026-08-02-pipelines-design.md
@@ -161,6 +161,41 @@ route inherits, with a named environment layering on top. Eighteen routes
sharing one Stripe key and differing in three values each should be expressible
as one bundle plus three-line overrides.
+### 4a. Reconciliation with app config/secrets (approved 2026-08-02, supersedes the sketch above)
+
+The same-day `carlos env`/`carlos secrets` feature
+(`2026-08-02-app-config-secrets-design.md`, merged as PR #9) already ships the
+storage, crypto and materialization this section sketched — one manifest per
+app at `apps/<acct>/<app>/config.json`, env vars plaintext and secrets sealed
+(ECDH-P256/HKDF/AES-GCM, `CARLOS_SECRETS_KEY` decrypting on-box), CAS writes,
+rendered per-route by the root adopt pass with restart-on-change. Environments
+do not duplicate any of that; they layer on it:
+
+- **`config.json` IS the default bundle.** Unchanged shape, unchanged key,
+ zero migration. Everything already set through `carlos env`/`secrets`
+ is the config every route inherits.
+- **A named environment is the same manifest shape at
+ `apps/<acct>/<app>/environments/<name>.json`** — same schema 1, same CAS,
+ same sealing. Names are `[a-z0-9-]{1,32}`, and `default` is reserved (it
+ means "the bundle every route already gets").
+- **Resolution is one overlay, not a chain:** default vars ∪ environment vars,
+ the environment winning per key. No environment-inherits-environment; a
+ chain is where config reasoning goes to die.
+- **A route binds at most one environment** — new additive registry column
+ `Environment` (`""` = default only), set by `carlos route -environment
+ <name>` with an existence rail (binding a bundle that does not exist in the
+ bucket is refused, not recorded). Promotion and channel changes never touch
+ it, which keeps §3's invariant structural.
+- **The adopt pass renders the merged view** per route, exactly where it
+ renders config today. Secrets stay sealed in both objects; the earlier
+ sketch's "referenced, never inlined" is satisfied by PR #9's sealing —
+ the bucket holds ciphertext either way. Exec-backed routes stay out of
+ scope, inheriting the app-config spec's §8 limitation.
+- **"Prod-wired" gets its honest definition:** a `canary/*` route is
+ prod-wired when it binds the same environment as the app's `stable` route
+ (or both bind default). This replaces the console's apex-host inference,
+ which was always a placeholder for exactly this.
+
## 5. Guard rails
**Prod-wired canaries are conspicuous.** A route on a `canary/*` channel bound