spec: account default resolution + membership discovery (#157)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+156
−0
docs/superpowers/specs/2026-08-12-account-default-resolution-design.md+156 −0
diff --git a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md| new file mode 100644 |
| index 000000000..8e64c7904 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md |
| @@ -0,0 +1,156 @@ |
| +# Account default resolution and membership discovery |
| + |
| +**Date:** 2026-08-12 · **Issue:** #157 · **Status:** approved (Paul, in-session) |
| + |
| +## Problem |
| + |
| +Every API-mode verb defaults `-account` to `ops` — an account that does not |
| +exist on real consoles — so a new user's first ship answers the deliberately |
| +opaque membership 404. Worse, nothing exposes a signed-in caller's own |
| +memberships: `carlos accounts list` opens the registry DB on local disk, and |
| +`carlos auth whoami` reports identity only. A user who omits `-account` is |
| +guessing at a value the console knew all along (Tom, 2026-08-11: first ship |
| +on console.carloku.com, three failures deep before learning his account is |
| +named by his sign-in address). |
| + |
| +Per the no-infra rule, the CLI must answer this itself: discover the |
| +caller's accounts, and when the answer is unambiguous, just use it. |
| + |
| +## Decisions (Paul, 2026-08-12) |
| + |
| +- **One membership → it is the default.** No flag needed. |
| +- **Several memberships → interactive chooser** on a terminal; **refuse and |
| + list** when stdin/stdout is not a TTY (a script cannot answer a prompt; |
| + refusing loudly beats hanging, and the refusal doubles as discovery). |
| +- **Chooser offers to save** the pick as the project's default account |
| + (`./.carlos/config`), one y/N prompt. |
| +- `carlos auth whoami` prints memberships; `carlos accounts list` gains a |
| + signed-in mode. |
| +- Direct/bucket mode is untouched: `ops` remains its genuine default. |
| + |
| +## Wire (additive) |
| + |
| +New endpoint, same token gate as whoami (`cliUser`): |
| + |
| + GET /api/cli/accounts |
| + → 200 {"accounts": [{"name": "tom@keymail.dev", "sqid": "bdk"}, …]} |
| + |
| +- Only the **caller's own live memberships** (deleted accounts excluded), |
| + sorted by name for deterministic output. |
| +- Nothing else modelled. Old CLIs never call it; a new CLI against an old |
| + console gets the mux's 404 and falls back (below). |
| + |
| +## CLI resolution (API mode) |
| + |
| +The `-account` flag default changes from `"ops"` to `""` on every verb that |
| +takes it (the `accountFlags` helper and every hand-rolled API-verb pair — |
| +`apps create`/`delete`/`restore`/`place` and any sibling found in the |
| +implementation sweep). |
| +Direct mode is unaffected: `resolveAccount` already answers `""` with |
| +`release.OpsAccount`. |
| + |
| +In API mode an empty account resolves through one shared seam on the |
| +session, in order: |
| + |
| +1. **Explicit flags** (`-account-id`, then `-account`) — travel verbatim, |
| + exactly as today. |
| +2. **Config default:** the `account` key of the nearest `.carlos/config` |
| + (same upward walk as the console default, then global). Honored **only |
| + when that file's `console` matches the session's console, or the file |
| + names no console** — a config pair points at one stack; `-console` to a |
| + different stack must not drag the other stack's account along. |
| +3. **Memberships** via `GET /api/cli/accounts`: |
| + - exactly one → use its **sqid, exact-matched** (`?exact=sqid`), and |
| + print a muted `account: <name> (<sqid>)` line under the "using …" |
| + banner so what was guessed is always visible; |
| + - several + TTY (stdin AND stdout are char devices) → numbered chooser, |
| + then the save prompt; |
| + - several + non-TTY → error: |
| + `you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)`; |
| + - zero (unreachable in practice — sign-in mints a personal account) → |
| + error naming the console. |
| +4. **Old console** (endpoint 404s) → send `ops`, byte-for-byte today's |
| + behavior. No regression against un-upgraded consoles. |
| + |
| +The resolution happens once per invocation, before the first request that |
| +needs an account path; verbs that never touch an account path never call it. |
| + |
| +## Chooser UX |
| + |
| + you belong to several accounts on https://console.carloku.com: |
| + 1. tom@keymail.dev (bdk) |
| + 2. acme (bce) |
| + account [1-2]: |
| + |
| + save acme as this project's default account? [y/N] |
| + |
| +- Pick by number; anything else (including empty) re-prompts once, then |
| + errors with the non-TTY message. EOF (^D) errors the same way. |
| +- `y` writes `{"console": <session console>, "account": <name>}` to |
| + `./.carlos/config` **in the working directory** — same placement rule as |
| + `auth default -project` ("run it where you want the file"). Any other |
| + answer saves nothing. |
| +- The saved value is the account **name** (readable in a committed file); |
| + it resolves server-side against memberships like any `-account` value. |
| +- Single-account users never see either prompt. |
| + |
| +## Config schema |
| + |
| +`cliConfig` gains `Account string \`json:"account"\``. Two behavior changes |
| +to the file handling: |
| + |
| +- **Rewrites preserve unknown fields** from now on: load into a raw map, |
| + set the keys being written, marshal back. Today's "unknown fields are |
| + dropped on rewrite" makes every future key lossy against old binaries; |
| + this stops the bleeding for keys added after this one. |
| +- **Accepted wrinkle:** binaries older than this change still drop the |
| + `account` key when they rewrite the file (`auth default <url>`). It is a |
| + default, not data — recoverable by re-choosing. |
| + |
| +A config `account` that no longer matches any membership fails server-side |
| +with the membership 404 — the error is not rewritten, but the muted |
| +`account: …` line above it names what the config supplied and from which |
| +file, so the fix is evident. |
| + |
| +## Discovery verbs |
| + |
| +- **`carlos accounts list`**: signed-in mode (no `CARLOS_DEPLOYMENT_BUCKET` |
| + / `CARLOS_DEPLOYMENT_DIR`, credential present) calls the endpoint and |
| + prints a NAME/SQID table. The registry form stays for boxes, keyed off |
| + the same `resolveMode` split every other dual-mode verb uses; gains |
| + `-console`. |
| +- **`carlos auth whoami`**: after identity, fetches the endpoint and prints |
| + `accounts: tom@keymail.dev (bdk), acme (bce)`. Degrades **silently** on |
| + 404/error — whoami's job is identity; the membership line is a bonus, and |
| + an old console must not make whoami look broken. |
| + |
| +## Security |
| + |
| +- The endpoint returns only the caller's own memberships, resolved from the |
| + token's address — no account enumeration surface. |
| +- The chooser writes only `./.carlos/config` (0755 dir, non-secret file, |
| + committed on purpose) and only after an explicit `y`. |
| +- The membership 404's byte-identical opacity for wrong/foreign accounts is |
| + unchanged. |
| + |
| +## Testing |
| + |
| +- Console: `authcli_test` — own memberships only, deleted excluded, sorted, |
| + 401 bare/wrong token. |
| +- CLI resolution: table test against httptest consoles — one/several/zero |
| + memberships, endpoint-404 fallback to `ops`, config-account honored, |
| + config console-mismatch ignored, explicit flags bypass everything. |
| +- Chooser: unit test with injected reader/writer — pick, re-prompt, EOF, |
| + save-yes writes the file, save-no doesn't. TTY gating itself is covered |
| + by the non-TTY refusal path in the resolution tests. |
| +- Config: `account` key round-trips; rewrite preserves unknown fields; |
| + console-only files still parse ("" account = no default at this level). |
| +- Existing `carlosTestEnv` rules apply to anything that shells the binary. |
| + |
| +## Out of scope |
| + |
| +- #81 (apps create echoes sqid where a human wants the name), #83 (promote |
| + version picker), #79 (ship-before-claim recovery). |
| +- A `carlos accounts default` verb — the chooser's save prompt plus editing |
| + the file covers it. |
| +- Any change to box-side/direct-mode account handling. |