# 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.

## 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 Maxy 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. Task 626 removed the turn-recorder loopback bypass; 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. (Task 153.)

**Caller.** `Sidebar.tsx`'s "+ New session" click opens the `NewSessionModal` (Task 223) — 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` (Task 239) — 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 (Task 135)

The metadata pane surfaces two distinct identifier values. Task 141 collapsed the three-id model that Task 135 introduced back to Task 080's 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.

| 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 (see Task 141) `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 |

## Memory MCP write-path outcome lines (Task 166)

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-process `mcp-memory-stderr-<date>.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. (Task 424 removed `memory-rank` and `memory-classify`.)

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-stderr-*.log
```

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

**Test contract (Task 168).** 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 (Task 183)

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]` (Task 178) 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]` (Task 183 — 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` ⇒ Task 185 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 (Task 213: 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 Task 213 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 (Task 213 dropped the 240-char head cap 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.
