From daa40ec72d68be8b054b83838bd78db97c6cb39d Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Thu, 6 Aug 2026 00:46:30 +0200 Subject: [PATCH] =?UTF-8?q?docs(ai):=20admin=20AI=20Policy=20console=20spe?= =?UTF-8?q?c=20(=C2=A76)=20=E2=80=94=20presented=20for=20approval,=20not?= =?UTF-8?q?=20built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the 7 real gaps (per-class enable, model/provider allow-lists, seats/usage UI, retrieval off-switch, consent) against the existing entitlement.ts/policy.tsx backend, proposed AiConsoleConfig schema, new endpoints, and a 6-section UI layout. Companion visual mockup presented separately. No application code changed — spec + mockup only, as instructed. --- docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md | 165 +++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md diff --git a/docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md b/docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md new file mode 100644 index 00000000..d93eaa30 --- /dev/null +++ b/docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md @@ -0,0 +1,165 @@ +# 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>; + + /** 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).