# Unified Claude + Codex selector

`multiacc-select` is the canonical policy engine for app-robot's
`DEFAULT_ENGINE=both` mode. It ranks a database-locked snapshot of both subscription
pools and returns one concrete provider/account identity. It is deliberately pure: the
caller owns telemetry collection, row locks, durable reservation, retries and launch.

## Invocation

```sh
multiacc-select --request-json - --response-json -
```

The command reads one UTF-8 JSON object from stdin and writes one JSON object to
stdout. `multiacc-select --version` reports the selector contract version.
`multiacc-select --help` (or `-h`) prints usage and exits successfully without
reading stdin. All three forms also work through `claude-multiacc select`.

The request schema is `claude-multiacc/pool-selection.v2` and contains:

- a canonical database timestamp and reservation key;
- the policy (`default_claude`, `default_codex`, `default_both`, `explicit`,
  `producer_retry`, or `reviewer`);
- any required provider, producer identity and explicit exclusions;
- the runner generation's candidate telemetry plus exact reservation history;
- optionally `headroom_band` and `session_gate`, the two ranking knobs below.

Both knobs are optional so a caller pinned to an older panel build keeps working: each
is a decimal clamped to 0–100, and a value that is not a plain decimal (a bool, a list,
`null`, `1e5`) is refused as `invalid_request` naming that field rather than silently
widening selection. `headroom_band` defaults to `30`, `session_gate` to `50`.

Candidates must be active, fresh, provider-capable, not currently limited, and not
covered by a live reservation. Percentages are finite decimals clamped to 0–100.
Canonical account IDs are NFC-normalized and trimmed with case preserved; an
NFC/case-folded key rejects case-only duplicate identities before ranking.

## Ranking: the session gate, then the weekly band

The eligible set is ranked in two cuts (operator's decision, 2026-09-03: "among
accounts where high session limits it must choose randomly from ones where highest
weekly limits").

1. **Session gate.** An account clears the gate when its quota is known and its session
   (5h) usage is at most `session_gate` points. If any account clears it, only those are
   ranked further — a nearly spent 5h bucket is about to reject the launch whatever the
   weekly headroom says. If nobody clears it the gate steps aside and every known
   account is ranked: the gate compares, it never empties the pool. Unknown quota never
   clears it. `session_gate: 100` disables the gate.
2. **Weekly band.** Among the gated accounts, the largest weekly remaining is the
   leader and `band_floor` is that minus `headroom_band`; every gated account at or
   above the floor is a peer. The band is measured on WEEKLY remaining, not on
   `min(weekly, session)` — session headroom already had its say in the gate.
   `headroom_band: 0` restores strict best-weekly ranking.
3. **Inside the band.** The least-recently-selected peer wins, then a spread digest over
   (reservation key, identity), then a stable Claude-before-Codex/account-id order. This
   is what makes a burst of parallel launches fan out instead of stacking on one
   account, and it is deterministic: the same request always yields the same winner.

With no usable quota anywhere the band cannot apply (`band_floor` is `null`) and the
plain ordering stands: known quota ahead of unknown, then highest effective headroom,
then least-recently-selected.

Success returns the concrete engine/account/runner generation, normalized score basis
(which still reports `effective_headroom` = min(weekly, session) for observability),
`headroom_band` / `band_floor` / `band_count`, `session_gate` / `session_ok_count` (how
many accounts cleared the gate; `0` means nobody did and the gate stepped aside), an
RFC 8785/SHA-256 candidate-snapshot digest and a selection digest. Both knobs are inputs
to the selection digest, because both can change which account wins. Stable failures are
`invalid_request`, `unsupported_schema`, `duplicate_candidate_identity`, and
`no_candidate`. A reviewer stays on the producer's provider and avoids its account when
another eligible account exists.

The selector does not read `~/.claude-accounts` or `~/.codex-accounts`. That would race
the panel's transaction and make the returned digest unprovable. The individual
`claude` and `codex` shims continue selecting directly inside their own pools for
ordinary interactive use.
