Version: Current
Historical release snapshot. These docs are frozen. Read current docs.
Authentication
acumen authenticates two different callers with two different credentials and keeps both in one ledger without turning either into the other.
Loopback-only deployments can leave key enforcement off. Enforcement is armed at
startup when a key is already stored (or ACUMEN_REQUIRE_KEYS=1), and it is
armed immediately when a key is created at runtime: the first
POST /v1/router/keys takes effect on the next request, not the next start.
A revoke is visible on the next request because every request looks the key up. Once armed, an empty key table stays armed; it fails closed. A restart with an empty table is the documented way back to an unarmed deployment.
With ACUMEN_REQUIRE_KEYS=1 and an empty key table the process is locked out
of its own key API, because creating the first key is itself an admin call.
Two planes
Section titled “Two planes”| Plane | Credential | Issued by | Presented on | Stored as |
|---|---|---|---|---|
| machine | virtual key vk_… |
POST /v1/router/keys (admin key) or POST /app/grants/new (consumer session) |
Authorization: Bearer vk_… on /v1/*, and on operator paths when the key holds admin |
SHA-256 hash in virtual_keys; plaintext returned once |
| human | browser session cookie acumen_session |
accepting an invite (POST /i/{token}) or signing in (POST /signin) |
the cookie on /app/* and /account |
SHA-256 of the token in sessions; a second per-session secret holds the CSRF value |
A virtual key is not a login: it cannot open /app or /account, and it cannot
mint another key unless it holds admin. A session cookie is not a model
credential: it cannot call /v1/*.
The human plane exists because a person signing in has no key yet. The machine plane is unchanged for SDKs and CI. Sign-in is invite-only and uses a recovery code.
For a complete migration guide covering base URL changes, virtual key handling, model aliases, streaming, error handling, and failover, see Migrating a client to acumen.
The grant ledger
Section titled “The grant ledger”grants is the one list of everything that can act for an account:
kind=api: a virtual key, with the sameidas itsvirtual_keysrow, so the credential and its lifecycle record cannot drift apart.kind=session: a browser sign-in, idgrt_plus thesessions.id.kind=delegation: reserved for scoped delegation; no handler writes it yet.
Every grant carries label, scopes, models, the caps, origin,
created_by, state (active, disabled, revoked, or the computed
expired), created_at, expires_at and revoked_at. grant_events is the
append-only history (created, revoked, signed in, account frozen),
rendered on the grant page.
The invariant is one api grant per virtual key, with the same id. It holds
for keys that predate the ledger and for keys created on the consumer surface.
It does not yet hold for a key created through POST /v1/router/keys: that
handler writes virtual_keys only. That is an open gap, not a second source of
truth. Enforcement always reads virtual_keys.
Revoking either path kills the key. The consumer’s
POST /app/grants/{id}/revoke deletes the virtual_keys row behind an api
grant; the operator’s DELETE /v1/router/keys?id=… deletes the same row. Because
KeyAuth looks the key up on every request, the next call is
401 invalid virtual key with no restart. Session revocation deletes the
sessions row, so the cookie stops resolving immediately.
Scopes
Section titled “Scopes”A key carries scopes (chat, embeddings, admin), an optional model allowlist
(empty means every model), daily and monthly USD caps, and RPM/TPM limits.
The model allowlist is checked before the exact-cache lookup, so a cached premium answer is never replayed to a key that may not call that model.
Rate and spend windows live in memory: a restart resets the current minute, day and month windows.
| Scope | Reaches |
|---|---|
chat |
the model doors and /v1/models |
embeddings |
/v1/embeddings |
admin |
every operator path, plus key CRUD |
Surfaces
Section titled “Surfaces”There are three surfaces across two credential planes, and /healthz is the only
path that never checks a credential.
| Surface | Paths | Who may call it |
|---|---|---|
| public liveness | /healthz |
anyone; returns ok and nothing about seats |
| operator | /health (deep, per-seat balances), /readyz, /console*, /metrics, /debug/pprof/*, /v1/router/*, /v1/hook*, /v1/route*, /v1/keys* |
an admin-scoped key, or a loopback deployment with enforcement unarmed |
| model | /v1/chat/completions, /v1/responses, /v1/messages, /v1/search, /v1/models |
a chat key, or none when unarmed |
| embeddings | /v1/embeddings |
an embeddings key |
| consumer | /, /i/{token}, /signin, /signout, /app*, /account* |
a browser session cookie; /, /signin and /i/{token} need none. Mounted ahead of KeyAuth, so no vk_ key is involved |
The deep /health body used to fall through to the chat scope, so any chat key
could read every seat’s plan, balance and circuit state. It is now an operator
path: a chat key gets 403 X-Acumen-Denied-By: scope, an admin key gets 200.
Bind rules
Section titled “Bind rules”Public binds fail closed. A non-loopback listener is refused at startup
unless -allow-remote is passed and ACUMEN_BEARER_TOKEN is set
(validateBind). On such a listener the process token is required on every path
except /healthz; /readyz and deep /health are not exempt. A listener that
fails these checks does not start.
Residual trust on a proxied loopback deployment. A reverse proxy on the same
host connects from 127.0.0.1, so acumen cannot tell a tailnet or public client
from a local one. A loopback bind with enforcement unarmed therefore trusts
whatever can reach the proxy. On the edge host, Caddy denies the operator paths
and tools/edge-check.sh asserts it from the host. Any deployment exposed beyond
the host should set ACUMEN_REQUIRE_KEYS=1, or create a key, so operator paths
need an admin key regardless of peer address.
Managing keys
Section titled “Managing keys”POST /v1/router/keys {"name":"ci","scopes":["chat"],"models":["cheap"], "daily_usd":5,"monthly_usd":50,"rpm":60,"tpm":200000}GET /v1/router/keys list (ids, limits, spend; never a key or hash)PATCH /v1/router/keys {"id":"key-…","daily_usd":10,"disabled":false}DELETE /v1/router/keys?id=key-…POST mints the key and returns the plaintext once. PATCH updates only the
fields present in the body (name, scopes, models, daily_usd,
monthly_usd, rpm, tpm, disabled) and answers 200 with the same view as
GET; unknown fields are rejected, and the hash and created_at are not
writable. DELETE is the revoke.
All four require a key with the admin scope, as does every operator path above.
Failure modes
Section titled “Failure modes”| Condition | Status | X-Acumen-Denied-By |
|---|---|---|
| missing or unknown key | 401 | key |
| disabled key that lacks the path’s scope | 403 | scope (the scope check runs before the disabled flag) |
| disabled key holding the path’s scope | 401 | key |
scope not held (e.g. a chat key on /health) |
403 | scope |
| model not on the allowlist (cache hit or dispatch) | 403 | model |
| RPM window exceeded | 429 with Retry-After |
rate |
| TPM window exceeded | 429, no Retry-After; retry after the minute rolls |
rate |
| daily or monthly USD cap crossed | 402 | budget |
Next steps
Section titled “Next steps”- Use the gateway runbook for operator checks and key/seat operations.
- For request behavior and endpoints, see the acumen API guide and API reference.
- Verify current auth behavior on the target deployment before relying on this snapshot (reviewed 2026-11-05).