DESIGN.md@docs · git:20260928.c22798a · 2026-09-28 · sha256 b1f05772dc31d37a

DESIGN.md@docs git:20260928.c22798aA

Immutable. This exact content never changes and is served at /api/v1/blob/b1f05772dc31d37a.

# Design conventions — gplay

Living reference for the CLI design decisions that didn't rise to ADR-level
(reversible, or "the obvious thing once you know the constraint") but that we
still want to be consistent about across commands.

For deeper rationale on the load-bearing choices, see:

- [ADR-0001 — Credential storage](./adr/0001-credential-storage.md)
- [ADR-0002 — Safe production defaults](./adr/0002-safe-production-defaults.md)
- [ADR-0003 — `--output json` is API pass-through](./adr/0003-json-passthrough.md)
- [ADR-0004 — Cascading config](./adr/0004-cascading-config.md)
- [ADR-0005 — TTY-aware output defaults](./adr/0005-tty-aware-output.md)
- [ADR-0019 — Canonical verb vocabulary](./adr/0019-canonical-verb-vocabulary.md)
- [ADR-0023 — JSON error envelope on failure](./adr/0023-json-error-envelope.md)
- [ADR-0024 — `GPLAY_READONLY` environment policy](./adr/0024-readonly-environment-policy.md)
- [ADR-0042 — 1.0 GA and the stability-label mechanism](./adr/0042-one-zero-ga-and-stability-label-mechanism.md)
- [ADR-0044 — Structured diagnostic codes](./adr/0044-structured-diagnostic-codes.md)

---

## 0. Verb vocabulary

Every command verb belongs to one of three categories. The rationale and the
considered alternatives live in
[ADR-0019](./adr/0019-canonical-verb-vocabulary.md); this is the normative
quick-reference.

**1. CRUD grammar** — generic resource verbs, held to strict consistency:

| Gesture | Verb | Example |
|---|---|---|
| Read many | `list` | `gplay tracks list` |
| Read one addressed resource | `view` | `gplay apps view`, `gplay tracks view` |
| Session / health (no addressed resource) | `status` | `gplay auth status` |
| Bring a new object into existence | `create` | `gplay tracks create <name>` |
| Add/remove membership of an existing entity | `add` / `remove` | `gplay apps add`, `gplay team users remove` |
| Delete a resource / clear a field | `remove` | `gplay team grants remove` |
| Write/declare state with explicit values | `set` | `gplay apps details set`, `gplay testers set` |

Deciding rules:

- **`view` vs `status`:** `view` reads a resource you point at (`--package`,
  `--track`, …); `status` reports session/health with nothing pointed at.
  `auth status` is the only `status`.
- **`create` vs `add`:** ask "am I making the object *exist*?" Yes → `create`
  (a closed track). No, it already exists and I am enrolling it in a
  collection → `add` (registering an app locally, adding a user to the org).
  gplay cannot create or delete an app on Play, so apps use `add`/`remove`,
  never `create`/`delete`.
- **`remove`, never `revoke`:** one delete verb across the surface.
- **`set`, never `edit`:** writes are declarative/idempotent (full-replace or
  deterministic patch), non-interactive, and `edit` would collide with the
  `Edit` transaction concept (`gplay edits …`).
- **No verb-less reads:** a read always carries `view` (so `apps details view`
  and `tracks availability view`, not the bare nouns).

**2. Domain verbs** — each names a real gesture no generic verb captures:
`upload`, `promote`, `rollout`, `halt`, `resume`, `complete`, `reply`, `pull`,
`apply`, `validate`, `download`, `enroll`, `rotate`, `login`/`logout`, and the
App Recovery trio `deploy`, `cancel`, `add-targeting`. Admission test: it must
state a domain gesture `set`/`create`/`view` could not say honestly. The
`releases` rollout state machine (`rollout`/`halt`/`resume`/`complete`) lives
flat here — these act on a release's rollout *state*, not on a "rollout"
resource, so they are not nested.
The recovery trio was admitted under this test and recorded in
[ADR-0030](./adr/0030-android-publisher-long-tail-surfaces.md): `deploy`
(activate a draft, ≠ `create`/`set`), `cancel` (terminate to status CANCELED,
≠ `remove` which deletes), and `add-targeting` (append-only audience widening,
≠ `set` which would imply replace/narrow). `add-targeting` is the lone
hyphenated domain verb — a deliberate, narrow exception for legibility (it
mirrors the API method `addTargeting`), not licence for hyphenated verbs
generally. `download` (`releases generated download`) was admitted on the same
test: it writes opaque binary bytes to a local file — a gesture `view`/`set`
cannot state — and mirrors the API method `.download`. It is gplay's only binary
download-to-file verb; its destination flag is `--dest` (`--dest -` for stdout),
never `--output`, which controls structured-data format
([ADR-0034](./adr/0034-generated-apks-binary-download-to-file.md)).
`enroll` and `rotate` (`gplay signing`) were admitted on the same test with
[PRD #476](https://github.com/PollyGlot/google-play-cli/issues/476): they act on
an app's *signing key custody*, a state no resource verb can state honestly.
`enroll` moves an app onto a key the developer hosts in their own Cloud KMS
(a one-way transition of the app itself, so not `create`, which makes an object
exist, nor `add`, which enrolls into a collection gplay can also `remove` from),
and `rotate` swaps that live key for a successor carrying an apksigner
proof-of-rotation lineage (an ordered succession, not `set`, which would imply a
field you can overwrite back). Both mirror the API methods
`appsigning.enrollApp` and `appsigning.rotateAppSigningKey`. `rotate` is
unrelated to the release `rollout` state machine above: different noun,
different resource.

Eight more domain verbs shipped before this list was checked by a test; each
passes the same admission test:

| Verb | Command | The gesture no generic verb states |
|---|---|---|
| `begin` | `edits begin` | open an explicit Edit and pin it for the writes that follow (a transaction boundary, not `create`) |
| `commit` | `edits commit` | commit the pinned Edit so its changes go live, and clear the pin |
| `discard` | `edits discard` | throw the pinned Edit away uncommitted and clear the pin (not `remove`: nothing live is deleted) |
| `refund` | `orders refund` | refund an order: money moves, irreversibly |
| `convert` | `subscriptions prices convert` | derive per-region prices from one base price, a pure computation that writes nothing |
| `migrate` | `subscriptions prices migrate` | move existing subscribers onto the current price (money-moving, not `set`) |
| `history` | `reviews history` | read the full review history from the monthly CSV reports, beyond the API's window |
| `audit` | `apps audit` | sweep apps for consistency drift, read-only, reporting findings (exit `70`) |

`submit` joined with the 2.0.0 renames (#597): `appstore submit` sends a hosted
app's assembled details to Google review, immediately and irrevocably. `set`
would state a declarative write you can overwrite back; this call starts a
review each time and cannot be recalled (ADR-0043 gates it with `--confirm`).

Two documented exceptions to the rules above: `edits status` is a second
`status` (it reports the local Edit pin, `--live` adds a server check), frozen
before the rule was enforced; and the `vitals` presets (`vitals crashes`,
`vitals anr`, …, `vitals anomalies`, `vitals errors counts|issues|reports`)
plus `vitals query` are verb-less reads by design: there is no resource to
`view`, only metric sets to query
([ADR-0027](./adr/0027-vitals-second-service-scope-readonly.md)).

**3. Reference / diagnostic / scaffold** — meta-commands outside the resource
grammar, keeping their own names: `version`, `exit-codes`, `install-skills`
([ADR-0028](./adr/0028-install-skills-command.md), installer mechanism
superseded by [ADR-0045](./adr/0045-install-skills-pinned-git-install.md)),
`auth doctor`,
`team permissions` (offline catalog), `schema` (offline API index), `init` (also
wired as `apps init`).

The vocabulary is enforced by `TestLeafContract` in `cmd/gplay`: a leaf whose
last word is in none of these lists, and is not one of the documented
exceptions, fails the build. 2.0.0 renamed the last leaves that broke these
rules (#597: `games … set|remove`, `appstore submit`,
`appstore publish-status set`, `appstore apk|image|policy upload`), so the
shrink-only allowlist that held them is empty.

---

## 1. Authentication

### Credential resolution precedence

In order, first match wins:

1. `--service-account <path-or-json>` flag on the command
2. `--account <name>` flag (selects a stored Account)
3. `GPLAY_SERVICE_ACCOUNT` env var (path or inline JSON)
4. `GPLAY_ACCOUNT` env var (name of a stored Account)
5. The Account marked **active** in `~/.gplay/config.json` (or `$XDG_CONFIG_HOME/gplay/config.json`)

If nothing resolves: exit code `10` with one message, the same on every command
(`kernel.NoAccountError`), that names each way to fix it: `gplay auth login`,
`--account` or `GPLAY_ACCOUNT`, `--service-account` or `GPLAY_SERVICE_ACCOUNT`.
A command that also needs a developer-id (`team`, `customapps create`, `reviews
history`) reports a missing Account before a missing developer-id: a
developer-id set first would only lead to the no-Account error one step later.

**Path or inline JSON.** A `--service-account` or `GPLAY_SERVICE_ACCOUNT` value
is inline JSON when its first non-whitespace character is `{`, and a file path
otherwise. A value that is neither JSON nor a readable file fails with exit `10`.
The value is echoed back only when it cannot be a credential (one line, at most
255 bytes, no PEM marker, no `{`, not base64 of JSON), so a mistyped path stays
diagnosable. Anything else is **never echoed**: the message names the source,
the value's length and the OS reason, plus a hint when the value looks
base64-encoded, wrapped in quotes or prefixed with a byte-order mark. gplay
diagnoses those shapes but does not decode them (#583).

**Absent vs. invalid.** Resolution has two distinct failure modes, and gplay
keeps them apart ([ADR-0020](adr/0020-resolution-error-surfacing.md)):

- **Absent** — no source is configured (none of layers 1–5 yield one), or the
  named/active Account has no key in the store. This is a benign state: a
  command that consumes a credential exits `10` ("run `gplay auth login`"),
  but `gplay auth status` reports "No active account" and exits `0`, and
  read-only `apps list` still works off the local registry.
- **Invalid** — a credential *was* provided but its bytes are unusable
  (malformed JSON, a missing required field, an unreadable file, a keystore
  read error). This is always an error: exit `10` with the underlying cause
  in the message (`could not read credential: <cause>`), on **every** command
  — including `auth status`, which no longer masks a corrupt active credential
  as "No active account".

### `gplay auth doctor`

Runs these checks in order, stopping on the first hard failure:

1. Service account JSON present, readable, well-formed (required fields:
   `client_email`, `private_key`, `token_uri`).
2. OAuth2 access token can be minted (RSA-signed JWT exchange succeeds).
3. Token bears the `androidpublisher` scope.
4. For every package in the local registry (or the one passed via
   `--package`): call `edits.insert` then `edits.delete` round-trip. Catches the
   common case "SA created but not invited on the app in Play Console".

Output: one `✅`/`❌` line per check, with an action hint on failure.

---

## 2. Package targeting

### Project pinning

`gplay init --package com.example.myapp` writes `.gplay/config.json` at the
repo root. Any subsequent command run inside that tree (we walk up from cwd
looking for `.gplay/`) defaults its target to that package — so `--package`
becomes optional.

`--package` always overrides.

### Cascading layers (see [ADR-0004](./adr/0004-cascading-config.md))

The same `config.json` schema appears at three levels. Later wins:

```text
$XDG_CONFIG_HOME/gplay/config.json     (global, machine-local — Accounts live here)
<repo>/.gplay/config.json              (project shared, committed — package pin)
<repo>/.gplay/config.local.json        (project local, gitignored — per-developer overrides)
GPLAY_* env vars                       (e.g. GPLAY_ACCOUNT, GPLAY_SERVICE_ACCOUNT,
                                        GPLAY_APP_STORE_PACKAGE — `appstore` axis, ADR-0043)
CLI flags                              (e.g. --account, --package, --service-account)
```

The walk-up that finds `.gplay/` refuses to traverse into `$HOME` (or any
ancestor of `$HOME`) so a stray `~/.gplay/config.json` cannot masquerade
as a project pin. `gplay init` refuses to run when `cwd == $HOME` for
the same reason.

### Field rules

- **`account` is forbidden in committed `config.json`.** Account names
  are machine-local; pinning one in shared state breaks teammates. The
  loader rejects it with an error naming the offending file path.
- `account` may appear in `config.local.json`, as `GPLAY_ACCOUNT`, or as
  `--account`.
- **An Account name is one plain path component** (#603): no `/`, no `..`.
  The loader refuses a path-like `account` in `config.local.json` (exit 2,
  naming the file), and the file keystore refuses one from any source, since
  the name becomes `<keystore>/<name>.json`.
- **A git-tracked `config.local.json` draws a warning** on stderr at every
  command (#603): it picks the Account for everyone who clones the repo.
  gplay asks `git ls-files` and stays silent when git is absent or the file
  is untracked.

### `.gplay/` contents

- `config.json` — package pinning (the `package` field). **Commit this.**
- `config.local.json` — per-developer overrides (`account`, rarely
  `package`). **Gitignore this** — `gplay init` writes the rule for you
  in `.gplay/.gitignore`.
- `edit-<package>.json` — open explicit Edit ID (see `CONTEXT.md` → Edit).
  **Gitignore this** too — transient and per-developer; covered by the
  same `.gplay/.gitignore`.

---

## 3. Release commands

### Status defaults (see ADR-0002)

| Target track | Default status | Default userFraction |
|---|---|---|
| `production` | `draft` | — |
| `internal` / `alpha` / `beta` / closed | `completed` | `1.0` |

Explicit overrides: `--complete`, `--staged <fraction>`, `--draft`.

### `--track` is passthrough

Any string is accepted. The four standard tracks (`internal`, `alpha`, `beta`,
`production`) are documented but not enforced — closed-test tracks with
custom names work out of the box.

### Release notes

Two flags, mutually exclusive:

- `--release-notes "<text>"` — single text applied to the app's
  `defaultLanguage`.
- `--release-notes-dir <dir>` — one file per locale (`en-US.txt`, `fr-FR.txt`,
  ...). Optional `default.txt` is used as fallback for locales without a
  dedicated file.

### Targeting a release

- `releases upload <aab>` → versionCode is read from the AAB; never a flag.
- `releases promote/rollout/halt/resume/complete --track <X>` → targets the
  **latest** release on the track. Override with `--version-code N` or
  `--release-name <name>`. If two releases coexist on the track (e.g.
  `inProgress` + `halted`) and no flag is passed, refuse with exit code `60`
  rather than guess.

### Rollout state machine

Each transition is its own verb:

- `gplay releases rollout --staged <fraction>` — set userFraction (status becomes
  `inProgress` if it wasn't already)
- `gplay releases halt`
- `gplay releases resume`
- `gplay releases complete` — userFraction → 1.0, status → `completed`

### Sub-surfaces under `releases`

`releases` also hosts grouping nouns for non-track surfaces (ADR-0030;
`generated` added in ADR-0034):

- **`releases sharing upload`** — Internal App Sharing: a non-track media upload
  (no Edit) returning a private shareable link (CONTEXT.md "Internal App
  Sharing").
- **`releases expansion-files upload/set/view`** — legacy OBB expansion files,
  an Edit artifact keyed by `apkVersionCode` (CONTEXT.md "Expansion file (OBB)").
  Labeled legacy (superseded by Play Asset Delivery). The expansion **`patch`
  type** (`--type patch`) is unrelated to the HTTP PATCH method: the API's
  `update` (PUT) and `patch` (PATCH) both write the single field
  `referencesVersion`, so gplay exposes one declarative `set` (PUT primary), not
  a PUT/PATCH pair.
- **`releases generated list/download`** — the APKs Play generates and signs from
  an uploaded AAB (CONTEXT.md "Generated APK"). **Read-only and Edit-free**
  (application-scoped, not under an Edit). `list` enumerates the artifacts for a
  `--version-code`; `download <downloadId>` streams one artifact's raw bytes to
  `--dest PATH` (or `--dest -` for stdout) — gplay's only binary download-to-file
  command, so it carries no `--output` flag
  ([ADR-0034](./adr/0034-generated-apks-binary-download-to-file.md)).

---

## 4. Edit lifecycle

### Implicit edits (default)

`begin → upload/patch → commit` is wrapped inside each write command. On any
failure after `begin`, the Edit is **auto-discarded** before the error
propagates. Pass `--keep-edit-on-failure` to bypass cleanup when debugging.

`SIGINT` and `SIGTERM` count as a failure: the in-flight request is canceled,
the Edit is discarded under a 5-second bound (inside the CI kill margin: GitHub
Actions sends `SIGTERM` 7.5s after `SIGINT`), and gplay exits `50`. A second
signal is not caught and kills gplay at once. Only a hard kill (`SIGKILL`, OOM,
runner eviction) can still leave an Edit open.

### Explicit edits

`gplay edits begin / commit / discard`. The Edit ID is persisted to
`.gplay/edit-<package>.json` so subsequent write commands in the same cwd
reuse it. No auto-discard in this mode — explicit `commit` or `discard` is
required.

Two read-only checks complete the lifecycle (#544):

- `gplay edits validate` runs Google's commit-time checks on the pinned Edit
  (`edits.validate`) without committing it. Exit `0` means the Edit would
  commit as it stands; a rejection surfaces the API's error envelope with the
  exit code its HTTP status maps to (§9, typically `30`). The Edit and the pin
  stay in place either way. No pinned Edit is exit `60`, like `commit`.
  `--output json` mirrors the API's `AppEdit` response.
- `gplay edits status --live` adds one `edits.get` on the pinned id. The
  report gains the server's `expiryTimeSeconds`; a `404` (the Edit expired or
  was discarded by another client) reports `open: false` and points at
  `gplay edits discard` to clear the stale pin, with exit `0`: the pin is never
  cleared implicitly. Without `--live`, `status` stays a local read with no
  auth and no network; `--live` without a pin also stays offline.

A write command that fails against a pinned Edit which no longer exists (an
`editExpired` reason, or a `404` that the same `edits.get` probe confirms is
the Edit itself rather than a resource inside it) names the pin file and
`gplay edits discard --package <pkg>` in its error. The exit code and the
diagnostic code stay those of the API failure; the pin is never cleared
implicitly.

### Committing while changes are in review (#598)

Every command that commits an Edit (`gplay edits commit`, and each write
command in implicit mode) takes two opt-ins, forwarded as `edits.commit` query
parameters. They are frozen like the commands carrying them (no
`[experimental]` label): each mirrors one Google parameter 1:1, so there is no
shape left to settle.

| Flag | Sent as | Effect |
|---|---|---|
| `--changes-in-review cancel` | `changesInReviewBehavior=CANCEL_IN_REVIEW_AND_SUBMIT` | Google's default, stated explicitly |
| `--changes-in-review error` | `changesInReviewBehavior=ERROR_IF_IN_REVIEW` | The commit fails while changes are in review; the review is left alone and Google does not invalidate the Edit |
| `--changes-not-sent-for-review` | `changesNotSentForReview=true` | Commit without sending the changes for review; they wait until someone sends them from the Play Console |

**The default is Google's and stays so.** With neither flag gplay sends no
parameter, and Google cancels any review in progress and submits everything
again, which restarts the review. Changing that default would change what every
existing pipeline publishes, so it is not on the table in `1.x`. A refusal in
`error` mode is Google's error envelope, passed through with the exit code its
status maps to (§9). A bad value is CLI misuse (exit `2`), rejected while flags
are parsed.

With an explicit Edit pinned, a write command stages into it and does not
commit, so the flags cannot apply there: the command warns on stderr and names
`gplay edits commit`, which is where they belong.

### A commit whose outcome is unknown

A commit that fails after the request left the machine (a timeout, a reset, a
`5xx`) may have been applied. It keeps the exit code its failure maps to (`50`
or `40`), but its diagnostic code is `COMMIT_OUTCOME_UNKNOWN`, **not
retryable** (§9.1): re-running an upload that did publish fails on the
already-used version code and reports a successful release as an error. The
message says how to check instead: the live state (`gplay releases list`, or
the Play Console) for an implicit Edit, `gplay edits status --live` for
`gplay edits commit` (the pin stays; an Edit that is gone was most likely
committed). A failure that proves the commit never left (a DNS or dial error, a
refused token exchange) keeps its ordinary code. `--retry` never replays
`edits.commit` in any case.

### Reads and the pinned Edit

Read commands show the **live, committed** state. An Edit-scoped read
(`releases list`, `tracks list`, `tracks view`, `tracks availability view`,
`testers list`, `metadata list`, `metadata pull`, `metadata images list`,
`metadata images pull`, `releases expansion-files view`, `apps view`,
`apps details view`, `apps audit`) opens its own read-only Edit and discards
it, even while an explicit Edit is pinned: after `edits begin` and
`releases upload`, `releases list` still shows what is published, not what is
staged. Reading the staged state is an opt-in: a read joins the pinned Edit
only when passed `--edit`. No read takes `--edit` yet; it earns its place
command by command through an issue. The reason is predictability: a read
whose source flips on the presence of a file in `.gplay/` answers the same
command differently in two directories, and the frozen reads keep the
semantics they shipped with (ADR-0042).

`releases artifacts list` (`[experimental]`, #543) predates this rule and reads
inside the pinned Edit without `--edit`, so that artifacts uploaded there are
visible. It is the one known exception.

### Concurrent reads (#602)

A sweep that costs one round trip per slot or per app runs its reads **four at
a time** (`internal/fanout`): `metadata images list` and `metadata images pull`
(each `images.list` slot, then each image download) and `apps audit` (one
read-only Edit per app). The limit is fixed, with no flag. Output order is the
serial order (results land by index, never by completion), and a failure
reports the error the serial walk would have hit first.

- **Writes inside one Edit stay sequential** (`images apply`, `metadata apply`):
  Google does not document concurrent writes to an Edit.
- **`images pull` stays all-or-nothing**: every byte is staged in memory and
  the tree on disk is written only after the last download succeeded, so a
  failed or interrupted pull leaves nothing a later `apply --prune` would read
  as deletions.
- **Rate limits**: `--retry` stays opt-in. Without it, a `429` anywhere in the
  burst fails the run (exit `60`) exactly as a serial `429` would; with it,
  each request is replayed on its own ([Opt-in retry](#opt-in-retry---retry)).

---

## 5. Reviews

- API hard limit: **only the last 7 days** are exposed. Surfaced in `--help`
  and as a stderr `warning:` line on **every** successful run, including an empty
  result (a quiet empty result must not read as "this app has no reviews").
- Auto-pagination is on by default; `--limit N` caps the result count, default
  is no cap.
- `--stars` (e.g. `1`, `1-2`, `1,3,5`) is a **client-side** filter — the API
  has no server-side rating filter.
- Long-history retrieval (CSV reports in the GCS bucket) ships as `reviews
  history --month` (ADR-0037).

---

## 6. Apps registry (workaround for missing `apps.list` endpoint)

The Google Play Developer API has no `apps.list`, so `gplay apps list` reads a
**local registry**:

- Populated by `gplay init --package X` (auto-adds) or `gplay apps add X`
- Stored in `~/.gplay/config.json` alongside Accounts
- `gplay apps view --package X` still hits the live API (via `edits.details`
  etc.) — only enumeration is local

Discovery of the apps a service account can reach ships as `apps accessible
list` (`apps.search` on the Reporting API, ADR-0039); it complements the local
registry rather than replacing it. The heavier Cloud Resource Manager + IAM
route stays out of scope.

### App icon on `apps view` (`[experimental]`, ADR-0038)

`gplay apps view` also reports the app's store **icon** for its default
language, read live via `edits.images.list` inside the same read-only Edit:

- **Durable handle is `sha256`.** The icon's content `sha256` is the stable,
  content-addressed identifier — the only value safe to persist, diff, or key a
  cache on. The `table`/`markdown` views show a `sha256` line only when the icon
  slot is non-empty; the `--output json` envelope adds an optional `icon` key
  `{"url":..,"sha256":..}`, omitted entirely when the slot is empty
  (missing == empty, [ADR-0013](./adr/0013-image-slot-reconciliation.md)).
- **`url` is a preview link — never persist it.** The `Image.url` has
  undocumented resolution, auth, and expiry; it is passed through verbatim but
  must not be stored. To fetch the actual icon **bytes**, use
  `gplay metadata images pull`.
- **Scope reality.** Reading the icon requires the full `androidpublisher` OAuth
  scope gplay already uses — Google exposes **no** narrower listings/images
  `.readonly` scope — so it confers no new permission beyond "the service
  account is invited on the app".
- **Not gated by `GPLAY_READONLY`.** The read mutates nothing (the Edit is
  always discarded), so it is exempt from the read-only policy
  ([ADR-0024](./adr/0024-readonly-environment-policy.md)) and keeps working under
  a read-only deployment.
- **No cache.** gplay never stores the icon between runs — each `apps view` is a
  faithful live read. Caching (keyed on `sha256`) is the consumer's
  responsibility.

`gplay metadata images list --type <AppImageType>` (`[experimental]`) narrows
the same live per-slot summary to a single image slot (CONTEXT.md "Image slot")
across locales; an unknown `--type` is refused client-side (exit 20) before any
API call, and `--output json` keeps its exact per-slot shape — `--type` only
narrows which slots appear.

---

## 7. Output

### Formats

A command's output Format is one of `table`, `json`, or `markdown`. The
dispatcher in `internal/output` layers three sources, the most explicit
winning:

1. `--output table|json|markdown`: always wins.
2. `$GPLAY_DEFAULT_OUTPUT`: a personal default, same value set as the
   flag. An unset or empty value means "not set"; any other unknown
   value is CLI misuse and fails with exit `2` naming the env var and
   the three valid formats.
3. `auto`, the TTY-aware detection:
   - `CI=true` (non-empty) → `json`
   - stdout is not a TTY → `json`
   - otherwise → `table`

`GPLAY_DEFAULT_OUTPUT` outranks the auto-detection, `CI` included: it is
a value the user typed, while CI and TTY state are guesses about intent,
so `GPLAY_DEFAULT_OUTPUT=table` still means `table` inside CI.

`--output table` in a piped context (e.g. behind `tee`) is the escape
hatch when the auto-detect is wrong. See
[ADR-0005](./adr/0005-tty-aware-output.md).

### Commands without `--output`

`auth login` and `auth logout` emit free-form human text and do not
expose `--output`. There is no structured payload that would survive
three Renderers, and forcing one would invent a schema with no consumer.
Any future command in the same shape (side-effecting, no structured
result) follows the same rule.

`releases generated download` follows it for the opposite reason: its
payload is **raw binary bytes**, not a Renderable. It writes the bytes to
`--dest PATH` (or `--dest -` for stdout) and names the byte count +
destination on a `✓` stderr line (§8), sidestepping a confusing
`--output`/`--output-file` collision
([ADR-0034](./adr/0034-generated-apks-binary-download-to-file.md)).

### `--output markdown`

Markdown is a first-rank Format, not "table-in-markdown syntax". Each
command renders the shape that fits its data:

- Tabular data → a Markdown table (helper: `output.MarkdownTable`).
- Status / info → a list of `- **Field**: value` lines.
- Checklists (`auth doctor`) → GitHub-style task list
  (`- [x] Check 1` / `- [ ] Check 2 — hint: ...`).

### `--output json` is API pass-through (ADR-0003)

The JSON output mirrors the Google Play Developer API's native response
shape, including its per-endpoint envelope (`{"reviews":[...]}`,
`{"tracks":[...]}`, etc.). Exception: `apps list` (no API source) uses
`{"apps":[...]}`.

**Scope.** The pass-through guarantee applies to commands that *wrap a
Developer API call* — their JSON is the API's response, unowned by gplay.
**Offline reference commands wrap no API call and synthesise their own JSON**
(`team permissions`, `schema`): the shape is gplay's, documented per command,
and free to evolve (a `schema` is additionally `[experimental]`). Pass-through
is a promise about *not reshaping the API*, not a promise that every `--output
json` is an API echo.

### `--output table`

Columns are chosen for readability — not pass-through. Each command's
default columns are documented in its `--help`. `--columns col1,col2,...`
lets the caller override.

### Control-sequence sanitization (human formats only)

API-returned strings are often user-generated (review text, store-listing
copy). The **table and markdown** renderers strip ANSI escape sequences (CSI,
OSC) and C0/C1 control characters from every cell, centrally at the render
boundary — so a hostile value cannot inject color/cursor/title sequences into a
terminal or CI log. The sanitization is rune-based: legitimate multi-byte
content (accents, CJK, emoji) is untouched. **`--output json` is never
sanitized** — machine consumers get the bytes verbatim (ADR-0003); fidelity
lives on the JSON path, safety on the human path.

### Errors (`--output json` error envelope, ADR-0023)

Errors are **never** pass-through. The human-readable line always goes to
**stderr** (DESIGN §8). Under `--output json` a failing command *additionally*
writes a single structured envelope to **stdout**, so an agent/CI consumer can
branch on the failure without scraping stderr:

```json
{
  "error": {
    "code": "EDIT_ALREADY_EXISTS",
    "exitCode": 60,
    "retryable": false,
    "operation": "edits.insert",
    "resource": { "kind": "package", "id": "com.example.app" },
    "package": "com.example.app",
    "message": "edits.insert on com.example.app: edit already exists (HTTP 409) [reason: editAlreadyExists]",
    "reasons": ["editAlreadyExists"]
  }
}
```

- `code` / `exitCode` / `retryable` / `message` are always present.
- `code` is the stable SCREAMING_SNAKE diagnostic code (§9.1) — the field to
  branch on, because it discriminates failures that share an exit code.
- `exitCode` mirrors the process exit code (§9).
- `retryable` says whether replaying the same command unchanged can plausibly
  succeed, so retry logic needs no per-cause table. Emitted even when `false`.
- `operation` names the API call that failed; omitted on a local failure,
  which is itself the signal that no call was made.
- `resource` names what the failed call addressed, on whichever addressing
  axis it uses: `kind` is one of `package`, `app` (a numeric Play Console app
  ID, accepted by `signing`), `developerAccount` (`team`, `customapps`),
  `gamesApplication`, `achievement`, `leaderboard` (`games`) or `bucket`
  (`reviews history`), and `id` the identifier. Omitted when no target is known
  (a local failure, an account-wide search). The kind vocabulary is
  append-only.
- `package` is present only when `resource.kind` is `package`, with the same
  value: it always holds a real Android package name. Up to 1.x it also carried
  the developer account, games application or bucket id of a non-package call;
  2.0.0 moved those to `resource` (#599,
  [ADR-0048](./adr/0048-v2-release-train.md)).
- `reasons` carries the upstream `error.errors[].reason` values verbatim when an
  API envelope was parsed; omitted otherwise.
- `requires` names the missing safety flag on an exit-3 refusal (extends the
  ADR-0017 dry-run `requires` to failure time); omitted otherwise.

The envelope also covers the CLI misuse cobra rejects before a command runs: an
unknown or repeated flag, a wrong number of positional arguments, an unknown
subcommand. There `main` resolves the format itself (the raw `--output` in the
argument list, because flag parsing may have stopped before reaching it, then
`GPLAY_DEFAULT_OUTPUT`, `CI` and the TTY check) and writes the same envelope when
nothing reached stdout yet. The one failure left without an envelope is an
invalid `--output` or `GPLAY_DEFAULT_OUTPUT` value: no format is known there.

Under `table` / `markdown` a failure leaves stdout empty (error → stderr only).
Exit codes and stderr are unchanged by the envelope. The envelope shape is part
of the public contract (ADR-0010); see
[ADR-0023](./adr/0023-json-error-envelope.md) and
[ADR-0044](./adr/0044-structured-diagnostic-codes.md).

---

## 8. Verbosity and logging

- **stdout** carries data only (the requested output).
- **stderr** carries logs, progress, warnings, errors. Always.
- `-v` / `--verbose` → info level on stderr (flow steps, edit ID, deduced
  versionCode, ...). It is the only verbosity flag: there is no debug level,
  no quiet mode, no progress bar and no colour, so there is nothing for a
  `-vv`, `--quiet` or `--no-color` to switch (#593). Adding one is a feature
  of its own, tracked as an issue first.
- Every stderr line a command writes goes through the kernel funnel, which
  owns the prefix of each level: `rc.Confirmf` (`✓ `), `rc.Warnf`
  (`warning: `, the only warning prefix), `rc.Notef` (`NOTE: `, e.g. a cursor
  listing's next `--page-token`), `rc.Logf` (a plain progress or result line)
  and `rc.Failf` (a per-item failure in a batch, the level a future `--quiet`
  would keep). Commands without a RunContext use `kernel.LoggerFor(cmd)`. A
  test fails on any direct stderr write under `commands/`, so a future
  `--quiet` stays one switch.

### Success confirmation (`✓`)

A command that **successfully mutates Google Play state** emits a single `✓`
line on **stderr** once the change is committed — the success counterpart to the
`warning:` and progress lines above. It is emitted **in addition to** the command's
stdout payload, so a human-legible success marker survives `--output json` and
piping (where stdout is machine data and the table view is absent).

- The line leans only on canonical terms (`track`, `status`, `versionCode`,
  `userFraction`); it never names the Play "release" object — the glossary's
  **Release** is the CLI's own distribution (CONTEXT.md), a different thing.
- `userFraction` is rendered as a percentage and only when `status` is
  `inProgress` (a partial rollout — the one case where the fraction informs).
- `--dry-run` never emits it: `✓` means *committed*. A dry-run already prints
  its plan to stdout.
- It is written through a single helper (`rc.Confirmf`), so a quiet mode, if
  one is ever added, can suppress every `✓` in one place.
- Wording is **not** part of the Public contract (§7) — it is free to evolve.

`releases upload/promote/rollout/halt/resume/complete` are the first commands to
carry it; the remaining payload-bearing mutations (`metadata apply`,
`metadata images apply`, `apps details set`, `compliance datasafety set`,
`tracks create`, `testers set`) follow the same rule. Commands that mutate only
**local** state and have no stdout payload (`auth login/logout`, `apps
add/remove/init`, `team`) already emit a `✓` as their sole output; this rule
brings the payload-bearing mutations in line.

### Request timeouts (`--timeout`)

Every API call carries a deadline so a hung connection fails the step in
seconds instead of stalling a CI job until the runner-level kill:

- **Control-plane calls** (Edits, tracks, reviews, metadata, team, …) get a
  **60s default** deadline, applied once where the kernel builds the
  authenticated HTTP client — every command inherits it, no per-command
  plumbing.
- **Media transfers** (the artifact bytes of `releases upload`,
  `releases sharing upload`, `releases expansion-files upload`,
  `releases mappings`, `metadata images apply`, the
  `appstore apk|image|policy upload` and `customapps create` surfaces, and
  the APK bytes of
  `releases generated download`) are **exempt from the default**: a
  multi-hundred-MB transfer is never killed by the short control-plane bound.
  The exemption is decided per request, so the same commands' Edit calls
  (`edits.insert`, `tracks.update`, `edits.commit`), the resumable initiate and
  offset probe, and the token exchange keep the 60s bound.
- Each **resumable chunk** (8 MiB) carries its own generous 5-minute bound: a
  connection that stops moving bytes without a reset is cut, then the upload
  probes the committed offset and resumes. After a failure the resume waits on
  the same backoff curve as `--retry` (500ms doubling to 30s, with jitter),
  restarting when the server's offset advances; eight attempts in a row
  without progress end the upload.
- The global **`--timeout <duration>`** flag (e.g. `--timeout 30s`,
  `--timeout 2m`) overrides both — it bounds *every* request, uploads included.
  Unset (`0`) means "60s for control-plane, unbounded for uploads".

A deadline-exceeded failure is a transport-level error and maps to **exit 50**
(network), the retry-safe bucket — so the same CI wrapper that retries a DNS
blip retries a timeout.

### Opt-in retry (`--retry`)

The global **`--retry N`** flag (default `0` = no retry) layers a transport
middleware on the authed client that retries the transient classes (transport
errors, HTTP 5xx, and 429 honoring `Retry-After`, capped at the 30s maximum
backoff) with exponential backoff plus jitter. Non-transient 4xx (auth,
validation) and `edits.commit` (a duplicate could double-publish) are never
retried. A write is replayed only when its API method is declared idempotent in
`internal/apiregistry` (GET, PUT, PATCH, DELETE, plus an allowlist of
read-shaped POSTs such as the vitals queries and `edits.validate`); any other
POST (an image upload, a create, a refund) is retried only when the failure
proves it never reached the server (a dial or DNS error, or a 429), so a 5xx
after the write may have landed is returned instead of duplicated. It is safe to
leave on.
When `--retry` is set, `--timeout` becomes a **per-attempt** bound rather than a
single per-request one; request bodies are recreated per attempt (uploads
re-send from a fresh reader). Details and CI examples: [`CI_CD.md`](CI_CD.md#4-exit-codes--retry-vs-fail).

### Read-only policy (`GPLAY_READONLY`, ADR-0024)

`--confirm` / `--grant-admin` are advisory for an AI agent that holds the
credential — it can pass them itself. `GPLAY_READONLY` is the
environment-enforced authority boundary a harness can impose independent of the
model's flag choices:

- When `GPLAY_READONLY` is **truthy** (`1`/`true`/`yes`/`on`), every command
  that mutates Google Play state is **refused with exit 4** — *before*
  credential resolution and any network I/O, regardless of the flags passed.
  Exit 4 is distinct from exit 3 on purpose: it is **not** resolvable by adding
  a flag (the message says so); the caller must change the environment.
- **Read commands and `--dry-run` of mutating commands still run**, so
  dashboards and agents can observe and plan with a production credential.
- Which commands mutate is a single registration-time annotation
  (`kernel.MarkMutating`), not an ad-hoc per-command check — see CONTRIBUTING.
- Scope: the policy blocks **Google Play mutations**. Local-only operations
  (credential `auth login`/`logout`, the local app registry) are not Play
  writes and are not gated. Fine-grained allowlists (`GPLAY_ALLOW`) are a
  future follow-up, out of scope here.

Under `--output json` the refusal is emitted as the standard error envelope
(exit code 4) on stdout (§7 / ADR-0023).

### Path containment (`GPLAY_ALLOW_EXTERNAL_SYMLINKS`)

gplay walks directory trees a repo owns (a metadata or images tree, a
release-notes directory, `.gplay/`), and a name in one of them can lie: a
`fr-FR.txt` symlinked at `~/.ssh/id_rsa` would be published to a public store
listing under the operator's own credentials. Every path built from one of
those trees is therefore **contained**: it is resolved through its symlinks and
must land at or under the resolved root, or the command is **refused with exit
2** naming the offending path. Both sides are resolved, so a checkout reached
through a symlink keeps working.

Two classes of path, and only one of them can be opened up:

- **Paths the operator arranged** are the names gplay found by reading their
  own directory (a locale directory, a `title.txt`, a note file).
- **Paths derived from API data** are the ones built from a string the Play API
  returned or from a package name (`<dir>/<locale>/`,
  `.gplay/edit-<package>.json`). These are contained **unconditionally**: no
  environment variable relaxes them, because that is the input the operator
  never got to audit.

`GPLAY_ALLOW_EXTERNAL_SYMLINKS` is the opt-in escape hatch for the first class,
for the monorepo layouts that legitimately share files between trees
(`metadata/en-US/images` symlinked at `shared/assets/en-US`, a `title.txt`
pointing at a shared translation):

- **Unset (the default), containment is closed.** The refusal message names the
  variable, so the operator of such a layout reads what to set rather than only
  what broke.
- **Truthy** (`1`/`true`/`yes`/`on`, the spelling `GPLAY_READONLY` uses), a path
  that leaves the tree **through a symlink** is followed, and a `NOTE:` line on
  **stderr** names the path and what it resolved to (stdout stays untouched,
  ADR-0003). One line per escaping path, not per read.
- It follows a link **out** of the tree; it does not allow a `..` component
  climbing **above** the root, and it does not touch the API-derived class. It
  is a door, not an off switch.
- Reads are what it covers in practice: `metadata`/`images` **pull** writes into
  `<dir>/<locale>/` built from the API's locale, so a pull into a symlinked
  locale directory stays refused whatever the environment says.

---

## 9. Exit codes

| Code | Meaning | Retry-safe? |
|---|---|---|
| `0` | Success | — |
| `1` | Generic error (fallback when nothing more specific fits) | No |
| `2` | CLI misuse (unknown flag, bad value, repeated single-value flag, wrong number of positional args) | No |
| `3` | Safety flag required — command is well-formed but a named acknowledgment flag (`--confirm` / `--grant-admin`) is missing; the message names it | Deterministic (re-run with the named flag) |
| `4` | Denied by environment policy (`GPLAY_READONLY`) — a mutating command was refused; the message names the env var | No — **not** resolvable by adding a flag; change the environment |
| `10` | Authentication failure (SA invalid, token refused by the token endpoint or by the API as a `401`, scope missing) | No |
| `11` | Authorization (`403` — SA not invited on the app, etc.) | No |
| `20` | Client-side validation (malformed AAB, unknown locale, ...) | No |
| `30` | API 4xx other than auth/perms (not found, conflict, gone, ...) | No |
| `40` | API 5xx (upstream temporarily unhealthy) | **Yes**, except an Edit commit (`COMMIT_OUTCOME_UNKNOWN`, §4) |
| `50` | Network (timeout, DNS, refused) | **Yes**, except an Edit commit (`COMMIT_OUTCOME_UNKNOWN`, §4) |
| `60` | State conflict (another Edit open and unrecoverable, rate-limited, ambiguous release target, ...) | Sometimes |
| `70` | Findings present: a read-only check command (`apps audit`) ran to completion and reported drift; the report on stdout is complete | No (not a failure; fix what the report names) |

Documented in `gplay help exit-codes` and `docs/CI_CD.md`.

**An interrupted command exits 50.** A command stopped by `SIGINT` or `SIGTERM`
before it succeeded exits `50` whatever step it was on, after discarding its
implicit Edit (§4), so re-running it is safe. A command that completed before
the signal was handled keeps its `0`.

**Exit 3 has no exceptions.** *Every* refusal for a missing safety-acknowledgment
flag exits `3` — never `2` — whatever the command and however destructive the
write. This is the one distinction an automated caller most needs: `2` means "you
typed it wrong", `3` means "you were asked to acknowledge, re-run with the flag".
Commands built the refusal by hand for a while and drifted to `2`; that is what
[#406](https://github.com/PollyGlot/google-play-cli/issues/406) and
[#408](https://github.com/PollyGlot/google-play-cli/issues/408) corrected. Build
the refusal with `exit.SafetyFlag("<flag>", …)` and never with a bespoke error
type — the helper also feeds `requires: ["<flag>"]` into the `--output json`
error envelope (§7 / ADR-0023), which is how an agent recovers without scraping
the message.

**Exit 2 owns the argument count.** A wrong number of positional arguments —
missing *or* surplus — is CLI misuse (`2`), the same bucket as an unknown flag
or an unknown subcommand. All four doors are closed centrally, never per
command: flag-parse failures by the root's `FlagErrorFunc`, unknown subcommands
by `kernel.GroupRunE`, positional-argument rejections by
`kernel.WrapArgErrors`, and repeated single-value flags by
`kernel.RejectRepeatedFlags` — each a single walk over the assembled tree, the
last statements of `newRootCmd`. Commands returned the generic `1` here for a while because cobra
hands an `Args` validator's error back untyped; that is what
[#426](https://github.com/PollyGlot/google-play-cli/issues/426) corrected. Like
the exit-3 harmonisation above, the fix *restores* this documented table rather
than changing the frozen contract (ADR-0010), which is why it shipped as a
`fix`. Never hand-roll an argument-count check in a command — declare the cobra
validator (`Args: cobra.ExactArgs(1)`, …) and let the kernel own the exit code.

Every runnable leaf declares a validator, `cobra.NoArgs` when it takes no
positional argument: cobra's default accepts any stray token and exits `0`
(`gplay version STRAY` did, as did `init`, `exit-codes` and the `auth` reads,
until #593), and a test in `cmd/gplay` walks the tree to keep it that way. The
rejection names the fix instead of a count: the missing placeholder taken from
the command's `Use` (`missing <artifact>`) or the stray token (`unexpected
argument "extra": gplay tracks list takes no positional arguments`), then the
usage line.

**A required flag says so.** A flag a command cannot run without ends its help
text with `(required)`, and its missing-value error names the flag and how to
pass it, in one shape: `missing --track: pass --track <name> (internal, alpha,
beta, production, or any closed-track name)`. The check stays in the command
rather than cobra's `MarkFlagRequired`, whose error would skip the command's
own wording.

**Exit 70 is not an error.** A check command that sweeps and reports (today only
`gplay apps audit`, PRD #449) exits `70` when its report carries at least one
finding: every call succeeded, the document on stdout is complete, and the
non-zero status exists purely so CI can gate on "clean" without parsing JSON. It
is a distinct code precisely so an automated caller can tell *found drift* (`70`)
from *could not look* (`10`/`11`/`30`/`40`/`50`); collapsing it into `1` would
make a healthy audit and a broken one indistinguishable. Build it with
`exit.Findingsf(…)`, and never for a call that actually failed: a per-app API
failure during a sweep is reported inside the document and drives the ordinary
API code.

**A repeated single-value flag is misuse, not last-wins.** pflag's default is to
keep the last value silently, so `--to alpha --to production` promotes to
production with nothing said about the dropped value: for an argv assembled from
a prompt that is a silent mis-ship. `kernel.RejectRepeatedFlags` turns the second
occurrence into a parse error (exit `2`) naming the flag and both values, before
auth and before any HTTP. Genuinely repeatable flags are recognised by their
value type, never by a flag-name list: `pflag.SliceValue` (what `StringSliceVar`
/ `StringArrayVar` produce) plus pflag's map values, which accumulate the same
way but publish no interface to say so and are matched on `Type()`. Declare a
repeatable flag as a slice or map flag and it keeps working for free
([#446](https://github.com/PollyGlot/google-play-cli/issues/446)).

**A listing cut by `--limit` always says so.** gplay has two listing shapes, and
the guarantee is not the same in both.

*Auto-paginated listings* (`reviews list`, `vitals errors issues`, `vitals
errors reports`, `vitals anomalies`, `team users list`, the
`iap`/`subscriptions` catalogs) follow `nextPageToken` to exhaustion, so the
only way their output is a prefix of the truth is an explicit `--limit`. When
that happens the command emits a `warning:` line on stderr naming the flag to
raise (`rc.WarnTruncated`); stdout stays a verbatim API mirror either way
(ADR-0003), so a JSON consumer sees byte-identical data with and without the
warning.

*Cursor listings* expose the API's own paging (`--page-token`, plus
`--page-size`) and return exactly ONE page, with
`nextPageToken` passed through verbatim in the body: a machine caller loses
nothing, but the table and markdown views carry no token column, so a human
reading them cannot tell a full listing from a first page. Every cursor
listing (`apps accessible list`, `appstore catalog events list`, `device-tiers
list`, `games achievements list`, `games leaderboards list`) closes that gap with
a `NOTE:` on stderr carrying the next `--page-token`, and says nothing on the
last page. That note is deliberately NOT `rc.WarnTruncated`: the remediation
is a cursor to pass back, not a cap to raise, and the standard truncation line
cannot carry the token.

A loop that walks every page itself goes through `api.Paginate`
(`internal/play/api`), which refuses a token the server already sent and, when
given a page bound, fails once the bound is reached with a token still set
rather than returning a partial list as if it were whole. `apps audit` bounds
its `apps.search` discovery that way (1000 pages).

Prefer the auto-paginated shape for a new listing. Reach for a cursor listing
when the collection is unbounded or the API charges per page, and then emit the
`NOTE:` so the human views say where the rest is.

### 9.1 Diagnostic codes

An exit code says which *bucket* a failure fell into; a **diagnostic code** says
which failure it was. Under `--output json` every failure carries one in the
error envelope's `code` field, so an agent branches on `EDIT_ALREADY_EXISTS`
rather than regexing the message for the word "already".

| Code | Exit | Retryable | Meaning |
|---|---|---|---|
| `GENERIC_ERROR` | 1 | No | Unclassified failure (no typed exit code) |
| `USAGE_ERROR` | 2 | No | CLI misuse |
| `SAFETY_FLAG_REQUIRED` | 3 | No | Re-run with the flag named in `requires` |
| `POLICY_READONLY` | 4 | No | Refused by the read-only environment policy |
| `AUTH_FAILED` | 10 | No | Authentication failed |
| `PERMISSION_DENIED` | 11 | No | Authorization failed (403) |
| `VALIDATION_FAILED` | 20 | No | Client-side validation rejected the input |
| `INVALID_ARGUMENT` | 30 | No | The API rejected the request as malformed (400) |
| `NOT_FOUND` | 30 | No | No such package, track, Edit or resource (404) |
| `BASE_PLAN_NOT_DRAFT` | 30 | No | `subscriptions apply`: the API only deletes a DRAFT base plan; deactivate it first, apply, then remove it |
| `API_ERROR` | 30 | No | Other API 4xx rejection |
| `UPSTREAM_UNAVAILABLE` | 40 | **Yes** | The API is temporarily unhealthy (5xx) |
| `NETWORK_ERROR` | 50 | **Yes** | Transport failure with no HTTP response |
| `COMMIT_OUTCOME_UNKNOWN` | 50 | No | An Edit commit failed after it was sent (timeout, reset or 5xx, so exit `50` or `40`) and may be live; check before re-running (§4) |
| `STATE_CONFLICT` | 60 | No | Remote state conflicts with the request (409) |
| `EDIT_ALREADY_EXISTS` | 60 | No | An Edit is already open on this package |
| `EDIT_EXPIRED` | 60 | No | The pinned Edit expired; clear its pin with `gplay edits discard`, then begin a new Edit |
| `RATE_LIMIT_EXCEEDED` | 60 | **Yes** | Rate or quota limit exceeded; back off |
| `FINDINGS_PRESENT` | 70 | No | A check command completed and reported findings; not a failure |

Classification is **total**: it is derived from the exit code an error already
carries, refined for an upstream failure by the HTTP status and Google's own
`error.errors[].reason`. A new typed error therefore inherits a code the day it
is written, with no dispatcher to remember to extend. An error that knows a
narrower code declares it by implementing `exit.Diagnoser`.

The Exit column is the *canonical* bucket, not a promise of equality: a handful
of wrapped errors keep a narrower exit code (a `400`+`editAlreadyExists` exits
`30`), and the envelope's own `exitCode` is always authoritative for a given
failure.

Codes are **append-only frozen contract** (ADR-0010/0042): a new failure mode
earns a new code, an existing one is never renamed or repurposed. The catalog is
introspectable without reading source, from `gplay help exit-codes` (human) and
`gplay schema --codes --output json` (machine). See
[ADR-0044](./adr/0044-structured-diagnostic-codes.md).

---

## 10. Tracks and testers

`gplay tracks create` and `gplay testers list/set` manage custom closed
testing tracks and their authorized audience. Both surfaces are shaped by
hard constraints of the Play Developer API — see `CONTEXT.md` (**Closed
track**, **Tester**) for the domain terms.

### Creating tracks

- `gplay tracks create <name>` creates a **closed testing** track. The
  create endpoint (`edits.tracks.create`) supports exactly one type
  (`CLOSED_TESTING`), so there is **no `--type` flag** — every created
  track is closed. Open/internal track creation has no API path.
- The new track's form factor is `DEFAULT` (phone). `WEAR` / `AUTOMOTIVE`
  closed tracks are deferred (issue #528) behind a future
  `--form-factor` flag — additive, non-breaking when it lands.
- Creating a track that **already exists** surfaces the API error (exit
  30); gplay does not fake idempotency. "Ensure exists" would be an
  explicit future `--if-not-exists`.
- There is **no `tracks delete`** — the API exposes
  create/get/list/patch/update but no delete. Removing a track is a Play
  Console-only gesture.

### Managing testers

- The `edits.testers` resource exposes a **single** field, `googleGroups[]`,
  and explicitly does not support individual tester emails ("email lists
  are not supported by this resource"). So `gplay testers` manages
  **Google Groups only** (`--group a@googlegroups.com,...`); there is **no
  `--email`**. Adding individuals one-by-one stays Console-only.
- Testers are **declarative**: `testers set` replaces the whole group list
  (maps 1:1 to `testers.update`), `testers list` reads it (`testers.get`).
  No `add` / `remove` — the typical case is a single group, and a
  declarative replace is idempotent (agent/CI-friendly).
- A bare `testers set` with neither `--group` nor `--clear` is a misuse
  (exit 2), so a forgotten `--group` can never silently wipe the list.
  Emptying the list on purpose is the explicit `--clear`.
- Testers are keyed per-track (`…/testers/{track}`), so `--track` is
  **required**. gplay does not restrict which track names are valid —
  `testers set --track production` is sent to the API, which rejects it;
  gplay surfaces that error rather than re-implementing the rule.

### Write safety

Both commands are writes: implicit Edit (open → mutate → commit), with
`--dry-run` (validate + preview the payload, no HTTP) and
`--keep-edit-on-failure` (skip auto-discard for debugging), matching
`releases upload` / `promote`. **No `--confirm`**: a closed test track is
low-stakes and reversible, unlike a production rollout.

### Discovering the create step

`releases upload` / `promote` keep their passthrough `--track` (§3.2). An
upload/promote to a closed track that **does not exist yet** fails at the
API; gplay attaches a hint pointing at `gplay tracks create <name>` (same
pattern as the other track-not-found hints). gplay **never auto-creates** a
track as a side effect of an upload — a typo'd `--track` must fail, not
silently spawn a phantom track.

---

## 11. Stability labels and the Public contract

Since `1.0`, every command sits on one side of a line
([ADR-0010](./adr/0010-versioning-public-contract-and-ga.md) /
[ADR-0042](./adr/0042-one-zero-ga-and-stability-label-mechanism.md)):

- **Unlabelled = frozen.** Part of the Public contract — its name, flags,
  semantics and exit codes cannot change without a **major** bump. Also frozen:
  the config schema and its precedence, Account resolution precedence, and the
  guarantee that `--output json` stays API pass-through — for the frozen,
  API-backed commands (§7 / ADR-0003). An experimental command is outside the
  contract in full, output shape included: `schema` in particular projects the
  embedded index rather than echoing an API response, so there is no upstream
  resource to pass through.
- **`[experimental]` = shipped, not frozen.** Free to change in any release.

**Not** covered either way: the `table` / `markdown` layouts, the *fields*
inside the pass-through JSON (Google owns those), and stderr wording.

### Declaring it

One registration-time annotation, exactly like `kernel.MarkMutating` (§8):

```go
root.AddCommand(kernel.Experimental(subscriptionsGroup))
```

`kernel.Experimental` labels the command **and the subtree already registered
under it**, so a whole young namespace takes one call; `kernel.IsExperimental`
walks up the parent chain, so a subcommand can never claim more stability than
its namespace. Call it after the children are added, so each carries the visible
tag rather than inheriting it silently.

### The default is a promise

Absence of a label is not "unknown", it is "frozen" — forgetting to label
over-promises. `TestStabilityRegistry_pinsPublicContract` in `cmd/gplay` walks
every runnable leaf and fails on any that is not explicitly classified, so a new
command cannot join the contract by omission.

### Where the label shows up

The `Short` carries a `[experimental]` marker (visible in the parent's command
list) and the `Long` opens with the consequence — may change in any release, pin
an exact release in CI. Both are applied by the helper; commands do not
hand-write them.

### Sub-feature labels

A few experimental *parts* of frozen commands (APK upload on `releases upload`,
the icon fields on `apps view`, `--type` on `metadata images list`) are marked
in prose in the help text. A command-level annotation cannot express those, and
three cases do not justify a flag-level mechanism.

### Graduation

Dropping the label is additive — a normal minor release. The reverse never
happens: a frozen command whose surface must change is a major bump.