| 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. |