# Admin identity model

Admin identity is carried by the surface a session arrives on. It is never gated
by a per-session step, and nothing a non-interactive channel cannot satisfy ever
blocks a tool.

The PIN identity gate — a universal PreToolUse hook that denied every
tool until the operator submitted a PIN — was retired. It deadlocked
non-interactive surfaces: a WhatsApp channel session can never enter a PIN, so the
channel reply tool was permanently unreachable. The hook, its `admin-identity-authenticate`
submit tool, the `/api/admin/identity/validate` loopback endpoint, and the standing
identity audit were all removed. The shared PIN primitive (`pin-validator.ts`) stays
— the legacy browser login (`POST /api/admin/session`) still uses it.

## How identity is carried, per surface

**Channels (WhatsApp, future channels).** The channel binding authenticates the
operator. An allowlisted sender phone in `account.json` `adminPhones` makes the
inbound session the `admin` role (`access-control.ts`), and the channel-PTY bridge
keys the admin session per sender (`bridge.ts`). No PIN, no prompt — the sender's
own number is the credential.

**Loopback (claude.ai/code).** The operator chats through the per-account `claude rc`
daemon over loopback. There is no inbound identifier on this surface, so the session
carries the single boot-time `USER_ID` (the owner, read from `users.json[0]` at
daemon start). When more than one admin shares the account, the admin agent identifies
which person it is speaking with conversationally — a convenience that loads the right
profile and history. This is stated in the admin `IDENTITY.md`: identification is
"purely as a convenience... never a gate and never blocks any tool"; single-admin
sessions skip it.

## Accepted boundary

On a channel (WhatsApp today), the inbound sender phone is bound to a person's
`userId` via `users.json` (`UserEntry.phone`), confirmed against the account's
`admins[]`. The channel admin session carries that `userId` — graph writes and
the `${USER_ID}` MCP env attribute to the actual sender. An unbound sender falls
into two cases. The owner's self-phone (a self-chat command, or a DM the gate
admits as `reason:'self-phone'`) resolves to the owner `userId` (`admins[]` row
with `role:'owner'`), which the UI passes as the per-session `userId` override —
so the owner's own-phone session keeps owner attribution even when the owner
phone is not bound in `users.json`. Every other unbound sender (an `adminPhones`
entry with no `users.json` phone) gets an empty `USER_ID`; the boot-time owner is
not substituted.

A WhatsApp admin whose inbound arrives as an unresolved LID (a cold mapping
cache) resolves to their own `userId` via a runtime LID cache
(`whatsapp-lid-map.json`), populated the first time their LID resolves to their
phone. A LID that has never resolved has no cache entry and gets an empty
`USER_ID` — a LID-form inbound normalizes to no phone, so it cannot match the
owner's E.164 self-phone and never takes the owner-self path. The
`op=admin-identity … userId=<value>` log line reports `unbound-empty` for a
non-owner unbound sender and `owner-self-unresolved` for an owner self-phone on
an account with no `role:'owner'` row.

On loopback (claude.ai/code), multiple admins resolve to the right *profile*
conversationally but share the owner's `userId` at the env/MCP layer.
Deterministic per-admin `userId` on loopback is deliberately out of scope:
making it deterministic again means a per-session identity step, which is exactly
the deadlock the retirement removed.

## Credential-write identity binding

The admin MCP credential tools (`remote-auth-set-password`, `admin-update-pin`,
`remote-auth-revoke-password`) write the `accessHash` and `pin` fields on
`users.json` rows. Their operating identity is `process.env.USER_ID`, which the
admin MCP inherits from its parent `claude` PTY.

A prior defect stamped the wrong identity onto that env. The `/rc-spawn` handler
built the child `USER_ID` as `overrideUserId ?? deps.userId`, where `deps.userId`
is the boot-time owner. An authenticated non-owner admin whose spawn request
carried the authenticated `adminUserId` but no per-session `userId` therefore ran
with the owner's `USER_ID`, and a credential tool called with no explicit
`userId` rewrote the owner's row. The audit recorded the owner as the actor, so
it read as an owner self-change.

The binding now holds:

- `USER_ID` at `/rc-spawn` is `resolvedAdminUserId (authenticated admin) ??
  overrideUserId ?? ''`. The owner env fallback is removed. An authenticated
  admin always wins; an unbound sender with no admin identity gets empty
  `USER_ID`, a documented-legal value. This changes the earlier accepted
  boundary: an unbound channel sender's rc-spawn no longer inherits the owner
  `userId` at the env layer. Owner attribution is restored for one case without
  reopening the leak: the WhatsApp channel resolves the owner self-phone
  to the owner `userId` client-side and sends it as `overrideUserId`, so it
  arrives as an explicit per-session identity, not a blanket env default; a
  non-owner unbound sender still gets empty `USER_ID`. The `claude rc` daemon env
  (not a per-session conversation) is unchanged and still carries the boot owner
  `userId`.
- A credential tool with no explicit `userId` resolves to the verified caller
  (`USER_ID`, empty treated as no identity). With no verified identity it errors
  and writes nothing; it never substitutes an ambient env value for a missing
  target.
- Writing a *different* admin's credential requires an explicit `userId` and
  `confirm: true`. Self writes and same-identity writes need no confirm. This
  keeps the "any admin may rotate any admin's PIN with an explicit userId" trust
  model while making a cross-identity change a deliberate two-signal act.
- Every credential write records `actor` (the verified caller), `target` (the
  row written), and `explicitUserId` in `users-audit.log`. The standing
  signatures: `[users-audit] … actor=<x> target=<y> explicitUserId=<bool>` and,
  per spawn, `[identity-audit] op=mcp-user-binding seat=<x> mcpUserId=<y>
  match=<bool>`. The alarms are `actor != target && explicitUserId=false` and
  `match=false` — a self-targeted write that hit another row, or an authenticated
  admin whose stamped `USER_ID` differs from that admin.

## Boot-time binding drift gate

The channel-admin binding is reassembled at read time from three independently
operator-maintained sources: `adminPhones` (role allowlist), `admins[]`
(membership), and `users.json` `UserEntry.phone` (the phone→userId binding). If
they disagree, `resolveAdminUserId` returns null and the session runs with an
empty `USER_ID` — here as a *misconfiguration*, not an intentional unbound
sender. These are `adminPhones` entries, never the owner self-phone (the owner
self-phone is attributed by the gate's `reason:'self-phone'` verdict), so no
drift entry silently inherits the owner `userId`.

On every server start the platform replays the resolution chain for each
account's `adminPhones` entry and logs one line per entry that would run with an
empty `USER_ID`:

    [admin-identity] binding-drift accountId=<id> phone=<p> reason=no-users-binding
    [admin-identity] binding-drift accountId=<id> phone=<p> reason=userid-not-admin userId=<u>

- `no-users-binding` — no `users.json` entry's `phone` matches this admin phone,
  so it can never resolve to a userId. Add the phone to the person's `users.json`
  entry.
- `userid-not-admin` — the phone is bound to `userId`, but that userId is not in
  this account's `admins[]`. Add the userId to `admins[]`.

The gate is informational: it never blocks boot, and steady-state boots with
consistent bindings emit nothing.
