# Migration Guide: 11.25.x → 11.26.0

## Overview

| Category | Details |
|----------|---------|
| **Breaking Changes** | None for the framework core. AI module is opt-in (disabled unless an `ai` config block is present). If you experimented with pre-release AI builds: read the **AI naming overhaul** section below — `CoreAiPromptTemplate` → `CoreAiSlot`, `CoreAiPromptSnippet` → `CoreAiPrompt`, routes / collection names changed, `'global'` user-prompt scope removed. |
| **New Features** | AI Assistant module: DB-backed LLM connections (encrypted API keys), provider-agnostic orchestrator (auto + plan mode), role-filtered tool registry, multi-turn conversations, SSE streaming, audit, per-user/tenant token budgets, an MCP server (optional OAuth 2.1), a prioritized connection-resolution chain, self-optimizing prompts (tenant-scoped admin slots + governed learning loop), per-session context-window handling with auto-detection, runtime placeholder registry, user-facing prompt templates ("Vorlagen") with placeholder substitution. |
| **Bugfixes** | MCP OAuth refresh-token rotation is now bound to `client_id` (impersonation fix); confidential MCP OAuth clients now actually verify their `client_secret`; conversation list endpoint no longer over-fetches the full `messages` array (use the detail endpoint to load message history). See "Operational notes" for details. |
| **Migration Effort** | None to adopt the update (no action required if you don't use AI). ~30–60 min to enable and integrate the AI module |

---

## Quick Migration (No Breaking Changes)

The AI module is **disabled by default**. If you do not add an `ai` config block, nothing changes — update and continue:

```bash
npm install @lenne.tech/nest-server@11.26.0
npm run update     # picks up the new (lazy) @modelcontextprotocol/sdk dependency if you enable MCP
npm run build
npm test
```

---

## What's New in 11.26.0

### AI Assistant Module

A reusable, fully overridable AI-assistant layer on top of the core. It is **provider-agnostic**: connections to any OpenAI-compatible endpoint (local runtimes such as Ollama, or hosted providers) are stored in the database and managed by admins at runtime. The orchestrator compensates for backends with limited capabilities (no native tool calling / no JSON responses) via emulated tool calling.

Full reference: `node_modules/@lenne.tech/nest-server/src/core/modules/ai/README.md`
Integration steps: `node_modules/@lenne.tech/nest-server/src/core/modules/ai/INTEGRATION-CHECKLIST.md`

#### 1. Enable the module (config)

Add an `ai` block to each relevant environment in `config.env.ts` (presence implies enabled; `enabled: false` keeps the config but disables it):

```typescript
ai: {
  maxIterations: 5,
  rateLimit: { max: 20, windowSeconds: 60 },
  // audit: true,                 // persist runs to aiInteractions (required for token budgets)
  // budget: { period: 'day', user: { maxTokens: 1_000_000 } },
  // mcp: true,                   // expose the tool registry as an MCP server at /ai/mcp
}
```

#### 2. Provide the secrets / env vars

API keys for connections are stored **AES-256-GCM encrypted at rest**. Provide an encryption secret. In dev/local/ci an insecure development default is used (with a warning); in **production/staging the app refuses to boot** when AI is enabled but no secret is set (same fail-loud guard as the email/cookie configs). The same applies to `NSC__AI__MCP__OAUTH_SECRET` when `ai.mcp.oauth` is enabled. Also note: if you change the encryption secret after keys were already stored, those keys can no longer be decrypted — the app logs the affected connections at boot and you must re-enter their API keys.

```bash
# .env (production)
NSC__AI__ENCRYPTION_SECRET=<32+ random chars>     # required when AI is used in production

# Optional one-time seed of a default connection (only seeded when AI_BASE_URL is set):
AI_BASE_URL=https://llm.example.com/v1
AI_API_KEY=your-llm-api-key
AI_MODEL=gpt-oss-120b
AI_SUPPORTS_JSON=true
AI_SUPPORTS_NATIVE_TOOLS=true

# Only when ai.mcp.oauth is enabled:
NSC__AI__MCP__OAUTH_SECRET=<32+ random chars>
```

See `.env.example` for the full list.

#### 3. Register tools (optional)

Tools self-register in the global `AiToolRegistry`. Implement `IAiTool` (or extend `AiTool`) and declare it as a provider in any module. Tools MUST route data access through `CrudService` with the caller's `serviceOptions` so `@Restricted`/`securityCheck` field filtering applies. See the reference tools in `src/server/modules/ai/tools/`.

#### 4. Enable the MCP server (optional)

If you set `ai: { mcp: true }` (or `ai: { mcp: { oauth: true, oauthSecret: '…' } }`), **install the MCP SDK** in your project:

```bash
pnpm add @modelcontextprotocol/sdk
```

The SDK is a peer-style optional dependency that the controller lazy-imports on the first MCP request — projects without MCP pay no install cost. When `ai.mcp` is set but the SDK is missing, `/ai/mcp` returns **503 Service Unavailable** with the install hint instead of a 500 trace.

For OAuth 2.1 (generic MCP clients with auto-discovery + dynamic registration), mount the OAuth router in `main.ts`:

```typescript
import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
// after the app is created, before listen():
await mountAiMcpOAuth(app, { baseUrl: process.env.BASE_URL });
```

Override `CoreAiMcpOAuthService.authorizeConsent()` for your login/consent UI.

### Capability auto-detection

`supportsJsonResponse` / `supportsNativeTools` on a connection are now optional with
**auto-detection**: leave them undefined and the module probes the endpoint to detect
support (eagerly on create, lazily on first prompt, and on demand via
`detectAiConnectionCapabilities` / `POST /ai/connections/:id/detect-capabilities`).
An explicit `true`/`false` is always authoritative and never probed. The optional
seed env vars `AI_SUPPORTS_JSON` / `AI_SUPPORTS_NATIVE_TOOLS` are now only applied
when set — omit them to auto-detect. See the README "Capability auto-detection" section.

### Connection-resolution chain

When multiple connections exist, one is chosen per request via a prioritized, fully overridable chain (global default → tenant default → user default → client selection → tenant-enforced → admin-enforced global → admin-enforced per tenant → code override). No connections → AI is effectively disabled (a translated "unavailable" response). Per-tenant availability is restrictable via the connection's `tenantIds`. See the README "Connection selection" section.

### Self-optimizing prompts (editable slots + governed learning)

The system prompt is assembled from **keyed slots** (`getSystemDefaultSlots()` ships built-in defaults, so it works with zero rows) — there are no hard-coded, inaccessible prompt strings. Admins can override any slot via the **tenant-scoped** `aiSlots` store (admin CRUD, GraphQL `…AiSlot(s)` / REST `/ai/slots`). The collection auto-records `tenantId` on every row from the request context; overrides apply only to the admin's tenant. `create()` is idempotent on `(tenantId, key)` so repeated "Override anlegen" actions never produce duplicates.

The admin REST surface adds two admin-UI helpers:
- `GET /ai/slots/effective` — framework defaults + tenant overrides + custom rows, each tagged with `isSystem` / `isOverride` so the UI knows which action to render.
- `POST /ai/slots/:id/reset` — delete a tenant override → framework default applies again.

Soft-deleting a system default for a tenant is done by storing an override row with `enabled: false`.

### User prompts ("Vorlagen") and the placeholder registry

User-facing re-usable prompts are stored in `aiPrompts` (each user authors them for themselves — `scope: 'user'` private — or for the whole tenant — `scope: 'tenant'` public; owner-only mutations). They're inserted into the chat input by a picker; the `'global'` scope from the early AI builds was removed (admins use slots for tenant-wide system-prompt changes instead).

Placeholders for slots AND user prompts (`{{userId}}`, `{{roles}}`, `{{tools}}`, `{{toolCatalog}}`, `{{documentation}}`, `{{learnedHints}}`) are resolved by `CoreAiPlaceholderRegistry` at run time. The current list is exposed via `GET /ai/placeholders` (S_USER); UIs should fetch it dynamically (don't hard-code names). Add project-specific placeholders by calling `registry.register({ name, description, resolve })` from any provider — no eval, no DB-stored function bodies.

**User-prompt placeholders are resolved before the LLM sees them.** `CoreAiService.prompt()` runs an extra `resolvePromptPlaceholders()` pass on the incoming `input.prompt` text — a stored user-prompt template like *"Erkläre dem Nutzer mit ID `{{userId}}` …"* gets the real value substituted at run time. Unknown tokens are left as-is so plain text with curly braces survives. The streaming path (`promptStream`) inherits the substitution via the shared entry point.

A **governed learning loop** records `suggested` hints (`aiPromptHints`, admin CRUD / `/ai/prompt-hints`) when tools fail; hints only reach the prompt once an admin **approves** them — set `ai.promptLearning.autoApply: true` to auto-approve. Hints only ADD guidance and can never relax the permission model. No action required to adopt; the defaults are active automatically.

### AI naming overhaul (only if you experimented with pre-release AI builds)

If you tested an early build of the AI module BEFORE 11.26.0, the rename below is breaking:

| Old (pre-release) | New (11.26.0) |
|---|---|
| `CoreAiPromptTemplate(Service)` | `CoreAiSlot(Service)` |
| `CoreAiPromptSnippet(Service)` | `CoreAiPrompt(Service)` |
| Collection `aiPromptTemplates` | `aiSlots` |
| Collection `aiPromptSnippets` | `aiPrompts` |
| REST `/ai/prompt-templates` | `/ai/slots` |
| REST `/ai/snippets` | `/ai/prompts` |
| GraphQL `…AiPromptTemplate(s)` | `…AiSlot(s)` |
| GraphQL `…AiPromptSnippet(s)` | `…AiPrompt(s)` |
| `promptTemplateService` override | `slotService` |
| `promptSnippetService` override | `promptService` |
| User-prompt `scope: 'global'` | Removed (admins use slots for tenant-wide changes) |

If you stored data in either pre-release collection, copy / re-create the rows in the new collections; the framework doesn't run a migration on its own. For production projects that never used the pre-release AI build there's nothing to do.

### Context window (per user/session, auto-detected)

Session histories are budgeted against the model's context window, which is **auto-detected per connection** (`ILlmProvider.detectContextWindow()` — Ollama `/api/show` probe, a known-model table, or the Claude alias) and persisted alongside the capability flags. Set `connection.contextWindow` to override, or `ai.contextWindow` (default `8192`) as the global fallback; overflowing sessions drop their oldest non-system turns and cap tool-results at `ai.maxToolResultChars` (default `12000`). No action required.

### Cumulative budget summary at every scope

`CoreAiBudgetService.buildSummary()` now always aggregates the user's running per-period token total via `getUsage({ userId }, period)` — including the soft `scope: 'llm'` fallback path where no hard limit is configured. Previously `usedTokens` was 0 for scope=`'llm'` and the UI fell back to per-request `promptTokens`. The new behaviour matches what user-facing token bars actually need (cumulative usage against whatever limit applies). Old client code that relied on `usedTokens === 0` under the `'llm'` scope keeps working but now sees a non-zero counter.

### Provider quota on the connection

Two new admin-only fields on `CoreAiConnection` let admins pin the provider's per-user soft quota once per connection (instead of every user / tenant override):

| Field | Type | Notes |
|---|---|---|
| `defaultUserMaxTokens` | `number` | Soft user-token cap over `defaultUserMaxPeriod`. Surfaced as `scope: 'llm'` (no 429). |
| `defaultUserMaxPeriod` | `'day' \| 'month' \| 'none'` | Defaults to `ai.budget.period` (or `'day'`) when unset. |

The budget-summary resolution chain is now:

1. User override (`aiBudgetLimits`) — hard 429 on overflow
2. Tenant override — hard 429
3. Config defaults (`ai.budget.user.maxTokens` / `tenant.maxTokens`) — hard 429
4. Connection `defaultUserMaxTokens` (NEW) — soft fallback (`scope: 'llm'`)
5. LLM `contextWindow` — last-resort soft fallback

No migration required to adopt; the soft fallback only kicks in when nothing else is configured.

### Tools are opt-in (READ THIS BEFORE INTEGRATING)

The AI module has **no automatic access to your domain models**. The framework registers two meta-tools (`ask_user_question`, `search_tools`); everything domain-specific must be registered by the project. A consumer project without an `AiToolsModule` boots fine but the assistant correctly answers *"I don't have a tool to do that"* for every domain question — that's NOT a bug.

**Action for new integrators:** copy the four reference tools (`find-users.tool.ts`, `get-user.tool.ts`, `delete-user.tool.ts`, `update-user-job-title.tool.ts`) from `node_modules/@lenne.tech/nest-server/src/server/modules/ai/tools/` into `<api>/src/server/modules/ai/tools/`, rewrite the relative `'../../../../core/...'` imports to the npm specifier `'@lenne.tech/nest-server'`, group them in an `AiToolsModule` (copy from the framework reference) and add it to `ServerModule.imports`. Security contract: every tool MUST route through a `CrudService` with `context.serviceOptions` so `@Restricted`, `securityCheck()` and tenant context apply. See `INTEGRATION-CHECKLIST.md` §3 + §4 for the step-by-step.

### New error codes

The module adds the `LTNS_0600`–`LTNS_0608` error codes (AI errors), translated de/en via the error-code module. No action required.

---

## Operational notes (no action required)

These are quality-of-life changes that don't require a migration but are worth knowing about:

### dotenv banner suppressed

The framework's `getEnvironmentConfig()` helper now calls `dotenv.config({ quiet: true })`, which silences dotenv's startup banner (`◇ injected env (N) from .env` + promotional `vestauth.com` tip). Genuine misconfiguration warnings still surface. Projects that call `dotenv.config()` directly are unaffected — if you want the same behaviour, add `{ quiet: true }` to your own call site.

### Vitest 4 + Vite 8 + unplugin-swc → set `oxc: false`

If your test stack uses Vitest 4 (which runs on Vite 8) together with `unplugin-swc`, you'll see this warning on every run:

> `esbuild` option is set to false, but `oxc` option was not set to false. `esbuild: false` does not have effect any more. […] please set `oxc: false` instead.

Vite 8 switched the default TS/JS transformer from esbuild to Oxc. `unplugin-swc` disables esbuild internally — without `oxc: false`, Oxc still runs in parallel to SWC. Fix in your `vitest.config.ts` (and `vitest-e2e.config.ts` if you have one):

```typescript
export default defineConfig({
  oxc: false,
  plugins: [swc.vite()],
  // …
});
```

This is a Vite-stack issue, not a nest-server change — but the framework's own configs were updated for it, so consumer projects on the same stack will hit it too.

### AI slot descriptions reworded

The GraphQL `description:` for AI Slot fields no longer uses `{{placeholders}}` notation (it now points to `GET /ai/placeholders` instead). This avoids spurious *"Unsupported interpolation"* warnings from spectaql / Handlebars-based doc generators. The behaviour of placeholder substitution is unchanged — the registry endpoint already documents the active token set.

### Conversation list excludes the `messages` array (PERF-1)

`findAiConversations` (GraphQL) and `GET /ai/conversations` (REST) now project the `messages` array OUT of the list result via `select: '-messages'`. Conversations carry up to 500 messages each — including them in every list call was unnecessary and grew the payload super-linearly for users with many long chats.

**If you were relying on the list endpoint to return `messages`**, switch to the per-id detail call:
- GraphQL: `getAiConversation(id) { id, messages { … } }`
- REST: `GET /ai/conversations/:id`

Both still return the full message history. The list endpoint is now meant for metadata only (id / title / createdAt / connectionId).

### MCP OAuth 2.1 security hardening (only relevant if `ai.mcp.oauth: true`)

Two security fixes landed for the OAuth layer between the initial 11.26.0 release and the patch:

- **Refresh tokens are now bound to the issuing client_id** (OAuth 2.1 §4.13.2 / §7.4). The previous `rotateRefreshToken(token)` accepted any caller — a stolen refresh token could be rotated by a different client and used to impersonate the original user. The signature is now `rotateRefreshToken(token, clientId)` and the SDK's `exchangeRefreshToken` is wired to forward the calling client's id.
- **`getClient()` now returns `client_secret`** so the MCP SDK's `clientAuth` middleware can verify it. Previously the secret was persisted on dynamic registration but dropped on read, silently downgrading confidential clients (`token_endpoint_auth_method !== 'none'`) to public clients (the SDK's secret-verify branch is gated by `if (client.client_secret) { … }`).

No action required for consumers that don't override `CoreAiMcpOAuthService`. If your project extends it, mirror the new `rotateRefreshToken(token, clientId)` signature and ensure `getClient()` does not strip `client_secret` from the returned shape.

### `ICoreModuleOverrides.ai` and `IAi.claudeCli` are now fully typed

These are additive type improvements — runtime behaviour is unchanged:

- `ICoreModuleOverrides.ai` now exposes `mcpClientService`, `modeService`, `placeholderRegistry`, `promptHintService`, `promptService`, `slotService`, `toolGrantService`, `toolPolicyService` (already accepted at runtime, TypeScript previously blocked them).
- `IAi.claudeCli?: { bin?, extraArgs?, maxBudgetUsd? }` is now typed — already consumed by `ClaudeCliProvider` but previously untyped.

If you cast to `as any` to pass these to `CoreModule.forRoot(...)`, you can drop the cast.

---

## Compatibility Notes

| Pattern | Status | Notes |
|---------|--------|-------|
| Projects without an `ai` config block | ✅ Unaffected | The module is not registered |
| Custom `security.secretFields` | ✅ Improved | The list is now **merged** (union) with the framework defaults instead of replacing them, so `password`/`apiKeyEncrypted`/etc. can no longer be accidentally dropped. If you relied on replacement to expose a default secret field, this changes behaviour (you cannot remove a built-in secret field anymore) |
| Overriding AI collaborators | ✅ Supported | All collaborators are overridable via `CoreModule.forRoot(env, { ai: { service, resolver, connectionResolver, slotService, promptService, … } })`. The override interface now lists every customization point explicitly |
| Clients that read `messages` from the conversation list endpoint | ⚠️ Behaviour change | `findAiConversations` / `GET /ai/conversations` no longer return the `messages` array. Use `getAiConversation(id)` / `GET /ai/conversations/:id` to load the full history. See "Operational notes → Conversation list excludes the `messages` array" |
| Custom `CoreAiMcpOAuthService` subclasses | ⚠️ Signature change | `rotateRefreshToken(token)` is now `rotateRefreshToken(token, clientId)` and `getClient()` returns `client_secret` / `client_secret_expires_at` / `token_endpoint_auth_method`. Mirror these changes in overrides. Stock service users are unaffected |

---

## Troubleshooting

| Issue | Solution |
|-------|----------|
| `aiPrompt` returns "No AI service is currently available" | No usable connection exists — create one (admin) or set `AI_BASE_URL` to seed a default |
| API keys encrypted with a dev default (warning logged) | Set `NSC__AI__ENCRYPTION_SECRET` (32+ chars) in production |
| Custom MCP controller endpoints return 404 | Ensure the MCP controller is enabled (`ai.mcp`) and OAuth is mounted in `main.ts` when using `ai.mcp.oauth` |
| `/ai/mcp` returns 503 "MCP server unavailable: the @modelcontextprotocol/sdk peer dependency is not installed" | Install the SDK in your project: `pnpm add @modelcontextprotocol/sdk` (it is a peer-style optional dep, lazy-imported only when MCP is used) |
| Token budgets not enforced | Budgets require `ai.audit: true` (usage is read from `aiInteractions`) |
| `aiPrompt` returns 500 "BSONError: input must be a 24 character hex string" | A client sent `conversationId: "null"` (literal string). The orchestrator now treats `"null"`/`"undefined"` strings as "no conversation" — upgrade to the latest 11.26.x patch if you still see this |

---

## Module Documentation

- AI module: `src/core/modules/ai/README.md`
- Integration: `src/core/modules/ai/INTEGRATION-CHECKLIST.md`
- Reference tools: `src/server/modules/ai/tools/`
- Configuration reference: `.claude/rules/configurable-features.md` (AI Assistant row + Module Override Pattern)
