# Admin AI Policy Console — Spec (docs/AI-ASSISTANT-CONCEPT.md §6)

**Status: SPEC + MOCKUP ONLY — not implemented.** Per explicit instruction, this
is presented for approval before any of it is coded. Nothing in this document
has a corresponding UI yet; the referenced *existing* files are the real,
already-shipped backend this console would sit on top of.

## 1. Why this exists

Every AI policy lever that exists today is either hardcoded, env-only, or has
a data endpoint with no UI:

| Lever | Today | Gap |
|---|---|---|
| AI Assistant on/off | `FeatureGates.aiAssistantEnabled`, toggle in the generic Policy tab ([policy.tsx](../app/(main)/admin/_tabs/policy.tsx)) | None — this one's real and stays where it is. |
| Which classes (`local`/`server`/`public`) are reachable | [`lib/ai/types.ts`](../lib/ai/types.ts): `local`+`public` hardcoded on, `server` auto-added only if `AI_SERVER_BASE_URL` is set ([`app/api/ai/policy/route.ts`](../app/api/ai/policy/route.ts)) | No admin override. An admin cannot disable `public` (BYOK) org-wide, or disable `server` while keeping the env var set for staging. |
| Server-class model list | Every completion-capable model Ollama reports, unfiltered ([`app/api/ai/server/models/route.ts`](../app/api/ai/server/models/route.ts)) | No allow-list. If the shared Ollama host has a large/expensive model loaded, every user can select it. |
| BYOK (public) provider endpoints | Fully open — a user can point `baseUrl` at anything ([`lib/ai/local-settings.ts`](../lib/ai/local-settings.ts)) | No admin allow-list of approved providers/base URLs. Pure client trust today. |
| Entitlement / seats (`server` class) | Real enforcement + data endpoints exist ([`lib/ai/entitlement.ts`](../lib/ai/entitlement.ts), [`app/api/admin/ai/entitlement/route.ts`](../app/api/admin/ai/entitlement/route.ts)) | **No UI.** An admin today can only set `seatsTotal` via `curl` against the admin API. |
| Usage / metering ledger | Real, append-only, already recorded on every `server`-class call | **No UI.** Same — `curl` only. |
| Retrieval (mail content → embeddings) | Always on when the `server` leg is reachable ([`lib/ai/retrieval/mail-embeddings.ts`](../lib/ai/retrieval/mail-embeddings.ts)) | No org-level off switch. A privacy-conscious admin cannot disable server-side mail-content augmentation independent of disabling the whole `server` class. |
| BYOK consent | Schema has `publicConsentVersion: string \| null`, permanently `null` ([`lib/ai/types.ts`](../lib/ai/types.ts)) | No admin-authored consent text or version bump flow. |

This console is the single screen that closes all seven gaps.

## 2. Scope boundary

**In scope:** a new admin tab that reads/writes the levers above.
**Not in scope** (explicitly deferred, flag if wrong): per-user overrides
(everything here is tenant-wide), model *pricing*, any billing/invoice
integration beyond the existing metering ledger, S/MIME/theme consoles
(separate features).

## 3. Data model additions

New persisted config, `AiConsoleConfig`, stored via the existing
config-manager convention (CONFIG dir, operator-authored — see
[`lib/admin/paths.ts`](../lib/admin/paths.ts)'s `getConfigDir()` vs
`getStatePath()` distinction; this is config, seats/ledger stay in STATE
where `entitlement.ts` already puts them):

```ts
export interface AiConsoleConfig {
  /** Per-class admin override. A class must be BOTH infra-available
   *  (server: AI_SERVER_BASE_URL set) AND enabled here to reach users.
   *  Missing entries default to true (local/public) / false (server) —
   *  matches today's DEFAULT_AI_ENTITLEMENT.classes behavior exactly, so
   *  turning this feature on changes nothing until an admin touches it. */
  classesEnabled: Partial<Record<AiClass, boolean>>;

  /** null = every completion-capable model Ollama reports (today's
   *  behavior, unchanged). Non-null = only these model names selectable
   *  for the `server` class. */
  serverModelAllowlist: string[] | null;

  /** null = unrestricted BYOK base URLs (today's behavior, unchanged).
   *  Non-null = base URL must start with one of these prefixes, checked
   *  client-side (advisory — see §6 open question on server-side
   *  enforcement) at profile-save time. */
  publicProviderAllowlist: string[] | null;

  /** Master switch for the retrieval leg (mail-content → embeddings).
   *  Independent of classesEnabled.server: an admin can allow chat but
   *  disable content augmentation. Defaults true (today's behavior). */
  retrievalEnabled: boolean;

  /** Admin-authored consent shown once per user before first BYOK/public
   *  use. Replaces the permanently-null publicConsentVersion. Bumping
   *  `version` re-prompts every user (their locally-stored acceptance is
   *  keyed by version — client-side change, not in this doc's scope). */
  consent: { version: string; text: string } | null;
}

export const DEFAULT_AI_CONSOLE_CONFIG: AiConsoleConfig = {
  classesEnabled: {},
  serverModelAllowlist: null,
  publicProviderAllowlist: null,
  retrievalEnabled: true,
  consent: null,
};
```

Entitlement (`tier`, `seatsTotal`, seat list, ledger) needs **no new schema**
— [`lib/ai/entitlement.ts`](../lib/ai/entitlement.ts) already has everything
the console needs to read and write.

## 4. New/changed API endpoints

- **`GET /api/admin/ai/policy`** (new, admin-protected) — returns
  `AiConsoleConfig`.
- **`PUT /api/admin/ai/policy`** (new, admin-protected) — validates and
  persists partial updates, audit-logs each change
  (`ai.console.classes_updated`, `ai.console.consent_updated`, etc.,
  following the existing `auditLog()` convention in
  [`app/api/admin/policy/route.ts`](../app/api/admin/policy/route.ts)).
- **`GET /api/ai/policy`** (existing, extend) — folds `classesEnabled` into
  the `classes` computation (a class only appears if infra-available AND
  admin-enabled), adds `retrievalEnabled` and the current `consent` block to
  the response so the client can gate/prompt correctly.
- **`GET/PUT /api/admin/ai/entitlement`** (existing, unchanged) — the console
  UI simply gets a front-end for what already exists.

## 5. UI — new admin tab "AI"

New file `app/(main)/admin/_tabs/ai-policy.tsx`, registered alongside the
existing tabs (Policy, Themes, Plugins, …) in whatever wires up the sidebar
today. Follows the exact visual conventions already in
[`policy.tsx`](../app/(main)/admin/_tabs/policy.tsx): bordered
`rounded-lg` sections with a `bg-muted/30` header strip, the same toggle
switch markup, save button that only appears when dirty.

Six sections, top to bottom (see the companion mockup for the visual):

1. **Provider Classes** — three cards (Local / Server / Public), each a
   toggle + one line of status. Server's card shows "Not configured
   (AI_SERVER_BASE_URL unset)" and disables its own toggle when infra isn't
   there, rather than letting an admin flip on something that 503s.
2. **Server: Model Allow-list** — only visible/enabled when Server is on.
   Multi-select pulled live from `/api/ai/server/models`, defaulting to "all
   models" (today's behavior) with an explicit switch to "restrict to
   selected".
3. **Public (BYOK): Provider Allow-list** — same pattern, but base-URL
   prefixes instead of model names (e.g. `https://api.openai.com`,
   `https://api.anthropic.com`), free-text add/remove list, defaulting to
   unrestricted.
4. **Entitlement & Seats** — tier picker (base/standard/pro — cosmetic today,
   no different enforcement per tier, flagged as such), seat total number
   input, and a live table of assigned seats each with a "Revoke" button —
   direct front-end for the existing `PUT .../entitlement` with
   `revokeUsername`.
5. **Usage** — read-only table, last 200 rows of the metering ledger
   (timestamp, user, model, tokens, latency), plus a one-line rollup (calls
   today, total tokens this week) computed client-side from the same rows —
   no new aggregation endpoint needed for a first cut.
6. **Retrieval & Consent** — one toggle (retrieval on/off) + a textarea for
   consent text with a version string input and a "Bump version (re-prompt
   all users)" button.

## 6. Open questions for approval

1. **Provider allow-list enforcement point.** Spec above checks the BYOK
   base-URL allow-list client-side only (at profile-save time in Settings).
   True enforcement would require routing BYOK calls through this app's own
   backend (losing the "no CORS problem, no server cost" property that made
   `public` attractive as client-direct in the first place — see
   [[vncmail-ai-assistant-rollout]] decision #3). Recommend: ship client-side
   only for now, document it as advisory, revisit if it needs to be a real
   boundary.
2. **Tier semantics.** `tier` (base/standard/pro) exists in
   `AiEntitlementState` today but nothing reads it to change behavior (seat
   count is the only real gate). This console would let an admin set it
   without it doing anything yet. Recommend: keep the picker (cheap, matches
   the schema, avoids a future migration) but label it "cosmetic — no
   tier-differentiated behavior yet" in the UI itself, not just this doc.
3. **Where the master `aiAssistantEnabled` toggle lives.** Recommend leaving
   it in the generic Policy tab (single source of truth, already shipped,
   already tested) and just linking to it from the top of this new AI tab
   ("AI Assistant is currently **ON** — change in Policy →") rather than
   duplicating the toggle in two places.

## 7. Explicitly not building yet

Per-user/per-group overrides, billing/invoice integration, server-side
enforcement of the BYOK allow-list (see 6.1), any change to how `local`
works (stays free/unmonitored/client-direct, per standing decision).
