# Admin API reference

[Documentation](../public-index.md) / Reference / Admin API

Use this reference to automate dashboard operations. For an illustrated introduction,
start with the [dashboard tour](../tutorial/dashboard.md); for model requests from
your application, use the separate [application API](client-api.md).


Endpoints below describe request shapes, authorization and refusal conditions. Field lists use `?` for optional values. Errors use the envelope `{"error": {"message", "type", "param", "code"}}`.

## Authentication, once

| Surface | Credential | Mutation requirements |
|---|---|---|
| `/admin/**` | Admin HTTP Basic or a login session | Session requests need `X-XSRF-TOKEN`; Basic without a session cookie does not |
| `/v1/**` | Virtual-key bearer token | Admin credentials do not authorize application traffic |
| `/internal/**`, writable `/actuator/env` and `/actuator/refresh` | ADMIN HTTP Basic | Operator-controlled surfaces |
| `/actuator/health`, `/actuator/info`, `/actuator/prometheus` | Open in the shipped application configuration | Restrict exposure at the deployment boundary |

`POST /admin/auth/login` sets an HttpOnly `JSESSIONID` and an `XSRF-TOKEN` cookie. The CSRF gate applies when a session cookie accompanies the request; a stale cookie can therefore make a Basic-auth mutation fail with `403 forbidden_error`.

Coarse `/admin/**` access requires a control-plane role (ADMIN, TENANT_ADMIN or VIEWER). Each endpoint then enforces its own permissions. Operator-only endpoints refuse TENANT_ADMIN with 403; scoped cross-tenant reads use 404 to avoid confirming existence. See [policy authorization](../design/authorization.md) for scopes and grants.

## Two wire conventions worth reading before copying anything

**Pricing fields spell per-1k without an underscore before the digit:** `input_per1k`,
`output_per1k`, `cache_read_per1k`, `cache_write_per1k` - Jackson's SNAKE_CASE does not
split before digits, while the database columns underneath are `_per_1k`. Copy the wire
spelling, not the column. One surface disagrees: `GET /v1/models` exposes `cost_per_1k`
(explicitly annotated), so the public listing and the admin registry spell the same unit two
ways.

### Replacement semantics

PUT is usually a full replace. Registry provider and model PUTs refuse a partial body
outright (run: `{"enabled": false}` alone answers `400: openai_compatible providers need a
base_url`). The org/app/user/key limits PUTs replace all five fields - an omitted field
becomes null, and null means *uncapped*, so a partial limits PUT silently removes caps.
The exceptions are stated per entry below: guardrail settings PUT is a patch, key policy
PUT treats null as clear-to-global, and scope policy has no PUT at all (delete, then
create).

---

## Sessions

| Endpoint | Contract |
|---|---|
| `POST /admin/auth/login` | Verify credentials, start the session. Body `{username, password}`; returns the identity payload (`accountId, username, role, orgId, workspaces`) and sets the cookies. Every failure is the same `401 invalid_credentials` - wrong password and unknown user are indistinguishable on purpose. |
| `POST /admin/auth/logout` | End the session. Needs the CSRF token. `204`. |
| `GET /admin/auth/me` | The same identity payload, for whoever is authenticated - session or Basic. |

## Organizations, keys, and their limits

| Endpoint | Contract |
|---|---|
| `POST /admin/organizations` | Create an org (the tree mirrors it as a TENANT scope automatically). Body `{name}`. |
| `GET /admin/organizations` | Paginated roster: `search`, `sort` (name / created_at), `page`, `size`. |
| `GET /admin/organizations/{orgId}` | One org. ADMIN, or TENANT_ADMIN/VIEWER reading their own. |
| `PUT /admin/organizations/{orgId}/limits` | **Full five-field replace**: `{rpm_limit?, tpm_limit?, max_budget?, concurrency_limit?, budget_window?}` - null or omitted means uncapped. Refuses `budget_window` with a named 400: the window rides the budget *rule* (monthly by default), not the limits row. Operator-only. |
| `GET /admin/organizations/{orgId}/default-model` | The tenant default and its reach over the org's active keys: `keys_active`, `keys_inheriting` (no default of their own), `keys_own_default`, `keys_refusing` (their own omitted-model override is `refuse`), plus `gateway_omitted_model` and `known` (whether the default names a registered model, alias or routing group; null without a default). One indexed count. Read like the org (VIEWER of the org and up). |
| `PUT /admin/organizations/{orgId}/default-model` | `{default_model}` - the tenant's default for requests that name no model, used when the key has none of its own; null or blank clears it. At most 200 characters. Resolved like any name, inside the key's allowed set. Every key of the tenant is re-read on its next request. Audit `org.update_default_model`. Same authorization as retention. |
| `PUT /admin/organizations/{orgId}/retention` | `{retention_days}` - null returns to the gateway default. Floor 30; out-of-range is refused, never clamped. |
| `GET /admin/organizations/{orgId}/retention/effective` | What is actually kept, by whom, for how long: configured/defaulted/floor/max, spend rollups, routing decisions (with the cap state resolved server-side), operator windows - one field per knob, always. |
| `GET /admin/organizations/{orgId}/effective-limits` | The org's own values beside what actually binds it. |
| `POST /admin/organizations/{orgId}/keys` | Issue a key. Body `{label?, kind, allowed_models?, budget_usd?, rate_limit_rpm?, rate_limit_tpm?, expires_at?, default_model?, scope_id?}`. A `scope_id` must lie inside the org's subtree (outside answers `404 unknown scope`), and on the app and user doors inside that app's group (`400`, naming why). A key's own limits attach at its chain mirror and bind in both flag states; what it inherits from a deeper home binds flag-on. `kind` is required (`service` or `personal`) with no default. `scope_id` homes the key at an authored tree scope: refused for the platform root (400), and an unknown or out-of-subtree scope answers `404 unknown scope` identically, so the door confirms nothing about other tenants' trees; absent means the derived chain mirror. Response carries the raw key once, plus `scope_id` disclosing the home. |
| `GET /admin/organizations/{orgId}/keys` | Paginated: `search` (prefix/label), `sort`, `page`, `size`. |
| `DELETE /admin/keys/{keyId}` | Revoke. Terminal. |
| `PUT /admin/keys/{keyId}/kind` | Reclassify: `{owner_account_id?}` with the kind implied by ownership rules. |
| `POST /admin/keys/{keyId}/rotate` | New key with the old key's full chain and settings, including its authored scope home (a key with no authored home derives its chain mirror); body `{grace_hours?}` - both keys work through the grace window, then the old one expires. Only ACTIVE keys rotate (409 otherwise). Raw key returned once, as at issuance. |
| `PUT /admin/keys/{keyId}/limits` | Same five-field replace semantics as the org door, clamped restrict-only against the chain above. |
| `GET /admin/keys/{keyId}/effective-limits` | Resolved through user, app, org, global - the tightest wins and is named. |
| `PUT /admin/keys/{keyId}/policy` | Per-key resolution policy: `{default_model?, unknown_model?, on_denied?, omitted_model?}` (`unknown_model` is `reject`, `resolve-only` or `fallback-to-allowed`; `on_denied` is `reject` or `substitute`; `omitted_model` is `route` or `refuse`, overriding `pointsman.resolution.omitted-model` for this key; anything else is a 400) - **null clears the field back to the global default** (this PUT is the null-clears exception). Scoped like the key's limits (16 October): ADMIN, or a TENANT_ADMIN of the key's own org; a foreign-org key answers 404 like a missing one. Read the current values first (`GET .../resolution`): the write replaces every field. |
| `GET /admin/keys/{keyId}/resolution` | What a request on this key gets with no model, an unknown model or a model outside its allowed set. Each of `default_model`, `omitted_model`, `unknown_model` and `on_denied` reports `stored` (on the key), `effective` (what requests get, through the request path's own precedence), `source` (`key`, `tenant`, `gateway`, or `none`) and `inherited` (the value with nothing stored on the key). Also returns `fallback_mode`, `route_preference`, `successor_inherits_allowlist` and `tenant_default_model`, so an editor can send the full-replace policy write without clearing anything. Read like the key's effective limits.
| `PUT /admin/allow-policy/{level}/{id}` | The allow-list at any level (`org`/`app`/`user`/`key`): `{allow_policy, allowed_models}` where policy is `explicit` or `inherit` (any other value is a 400; absent means `inherit`). Returns the stored set plus hygiene `warnings` (shadowed grants, dangling model names) rather than refusing them. |
| `GET /admin/usage?org=` | Spend and token summary for one org. `org` is required - missing answers `400 invalid_request_error` naming the parameter. |
| `GET /admin/audit` | Committed mutations only ("who changed what"; denied actions are a filed roadmap item). Params `target?`, `since?` (ISO instant), `page`, `size` (max 200). Omitting `since` leaves the lower time bound open (the query uses the epoch sentinel). |

## Workspaces (apps), members, users

| Endpoint | Contract |
|---|---|
| `POST /admin/organizations/{orgId}/apps` | Create a workspace: `{name, max_budget?}`. Mirrors as a GROUP scope. |
| `GET /admin/organizations/{orgId}/apps` / `GET /admin/apps/{appId}` | Roster (paginated, searched) and detail. |
| `GET/POST/DELETE /admin/apps/{appId}/members[...]` | Workspace membership: `{account_id, workspace_role}`; delete by account id. |
| `POST /admin/apps/{appId}/keys` / `GET .../keys` | Issue and list workspace-scoped keys - same body and same refusals as the org key door. |
| `PUT /admin/apps/{appId}/limits` / `GET .../effective-limits` | The five-field replace and its resolved view, at app level. |
| `POST /admin/apps/{appId}/users` / `GET .../users` | Create (`{email}`) and list users in a workspace; `GET /admin/organizations/{orgId}/users` lists across the org. |
| `GET /admin/users/{userId}` `/keys` / `POST .../keys` / `PUT .../limits` / `GET .../effective-limits` | The user tier: detail, attributed keys (issue resolves app and org from the user authoritatively - contradicting ids are refused as a chain violation), limits, effective view. |
| `POST /admin/users` / `GET /admin/users` | Admin *accounts* (operators and tenant admins), distinct from hierarchy users. |

## Limit rules

One rule shape everywhere - `{applies_when, count_by, amount, budget, window, action,
action_duration_seconds?, degrade_to?}` - documented with its vocabularies in
[gateway-controls.md](../gateway-controls.md). Unknown words in any closed vocabulary are
refused at the door naming the legal set. Responses add `id` and `inherited`.

| Endpoint | Contract |
|---|---|
| `GET/POST /admin/organizations/{orgId}/rules` / `PUT/DELETE .../rules/{ruleId}` | Rules at the org. |
| `GET/POST /admin/apps/{appId}/rules` / `PUT/DELETE .../rules/{ruleId}` | The same door at app level. |
| `POST/GET /admin/scopes/{id}/rules` / `DELETE .../rules/{ruleId}` | The same machinery addressed by tree scope - dedup by counter shape, restrict-only clamp against every ancestor. ADMIN at the scope. |

## Budget trust (the budget fence)

Budget trust belongs to a tenant slot (`tag`) and generation. Counter-store restart, data loss or rollback can fence the slot; budgeted traffic then receives `503 budget_recovering` until recovery establishes a trusted generation. These endpoints are operator-only (root ADMIN). Follow the [recovery procedure](../operating-notes.md), not a counter reset.

| Endpoint | Purpose |
|---|---|
| `GET /admin/budget/trust` | List slots and their generation, state and activation; fenced slots first |
| `GET /admin/budget/trust/{tag}/evidence` | Read the current generation's evidence and unresolved reservations |
| `POST /admin/budget/trust/{tag}/fence` | Fence a trusted slot whose balance is known to be wrong |
| `POST /admin/budget/trust/{tag}/attest` | Establish reviewed balances in a new generation |
| `GET /admin/budget/held` | List held settlement records and their inclusion/archive status |
| `POST /admin/budget/held/{event_id}/archive` | Preserve an included held record in the journal archive |

### Trust and activation states

| Field | Values | Meaning |
|---|---|---|
| `state` | `TRUSTED`, `FENCED`, `RECOVERING` | Trust state of the slot's generation |
| `activation` | `NONE`, `ACTIVATING`, `ACTIVE` | Whether that Redis epoch is durably activated and admitting traffic |
| `generation` | Generation number | Version reviewed before a conditional recovery write |

### Fence a known-invalid balance

Request body: `{expected_generation, evidence_ref, reason}`.

1. Match the reviewed generation; a stale value returns 409.
2. Fence PostgreSQL state first, then that generation's Redis epoch, then this instance's cache. The Redis epoch closes admission across replicas.
3. Let already-admitted requests settle normally. Repeating the operation on an already fenced slot changes nothing.

The operation audits `budget.fence`. Invalid input returns 400; an unknown slot returns 404.

### Attest reviewed balances

Request body: `{expected_generation, evidence_ref, balances: {counter_key: micros}}`.

| Condition | Result |
|---|---|
| Positive `expected_generation` matches under the transaction lock | A fenced/recovering slot can open generation + 1 |
| Missing or invalid required fields, foreign-slot counter key or negative balance | 400 |
| Unknown slot | 404 |
| Stale generation or slot not fenced/recovering | 409, without recovery writes |
| Balance below a known evidence `lower_bound` | 409 names the floor; nothing is written |
| Evidence floor is `UNKNOWN` | Attestation can be accepted; `evidence_status` is recorded in the audit |
| Corrupt or conflicting journal archive evidence | 409 until archive problems are resolved |

Each named counter begins at its attested balance. An unnamed counter whose period began before the attestation stays refused; a new period begins at zero. The audit action is `budget.attest`.

> **Evidence completeness:** a known lower bound is not proof that all spend is known. With an open recovery boundary, `backup_gap` identifies spend after the backup that the gateway cannot observe. Establish the balance from the required independent evidence.

### Read counter evidence

`GET /admin/budget/trust/{tag}/evidence` is read-only and returns 404 for an unknown slot.

| Field | What it represents |
|---|---|
| `init` | Initial balance and its basis |
| `settled` | Recorded charges |
| `held` | Journal settlements whose database rows were lost and which an attestation has not included for this counter |
| `outstanding` | Admitted, unsettled reservations |
| `uncertain` | Prepared/unknown reservations or pending releases; contribution is bounded by `[0, reserved]` |
| `deferred` | Owed adjustments already included in `settled` |
| `redis` | Live counter value for information; not independent evidence |
| `lower_bound`, `upper_bound` | Evidence bounds |
| `status` | `EXACT`, `BOUNDED` or `UNKNOWN` |
| `backup_gap` | An open recovery boundary with unobservable post-backup spend |
| `archiveProblems` | Corrupt/conflicting archive files; a nonempty list blocks attestation |

Held evidence includes archived records whose inclusion was lost in a restore.

#### Unresolved reservations

`unresolvedReservations` lists rows marked `reserve_unresolved:*` in the slot's current or a later generation. It corresponds to `pointsman_budget_reservation_unresolved` and returns `total`, `limit` (100) and the oldest `items`.

Each item includes `{requestId, counterKey, generation, state, reservedMicros, reason, createdAt, dispatches, dispatchOutcomes}`.

- A crash-orphaned row is `ADMITTED`, with `interrupted_dispatched` or `interrupted_no_dispatch_record`. Boot recovery records `503 accounting_interrupted` for it.
- Its reservation remains in `outstanding`, so the floor retains the hold.
- `reservedMicros` is the counter hold, not a maximum provider bill.
- Zero recorded dispatches is missing evidence, not proof that nothing was sent.

### Preserve held settlement records

`GET /admin/budget/held` returns event ID, slot, generation, cost in micros, SHA256, counters, `includedFor`, `archivable` and `archived`.

| State | Meaning |
|---|---|
| `includedFor` | Counters whose attested balances include this record (`budget_held_inclusion`) |
| `archivable=true` | Included for every counter the record names |
| `archived=true`, inclusion lost by restore | Re-include it through attestation; do not archive it again |
| Not yet included | Its cost remains in the relevant evidence floor |

Archive with `POST /admin/budget/held/{event_id}/archive`, body `{evidence_ref}`. It moves the record intact to `a/<event>.<sha256>.held.json`, ending retries/replay without deleting the evidence.

- 409 until every named counter includes the record through attestation.
- 404 if the event is not held.
- `ALREADY_ARCHIVED` for a repeat.
- Audit action: `budget.held.archive`.

## Scopes, grants, and policy (the tree)

| Endpoint | Contract |
|---|---|
| `GET /admin/scopes/root` | The platform root. |
| `GET /admin/scopes/{id}/tree` | The subtree from a node: children with ids, names, slugs. |
| `POST /admin/scopes` | Author a scope: `{parent_id, name, slug}`. Refuses a missing parent_id by name (the root exists once and is never created here), an unknown parent (404), a sibling slug collision, and depth beyond the configured maximum (`POINTSMAN_SCOPES_MAX_DEPTH_BELOW_TENANT`, default 2 - a number an operator raises, not a schema anyone migrates). Kind comes from position - a child of the root is a TENANT, everything else a GROUP; the caller never says. **OWNER at the parent**: handing out structure is delegation-shaped power, so it sits above ADMIN. |
| `GET/POST /admin/scopes/{id}/grants` / `DELETE /admin/scopes/grants/{grantId}` | Grants: `{principal_id, role, expires_at?}` with roles ordered VIEWER < DEVELOPER < ADMIN < OWNER. The door enforces the delegation rule through the domain function itself: a grantor confers STRICTLY below their own strength, never at or above it - an ADMIN attempting to grant OWNER is refused, verbatim, and so is a VIEWER granting VIEWER or an ADMIN granting ADMIN; an OWNER may grant any role including OWNER, since nothing sits above it . Revocation takes the standing to grant, an actor may also revoke an authored grant when their current applicable role is at least that grant's role. Authorship alone does not bypass lost authority. |
| `GET /admin/scopes/{id}/limits/preview` | Every level from the target to the root, each level's rules, and per amount WHICH level binds - named by the server, not inferred. |
| `POST /admin/scopes/{id}/policy` | A policy value at a scope: `{subject_kind, subject_id?, key, value}`. Refuses by name: unknown key or subject kind (closed vocabularies, the refusal lists them); a PRINCIPAL or CREDENTIAL subject id with no backing row; entries the path's composed policy already excludes (storing them would create a control that looks set and does nothing); and a duplicate `(scope, subject, key)` as 409 - **an edit is delete-then-create; there is deliberately no PUT.** |
| `GET /admin/scopes/{id}/policy` / `DELETE .../policy/{policyId}` | List and remove. |
| `GET /admin/scopes/{id}/policy/preview` | The composed policy along the real path with per-level provenance (what each level removed), and `enforced_on_request_path` reporting only the data-path flag. The preview can compose subject rows the live request path does not consume in the same way; see [live composition limits](../design/authorization.md#preview-versus-live-composition). |

## The registry: providers, models, credentials

| Endpoint | Contract |
|---|---|
| `GET/POST /admin/registry/providers` / `PUT/DELETE .../{id}` | Provider rows: `{name, adapter_type, base_url?, credential_ref?, extra_headers?, quirks?, timeout_ms?, enabled}`. PUT is a full replace and refuses partial bodies by naming the missing field. Responses always carry `credential_resolves`. Refusals worth knowing: credential-shaped values in `extra_headers` (credentials belong in `credential_ref`); reserved header names in `extra_headers` (`Authorization`, `Content-Type`, `X-Pointsman-Dispatch-Id`; a row stored before the last was reserved has its value dropped at load, with a WARN and `gateway_registry_reserved_header_stripped_total`); unknown quirk flags (a quirk you believe is active while the provider gets spec-violating requests is worse than a rejected config). |
| `POST /admin/registry/providers/{id}/probe` | A real one-token call through the real adapter and credential; verdict recorded on the row (`probe_status`, `probe_at`). Advisory - enabling is just the PUT. |
| `GET/POST /admin/registry/models` / `PUT/DELETE .../{id}` | Read, create, fully replace or remove a model row. Fields and serving-identity rules are described under [Model fields and activation](#model-fields-and-activation). |
| `GET/POST /admin/registry/providers/{providerId}/credentials` / `PUT .../{id}` / `POST .../{id}/rotate` / `DELETE .../{id}` | Sibling credentials for one provider (the pool). Create: `{label, credential_ref, enabled?, tpm_limit?}`. **PUT is a patch over label, enabled and the TPM ceiling: a field the body does not carry keeps its value, `tpm_limit: null` clears the ceiling ; it refuses a changed ref - swapping the secret is the rotation door**, which replaces the secret behind a live key while draining the old client. Reads never expose a secret: the `env:NAME` ref plus `credential_resolves`, exactly as the primary. Refuses (400) a sibling on a code-backed provider, a changed ref on PUT, a rotation to the same ref, the label `primary`, a raw secret, a non-positive `tpm_limit`; refuses (409) any change that would leave an enabled provider with no live key. The behaviours: [provider credential pools](../design/adaptive-routing.md). |
| `POST /admin/registry/import` / `GET /admin/registry/export` | The one config pipeline: all-or-nothing (one transaction, one version bump, one audit row), accepting JSON or YAML; export is its round-trip. |

### Model fields and activation

Model rows use `{provider_name, model_id, aliases?, input_per1k?, output_per1k?, cache_read_per1k?, cache_write_per1k?, context_window?, max_output?, capabilities?, fallback_model?, tags?, lifecycle?, accepted_response_models?, enabled, ...}`.

#### Accepted response identities

`accepted_response_models` declares additional exact native model IDs that a deployment accepts as its serving identity. Its own `model_id` is always accepted.

| Rule | Contract |
|---|---|
| Length and count | At most 16 IDs; each 1–96 characters from `[A-Za-z0-9._:/@+-]` |
| Uniqueness | No duplicates, the row's own ID, or another row's ID/alias; invalid declarations return 400 |
| Omitted on PATCH | Preserve the current list |
| Omitted on PUT, POST or import | Use an empty list |
| Cache identity | A separate registry-wide trust-policy digest partitions exact/semantic entries and in-flight sharing for new requests, including fallback routes |
| Existing requests | Keep their admitted policy; reordering the same accepted-ID set does not invalidate reuse |
| Quality identity | Does not change generation settings or the quality qualification digest |

#### Model write refusals

| Input | Refusal or load behavior |
|---|---|
| Retired `routing_patterns` field | Refused rather than silently ignored |
| Enabling a null-priced model with budget enforcement | Declare explicit rates; zero means deliberately free |
| Retiring model without a successor | Refused unless acknowledged |
| Model ID already registered under another provider | 409; model IDs must be globally unique |
| Capability unsupported by a code-backed adapter | 400 names the capability and adapter. Anthropic/Ollama adapters carry text only; unsupported stored declarations are stripped on load, warned and counted by `gateway_registry_capability_stripped_total` |

#### Reasoning effort

`capabilities.reasoning_effort` accepts `none`, `minimal`, `low`, `medium` or `high` only on an `openai_compatible` row. The value is sent verbatim; Pointsman does not translate vendors' reasoning levels. Unsupported adapters or values are refused on write and stripped/countable on load (`capability=reasoning_effort`). The upstream model must support the value or can return its typed 400.

The setting participates in the quality registry digest and response-cache identity. It is fixed by the operator's model row, not an arbitrary caller body override.

#### Admission snapshots and cache activation

1. Admission pins a registry snapshot. Routing, quality decisions and serialized attempts use that snapshot.
2. If a row changes before an attempt sends, the attempt is refused as `configuration_changed`: no send, retry, breaker fault or arm penalty. The fallback ladder may move within the decision's constraints.
3. Cache lookup, coalescing and publication use the generation identity computed at admission: a registry-wide digest of declared generation settings.
4. Changing any model's generation settings invalidates reusable work across routes, including fallback answers. Non-generation edits such as prices or aliases do not.
5. A response is published, or accepted by a coalesced waiter, only when the serving model belongs to the route (or nothing involved declares a setting). Otherwise the waiter executes its own call.

This provider-state snapshot binding applies to `openai_compatible` rows. Code-backed adapters retain their own provider resolution.

> **Cache namespace and rollback:** generation identity uses `gen/v3`. Deploying that namespace starts routes cold; older entries are unreachable. An older binary can read its own earlier namespace after rollback. Invalidate the cache with `DELETE /internal/cache` if generation settings or accepted-response-ID policy changed in the meantime.

### Paged registry browsing

The dashboard uses these ADMIN-only additions. The existing unpaged endpoints above keep their array responses.

| Endpoint | Contract |
|---|---|
| `GET /admin/registry/providers/page` | `page=0`, `size=25`, `search=`. Literal case-insensitive substring over provider name, adapter and endpoint. |
| `GET /admin/registry/models/page` | Same parameters, plus `enabledOnly=false`. Search covers model ID, provider name and family. |
| `POST /admin/registry/models/availability` | Read-only JSON array of at most 1000 exact model IDs (each at most 255 characters); returns the enabled IDs found. Missing/disabled IDs are omitted. Normal admin authentication and POST CSRF rules apply. This is registration availability, not tenant authorization. |

Page responses are `{ "items": [...], "total": 123, "page": 0, "size": 25, "modelCounts": {} }`. `total` counts all matching rows. Provider pages include per-provider registered-model counts keyed by the returned provider names; model pages leave that map empty. Page must be 0..100000, size 1..100, search at most 200 characters; invalid bounds return 400. Out-of-range pages return an empty `items` array with the matching total.

Ordering is provider name/UUID for providers and provider name/model ID/UUID for models. Each response is read in one repeatable-read transaction. Offset positions can shift when the registry changes between requests; this API is live browsing, not a snapshot export. Use the configuration export endpoint for a complete configuration document. Model page responses preserve the existing successor-narrowing warnings. These routes are control-plane reads and do not dispatch provider calls.

Paged browsing is a control-plane capability; its availability does not expand the [qualified runtime scope](../release-status.md).

### Operator-fixed thinking mode

An openai_compatible model row may declare capabilities.thinking_mode as
enabled or disabled. Pointsman sends the corresponding native
thinking: {"type":"enabled"} or thinking: {"type":"disabled"} on both sync
and stream requests. An absent setting preserves the provider default.
Kimi K2.6 defaults to thinking enabled, so use disabled to select its
non-thinking profile. This is an operator model-row control, not a
caller-controlled chat field or a generic body-injection map.

The admin API refuses other values, a non-openai_compatible adapter, and
a row that declares both thinking_mode and reasoning_effort. A hand-edited
invalid row fails registry reload and leaves the prior good snapshot in
service. A supported setting participates in the quality registry digest,
the registry-wide cache and in-flight generation identity (including
fallback routes), and the served-generation response header. A change
does not alter requests already admitted under their pinned snapshot.
See the [Kimi setup example](../how-to/add-a-provider-and-a-model.md#kimi-k26-non-thinking-mode).

## Guardrails and control

| Endpoint | Contract |
|---|---|
| `GET /admin/control/effective` | Reported configuration/policy values with source and mutability, plus the guardrail catalog and parameter descriptors. Global RPM/TPM/spend read the constructed platform-rule snapshot (`source: runtime-holder`, `mutability: boot`); null means no loaded ceiling, including a non-positive boot target. Rule windows are described from that snapshot and spend stays in exact micro-dollars. Current property targets for those three are not separately projected. Global concurrency retains its separate mutable-property read/refreshable metadata. These observations are not proof of traffic enforcement. See [ownership](dashboard-configuration.md). *Source and focused API checks.* |
| `PUT /admin/control/guardrails/{ruleKey}` | **A patch, not a replace**: `{enabled?, mode?, params?, tenant_policy?}`. An absent `enabled` or `mode` keeps the rule's current value: the stored setting, else the boot configuration. Until 2026-09-25, a mode-only patch switched the rule off, and an enabled-only patch was refused. Rule parameters nest under `params` - a flat body is accepted with 204 and the parameters silently ignored, so read the effective surface back after writing. Secrets are write-only: absent preserves, explicit null clears. Boot-badged parameters (compiled regex) refuse live edits. |
| `GET /admin/control/guardrails/tenant?org=` / `PUT /admin/control/guardrails/{ruleKey}/tenant?org=` | Tenant tightening - enable what is off globally, push monitor to enforce. Loosening below the global setting is refused by the resolver; the operator can lock a rule against tenant changes entirely. TENANT_ADMIN may write their own org. *From code (P5c proved the loosening refusal live).* |
| `GET /admin/control/guardrails/workspace` `/workspace-effective` / `PUT .../{ruleKey}/workspace` | The same tightening one level down, and the workspace-wide effective view with binding levels. |
| `POST /admin/guardrails/dry-run` | `{messages, response_text?}` through the real chain: per-rule `would pass / would block / would redact: N / not reached` verdicts with zero side effects - no audit rows, no metrics, and externals are never called with your text (`external - not evaluated`). Operator-only, deliberately: it evaluates the global chain, and a tenant reading it would trust a result that describes someone else's configuration. |

## Observe, ops, and the bandit

| Endpoint | Contract |
|---|---|
| `GET /admin/observe/requests?org=` | The request ledger, newest first; `org` is required (missing answers 400 naming it). Rows carry model, provider, tokens, cost, cache_hit, status, error_type, latency. `provider` is the deployment the request's send went to (since 17 October 2026: one model behind two providers is two deployments, never the registry's owner of the model): a coalesced delivery names its leader's, a cache hit names none (null: no provider served it), a failure names the last deployment a send went to, or none when nothing was sent. `GET .../requests/{id}` is one row's detail. |
| `GET /admin/observe/metrics/overview` | The golden metrics, ADMIN-proxied. |
| `GET /admin/observe/analytics/overview` `/groupby` `/cache` `/errors` | Ledger-backed analytics per org: KPIs, group-bys, cache hit rate with hit-vs-miss latency and savings estimate, error breakdowns. |
| `GET /admin/ops/spend?org=` | The flat ops spend view with as-of rollups. |
| `GET /admin/ops/decisions?org=` | The routing decision log - arm chosen, provenance, propensity. `provenance` filters by `posterior`, `exploration_floor`, `uniform_prior` or `cost` (16 October: a lowest-cost bandit group's decision, logged under its tenant with no posterior and no propensity); any other value is no filter. `feedback_score`, `feedback_revision` and `feedback_state` (`rated` or `withdrawn`; 18 October) are the caller's feedback as the gateway accepted it. Only a higher revision replaces them, and a withdrawal keeps its revision with no score. They are not the learner's application, which the learner records itself. Rows rated before revisions were kept have no revision or state. Null fields are omitted. |
| `GET /admin/ops/convergence` | Registry convergence: whether every instance serves the current version. |
| `GET /admin/ops/guardrails/health` | Per rule: enabled/mode, fail posture, and degradation counts (`failed_open` / `failed_closed`) in 3-hour buckets - counts are audit-backed and exact, latency is instance-local. |
| `GET /admin/bandit/state` | The belief surface: per sampling key (tenant + task type, with its `learning_semantics`: `ts2`, or `ts1` for frozen legacy keys), every arm's posterior, mean, seeded-vs-live observation split and, for `ts2` keys, three separate quantities - `effective_mass` (the discounted evidence the sampler draws from), `observed_decisions` and `rated_decisions` (null for `ts1`), plus fetch staleness (`fetched`, `version`, `age_seconds`) and `lints_key_count`, the number of sampling keys routed by the contextual (LinTS) sampler, which are disclosed as a count rather than drawn as Beta rows. |
| `GET /admin/bandit/cost-state[?org=&page=&size=]` | What the in-process cost sampler has learned (16 October): per state (a `strategy: bandit, objective: cost` group of one tenant, `kind: group`; or a quality profile with `selection: cost`, `kind: profile`), per member - one per deployment, `provider` and `model` (17 October) - `observations` (decayed; 0 = the declared price alone), `cost_vs_declared` (the multiplier selection centres on: the posterior of the member's own observations shrunk toward the state's pooled multiplier, so a member with no evidence of its own reads at the group's multiplier rather than 1.0; 1.0 = as declared, which is day one; null for a free or unpriced model, which has none), with the state's `prior_cost_vs_declared` (the pooled multiplier itself), `completed` (share of calls that returned) and `price` (`declared`, `free` for declared zero rates, `unknown` for no declared price - never treated as free - or null before the member was first priced). Each member also has `priced_updates`, `reliability_updates` and `failure_updates` (18 October): updates applied since the sampler started, cumulative and never discounted. `observations` ages and can hold steady under steady traffic, so it cannot show that learning continues; these counts can. The response's `sampler_instance` names the sampler that counted them, and a new value means a restart, with the counts at zero again. Also `learn_from_direct` (the operator's setting). An ADMIN sees every tenant; anyone else sees their own org's states only, server-injected; a profile serving every organisation reaches an ADMIN only. In-memory on the instance: a restart starts again from declared prices. |

## Quality-guarded routing (experimental)

Authorization is decided in each handler: operator endpoints need root ADMIN (gate `admin.quality`);
organization endpoints need that organization's ADMIN (legacy TENANT_ADMIN) or root ADMIN (gate
`admin.org.quality`). Every write is audited; writes return `503 quality_disabled` while
`pointsman.quality.enabled` is false. [How-to](../how-to/quality-guarded-routing.md).

| Endpoint | Behaviour |
|---|---|
| `GET /admin/quality/status` | What this instance enforces: enabled, policy version, expiry, last refresh problem, every profile's baseline chain, baseline evidence (`baseline_unqualified` when none) and qualified models with evidence expiry. Each profile also reports `selection` (`cheapest`, `bandit` or `cost`) and `feedback_floor` (16 October). |
| `POST /admin/quality/orgs/{org}/validators` | Register a key of that organization as a trusted validator for listed rubrics: `key_id`, `name`, `rubrics`, `gold_allowed`. A key of another organization is `404`; a second registration `409`. Audit `quality.validator.register`. |
| `GET /admin/quality/orgs/{org}/validators` | The organization's validators. |
| `POST /admin/quality/orgs/{org}/validators/{id}/deactivate` | Deactivate (the row and its evidence stay). Audit `quality.validator.deactivate`. |
| `GET /admin/quality/orgs/{org}/evidence/{evidence_key}` | Derived counts per namespace (trusted, proxy, voluntary): independent, successes, failures, severe, conflicts, overridden. |
| `POST /admin/quality/epochs` | Register a frozen evaluation spec (body = spec). `201` new, `200` same spec again; `400 spec_invalid` / `422 spec_unsupported` with every reason. Audit `quality.epoch.register`. |
| `POST /admin/quality/epochs/{id}/adjudicate` | The one sealed batch for that epoch; the result is stored forever. A second batch is `409 epoch_already_adjudicated`; over `max-batch-rows` is `413`. Audit `quality.epoch.adjudicate`. |
| `GET /admin/quality/epochs[?profile_id=&limit=]`, `GET /admin/quality/epochs/{id}` | Every epoch, failed ones included, with spec, result and `respecified_after_look`. Each epoch also reports `latency_gate` (`quantile`, `ratio_max`, `max_ms`), null when its spec gates on quality only (16 October). |
| `POST /admin/quality/policy/preview` | Validate a draft and bind its evidence: `valid`, `errors`, `warnings`, the bound document and the change against the active version. Writes nothing. |
| `POST /admin/quality/policy/promote` | `document`, `expected_version` (the active version, or null for the first), `reason`. Invalid: `400 policy_invalid`; stale expectation: `409 stale_expected_version`. Audit `quality.policy.promote`. |
| `POST /admin/quality/policy/rollback` | `target_version`, `expected_version`, `drop_expired`, `reason`: a NEW version from the old document, re-validated today; expired evidence or registry drift is `409 rollback_refused` unless dropped. Audit `quality.policy.rollback`. |
| `GET /admin/quality/policy/versions[?limit=]`, `GET /admin/quality/policy/versions/{v}` | Every version, the document on the single read. |
| `GET /admin/quality/audit` | The audit lane: enabled, sample rate, queued, dropped since this process started, and jobs by status. |
| `GET /admin/quality/audit-window-corpus?profile_id=&from=&to=` | The identity to pin as a spec's `corpus_sha256` BEFORE sealing that window. |
| `POST /admin/quality/epochs/{id}/seal-audit-window` | `from`, `to` (closed, half-open): every selection becomes a row, unlabelled ones `missing`; refused when the window was not the pinned one (`409 window_not_preregistered`), is still open, or had dropped selections (`409 audit_window_incomplete`). Audit `quality.epoch.seal_audit_window`. |
| `GET /admin/quality/auxiliary` | The operator account: calls, spent, reserved and unknown exposure against their limits; `billing_owner: operator`. |
| `GET /admin/quality/observation` | Decision-only observations (served vs would-serve, bounded) and alternative-answer attempts (status and cost only). |
| `POST /admin/quality/auxiliary/attempts/{id}/reconcile` | `actual_micros`, `note`: settles one UNKNOWN attempt with a reviewed amount. Anything else is `409 not_unknown`. Audit `quality.aux.reconcile`. |

## Internal and actuator

| Endpoint | Contract |
|---|---|
| `DELETE /internal/cache?tenant=` | Invalidate the semantic cache - one tenant's entries, or everything when `tenant` is omitted. The tenant is a literal id: a value carrying `*`, `?`, `[`, `]` or `\` is refused (400) rather than matched, because until 2026-09-05 `?tenant=*` flushed every tenant while the response reported one. Fans out: the L2 flush is shared, and every other instance drops its L1 on `illg:cache:invalidate` (M-3). Basic ADMIN. |
| `GET /internal/thread-info` | Virtual-thread diagnostics. Basic ADMIN - run: 401 without credentials, despite an older code comment claiming this surface is open. |
| `POST /actuator/env` / `POST /actuator/refresh` | The writable environment (Basic ADMIN): how runtime-tunable values move without a restart - the cache threshold retune is the worked example in [the cache how-to](../how-to/tune-the-semantic-cache.md). |
| `GET /actuator/health` `/info` `/prometheus` | Open on purpose: an unauthenticated 401 probe reads as a dead instance, and the scrape endpoint feeds Prometheus. |

## Correlated provider dispatch history

`GET /admin/observe/requests/{id}/dispatches?page=0&size=25` returns physical provider
sends for an authorized ledger record. `{id}` is the numeric ledger ID, not a caller-supplied
request header. Authorization and foreign/missing-ID behavior match the ledger detail
endpoint. The query uses the stored internal request UUID and organization together.

The response carries `request_id`, `correlation_available`, `items`, `page`, and `has_next`.
Page size is bounded to 1–100. Each item includes dispatch UUID, sequence/rung/attempt,
provider/model, recorded outcome, optional HTTP status/error class/cost, usage provenance,
dispatch/finish timestamps, and `reported_model` (V68: the identity the provider reported for that
send, sanitized; null when it named none). `model` is the dispatched deployment - the identity the
tariff belongs to. Credential IDs and secrets are not returned. Unknown usage
is not zero. A legacy row without the internal UUID returns `correlation_available: false`;
a currently empty list can also reflect journal-delivery lag and must not be treated as proof
that no provider send occurred. Ledger list/detail entries additionally expose nullable
`request_id` for this correlation.

## Account update safety

`PATCH /admin/users/{userId}` retains its existing root-owner authorization. Disabling the
signed-in account or the final enabled global administrator returns 409. Competing global
administrator deactivations serialize on ordered row locks before checking remaining access.
Password replacements must contain at least 12 characters and at most 72 UTF-8 bytes;
invalid replacements return 400. Password values are never written into the audit detail.
These rules affect account updates, not the separate account-creation contract.


### Paged keys, accounts and quality history

These additive browse endpoints preserve the authorization of their corresponding legacy list APIs. Legacy endpoints and their response shapes remain available.

| Endpoint | Authorization | Literal case-insensitive search | Order |
|---|---|---|---|
| `GET /admin/organizations/{orgId}/keys/browse` | Existing organization visibility gate | Key prefix or label | Created time descending, UUID descending |
| `GET /admin/users/page` | Existing root account-list ADMIN gate | Username or account UUID | Username, UUID |
| `GET /admin/quality/epochs/page` | Existing root quality ADMIN gate | Profile ID or epoch UUID | Registered time descending, UUID descending |
| `GET /admin/quality/policy/versions/page` | Existing root quality ADMIN gate | Version number, reason or author | Version descending |

All accept `page` (zero-based, 0..100000), `size` (1..100, default 25), and `search` (up to 200 characters, trimmed). Invalid bounds return 400. `%` and `_` are literal text. Search, totals and paging execute in the database. Key browsing also accepts `unclassified=true` and returns `{items,total,page,size,unclassified_total}`. The unclassified total covers the entire authorized organization regardless of the search or kind filter. It is not a count of just the visible page. Responses expose prefixes, never raw keys or key hashes.

Account and quality pages use `{items,total_items,total_pages,page,size}`. Account responses omit password hashes. Quality history queries select summary metadata without loading sealed policy documents, epoch specs or adjudication results. Full details remain available through the exact-ID endpoints. `GET /admin/quality/policy/versions/{version}` now resolves that version directly, including versions older than the latest 200.

Counts and rows share a repeatable-read transaction in each inventory read. Pages are live offset views, not multi-request snapshots or export/completeness proofs. Concurrent changes can shift later pages. The dashboard resets its page when the scope or filter changes, keeps mutations tied to explicit selected IDs, and refreshes totals after mutations. No access grants, key-kind semantics, promotion checks, rollback checks or quality verdicts are changed by browsing.

These routes are included in the matching gateway/dashboard candidate accepted on
28 October (`e71e5da8`); see [launch status](../release-status.md) for combined source,
package and endurance evidence. Use the matched release artifacts rather than
assuming an older gateway implements these APIs. Bounded output is not evidence of
constant-time substring search or a throughput benchmark.

See [Dashboard configuration ownership](dashboard-configuration.md) for conditional registry edits, Jev partial deployment drafts, operational observations and intentional API/deployment-only scope.
