# Claude credential administration (Superadmin)

Owner decision, 2026-09-09. The extraction lane went down twice on an interactive
`claude auth login` that expired where nobody could see it: on 2026-09-09 the reused-corpus
regression sent all 40 documents into a dead session and scored none of them
([the regression decision](persistence-regression-decision-260909.md)). This adds the
credential to the platform: a long-lived token (`claude setup-token`), entered by a platform
superadmin, encrypted, tested by the worker, and only then used.

## What was decided

| Id | Decision |
|----|----------|
| D-AC-1 | The lifecycle is **save → test → activate**. A stored token is inactive; the worker proves it with one real call; only a credential that passed can be activated. Nothing puts an untested credential in front of production. |
| D-AC-2 | **The worker runs the test**, not the web host. The page queues the request and the worker answers within a poll cycle. The alternative — granting the IIS app pool execute rights on the pinned CLI — was refused: the web host does not run the extraction binary. |
| D-AC-3 | **Fail closed.** An activated token is passed as `CLAUDE_CODE_OAUTH_TOKEN`, which outranks the configuration directory's login, so a rejected credential stops the lane instead of silently falling back to a session nobody chose. |
| D-AC-4 | The bench keeps its own interactive login and its preflight refusal. "The bench opens no database" still holds, so it is not an identity in this store. |

## Facts this rests on, probed on the POC server (2026-09-09, CLI 2.1.259)

- `CLAUDE_CODE_OAUTH_TOKEN` **outranks** a signed-in configuration directory: with the variable set,
  `auth status` reports `authMethod: oauth_token` even though `claude.ai` credentials sit beside it.
- `auth status` proves **which** credential is configured, never that it works — a token invented on
  the spot still reported `loggedIn: true`. Only a call the API answers proves a credential, which is
  why the test is one real prompt (measured at about four US cents) and never runs on a timer.
- The web host cannot inspect any of this itself: `D:\ApolloData\worker-claude` and
  `C:\Apps\Sibyla\tools\claude\claude.exe` are ACLed to SYSTEM, Administrators and `SibylaWorker`.

## How to use it

1. On the server, signed in to the subscription, run `claude setup-token` and copy the token.
2. Open **Administration → Claude credentials** (`/admin/ai-credentials`, platform superadmin only).
3. Paste the token, add a note and the expiry if the issuing flow reported one, and save. Saving
   asks for the test automatically.
4. Within a minute the row shows **Passed** or **Failed**, with the CLI's own words on a failure.
5. Press **Activate**. The next document runs on that credential, and the credential it replaces is
   revoked in the same step.

**Revoking the active credential** leaves the worker on its configuration-directory login again —
the state this feature exists to replace. That is deliberate and it is one click, but nothing does
it by accident.

## What the page shows

The status panel is what the **worker** last reported (AICLIS): signed in or not, the method, the
account and plan, whether it is running on an activated token or the interactive login, and — while
it is on the interactive login — when that session expires, with a warning inside seven days. The
report is refreshed when the worker starts and every ten minutes; a report older than thirty minutes
is marked stale, because a silent reporter is exactly the failure this replaces.

## The page keeps itself current (added 2026-09-10)

A credential under test settles within seconds, and the first live use of this page found the gap:
saving left the row **Testing** with **Activate** disabled, the worker answered fourteen seconds
later, and nothing on screen changed — so the button the owner needed stayed greyed out and a click
on it did nothing at all. While any credential is in flight the page now reloads itself every three
seconds and stops as soon as nothing is pending; after three minutes without an answer it says so
instead of spinning. A credential that has passed carries a line telling you it is waiting to be
activated.

The status panel's **Last verified call** is also written from the credential's own test on the day
it goes live: a credential is proven *before* it is activated, so the status row's own last call is
empty then, and "Never" would read as "never verified".

## Where things live

| Piece | Where |
|-------|-------|
| Tables | `aicred` (credentials), `aiclis` (one status row per identity) — platform-scoped, no `owner_id`; RLS forced with `superuser_access` + `worker_access`; the AI-reader role is revoked from `aicred` as it is from `credst` |
| Migration | `20260909164953_AiCredentials` — applied to Main and Preview on 2026-09-09 |
| Encryption | `CredentialProtector`, purpose `Apollo.CredentialStore.v1`, the ring at `D:\ApolloData\dp-keys` that both hosts already share |
| Service | `Sibyla.Platform.Infrastructure/Ai/AiCredentialService.cs` |
| Page | `Sibyla.Web/Components/Pages/Ai/AiCredentials.razor` |
| Worker | `ClaudeCredentialStore`, `ClaudeAuthMonitor`, and the token resolution in `ClaudeCliProcess` |
| Fallback | `Worker:ClaudeOAuthToken` in the `SIBYLA_SECRETS_FILE` JSON still works and is used when no credential is activated |

## In production since 2026-09-10

Release `20260909-175028-829c4bb` carries this code on all three hosts. The lifecycle ran live that
morning: the token was saved at 00:41:18 UTC, the worker tested it at 00:41:32 — fourteen seconds,
outcome `ok`, "The CLI answered." — it was activated at 00:50:27, and the worker's next status report
at 00:51:33 read `source: token`. Extraction now runs on the stored credential.

The interactive login in `D:\ApolloData\worker-claude` is untouched and still valid until
2026-10-07; it is what the worker would fall back to if the credential were revoked.

The self-refresh and the "Last verified call" correction below were written after that sitting and
reach the browser only with the next release.

## E-mail alerts (added 2026-09-10)

The warning chips were visible only to whoever opened the page, which is the same blind spot as the
expired login. The web host now runs a sweep (`AiCredentialAlertSweep`, every ten minutes, first
pass two minutes after start) that reads the worker's last report and evaluates it with the **same
reading the page uses** (`AiCredentialHealth` in the domain, so a chip and an e-mail cannot disagree).
It e-mails the owner when it finds:

| Finding | Meaning |
|---|---|
| `no-report` | the worker has never reported |
| `stale` | the last report is more than thirty minutes old |
| `not-signed-in` | the CLI reports no credential configured |
| `call-rejected` | the last verified call was rejected, with the CLI's words |
| `login-expiring` | the worker runs on the interactive login and it expires within seven days |
| `token-expiring` | the worker runs on an activated token whose recorded expiry is within seven days (the page now shows this too, as **Token expires**) |

A finding is sent once and repeated every 24 hours while it stays open; when it goes, one message
says so. The memory is AUDLOG: platform rows (no owner, actor `system`) with actions
`ai.credential.alert.{finding}` and `ai.credential.alert.{finding}.cleared`, written only after the
message left, so a failed send is retried on the next pass. The page's **E-mail alerts** line shows
the recipients and the last alert.

**Configuration** belongs in the web host's `SIBYLA_SECRETS_FILE` JSON, never in a release:

```json
"Alerts": {
  "Smtp": { "Host": "smtp.example.net", "Port": 587, "UserName": "sibyla@example.net", "Password": "…", "From": "sibyla@example.net", "UseStartTls": true },
  "To": ["owner@example.net"],
  "SweepMinutes": 10,
  "RepeatHours": 24
}
```

Without an `Alerts` section the sweep still runs and logs its findings as warnings, sends nothing,
records nothing, and the page says **Alerts are not configured**. The sender is the framework's
SMTP client over STARTTLS, plain text, no attachments; it is the first outbound channel the platform
has and is used for nothing else. Coverage: `AiCredentialHealthTests` (the reading and the
planner), `AiCredentialAlertSweepTests` (the sweep over Preview with a recording sender).

## What is not covered

- No alert channel other than e-mail, and no alert for anything but the worker's credential.
- The bench identity is not managed here (D-AC-4); it is renewed with
  `local\renew-regression-login.ps1` and its runs refuse early when the login has lapsed.
- The page carries render and access smoke coverage (`RenderSmokeTests`, `/admin/ai-credentials`) and
  its labels are held by `LocalizationCoverageTests`; the save/test/activate lifecycle itself was
  proven by the live sitting above, not by an automated browser test.
