DESIGN.md@docs · git:20260830.c952b68 · 2026-08-30 · sha256 e04b4744c144fa9f

DESIGN.md@docs git:20260830.c952b68A

Immutable. This exact content is served forever at /api/v1/blob/e04b4744c144fa9f.

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

**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), `init`.

---

## 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 a message pointing at `gplay auth login`
and the env var docs.

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

### `.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 --to <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.

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

---

## 5. Reviews

- API hard limit: **only the last 7 days** are exposed. Surfaced in `--help`
  and as a stderr `WARN:` 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) is in `BACKLOG.md`.

---

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

Backlog: real discovery via Cloud Resource Manager + IAM (see `BACKLOG.md`).

### 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",
    "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` / `package` name the API call that failed; omitted on a local
  failure, which is itself the signal that no call was made.
- `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.

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, ...).
- `-vv` → debug level (HTTP method + URL, headers, truncated bodies).
- `-q` / `--quiet` → only errors on stderr.
- Progress bars (e.g. AAB upload) are active **only in TTY** and disabled by
  `--quiet`.
- Color is auto in TTY, disabled in pipes, disabled if `NO_COLOR` env or
  `--no-color` is set.

### 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
`WARN:` 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 `--quiet` can suppress
  every `✓` in one place once that flag lands.
- 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 uploads** (`releases upload`, `releases sharing upload`,
  `releases expansion-files upload`, `metadata images apply`) are **exempt from
  the default**: a multi-hundred-MB transfer is never killed by the short
  control-plane bound.
- 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`) — with exponential backoff
plus jitter. Non-transient 4xx (auth, validation) and `edits.commit` (a
duplicate could double-publish) are never retried, so 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, 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** |
| `50` | Network (timeout, DNS, refused) | **Yes** |
| `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`.

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

**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` or `--max-results`) 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. `apps accessible
list` and `appstore catalog events list` close that gap with a `NOTE:` on stderr
carrying the next `--page-token`; `device-tiers list`, `games achievements list`
and `games leaderboards list` do not, and their human views stay silent about
the next 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.

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) |
| `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 |
| `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; 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 (`BACKLOG.md`) 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.