carlos / platform Public

spec: account default resolution + membership discovery (#157)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8808e29f52c5de29e4daed10a4926b0565a8ecc2 parent a73539f
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.