carlos / platform Public

spec/plan: config through the console API, and config that converges (issues #43, #44)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVANvB83eHrRANRpEL8mn
Paul Campbell c0fec0a74ed9fad99f7a8fdd9d8f378a17b35e7c parent 3b8e323
2 files changed, +580 −0
  • docs/superpowers/plans/2026-08-04-config-console-api.md +382 −0
  • docs/superpowers/specs/2026-08-04-config-console-api-design.md +198 −0
diff --git a/docs/superpowers/plans/2026-08-04-config-console-api.md b/docs/superpowers/plans/2026-08-04-config-console-api.md
new file mode 100644
index 000000000..d23bf2c2b
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-config-console-api.md
@@ -0,0 +1,382 @@
+# Config Console API + Fast Convergence Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `carlos env`/`secrets` work through the console API with `carlos auth login` alone (#43), and a config write reaches running instances in seconds, not an hour (#44).
+
+**Architecture:** Three new console doors under the existing `/api/cli/` gate relay config reads/writes (secrets stay client-sealed, opaque to the console); every appconfig write also touches a per-app generation object; a new agent loop mirrors `canaryAdoptLoop`, polling generation etags and running `SyncExec` on change; `carlos env sync` touches the generation on demand.
+
+**Tech Stack:** Go stdlib only (net/http, crypto/ecdh), existing internal packages (`appconfig`, `release`, `console`, `registry`). No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-08-04-config-console-api-design.md` — read it first; wire shapes and refusal orders there are normative.
+
+## Global Constraints
+
+- `go build ./... && go vet ./... && go test ./... -count=1` clean before every commit; `gofmt -l` clean for touched files.
+- Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; disable sandbox if the toolchain reports "read-only file system".
+- Wire-shape changes are additive; anything written before this change must still parse.
+- Console gate ordering: membership 404s are byte-identical before any other refusal.
+- Secret VALUES never appear in logs, audit rows, error messages, or API responses.
+- Comment style: match the codebase — comments state constraints and why, extensively; mimic neighboring files.
+- Direct-bucket mode behavior is byte-identical to today (except the added generation touch after successful writes).
+
+---
+
+### Task 1: appconfig — generation object, combined apply exports, CheckEnvelope, ExecConfigApps
+
+**Files:**
+- Create: `internal/appconfig/generation.go`, `internal/appconfig/generation_test.go`
+- Modify: `internal/appconfig/store.go` (apply, SetExecDelivery), `internal/appconfig/environments.go` (applyEnvironment), `internal/appconfig/crypto.go` (CheckEnvelope), `internal/appconfig/crypto_test.go`, `internal/appconfig/syncexec.go` (ExecConfigApps)
+- Test: `internal/appconfig/syncexec_test.go` (ExecConfigApps cases)
+
+**Interfaces (Produces — later tasks rely on these exact signatures):**
+```go
+func GenKey(acct, app string) string // release.AppPrefix(acct, app) + "config-gen.json"
+func TouchGeneration(ctx context.Context, s release.Store, acct, app string) error
+func Apply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) (int, error) // default bundle, sets+unsets in one CAS write
+func ApplyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (int, error)
+func CheckEnvelope(envelopeB64 string) error
+func ExecConfigApps(routes []registry.Route) [][2]string // distinct {account, app}, sorted, exec-config-shaped routes only
+```
+
+- [ ] **Step 1: failing tests for TouchGeneration + bump-on-write** (`generation_test.go`): using `release.NewDirStore(t.TempDir())`:
+ - `TestTouchGenerationCreatesAndMoves`: touch twice; `Get(GenKey(...))` exists after first, etag differs after second, body decodes to `{Schema:1, TouchedAt, Nonce}` with nonemtpy nonce.
+ - `TestTouchGenerationCASConflictIsSuccess`: seed the gen key, then simulate a racing writer by wrapping DirStore in a store whose `Put` first writes a different value via the inner store (changing the etag) and then forwards the original call — the forwarded CAS `Put` fails with `release.ErrCASConflict`; assert `TouchGeneration` returns nil.
+ - `TestWritesBumpGeneration`: after `SetEnv`, `SetSecret`, `Unset`, `SaveEnvironment`, `UnsetEnvironment`, `SetExecDelivery`, `Apply`, `ApplyEnvironment` each — gen etag changed from before. After a FAILED write (e.g. `Unset` on an app with no bundle) — gen etag unchanged.
+- [ ] **Step 2: run, verify failure** (undefined: GenKey/TouchGeneration/Apply/ApplyEnvironment).
+- [ ] **Step 3: implement `generation.go`** — key code:
+```go
+// Generation is the small sibling object every config write touches —
+// apps/<acct>/<app>/config-gen.json — so the agent's fast loop (host
+// package) can watch ONE cheap etag per app instead of re-reading every
+// bundle hourly. One per app across ALL bundles: SyncExec resolves
+// per-(app, environment) itself, so the signal only needs app granularity.
+// Change detection is by etag; the fields exist for humans and to make
+// each touch's bytes distinct. Absent is legal forever (app last written
+// by an older CLI): the hourly configSyncLoop remains the floor.
+type Generation struct {
+ Schema int `json:"schema"` // 1
+ TouchedAt string `json:"touched_at"`
+ Nonce string `json:"nonce"`
+}
+
+func GenKey(acct, app string) string { return release.AppPrefix(acct, app) + "config-gen.json" }
+
+// TouchGeneration bumps app's generation object. A CAS conflict is SUCCESS,
+// not an error: it means another writer touched the generation between our
+// read and our write, so the signal this function exists to fire has
+// already fired. os.ErrNotExist on the read means first touch: create-only.
+func TouchGeneration(ctx context.Context, s release.Store, acct, app string) error {
+ if !release.ValidAccount(acct) { return fmt.Errorf("config: invalid account %q", acct) }
+ if !release.ValidApp(app) { return fmt.Errorf("config: invalid app %q", app) }
+ key := GenKey(acct, app)
+ _, etag, err := s.Get(ctx, key)
+ if err != nil && !errors.Is(err, os.ErrNotExist) { return err }
+ if errors.Is(err, os.ErrNotExist) { etag = "" }
+ nonce := make([]byte, 16)
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return err }
+ raw, err := json.Marshal(Generation{Schema: 1, TouchedAt: time.Now().UTC().Format(time.RFC3339), Nonce: hex.EncodeToString(nonce)})
+ if err != nil { return err }
+ if _, err := s.Put(ctx, key, raw, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) { return nil }
+ return err
+ }
+ return nil
+}
+```
+ Wire the bump into the three write paths, after their successful `Put`, best-effort with a log line (the manifest write LANDED; failing the call now would report failure for a change that is live, so the touch degrades to the hourly floor instead):
+ - `store.go` `apply(...)`: on success, `touchLogged(ctx, s, acct, app)`.
+ - `store.go` `SetExecDelivery(...)`: same, before returning nil.
+ - `environments.go` `applyEnvironment(...)`: same.
+```go
+// touchLogged is TouchGeneration demoted to a log line, for use AFTER a
+// successful manifest write: the write landed, so a failed touch must not
+// fail the call — it only means convergence falls back to the hourly pass.
+func touchLogged(ctx context.Context, s release.Store, acct, app string) {
+ if err := TouchGeneration(ctx, s, acct, app); err != nil {
+ log.Printf("config: %s/%s: generation touch failed (converges on the hourly pass): %v", acct, app, err)
+ }
+}
+```
+ Export the combined appliers (one CAS write for a mixed set+unset — the console door needs both in one request):
+```go
+// Apply writes sets and removes unsets from the DEFAULT bundle in one CAS
+// write, returning how many unsets were actually present — the console
+// door's shape (one PUT may carry both). SetEnv/SetSecret/Unset remain the
+// narrow verbs the CLI's direct mode uses.
+func Apply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) (int, error) {
+ return apply(ctx, s, acct, app, DefaultBundle, sets, unsets)
+}
+// ApplyEnvironment is Apply for a named environment bundle.
+func ApplyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (int, error) {
+ return applyEnvironment(ctx, s, acct, app, envName, sets, unsets)
+}
+```
+- [ ] **Step 4: run, verify pass.**
+- [ ] **Step 5: failing tests for CheckEnvelope** (`crypto_test.go`): Seal output passes; `""`, non-base64, valid base64 shorter than 93 bytes, and 93+ bytes whose first 65 are not a P-256 point all return errors naming the defect (not the envelope content).
+- [ ] **Step 6: implement in `crypto.go`:**
+```go
+// CheckEnvelope structurally validates a sealed envelope WITHOUT any key
+// material: base64 decodes, the ephemeral point parses, and there is room
+// for a nonce and a GCM tag. The console door runs this at SET time for
+// the same reason ValidVarValue runs then — a garbage envelope accepted
+// now would stall the app's whole config at render time, hours later and
+// out of sight. It cannot prove decryptability (only the box key can);
+// it proves the shape Open would not reject outright.
+func CheckEnvelope(envelopeB64 string) error {
+ raw, err := base64.StdEncoding.DecodeString(envelopeB64)
+ if err != nil { return fmt.Errorf("envelope is not valid base64") }
+ const minLen = ephLen + nonceLen + 16 // 16: GCM tag; empty plaintext is legal
+ if len(raw) < minLen { return fmt.Errorf("envelope too short (%d bytes, want at least %d)", len(raw), minLen) }
+ if _, err := ecdh.P256().NewPublicKey(raw[:ephLen]); err != nil { return fmt.Errorf("envelope has no valid ephemeral key") }
+ return nil
+}
+```
+- [ ] **Step 7: run, verify pass.**
+- [ ] **Step 8: failing tests for ExecConfigApps** (`syncexec_test.go`): routes covering — socket exec route (in), TCP-addr exec route (in, opt-in NOT consulted: shape only), unit-backed (out), platform (out), Status "draining" (out), App "" (out), two routes same acct/app (deduped), route with invalid Account (maps to `release.OpsAccount` via `RouteAccount`); result sorted.
+- [ ] **Step 9: implement in `syncexec.go`:**
+```go
+// ExecConfigApps is the distinct (account, app) set the agent's fast
+// generation loop (host package) polls: every app with at least one route
+// SyncExec could deliver config to. The SHAPE half only — the TCP-addr
+// case's opt-in is not consulted (that costs a bucket read per app, which
+// is exactly what the fast loop exists to avoid); polling an un-opted
+// app's generation is one wasted small read, and the SyncExec pass a
+// change triggers applies the opt-in properly. Exported from this file so
+// the loop's eligibility can never drift from SyncExec's own predicates.
+func ExecConfigApps(routes []registry.Route) [][2]string {
+ seen := map[[2]string]bool{}
+ var out [][2]string
+ for _, rt := range routes {
+ if !execSyncable(rt) && !execAddrCandidate(rt) { continue }
+ k := [2]string{release.RouteAccount(rt), rt.App}
+ if seen[k] { continue }
+ seen[k] = true
+ out = append(out, k)
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i][0] != out[j][0] { return out[i][0] < out[j][0] }
+ return out[i][1] < out[j][1]
+ })
+ return out
+}
+```
+- [ ] **Step 10: run package tests; `go vet ./internal/appconfig/`; gofmt; commit** `appconfig: config generation object — every write leaves a cheap etag to watch`
+
+---
+
+### Task 2: console — config doors (GET/PUT config, POST config/sync), SecretsPub
+
+**Files:**
+- Create: `internal/console/apiconfig.go`, `internal/console/apiconfig_test.go`
+- Modify: `internal/console/server.go` (Config gains `SecretsPub string`), `internal/console/authcli.go` (three routes), `cmd/console/main.go` (env plumbing)
+
+**Interfaces:**
+- Consumes (Task 1): `appconfig.Apply`, `appconfig.ApplyEnvironment`, `appconfig.TouchGeneration`, `appconfig.CheckEnvelope`, plus existing `appconfig.Get/LoadEnvironment/ListEnvironments/SetExecDelivery/ValidVarName/ValidVarValue/ValidEnvironmentName/Var/DefaultBundle/ExecDeliveryFile` and existing console helpers `apiAccountApp`, `s.cli.write.allow`, `readJSON`, `writeJSON`, `jsonError`, `s.audit`.
+- Produces (Task 3 client parses these): the §2 wire shapes of the spec, verbatim.
+
+- [ ] **Step 1: routes + Config field.** `server.go` Config:
+```go
+ // SecretsPub is the deployment's CARLOS_SECRETS_PUBKEY, served (never
+ // used) by the CLI config door so `carlos secrets set` can seal
+ // client-side with `carlos auth login` alone. Public material by
+ // design — it only encrypts; the console cannot decrypt anything with
+ // or without it. Optional: "" omits the field from the API response
+ // and the CLI falls back to requiring the env var.
+ SecretsPub string
+```
+ `authcli.go`, after the ledger routes:
+```go
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/config", s.handleAPIConfigGet)
+ s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/config", s.handleAPIConfigPut)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/config/sync", s.handleAPIConfigSync)
+```
+ `cmd/console/main.go` in `serve()`'s `console.Config{...}`: `SecretsPub: secretsPub(),` with:
+```go
+// secretsPub reads CARLOS_SECRETS_PUBKEY for the console to SERVE to CLI
+// clients (config door) — never to use. Optional; garbage is refused at
+// boot rather than handed to every `carlos secrets set` to choke on.
+func secretsPub() string {
+ raw := strings.TrimSpace(os.Getenv("CARLOS_SECRETS_PUBKEY"))
+ if raw == "" { return "" }
+ if _, err := appconfig.ParsePublicKey(raw, "CARLOS_SECRETS_PUBKEY"); err != nil { log.Fatal(err) }
+ return raw
+}
+```
+- [ ] **Step 2: failing tests** (`apiconfig_test.go`, follow `apicli_test.go`'s fixture pattern — httptest server, DirStore, seeded members/tokens):
+ - Gate: no token → 401; non-member account, unknown account, unknown app → byte-identical bodies AND status vs each other (capture and compare, like existing gate tests) for GET, PUT and sync.
+ - GET empty app → `{"bundle":"default","environments":[],"vars":[]}` (no exec_delivery, and no secrets_pubkey when `SecretsPub` unset); with `SecretsPub: "abc"` set in fixture config → field present.
+ - PUT set env+secret (a real `appconfig.Seal` envelope) → 200; GET shows env value, secret key with NO value/envelope field; bucket manifest holds the envelope verbatim; gen object exists.
+ - PUT unset with one real and one absent key → `removed:1`.
+ - PUT to `?environment=staging` equivalent (body `environment:"staging"`) round-trips via GET; `environments` lists `staging`.
+ - Refusal matrix (each asserting status AND that the response `error` text is specific): empty request; environment `"default"`; bad env name; bad var name; env var value with newline (ValidVarValue refusal); secret with garbage envelope; secret var carrying `value`; env var carrying `envelope`; exec_delivery `"x"`; exec_delivery with environment; unset-only on nonexistent bundle → 409.
+ - exec_delivery `"file"` then GET shows it; `"off"` clears it.
+ - Sync door: gen etag moves; audit row `config-sync`.
+ - Audit rows for PUT: action `config`, detail contains counts and `(cli)`, NEVER the values (assert the secret's plaintext and envelope both absent from every audit row).
+ - Rate limit: after exhausting `s.cli.write` budget (fixture pattern from promote tests) → 429.
+- [ ] **Step 3: run, verify failures.**
+- [ ] **Step 4: implement `apiconfig.go`.** Shape (door order per spec §2 — gate, limiter on writes, cheap refusals, then store):
+```go
+func (s *Server) handleAPIConfigGet(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok { return }
+ envName := r.URL.Query().Get("environment")
+ if !validAPIEnvironment(w, envName) { return }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ var m *appconfig.Manifest
+ var err error
+ if envName == "" { m, err = appconfig.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ } else { m, err = appconfig.LoadEnvironment(ctx, s.cfg.Releases, acct.Sqid, app, envName) }
+ // LoadEnvironment on an absent bundle: distinguish os.ErrNotExist →
+ // empty bundle answer (a bundle you haven't created yet lists empty,
+ // matching appconfig.Get's own convention for config.json)…
+ ...
+ envs, err := appconfig.ListEnvironments(ctx, s.cfg.Releases, acct.Sqid, app)
+ // vars: sorted by key; {"key","kind","value"} for env, {"key","kind"} for secret.
+ // exec_delivery only when envName == "" and m.ExecDelivery != "".
+ // secrets_pubkey only when s.cfg.SecretsPub != "".
+}
+```
+ PUT decodes `{Environment string; Set map[string]struct{Kind, Value, Envelope string}; Unset []string; ExecDelivery *string}` (pointer: absent ≠ empty; accept `"off"` → `""`). Validate everything before ANY store call; build `map[string]appconfig.Var`; call `appconfig.Apply` or `appconfig.ApplyEnvironment`; then `appconfig.SetExecDelivery` if requested (default bundle only). Map errors: `errors.Is(err, release.ErrCASConflict)` → 409 `"config changed concurrently — try again"`; the pre-checked unset-only-nonexistent case → 409 with appconfig's own message (pre-check via `appconfig.Get`/`LoadEnvironment` presence is NOT available — instead let `Apply` return it and string-match `strings.Contains(err.Error(), "nothing to unset")`, documented as a string match like `shipImmutableConflict`); anything else → 502 `"could not store the config — try again"`. Audit AFTER success only.
+ Sync handler: gate → write limiter → `appconfig.TouchGeneration` → 502 on error → audit `config-sync` → `{"result":"requested"}`.
+- [ ] **Step 5: run, verify pass; run whole console package.**
+- [ ] **Step 6: gofmt, vet, commit** `console: CLI config doors — env/secrets/sync through the same gate as ship (issue #43)`
+
+---
+
+### Task 3: CLI — API mode for env/secrets, `env sync` verb
+
+**Files:**
+- Modify: `apimode.go` (client methods), `appconfig_cli.go` (every subcommand), `cli.go` only if verb help text lists subcommands (check `carlosUsage`), `auth_test.go`/`apimode_test.go` (client tests)
+- Test: `apimode_test.go` additions
+
+**Interfaces:**
+- Consumes (Task 2 wire shapes; Task 1 `appconfig.TouchGeneration` for direct-mode sync).
+- Produces:
+```go
+type apiConfigVar struct{ Key, Kind, Value string }
+type apiConfig struct { Bundle, ExecDelivery, SecretsPubkey string; Environments []string; Vars []apiConfigVar }
+func (s *apiSession) configGet(ctx context.Context, account, app, environment string) (apiConfig, error)
+type apiConfigApply struct { Environment string; Set map[string]appconfig.Var; Unset []string; ExecDelivery *string }
+func (s *apiSession) configApply(ctx context.Context, account, app string, req apiConfigApply) (removed int, err error)
+func (s *apiSession) configSync(ctx context.Context, account, app string) error
+```
+ (`configApply` marshals `Set` into the wire's `{"kind","value","envelope"}` objects; PUT via a `putJSON` helper mirroring `postJSON` with `http.MethodPut`.)
+
+- [ ] **Step 1: failing client tests** (`apimode_test.go` pattern: httptest server asserting method/path/body, canned responses): configGet parses full shape incl. omitted fields; configApply sends exact JSON (golden body compare) and surfaces `removed`; 404-no-body → membership message; JSON error body → verbatim; configSync happy + error.
+- [ ] **Step 2: run, verify failure.**
+- [ ] **Step 3: implement apimode.go methods** (mirror `channels`/`promote` exactly: getJSON/postJSON/putJSON, `apiErrorFromBody`).
+- [ ] **Step 4: rewire `appconfig_cli.go`.** Pattern per subcommand, matching `runChannels`:
+```go
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
+ ...
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil { log.Fatal(err) }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ // API branch: *acctRaw verbatim, console resolves membership.
+ ...
+ return
+ }
+ // direct branch: byte-identical to today (openStore(), acctOf()).
+```
+ - `env set` API: `configApply{Environment: *environment, Set: {k: {Kind:"env", Value:v}}}`; same success line.
+ - `env unset`/`secrets unset` API: `Unset: fs.Args()`; print `unset %d of %d ...` with the RESPONSE's removed.
+ - `env list` API: configGet; identical header/exec-delivery/tabwriter output as direct.
+ - `env environments` API: configGet(default).Environments, one per line.
+ - `env exec-delivery` API: `ExecDelivery: &value` where value is `"file"` or `"off"` (send `"off"`, not `""` — wire vocabulary per spec); keep the `-environment` refusal client-side too.
+ - `secrets set` API: pubkey via `CARLOS_SECRETS_PUBKEY` env if set, else `configGet(...).SecretsPubkey`, else `log.Fatal("no CARLOS_SECRETS_PUBKEY in env and this console does not serve one — set the env var, or set CARLOS_SECRETS_PUBKEY in the console's environment")`. Then Seal per key (same ValidVarValue-first order as direct) and `configApply` with `{Kind:"secret", Envelope:...}`.
+ - `secrets list` API: configGet, print secret-kind keys sorted.
+ - `secrets genkey`: untouched (local, no store).
+ - NEW `env sync`:
+```go
+func runEnvSync(args []string) {
+ fs := flag.NewFlagSet("env sync", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ _, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ if *app == "" { log.Fatal("usage: carlos env sync -app <a> [-account <sqid-or-name>] [-console <origin>]") }
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil { log.Fatal(err) }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ if err := sess.configSync(context.Background(), ...); err != nil { log.Fatal(err) }
+ } else if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("config sync requested for %s — exec-backed instances converge within seconds; unit-backed routes on the next root adopt pass\n", *app)
+}
+```
+ Register `"sync"` in `RunEnv`'s switch; add to `envUsage` (`sync -app <a> request immediate config convergence (issue #44)`).
+- [ ] **Step 5: run full test suite; verify direct-mode tests still pass untouched.**
+- [ ] **Step 6: gofmt, vet, commit** `cli: env/secrets through the console API, and env sync (issues #43, #44)`
+
+---
+
+### Task 4: agent — fast generation poll loop
+
+**Files:**
+- Modify: `internal/host/agent.go`
+- Test: `internal/host/agent_test.go` (or the file where agent helpers are tested — check for an existing `_test.go` in `internal/host`)
+
+**Interfaces:**
+- Consumes (Task 1): `appconfig.ExecConfigApps`, `appconfig.GenKey`; existing `appconfig.SyncExec`, `openDeployStore`, `registry.Registry.List`, `activator.Service.Bounce`.
+- Produces: `genPollPass` (tested), `configGenLoop` (untested by convention, like adoptLoop).
+
+- [ ] **Step 1: failing tests for `genPollPass`** with a DirStore and fabricated routes:
+ - first sight of an app with a gen object: cache filled, returns false (no sync).
+ - unchanged etag: false.
+ - touched gen (etag moved): true, cache updated.
+ - absent gen: false, and stays false across passes.
+ - absent→present: true.
+ - two apps, one changed: true (single flag — caller runs ONE SyncExec).
+ - store read error on one app: that app's cache untouched, other apps still evaluated, error reported once in the returned error slice/log capture.
+- [ ] **Step 2: run, verify failure.**
+- [ ] **Step 3: implement.**
+```go
+// genPollPass is one tick of configGenLoop, split out for tests: read every
+// exec-config app's generation etag and report whether ANY moved since the
+// cache was built. First sight records silently — boot must not re-bounce
+// the world; configSyncLoop's ~1min first pass owns boot convergence.
+// Absent stays silent (an app last written by an older CLI is legal
+// forever). absent→present and any etag change are the signal. One bool
+// for the whole pass: the caller runs one SyncExec, which resolves every
+// app itself — per-app sync would multiply bucket reads for nothing.
+func genPollPass(ctx context.Context, reg *registry.Registry, store release.Store, cache map[[2]string]string) (bool, error) { ... }
+
+// configGenLoop is canaryAdoptLoop's shape applied to issue #44: config
+// should converge like a canary adopts. Gated on CARLOS_SECRETS_KEY exactly
+// as configSyncLoop is — without the key SyncExec can't render, so polling
+// would signal no one. Every ~3s: one small GET per exec-config app; on any
+// generation movement, one SyncExec pass (which bounces only what actually
+// changed — spurious touches cost a read, never a restart).
+func configGenLoop(reg *registry.Registry, svc *activator.Service, dd string) { ... }
+```
+ In the cache: key `[2]string{acct, app}`, value etag, with a sentinel `genAbsent = "\x00absent"` for known-absent (distinguishes "never seen" from "seen, absent"). Loop body: sleep `3*time.Second + rand.N(time.Second)`; `reg.List()`; `appconfig.ExecConfigApps`; per app `store.Get(ctx, appconfig.GenKey(...))` — etag on success, `genAbsent` on `os.ErrNotExist`, skip-and-collect on other errors; compare/update cache; drop cache entries for apps no longer routed; if any moved → `appconfig.SyncExec(...)` with the same `priv`/`bounce` wiring `configSyncLoop` builds (factor the bounce closure or duplicate its three lines, matching file style). Log SyncExec errors like the other loops; log read-error aggregates as one line per pass.
+ Start it in `runAgent` beside the others: `go configGenLoop(reg, svc, dd)`. Arm log: `agent: config generation loop armed (3s + jitter, exec-config apps)`.
+- [ ] **Step 4: run, verify pass; whole host package.**
+- [ ] **Step 5: gofmt, vet, commit** `host: config generation loop — a set converges like a canary adopts (issue #44)`
+
+---
+
+### Task 5: e2e + docs
+
+**Files:**
+- Create: `config_e2e_test.go` (repo root, `auth_e2e_test.go` pattern)
+- Modify: `docs/superpowers/specs/2026-08-03-cli-console-api-design.md` (§7: note env/secrets moved in-scope by the 2026-08-04 spec), `README.md` (only if it documents env/secrets usage — check)
+
+**Interfaces:** Consumes everything; produces the merge-gate evidence.
+
+- [ ] **Step 1: write the e2e test** — httptest console with DirStore + `SecretsPub` from a `GenerateKey` pair, device-flow login (copy `auth_e2e_test.go`'s token mint), then through the REAL CLI functions in API mode (env vars pointed away from any store env):
+ 1. `env set` PORT=8080 → console GET shows it; 2. `secrets set` (no CARLOS_SECRETS_PUBKEY in env — console-served path) → GET lists key name only; bucket envelope opens with the private key to the exact plaintext; 3. `env unset` removed=1; 4. `env sync` → gen etag moved; 5. non-member token → env set fails with the membership message.
+- [ ] **Step 2: run it; then the FULL suite: `go build ./... && go vet ./... && go test ./... -count=1`; `gofmt -l` on every touched file.**
+- [ ] **Step 3: spec cross-ref edit + commit** `e2e/docs: config through the console API, end to end`
+
+## Self-review notes
+
+- Spec §2 refusal matrix ↔ Task 2 Step 2 cases: matched one-to-one.
+- Type names used across tasks: `appconfig.Var{Kind,Value,Envelope}` (existing), `apiConfigApply.ExecDelivery *string` (Task 3) matches Task 2's pointer-decoded field; wire sends `"off"`, server maps to `SetExecDelivery("")`.
+- `removed` flows server (`Apply` return) → wire (`"removed"`) → CLI print: consistent.
+- Gen etag sentinel only lives in Task 4; Tasks 1–3 never read the cache.
diff --git a/docs/superpowers/specs/2026-08-04-config-console-api-design.md b/docs/superpowers/specs/2026-08-04-config-console-api-design.md
new file mode 100644
index 000000000..b8e1fe040
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-04-config-console-api-design.md
@@ -0,0 +1,198 @@
+# Config through the console API, and config that converges — issues #43 and #44
+
+**Date:** 2026-08-04 · **Status:** approved (Paul, via issues #43/#44 — "aiming
+today to be able to do all day-to-day app related admin via API commands") ·
+**Slice:** 1
+
+## Problem
+
+Two halves of the same gap, found in one deploy (seapointish, #43/#44):
+
+1. **#43** — `carlos env`/`carlos secrets` still open the deployment bucket
+ directly. Every config path in `appconfig_cli.go` calls `openStore()`
+ unconditionally; there is no credential-first mode resolution there at
+ all, unlike ship/promote (spec 2026-08-03-cli-console-api-design.md, which
+ listed env/secrets as out of scope in §7). So "give Vicky deploy access"
+ still means giving her AWS credentials the moment her app needs a secret.
+2. **#44** — a config write sits in the bucket for up to ~70 minutes before
+ an instance sees it (`configSyncLoop` is hourly + jitter), and the only
+ lever to apply it sooner is `systemctl restart carlos-edge` over SSM —
+ restarting the whole box's TLS terminator to deliver one app's variable.
+ Canary releases already solved this exact complaint with
+ `canaryAdoptLoop`; config has no equivalent, and no CLI verb either.
+
+## Decisions
+
+1. **Config gets console doors, same gate as the release doors.** Secrets
+ are sealed client-side with `CARLOS_SECRETS_PUBKEY` before they leave the
+ operator's machine — the console relays an opaque envelope, strictly less
+ sensitive than the artifacts it already accepts. Only the box holding
+ `CARLOS_SECRETS_KEY` can ever decrypt; the console gains no new secret.
+2. **The console may serve the secrets PUBLIC key** (`Config.SecretsPub`,
+ from `CARLOS_SECRETS_PUBKEY` in the console's env; optional). It is
+ public material by design ("can be held widely — it only encrypts"), and
+ serving it is what makes `carlos auth login` sufficient for
+ `secrets set` with no other setup. CLI resolution: env var wins, console
+ fallback, neither → a fatal that names both fixes.
+3. **Convergence is a generation object, not a faster full sweep.** Every
+ successful config write (default bundle, environment bundle,
+ exec-delivery flag) also touches `apps/<acct>/<app>/config-gen.json`.
+ The agent polls that one small object per exec-config app every ~3s
+ (mirroring `canaryAdoptLoop`) and runs `SyncExec` only when an etag
+ changes. `SyncExec` already bounces only on real content change, so a
+ spurious touch is one wasted read, never a bounce.
+4. **`carlos env sync` is the escape hatch** — a generation touch, remote
+ (console door) or direct (bucket). It targets the app, never the box's
+ shared edge service.
+5. **Direct-bucket mode is byte-identical to today** when
+ `CARLOS_DEPLOYMENT_BUCKET`/`_DIR` is set — same credential-first
+ resolution, same `-console`-alongside-store-env refusal as ship/promote.
+ Old CLIs keep working; they just don't bump the generation, and the
+ hourly loop remains the floor for them.
+
+## §1 Wire shape: the generation object (additive)
+
+`apps/<acct>/<app>/config-gen.json`, sibling of `config.json`:
+
+```json
+{"schema": 1, "touched_at": "2026-08-04T12:00:00Z", "nonce": "<16 hex bytes>"}
+```
+
+One per app, covering ALL its bundles (default + every environment): the
+consumer (`SyncExec`) resolves per-(app, environment) itself, so the signal
+only needs app granularity. Change detection is by ETAG, not content —
+`touched_at`/`nonce` exist for humans debugging, and to guarantee distinct
+bytes per touch. Written via CAS read-modify-write; a CAS conflict on the
+touch means someone else just touched it, which is success (the signal
+fired), never an error. Absent gen object = app last written by an older
+CLI: legal forever, hourly convergence still applies. Bump failure after a
+successful manifest write is logged, never returned — the write DID land;
+the cost is hourly-instead-of-seconds, not wrongness.
+
+Consumer audit (the rule this repo has paid for three times): listers of
+`apps/` are `domains/reconcile.go` (attributes keys by path segments; a new
+sibling file under an app prefix it already attributes is inert) and
+`release/qualified.go`'s key parsers (never read config keys). The gen
+object only ever exists alongside `config.json`, so it can't mint an app
+where none existed.
+
+## §2 API surface (server)
+
+Three doors under `registerCLIRoutes`, all bearer, all through the same
+`apiAccountApp` gate (byte-identical 404 for unknown account / unknown app /
+someone else's account), write limiter `s.cli.write` keyed by address on the
+mutating two. No signing-key check — config manifests aren't signed.
+
+- `GET /api/cli/apps/{account}/{app}/config[?environment=<name>]`
+
+ ```json
+ {"bundle": "default",
+ "exec_delivery": "file",
+ "secrets_pubkey": "<b64>",
+ "environments": ["staging"],
+ "vars": [{"key": "PORT", "kind": "env", "value": "8080"},
+ {"key": "BANK_KEY", "kind": "secret"}]}
+ ```
+
+ `vars` sorted by key; secret values structurally absent (the console
+ couldn't read them if it wanted to). `exec_delivery` only for the default
+ bundle, omitted when unset. `secrets_pubkey` omitted when the console has
+ no `SecretsPub`. `environments` always present (possibly empty) — powers
+ `carlos env environments` without a fourth door.
+
+- `PUT /api/cli/apps/{account}/{app}/config`
+
+ ```json
+ {"environment": "",
+ "set": {"PORT": {"kind": "env", "value": "8080"},
+ "BANK_KEY": {"kind": "secret", "envelope": "<b64>"}},
+ "unset": ["OLD_KEY"],
+ "exec_delivery": "file"}
+ ```
+
+ Refusals, cheap-first after the gate: empty request (no set, no unset, no
+ exec_delivery) → 400; `environment` `"default"` or invalid → 400; bad var
+ name/value (`ValidVarName`/`ValidVarValue`) → 400; malformed envelope
+ (`CheckEnvelope` — structural: base64, eph point parses, minimum length;
+ refused NOW because a garbage envelope otherwise stalls the app's whole
+ config at render time, hours later and out of sight) → 400; secret var
+ with `value` or env var with `envelope` → 400; `exec_delivery` alongside
+ a named environment → 400 (app-level only, same rule as the CLI);
+ `exec_delivery` not `"file"`/`"off"` → 400; unset-only against a bundle
+ that doesn't exist → 409 (same refusal `applyAt` makes — a phantom bundle
+ satisfies route existence rails); CAS conflict → 409 "config changed
+ concurrently — try again"; store failure → 502, bucket never described in
+ bucket terms. Success: `{"result": "ok", "removed": N}` — N actual
+ removals, not the argument count (2026-08-02 review, MINOR 9, applies on
+ the wire too). Values never audited, never logged: audit detail is counts
+ and bundle, e.g. `config: 2 set, 1 unset (default) (cli)`; exec-delivery
+ audits as `exec-delivery file (cli)`.
+
+- `POST /api/cli/apps/{account}/{app}/config/sync` — body `{}`. Touches the
+ generation. `{"result": "requested"}`. Audited (`config-sync`): it makes
+ instances restart, which an operator reading audit rows must be able to
+ see.
+
+The console's box role already covers `apps/<sqid>/<app>/config*` writes —
+the same store credentials the ship door verified live on tito (spec
+2026-08-03 §4).
+
+## §3 CLI
+
+Every `env`/`secrets` subcommand gains `-console` and the exact
+`resolveMode` split ship/promote use: store env set → direct, unchanged;
+credential → API mode with the `using <console> as <addr>` line; both →
+refused. New subcommand `carlos env sync -app <a>` (both modes; direct mode
+touches the gen object itself). `secrets set` in API mode seals with
+`CARLOS_SECRETS_PUBKEY` if set, else the console's served pubkey, else a
+fatal naming both fixes. `secrets genkey` is local-only either way (mints a
+keypair, touches no store).
+
+## §4 Agent
+
+`configGenLoop`, beside `canaryAdoptLoop`: gated on `CARLOS_SECRETS_KEY`
+exactly like `configSyncLoop` (no key → SyncExec can't run → polling would
+signal no one). Every 3s + jitter(1s): list routes, take the distinct
+(account, app) pairs of exec-config-shaped routes
+(`appconfig.ExecConfigApps` — the exported union of `execSyncable` and
+`execAddrCandidate` shapes, so the loop's eligibility can never drift from
+SyncExec's), read each gen object's etag. First sight of an app records
+silently (boot must not re-bounce the world; `configSyncLoop`'s ~1min first
+pass owns boot convergence). Any etag change — including absent→present —
+triggers ONE `SyncExec` pass for the tick, not one per app. Absent stays
+absent silently. Hourly `configSyncLoop` stays as the floor (old CLIs,
+missed ticks, #27's boot-retry gap). Unit-backed routes keep the root adopt
+path — the agent cannot restart units, and this spec does not try.
+
+Cardinality note: unlike canary channels this polls every exec-config app,
+but one small GET per app per 3s against the store the canary loop already
+hits every 2s is the same order of cost the platform has already accepted;
+the read is the cheapest object in the bucket.
+
+## §5 Out of scope (recorded, deliberate)
+
+- Unit-backed route fast convergence (root adopt path owns it).
+- `#27` (configSyncLoop boot retry) — narrowed by this spec (the gen loop
+ re-arms convergence on the next write) but not fixed by it.
+- Browser console config UI — CLI-only surface today, same as ledgers.
+- Notifying/redelivering on secrets ROTATION (`genkey` re-runs) — key
+ distribution is host.env territory.
+
+## §6 Testing
+
+- appconfig: gen bumped by every write path (apply / applyEnvironment /
+ SetExecDelivery); CAS-conflict touch is success; CheckEnvelope
+ accepts Seal's output and refuses truncation/garbage/valid-b64-wrong-shape.
+- console: refusal matrix per §2 (gate 404s byte-identical, every 400/409),
+ round-trips (env, sealed secret opaque end-to-end, environments,
+ exec-delivery), secrets_pubkey presence/absence, sync door touches gen,
+ audit rows carry counts never values.
+- CLI: mode resolution reuse (matrix already pinned by apimode_test.go);
+ API-branch output parity with direct mode; secrets pubkey resolution
+ order (env → console → fatal).
+- agent: the per-tick decision extracted as a pure-ish helper
+ (`genPollPass`) and tested (first-sight silent, change triggers, absent
+ stays silent, one sync per tick); the loop itself stays untested by
+ convention like adoptLoop/statusLoop.
+- e2e (auth_e2e_test.go pattern): login → env set → list → secrets set
+ (console-served pubkey) → list names → unset → sync → gen object moved.