# Admin Session — restart survival and SDK-resume contract

The admin PIN-gated session-store is the in-memory `Map<sessionKey, Session>` at [`platform/ui/app/lib/claude-agent/session-store.ts`](../../../ui/app/lib/claude-agent/session-store.ts). Every `systemctl --user restart {brand}.service` (notably the one the agent arms 3 s after Cloudflare-setup completion via `systemd-run --on-active=3s` to avoid the cgroup trap) wipes that Map. This reference documents how an admin session survives the restart without forcing PIN re-entry, and how the SDK conversation chain is preserved across the gap.

## Signed sessionKey

`POST /api/admin/session` mints sessionKeys as HMAC-signed tokens (same primitive as `remote-auth.ts`'s `__remote_session` cookie, generalised to `session-store.ts`):

```
v1.<base64url(payloadJson)>.<base64url(hmac-sha256(secret, payloadJson))>

payload = { v: "adm", a: <accountId>, u: <userId>, c: <createdAtMs>, n: <16-byte hex nonce> }
```

The HMAC secret lives at `~/.${brand}/credentials/admin-session-secret` (mode `0o600`, parent dir `0o700`), self-provisioned on first read via the `wx`-create pattern from [`remote-auth.ts::getSecret`](../../../ui/app/lib/remote-auth.ts). The secret is separate from `REMOTE_SESSION_SECRET_FILE` (the `__remote_session` cookie key) — two token surfaces, two security domains, no token-confusion attack across schemas.

**Validation path.** `validateSession(sessionKey, 'admin')`:

1. In-memory `Map.get(sessionKey)` hit → existing behaviour (age check, grant check, return ok).
2. Map miss AND `agentType === 'admin'` → `tryRehydrateAdminSession(sessionKey)`:
   - Parse `v1.…` token, verify HMAC against the on-disk secret (timing-safe compare).
   - Schema-validate the payload (`v === 'adm'`, all required fields present).
   - TTL check: `Date.now() - payload.c <= 24h`. Expired → return `{kind: 'expired', ageMs}` → caller projects onto the existing `session-expired-age` rejection.
   - Otherwise, re-register the in-memory entry with `{agentType: 'admin', accountId, userId, wantsPriorConversation: true}` and return `{kind: 'ok', …}`.
3. Map miss with no valid token → `session-not-registered` (existing legacy `crypto.randomUUID()` sessionKeys land here).

**Observability.** `[session-rehydrate-from-token] sessionKey=… accountId=… userId=… ageMs=…` fires once per successful rehydrate in `server.log`. A tampered token fails HMAC and returns `invalid-token` silently (no log line — would otherwise be a noise/attack-amplifier surface); the request's downstream `[session] middleware-reject status=401 code=session-not-registered` is sufficient.

**Adjacent 401 surface — remote-auth gate.** The PIN-session reject-code taxonomy (`session-missing`, `session-not-registered`, `session-expired-age`, `grant-expired` — the `AdminRejectCode` union in `platform/ui/app/lib/useAdminFetch.ts`) shares its 401 transport with one code from a different security domain: the remote-auth gate (`server/index.ts`, the `__remote_session` cookie's 24h TTL) answers an expired/missing/invalid remote-auth token on any `/api/*` path with `401 {"code":"remote-auth-required"}` — document navigations keep getting the login HTML with a redirect. Client handling is deliberately split: the PIN codes route to enter-pin recovery, while `remote-auth-required` triggers a full page reload (the gate then serves the login page for the current path), because a PIN screen behind an expired remote-auth token cannot reach its own API. Every client site that POSTs to this 401 surface honours the split: `useAdminFetch`, the auth heartbeat, and the pre-auth login/onboarding POSTs in `useAdminAuth.ts` (`doLogin` → `POST /api/admin/session`; `handleSetPin`/`handleChangePin` → `POST`/`DELETE /api/onboarding/set-pin`). The login POST is the one most likely to hit an expired gate first, since the 24h TTL can lapse while the operator sits on `enter-pin`; treating its `remote-auth-required` 401 as a bad PIN (the pre-fix behaviour) left the operator stuck on "Invalid PIN" with the correct PIN. The gate's log line is `[remote-auth] login required … respond=401|html`.

## SDK-resume contract on PIN-rebind

The Anthropic SDK is stateless against its own JSONL: every cold-create with `resume: <agentSessionId>` reads `${CLAUDE_CONFIG_DIR}/projects/<encoded-cwd>/<agentSessionId>.jsonl` verbatim. The Real Agent graph (`Conversation.agentSessionId`) is just the pointer into the JSONL, not a parallel transcript. The PIN-rebind contract preserves this:

1. **Rehydrate** restores `(accountId, userId)` into the in-memory session. `sessionId` and `agentSessionId` are empty.
2. **Chat-route first POST** (`platform/ui/server/routes/admin/chat.ts`):
   - `consumeWantsPriorConversation(sessionKey)` returns true → look up prior admin conversation.
   - `getMostRecentAdminConversationForUser(accountId, userId)` returns `{sessionId, agentSessionId}` for the most recent admin Conversation node carrying a non-null `agentSessionId`.
   - `setSessionIdForSession(sessionKey, sessionId)` binds the prior `sessionId` so the operator's chat lands in the SAME conversation, not a new one.
   - `setAgentSessionId(sessionKey, agentSessionId)` seeds the in-memory session's pointer; subsequent `invokeAdminAgent`'s `getAgentSessionId(sessionKey)` returns this value naturally.
3. **`invokeAdminAgent`** passes `resume: <priorAgentSessionId>` to the SDK options. The SDK opens its on-disk JSONL for that session id and continues from there.

No `<previous-context>` prompt-stuffing. No Neo4j transcript replay. The SDK reads its own JSONL — the only canonical source of multi-block content (`thinking` with signed signatures, `tool_use`/`tool_result` chains, multi-modal blocks) the graph cannot losslessly reconstruct.

## PIN-rebind vs `/sessions/new`

The `wantsPriorConversation` marker is the discriminator between continuity and explicit fresh-start:

| Trigger | Marker set? | Chat-route behaviour |
|---|---|---|
| Signed-token rehydrate post-restart (`tryRehydrateAdminSession`) | yes | Resume prior conversation |
| Explicit PIN re-entry (`POST /api/admin/session`, `createAdminSession`) | yes | Resume prior conversation |
| Operator clicks "New conversation" (`POST /api/admin/sessions/new`) | NO | Cold-mint new conversation |

Single-shot: the chat-route consumes the marker on the first POST, so subsequent chats in the same session continue with the bound `sessionId` naturally.

## Observability summary

Per chat POST, one `[client-acquire]` line lands in the per-conversation stream log at `{accountDir}/logs/claude-agent-stream-<sessionId>.log`:

```
[client-acquire] sessionKey=<sk12>… resume=<priorAgentSessionId8|none> reason=<warm|cold|pin-rebind>
```

- `warm` — pool entry still alive (no restart in between)
- `cold` — fresh mint (no prior admin conversation, or `/sessions/new`)
- `pin-rebind` — post-restart resume of prior admin conversation

The pre-existing `[client-cold-create resumedFrom=<id>]` line from `client-pool.ts` fires once per actual SDK subprocess spawn; combined with `[client-acquire]` it gives a complete shape of every acquire-and-spawn event.

Diagnostic grep:

```bash
grep -E '\[session-rehydrate-from-token\]|\[client-acquire\] reason=pin-rebind' ~/.${brand}/logs/server.log
grep '\[client-acquire\]' ~/.${brand}/logs/claude-agent-stream-*.log
```

## AskUserQuestion investigation gate

PreToolUse hook `platform/plugins/admin/hooks/askuserquestion-investigate-gate.sh` blocks `AskUserQuestion` when no read-only investigation tool fired in the current turn. When the admin agent surfaces the operator-visible block message instead of a menu, the next move is to search the operator's literal phrase via `ToolSearch` or `Grep` first, then re-attempt the question with evidence. Grep `[ask-gate] decision=block` on `server.log` to count recurrences. Full contract in [`.docs/hooks.md`](../../../../.docs/hooks.md) under `askuserquestion-investigate-gate.sh`.

## Spawn-with-`initialMessage` wrapper

The Hono route `POST /api/admin/claude-sessions` at [`platform/ui/server/routes/admin/claude-sessions.ts`](../../../ui/server/routes/admin/claude-sessions.ts) is the cookie-auth entry point for opening an admin PTY session with a first user prompt (the Sidebar new-session-with-text path). It owns the per-spawn enrichment (`aboutOwner`, `dormantPlugins`, `activePlugins`, `specialistDomains`, `tunnelUrl`) and resolves `senderId` from the admin session cookie. The session manager binds to `127.0.0.1` only so the wrapper is the sole authorised caller. `dormantPlugins` is `installed − enabledPlugins` with one exclusion: PLUGIN.md frontmatter `surface: platform` (admin, docs) marks platform-shell plugins that ship with every install and are never opt-in features, so they never surface in the `<dormant-plugins>` nudge.

**Body schema.** `{channel?, permissionMode?, initialMessage?, specialist?, model?}`, cookie-auth only (`hidden: false`). `permissionMode` accepts one of five values matching Claude Code's CLI: `'default'`, `'acceptEdits'`, `'plan'`, `'auto'`, `'bypassPermissions'`. The wrapper rejects nothing structurally — string fields are coerced or defaulted (`channel` → `'browser'`, `permissionMode` → undefined, `initialMessage` → null if empty/whitespace).

**Auth surface.** One: `cookie` — `requireAdminSession` validates `session_key` and resolves `senderId` from the in-memory session store. The turn-recorder loopback bypass was removed; there is no loopback caller and no `adminSessionId` body field.

**Forwarded endpoint.** One upstream call per spawn-with-message: `POST {managerBase}/public-spawn` (enriched body, with `initialMessage` inlined when set). The manager appends `initialMessage` as the trailing positional argv to `claude` so the CLI processes it as the session's first user turn at PTY startup — the JSONL first `role=user` line equals `initialMessage` verbatim. No follow-up `/<sessionId>/input` call, no bracketed-paste. The HTTP response streams the spawn upstream body straight through.

**Caller.** `Sidebar.tsx`'s "+ New session" click opens the `NewSessionModal` — no POST fires on click. Modal submit POSTs `{channel:'browser', permissionMode, model, initialMessage}` where `initialMessage` is the operator's typed text verbatim; `permissionMode` and `model` are per-session overrides local to the modal. The sidebar's own mode trigger persists across refresh, new tab, and new device under `(accountId, userId)` via `/api/admin/session-defaults` — a single `:SpawnPreference` node MERGEd on the composite key with `permissionMode` and `model`. The on-the-wire signal that the contract held is the `[claude-session-manager:wrapper] spawn-request-in surface=cookie` log line followed by `forward-spawn-done`; it always carries `initialMessage=yes`, which a regressed client gate would flip to `no`.

## Session identifiers

The metadata pane surfaces two distinct identifier values. The three-id model was collapsed back to a single-identity contract: claude's bridge suffix and the JSONL basename UUID are two phases of one claude-side identity, not two operator-visible identifiers. The manager resolver accepts either phase on any route. The wire emits one canonical `sessionId` — whichever phase is current — and the pane shows one row.

**Re-seat mints a new id.** Model, mode, and effort are inception-only levers — claude reads them when a session is born and the `claude rc` daemon exposes no per-session switch — so changing one on an *existing* session forks it: `/api/admin/session-reseat` pre-mints a fresh `sessionId`, the manager `/rc-spawn` fork branch runs `--resume <old> --fork-session --session-id <new> --model <model>` (copying history into the new id), and the operator lands on the fork. The fork pins the chosen model and, when supplied, the chosen `permissionMode` (pushed as `--permission-mode`, one of the 5 composer-writable modes) and `effort` (`low|medium|high|xhigh`, merged into the per-spawn inline `--settings.effortLevel`); each is validated against its allowlist before reaching the argv (a present-but-disallowed value is a 400). Both `/chat`'s composer pickers and the dashboard's per-row Re-seat control drive this one fork, for webchat and WhatsApp/Telegram sessions alike. On `/chat`, every picker now carries the operator's *current* model, mode, and effort and overrides only the picked lever, so changing one never silently resets the other two (a non-writable current mode is omitted rather than rejected). When a re-seated webchat session runs in `default` ("Ask permissions") mode, a tool the agent attempts that needs approval surfaces an Allow/Deny prompt in `/chat` and blocks until the operator answers — over Claude Code's channel permission relay, the only interactive ask surface (WhatsApp stays text-only). The full write path is in [`admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).

**The footer reads model/effort/turn-count from one shared JSONL resolver.** The `/chat` footer's model / effort / turn-count are the manager `GET /:sessionId/meta` enrichment, read from the session's JSONL tail. That read resolves the JSONL by the **same all-slug scan** the transcript uses — `findExistingJsonlForSessionId`, covering top-level and `archive/` under every project slug — not the single house-cwd slug. Previously the enrichment gated on the `spawnCwd` slug, so a sub-account session rc-spawned in a different cwd (its JSONL under a different slug) reported `model:null turns:0` while its transcript rendered; the transcript already scanned all slugs. `findSessionProjectDir` (the transcript resolver) delegates to the same function, so the two are one resolver — a session findable on disk is never reported `model:null`. The standing line is `[session-meta] op=enrich session=<id8> resolver=scan jsonl=<found|absent> model=… effort=… turns=…`.

The same all-slug scan runs on the **row-absent branch** too. The manager's fs-watcher is armed to only the boot/house account's project dir, so a sub-account session (its JSONL under a different slug) has no watcher row and `resolveRow` returns nothing. Rather than 404 — which gives the footer no payload at all, blanking model/effort/thinking — `/meta` scans for the JSONL first and, when found, returns an enriched 200 built rowless (`pid:null`, `role:null`, `status:ended`, `activity:idle`; model/turns/tokens from the tail, effort from settings). Only a session id with no JSONL anywhere still 404s. The row-absent line carries a `row=absent` token to distinguish it: `resolver=scan row=absent jsonl=found …` for the enriched 200, `resolver=none row=absent jsonl=absent …` for the genuine 404. So a session the transcript can render can never blank its footer on account of a missing watcher row.

| Operator label | What it is | Manager wire field | Log key |
|---|---|---|---|
| `sessionId` | Claude's session. Two phases: bridge suffix (`session_xxx`, the URL segment in `claude.ai/code/<session_xxx>`, set when claude prints the `/remote-control` URL) and JSONL basename UUID (claude's intrinsic id on disk, bound when the first turn flushes the JSONL). Both phases coexist on a live row after URL capture; the manager wire emits the bridge form when set, falling back to the JSONL basename in the pre-URL-capture window. The resolver routes either phase to the same row, so callers never need to choose. | `sessionId` (collapsed from the earlier three-field surface `sessionId` + `claudeSessionId` + `bridgeSessionId`) | `sessionId=` |
| `accountId` | Account / workspace UUID. For the admin channel, the value the manager carries as `senderId` IS the accountId; for non-admin channels the same wire field carries a phone / email / chat-id (the channel-level sender), so its log key on those paths is still `senderId=`. | `senderId` (channel-agnostic) | `accountId=` in admin-flow emit sites; `senderId=` in channel-bridge sites |

## Per-session usage & cost metering

Each session row carries a usage icon (Lucide `Gauge`) that opens a modal with a per-day table: active time (thinking / messaging / tool use), token classes, and estimated GBP cost for Opus 5 and Sonnet 5, plus a TOTAL row. The modal fetches `GET /api/admin/session-usage?sessionId=<uuid>`, which proxies the manager's `GET /:sessionId/metering`. The admin agent reaches the identical table through the work-plugin MCP tool `session-metering` (loopback to the same endpoint) — one deterministic core, two adapters, byte-identical data. The full model, pricing provenance, and caveats (span ≠ active; cache-read dominates cost; tokens are turn-level with thinking redacted; Sonnet 5 shares the Opus tokenizer so its counts are used unscaled; `cache_creation` priced at the 5-minute rate) are in [`session-metering.md`](../../../.docs/session-metering.md).

## Memory MCP write-path outcome lines

Every write-path tool in the memory plugin MCP emits one structured line per invocation to `server.log` via the loopback `/api/admin/log-ingest` route. The operator can answer "did this write succeed?" from a single greppable surface — no need to cross-reference the per-session `mcp-memory-<sessionId>.log`.

Line shapes:

```
[mcp:memory] <tool-name> account=<id|—> session=<convId|—> labels=<csv|—> name=<value|—> result=ok elementId=<id|—> ms=<n>
[mcp:memory] <tool-name> account=<id|—> session=<convId|—> labels=<csv|—> name=<value|—> result=error reason=<slug> detail=<≤120 chars> ms=<n>
```

Enumerated `reason` slugs (failure path): `invalid-input | schema-unknown-label | schema-property-naming | account-isolation | producedby-task-not-found | constraint-violation | conflicting-element-id | invalid-target-node-id | gate-blocked | neo4j-driver-error`. Free-form error text remains in the `detail` field. New slugs are added only by amending the helper's union type; nothing silently falls through to a generic `unknown`.

Wired tools: `memory-write`, `memory-update`, `memory-delete`, `memory-restore`, `memory-empty-trash`, `memory-archive-write`, `memory-ingest`, `memory-ingest-extract`, `memory-ingest-web`, `memory-reindex`, `memory-edit-attachment`, `memory-rename-attachment`, `profile-update`, `profile-delete`, `graph-prune-denylist-add`, `graph-prune-denylist-remove`, `conversation-archive-derive-insights`, `conversation-archive-enrich-rejection`, `conversation-memory-expunge`. Read-path tools (`memory-search`, etc.) are intentionally out of scope — reads do not change state and their failures already reach the caller. (`memory-rank` and `memory-classify` were removed.)

Diagnostic grep:

```bash
# Did the most recent memory-write attempt land, and what was it?
grep '\[mcp:memory\] memory-write .* result=' ~/.${brand}/logs/server.log | tail

# All failed writes across every memory MCP tool, grouped by reason:
grep '\[mcp:memory\] .* result=error' ~/.${brand}/logs/server.log | awk -F'reason=' '{print $2}' | awk '{print $1}' | sort | uniq -c | sort -rn

# Did the loopback POST itself fail? (Helper falls back to stderr and re-writes
# the structured line, so the event is never lost — this just flags that the
# session-manager's /api/admin/log-ingest was unreachable for some window.)
grep '\[mcp:memory\] log-ingest-failed' ~/.${brand}/logs/mcp-memory-*.log
```

The existing raw capture is unchanged on error paths — the new structured line is additive, not a relocation. Crash-mode debugging continues to read the raw sink `mcp-memory-<sessionId>.log`; routine "did it land?" questions read `server.log`.

**Test contract.** Each of the 20 wired tools carries a `*-emit.test.ts` fixture under `platform/plugins/memory/mcp/src/tools/__tests__/` that mocks `fetch` and asserts the wire body. A refactor that drops `withWriteEvent` from any tool fails the matching tool's tests with a clear `expected "vi.fn()" to be called 1 times, but got 0 times` error, naming the tool. A shared helper at `__tests__/_helpers/emit-capture.ts` standardises the fetch capture + Neo4j Session stub so each tool test stays a few lines of mocks + one happy + one error assertion. Drift between the helper's body shape and the route's validator is pinned by an end-to-end test at `platform/ui/server/routes/admin/__tests__/log-ingest-mcp-end-to-end.test.ts`, which routes the helper's POST through the real Hono `log-ingest` route with a loopback `remoteAddress` shim.

## Diagnosing missing tools in production

When a database-operator spawn fails to call any of its 11 tools, the question is which of two layers dropped them:

- **Outcome A — tools missing at the model boundary.** The CLI / SDK exposes fewer tools than the framework's `--allowed-tools` argv listed. The model never sees the missing names, so it cannot call them and writes nothing to the graph.
- **Outcome B — tools present, model abstains.** The SDK exposes all 11 tools; the model has them but chooses not to call any.

Three log-line shapes, all keyed on the same database-operator `sessionId`, distinguish the two:

```
[pty-spawn-tool-inventory] sessionId=<id> specialist=<name> argv-tools=<n> mcp-listed-tools=<n> exposed=<csv> not-exposed=<csv>
[recorder-session-init]    sessionId=<id> specialist=<name> toolsExposedCount=<n> toolsExposed=<csv> toolsArgvCount=<n> toolsArgvMissingFromExposed=<csv|—> source=<probe>
[recorder-turn]            sessionId=<id> turnIndex=<n> stopReason=<r> textBytes=<n> text=<full json-escaped> toolUseNames=<csv|—> containsToolCallText=true|false
```

- `[pty-spawn-tool-inventory]` measures the **framework** layer — the argv built and the MCP servers' published tools. Argv-tools count must be 11; mcp-listed-tools must be ≥ 11.
- `[recorder-session-init]` (emitted by the JSONL tail in `claude-session-manager`) measures the **CLI/SDK** layer — what the SDK actually exposed to the model. The `source=` field names the data path; `toolsArgvMissingFromExposed` is the Outcome A fingerprint:
  - `source=system.init` / `command_permissions` / `deferred_tools_delta` ⇒ the SDK wrote an init record to the JSONL and the tail matched one of the three known shapes. The bridge-attached `--remote-control` mode writes `attachment.deferred_tools_delta`; the headless SDK init record (`system.subtype=init`) is what `claude -p --output-format=stream-json` modes write.
  - `source=headless-argv-fallback` ⇒ a finding (2026-05-20): headless `claude --verbose` PTY mode (which headless database-operator spawns use) does **not** write any tool-list record to the JSONL — none of the three probe shapes ever appears. The tail falls back to the spawner's `--allowed-tools` argv as the exposed set, independently verified by `[pty-spawn-tool-inventory]`'s `exposed=` field. Treat this source identically to a positive JSONL match: the argv set is authoritative for headless spawns.
  - `toolsArgvMissingFromExposed=—` ⇒ all argv tools made it through; Outcome A is ruled out.
  - Any non-`—` value enumerates the names the SDK dropped and is the diagnosis. Only meaningful when `source` is JSONL-derived; under `headless-argv-fallback` the exposed set equals argv by construction, so this field is always `—`.
- `[recorder-turn]` measures the **model** layer — Haiku's verbatim words (full `text=`, not the prior 240-char `textHead`) and the tool names it actually called (`toolUseNames`). `toolUseNames=—` paired with `toolsArgvMissingFromExposed=—` is the Outcome B signature: tools present, model abstained. `containsToolCallText=true` flags any assistant text that contains `<tool_call>`, `<function_calls>`, or `</invoke>` — the recorder model copying the tool-call shape into prose (the regression that was closed).

The three lines are emitted into `server.log` via the platform UI's `/api/admin/log-ingest` loopback route, so a single grep against `server.log` answers the question for any past, present, or future recorder spawn:

```bash
SID=<rec-sessionId>
grep -E "\[(pty-spawn-tool-inventory|recorder-session-init|recorder-turn|mcp:(memory|contacts|tasks))\] .*sessionId=${SID}" ~/.${brand}/logs/server.log
```

**Reading the output.**

1. The single `[pty-spawn-tool-inventory]` line proves what the framework provisioned. If `argv-tools < 11`, the bug is upstream of this evidence chain — the agent file or the spawn site.
2. The single `[recorder-session-init]` line proves what the model received. If `toolsArgvMissingFromExposed` is non-`—`, the named tools were dropped by the CLI/SDK; that is Outcome A and the fix lives in the `--mcp-config` resolution or the CLI/SDK handshake.
3. Each `[recorder-turn]` line records one assistant turn. If `toolUseNames=—` across every turn AND `toolsArgvMissingFromExposed=—`, the model could call the tools and chose not to; `text=` carries the model's verbatim reasoning (the 240-char head cap was dropped so the whole turn is observable from server.log) and is the next surface to read. A `containsToolCallText=true` row is the recorder-relapse signature: the model emitted tool-call shape as prose instead of a real `tool_use` block.

**Loopback gate.** The `/api/admin/log-ingest` route is loopback-only on both the socket address and (when present) the `X-Forwarded-For` header. A LAN client whose request reaches the route via a proxy still gets a 403 because XFF tokens are required to be loopback when XFF is set. Without this gate the evidence chain would be worthless — any LAN client could forge `[recorder-session-init]` lines under a spoofed sessionId.

## Out of scope

- Public/WhatsApp/Telegram sessions — out of scope. Their sessionKeys remain `crypto.randomUUID()` and follow the existing rejection-on-restart contract.
- Multi-user PIN identity carry-forward across the same signed token — the token is bound to one `userId`; rotating PINs mints a fresh token.
- Replacing `systemctl restart` with in-process cloudflared reload — separate task. The restart-survival contract documented here is the operator's safety net while that rewrite is pending.
