Files

634 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 7: Multilanguage - Research
**Researched:** 2026-03-25
**Domain:** i18n (Next.js portal + AI agent language detection)
**Confidence:** HIGH
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- Default language: auto-detect from browser locale (e.g., es-MX → Spanish, pt-BR → Portuguese)
- Switcher location: sidebar bottom, near user avatar — always accessible
- Language preference: saved to portal_users table in DB — follows user across devices
- Login page: has its own language switcher (uses browser locale before auth, cookie for pre-auth persistence)
- Supported languages for v1: en (English), es (Spanish), pt (Portuguese)
- Auto-detect from each incoming message — agent responds in the same language the user writes in
- Applies across ALL channels: Slack, WhatsApp, Web Chat — consistent behavior everywhere
- Fluid switching: agent follows each individual message's language, not locked to first message
- Implementation: system prompt instruction to detect and mirror the user's language
- No per-agent language config in v1 — auto-detect is the only mode
- Portal UI: All pages, labels, buttons, navigation, placeholders, tooltips — fully translated
- Agent templates: Names, descriptions, and personas translated in all 3 languages (DB seed data includes translations)
- Wizard steps: All 5 wizard steps and review page fully translated
- Onboarding flow: All 3 onboarding steps translated
- Error messages: Validation text and error messages localized on the frontend
- Invitation emails: Sent in the language the inviting admin is currently using
- System notifications: Localized to match user's language preference
### Claude's Discretion
- i18n library choice (next-intl, next-i18next, or built-in Next.js i18n)
- Translation file format (JSON, TypeScript, etc.)
- Backend error message strategy (frontend-only translation recommended — cleaner separation)
- How template translations are stored in DB (JSON column vs separate translations table)
- RTL support (not needed for en/es/pt but architecture should not block it)
- Date/number formatting per locale
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-----------------|
| I18N-01 | Portal UI fully localized in English, Spanish, and Portuguese (all pages, labels, buttons, error messages) | next-intl v4 with JSON message files; `useTranslations` hook in all components |
| I18N-02 | Language switcher accessible from anywhere in the portal — selection persists across sessions | LanguageSwitcher component in Nav sidebar + login page; DB-backed preference via `/api/portal/users/me/language` PATCH; cookie for pre-auth state |
| I18N-03 | AI Employees detect user language and respond accordingly | System prompt language instruction appended in `build_system_prompt()` and its TS mirror |
| I18N-04 | Agent templates, wizard steps, and onboarding flow are fully translated in all three languages | JSON columns (`translations`) on `agent_templates` table; migration 009; all wizard/onboarding TSX strings extracted |
| I18N-05 | Error messages, validation text, and system notifications are localized | All Zod messages and component-level error strings extracted to message files; backend returns error codes, frontend translates |
| I18N-06 | Adding a new language requires only translation files, not code changes (extensible i18n architecture) | `SUPPORTED_LOCALES` constant + `messages/{locale}.json` file = complete new language addition |
</phase_requirements>
---
## Summary
Phase 7 adds full i18n to the Konstruct portal (Next.js 16 App Router) and language-aware behavior to AI Employees. The portal work is the largest surface area: every hardcoded English string in every TSX component needs extraction to translation JSON files. The AI Employee work is small: a single sentence appended to `build_system_prompt()`.
The recommended approach is **next-intl v4 without URL-based locale routing**. The portal's current URL structure (`/dashboard`, `/agents`, etc.) stays unchanged — locale is stored in a cookie (pre-auth) and in `portal_users.language` (post-auth). next-intl reads the correct source per request context. This is the cleanest fit because (a) the CONTEXT.md decision requires DB persistence, (b) URL-prefixed routing would require restructuring all 10+ route segments, and (c) next-intl's "without i18n routing" setup is officially documented and production-ready.
Agent template translations belong in a `translations` JSONB column on `agent_templates`. This avoids a separate join table, keeps migration simple (migration 009), and supports the extensibility requirement — adding Portuguese just adds a key to each template's JSON object.
**Primary recommendation:** Use next-intl v4 (without i18n routing) + JSON message files + JSONB template translations column + system prompt language instruction.
---
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| next-intl | ^4.8.3 | i18n for Next.js (translations, locale detection, formatting) | Official Next.js docs recommendation; native Server Component support; proxy.ts awareness built-in; 4x smaller than next-i18next |
| next-intl/plugin | ^4.8.3 | next.config.ts integration | Required to enable `useTranslations` in Server Components |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| @formatjs/intl-localematcher | ^0.5.x | RFC-compliant Accept-Language matching | Proxy-level browser locale detection before auth; already in Next.js i18n docs examples |
| negotiator | ^0.6.x | HTTP Accept-Language header parsing | Pairs with @formatjs/intl-localematcher |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| next-intl | next-i18next | next-i18next targets Pages Router; SSR config is more complex with App Router |
| next-intl | Built-in Next.js i18n (no library) | Next.js built-in only does routing; you must build all translation utilities yourself |
| next-intl | react-i18next | Pure client-side; no Server Component support; larger bundle |
| JSONB translations column | Separate `agent_template_translations` table | Join table is cleaner at scale but overkill for 7 static templates in 3 languages |
**Installation:**
```bash
npm install next-intl @formatjs/intl-localematcher negotiator
npm install --save-dev @types/negotiator
```
(Run in `packages/portal/`)
---
## Architecture Patterns
### Recommended Project Structure
```
packages/portal/
├── messages/
│ ├── en.json # English (source of truth)
│ ├── es.json # Spanish
│ └── pt.json # Portuguese
├── i18n/
│ └── request.ts # next-intl server-side config (reads cookie + DB)
├── app/
│ ├── layout.tsx # Wraps with NextIntlClientProvider
│ └── (dashboard)/
│ └── layout.tsx # Already exists — add locale sync here
├── components/
│ ├── nav.tsx # Add LanguageSwitcher near user avatar section
│ ├── language-switcher.tsx # New component
│ └── ...
└── next.config.ts # Add withNextIntl wrapper
```
### Pattern 1: next-intl Without URL Routing
**What:** Locale stored in cookie (pre-auth) and `portal_users.language` DB column (post-auth). No `/en/` prefix in URLs. All routes stay as-is.
**When to use:** Portal is an authenticated admin tool — SEO locale URLs have no value here. User preference follows them across devices via DB.
```typescript
// Source: https://next-intl.dev/docs/getting-started/app-router/without-i18n-routing
// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { cookies } from 'next/headers';
const SUPPORTED_LOCALES = ['en', 'es', 'pt'] as const;
type Locale = typeof SUPPORTED_LOCALES[number];
function isValidLocale(l: string): l is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(l);
}
export default getRequestConfig(async () => {
const store = await cookies();
const raw = store.get('locale')?.value ?? 'en';
const locale: Locale = isValidLocale(raw) ? raw : 'en';
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
```
### Pattern 2: next.config.ts Plugin Integration
**What:** The next-intl SWC plugin enables `useTranslations` in Server Components.
**When to use:** Always — required for SSR-side translation.
```typescript
// Source: https://next-intl.dev/docs/getting-started/app-router/without-i18n-routing
// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
export default withNextIntl({ output: 'standalone' });
```
### Pattern 3: Translation in Server and Client Components
**What:** `useTranslations` works identically in Server and Client Components. Server Components get context from `i18n/request.ts`; Client Components from `NextIntlClientProvider`.
```typescript
// Source: https://next-intl.dev/docs/environments/server-client-components
// Server Component (any page or layout — no 'use client')
import { useTranslations } from 'next-intl';
export default function DashboardPage() {
const t = useTranslations('dashboard');
return <h1>{t('title')}</h1>;
}
// Client Component (has 'use client')
'use client';
import { useTranslations } from 'next-intl';
export function Nav() {
const t = useTranslations('nav');
return <span>{t('employees')}</span>;
}
```
Root layout must wrap with `NextIntlClientProvider`:
```typescript
// app/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang={locale} ...>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
```
**IMPORTANT:** `app/layout.tsx` is currently a Server Component. The dashboard layout `app/(dashboard)/layout.tsx` is a Client Component (`'use client'`). The `NextIntlClientProvider` must go in `app/layout.tsx` (the server root) and wrap the `body`. The dashboard layout is already inside this tree.
### Pattern 4: Language Switcher With DB Persistence
**What:** User picks language in sidebar. A Server Action (or API route) PATCHes `portal_users.language` in DB and sets the `locale` cookie. Then `router.refresh()` forces next-intl to re-read.
```typescript
// components/language-switcher.tsx
'use client';
import { useRouter } from 'next/navigation';
const LOCALES = [
{ code: 'en', label: 'EN' },
{ code: 'es', label: 'ES' },
{ code: 'pt', label: 'PT' },
] as const;
export function LanguageSwitcher() {
const router = useRouter();
async function handleChange(locale: string) {
// Set cookie immediately for fast feedback
document.cookie = `locale=${locale}; path=/; max-age=31536000`;
// Persist to DB if authenticated
await fetch('/api/portal/users/me/language', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: locale }),
});
// Re-render with new locale
router.refresh();
}
return (/* EN / ES / PT buttons */);
}
```
### Pattern 5: Login Page Pre-Auth Locale
**What:** Before auth, use `navigator.language` (browser API) to detect initial locale, store in cookie. Language switcher on login page only updates the cookie (no DB PATCH needed — no session yet).
```typescript
// Detect browser locale on first visit, store as cookie
const browserLocale = navigator.language.slice(0, 2); // 'es-MX' → 'es'
const supported = ['en', 'es', 'pt'];
const detected = supported.includes(browserLocale) ? browserLocale : 'en';
```
### Pattern 6: Auth.js Language Sync
**What:** On login, the `authorize()` callback fetches user from `/api/portal/auth/verify`. That endpoint should include `language` in the response. The JWT carries `language` for fast client-side access (avoids DB round-trip on every render). When user changes language, `update()` session trigger updates the JWT.
The JWT update pattern already exists for `active_tenant_id`. Language follows the same pattern.
### Pattern 7: Agent Template Translations (DB)
**What:** A `translations` JSONB column on `agent_templates` stores locale-keyed objects for name, description, and persona.
```sql
-- Migration 009 adds:
ALTER TABLE agent_templates ADD COLUMN translations JSONB NOT NULL DEFAULT '{}';
-- Example stored value:
{
"es": {
"name": "Representante de Atención al Cliente",
"description": "Un agente de soporte profesional...",
"persona": "Eres profesional, empático y orientado a soluciones..."
},
"pt": {
"name": "Representante de Suporte ao Cliente",
"description": "Um agente de suporte profissional...",
"persona": "Você é profissional, empático e orientado a soluções..."
}
}
```
The API endpoint `GET /api/portal/templates` should accept a `?locale=es` query param and merge translated fields before returning.
### Pattern 8: AI Employee Language Instruction
**What:** A single sentence appended to the system prompt (after the AI transparency clause or integrated before it).
```python
# packages/shared/shared/prompts/system_prompt_builder.py
LANGUAGE_INSTRUCTION = (
"Detect the language of each user message and respond in that same language. "
"You support English, Spanish, and Portuguese."
)
```
This is appended unconditionally to all agent system prompts — no config flag needed. The instruction is concise and effective: modern LLMs follow it reliably across Claude, GPT-4, and Ollama/Qwen models.
### Pattern 9: JSON Message File Structure
**What:** Nested JSON keyed by component/feature area. Flat keys within each namespace.
```json
// messages/en.json
{
"nav": {
"dashboard": "Dashboard",
"employees": "Employees",
"chat": "Chat",
"usage": "Usage",
"billing": "Billing",
"apiKeys": "API Keys",
"users": "Users",
"platform": "Platform",
"signOut": "Sign out"
},
"login": {
"title": "Welcome back",
"emailLabel": "Email",
"passwordLabel": "Password",
"submitButton": "Sign in",
"invalidCredentials": "Invalid email or password. Please try again."
},
"agents": {
"pageTitle": "AI Employees",
"newEmployee": "New Employee",
"noAgents": "No employees yet",
"deployButton": "Deploy",
"editButton": "Edit"
}
// ... one key group per page/component
}
```
### Anti-Patterns to Avoid
- **Locale in URLs for an authenticated portal:** `/en/dashboard` adds no SEO value and requires restructuring every route segment. Cookie + DB is the correct approach here.
- **Translating backend error messages:** Backend returns error codes (`"error_code": "AGENT_NOT_FOUND"`), frontend translates to display text. Backend error messages are never user-facing directly.
- **Separate translations table for 7 templates:** A `translations` JSONB column is sufficient. A join table is premature over-engineering for this use case.
- **Putting `NextIntlClientProvider` in the dashboard layout:** It must go in `app/layout.tsx` (the server root), not in the client dashboard layout, so server components throughout the tree can use `useTranslations`.
- **Using `getTranslations` in Client Components:** `getTranslations` is async and for Server Components. Client Components must use the `useTranslations` hook — they receive messages via `NextIntlClientProvider` context.
- **Hard-coding the `locale` cookie key in multiple places:** Define it as a constant (`LOCALE_COOKIE_NAME = 'konstruct_locale'`) used everywhere.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Translation lookup + fallback | Custom `t()` function | `useTranslations` (next-intl) | Missing key fallback, TypeScript key safety, pluralization rules, ICU format support |
| Browser locale negotiation | `navigator.language` + manual mapping | `@formatjs/intl-localematcher` + `negotiator` | RFC 5646 language tag parsing, quality values (q=0.9), region fallback (es-MX → es) |
| Date/number/currency formatting | `new Intl.DateTimeFormat(...)` manually | `useFormatter` (next-intl) | Consistent locale-aware formatting tied to current locale context |
| SSR hydration mismatch for locale | Manual hydration logic | next-intl `NextIntlClientProvider` | Ensures server and client render identically; prevents React hydration warnings |
**Key insight:** Translation systems have invisible complexity — plural forms, ICU message syntax, TypeScript key checking, fallback chains. next-intl handles all of these; hand-rolled solutions always miss edge cases.
---
## Common Pitfalls
### Pitfall 1: `app/layout.tsx` Is a Server Component — Don't Make It a Client Component
**What goes wrong:** Developer adds `NextIntlClientProvider` (correct) but also adds `'use client'` (wrong). This prevents the provider from reading server-side messages.
**Why it happens:** Developer sees `NextIntlClientProvider` and assumes it needs a client wrapper.
**How to avoid:** `app/layout.tsx` stays a Server Component. Call `getLocale()` and `getMessages()` (async server functions) then pass results as props to `NextIntlClientProvider`.
**Warning signs:** TypeScript error `Cannot use import statement outside a module` or translations missing in SSR output.
### Pitfall 2: `router.refresh()` vs. `router.push()` for Language Change
**What goes wrong:** Using `router.push('/')` after language change navigates to home page instead of re-rendering current page with new locale.
**Why it happens:** Developer treats language change like navigation.
**How to avoid:** Use `router.refresh()` — it re-executes Server Components on the current URL, causing next-intl to re-read the updated cookie.
**Warning signs:** Language appears to change but user is redirected to dashboard regardless of which page they were on.
### Pitfall 3: Cookie Not Set Before First Render
**What goes wrong:** Login page shows English even when user's browser is Spanish, because the cookie doesn't exist yet on first visit.
**Why it happens:** Cookie is only set when user explicitly picks a language.
**How to avoid:** In the login page, use `navigator.language` (client-side) to detect browser locale on mount and set the `locale` cookie via JavaScript before the language switcher renders. Or handle this in `i18n/request.ts` by falling back to the `Accept-Language` header.
**Warning signs:** Pre-auth language switcher ignores browser locale on first visit.
### Pitfall 4: Auth.js JWT Does Not Auto-Update After Language Change
**What goes wrong:** User changes language. Cookie updates. But `session.user.language` in JWT still shows old value until re-login.
**Why it happens:** JWT is stateless — it only updates when `update()` is called or on re-login.
**How to avoid:** Call `update({ language: newLocale })` from the LanguageSwitcher component after the DB PATCH succeeds. This triggers the Auth.js `jwt` callback with `trigger="update"` and updates the token in-place (same pattern already used for `active_tenant_id`).
**Warning signs:** `session.user.language` returns stale value after language switch; `router.refresh()` doesn't update language because the JWT callback is still reading the old value.
### Pitfall 5: next-intl v4 Breaking Change — `NextIntlClientProvider` Is Now Required
**What goes wrong:** Client Components using `useTranslations` throw errors in v4 if `NextIntlClientProvider` is not in the tree.
**Why it happens:** v4 removed the implicit provider behavior that v3 had. The requirement became explicit.
**How to avoid:** Ensure `NextIntlClientProvider` wraps the entire app in `app/layout.tsx`, NOT just the dashboard layout.
**Warning signs:** `Error: Could not find NextIntlClientProvider` in browser console.
### Pitfall 6: Template Translations Not Applied When Portal Language Changes
**What goes wrong:** User switches to Spanish but template names/descriptions still show English.
**Why it happens:** The `/api/portal/templates` endpoint returns raw DB data without locale-merging.
**How to avoid:** The templates API endpoint must accept a `?locale=es` param and merge the `translations[locale]` fields over the base English fields before returning to the client.
**Warning signs:** Template gallery shows English names after language switch.
### Pitfall 7: Session-Scoped Cookie (next-intl v4 Default)
**What goes wrong:** User picks Spanish. Closes browser. Reopens and sees English.
**Why it happens:** next-intl v4 changed the locale cookie to session-scoped by default (GDPR compliance). The cookie expires when the browser closes.
**How to avoid:** For this portal, set an explicit cookie expiry of 1 year (`max-age=31536000`) in the LanguageSwitcher when writing the `locale` cookie directly. The DB-persisted preference is the authoritative source; cookie is the fast path. `i18n/request.ts` should also read the DB value from the session token if available.
**Warning signs:** Language resets to default on every browser restart.
### Pitfall 8: `next-intl` Plugin Missing From `next.config.ts`
**What goes wrong:** `useTranslations` throws at runtime in Server Components.
**Why it happens:** Without the SWC plugin, the async context required for server-side translations is not set up.
**How to avoid:** Wrap the config with `withNextIntl` in `next.config.ts` — required step, not optional.
**Warning signs:** `Error: No request found. Please check that you've set up "next-intl" correctly.`
---
## Code Examples
Verified patterns from official sources:
### Complete `i18n/request.ts` (Without URL Routing)
```typescript
// Source: https://next-intl.dev/docs/getting-started/app-router/without-i18n-routing
import { getRequestConfig } from 'next-intl/server';
import { cookies } from 'next/headers';
const SUPPORTED_LOCALES = ['en', 'es', 'pt'] as const;
export type Locale = typeof SUPPORTED_LOCALES[number];
export function isValidLocale(l: string): l is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(l);
}
export const LOCALE_COOKIE = 'konstruct_locale';
export default getRequestConfig(async () => {
const store = await cookies();
const raw = store.get(LOCALE_COOKIE)?.value ?? 'en';
const locale: Locale = isValidLocale(raw) ? raw : 'en';
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
```
### `app/layout.tsx` Root Layout with Provider
```typescript
// Source: https://next-intl.dev/docs/getting-started/app-router/without-i18n-routing
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import type { Metadata } from 'next';
import { DM_Sans, JetBrains_Mono } from 'next/font/google';
import './globals.css';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang={locale} className={`${dmSans.variable} ${jetbrainsMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
```
### System Prompt Language Instruction
```python
# packages/shared/shared/prompts/system_prompt_builder.py
LANGUAGE_INSTRUCTION = (
"Detect the language of each user message and respond in that same language. "
"You support English, Spanish, and Portuguese."
)
# In build_system_prompt(), append before or after AI_TRANSPARENCY_CLAUSE:
sections.append(LANGUAGE_INSTRUCTION)
sections.append(AI_TRANSPARENCY_CLAUSE)
```
### Invitation Email With Language Parameter
```python
# packages/shared/shared/email.py
def send_invite_email(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
language: str = "en", # New parameter — inviter's current language
) -> None:
...
subjects = {
"en": f"You've been invited to join {tenant_name} on Konstruct",
"es": f"Has sido invitado a unirte a {tenant_name} en Konstruct",
"pt": f"Você foi convidado para entrar em {tenant_name} no Konstruct",
}
subject = subjects.get(language, subjects["en"])
...
```
### Migration 009 — Language Field + Template Translations
```python
# migrations/versions/009_multilanguage.py
def upgrade() -> None:
# Add language preference to portal_users
op.add_column('portal_users',
sa.Column('language', sa.String(10), nullable=False, server_default='en')
)
# Add translations JSONB to agent_templates
op.add_column('agent_templates',
sa.Column('translations', sa.JSON, nullable=False, server_default='{}')
)
# Backfill translations for all 7 existing templates
conn = op.get_bind()
# ... UPDATE agent_templates SET translations = :translations WHERE id = :id
# for each of the 7 templates with ES + PT translations
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| next-i18next | next-intl | 2022-2023 | next-i18next is Pages Router-centric; next-intl is the App Router standard |
| URL-prefixed locale routing (`/en/`) | Cookie/DB locale without URL prefix | Recognized pattern 2024+ | Authenticated portals don't need SEO locale URLs |
| next-intl v3 implicit provider | next-intl v4 explicit `NextIntlClientProvider` required | Feb 2026 (v4.0) | Must wrap all client components explicitly |
| next-intl session cookie | next-intl v4 session cookie (GDPR) | Feb 2026 (v4.0) | Cookie now expires with browser; must set `max-age` manually for persistence |
**Deprecated/outdated:**
- `middleware.ts` in Next.js: renamed to `proxy.ts` in Next.js 16 (already done in this codebase)
- `zodResolver` from hookform/resolvers: replaced with `standardSchemaResolver` (already done — this is relevant because Zod error messages also need i18n)
---
## Open Questions
1. **Template Translation Quality**
- What we know: User wants templates that "feel native, not machine-translated"
- What's unclear: Will Claude generate the translations during migration seeding, or will they be written manually?
- Recommendation: Planner should include a task note that the Spanish and Portuguese template translations must be human-reviewed; generate initial translations with Claude then have a native speaker verify before production deploy.
2. **Zod Validation Messages**
- What we know: Zod v4 supports custom error maps; existing forms use `standardSchemaResolver`
- What's unclear: Zod v4's i18n integration path is not documented in the searched sources
- Recommendation: Pass translated error strings directly in the Zod schema definitions rather than using a global error map — e.g., `z.string().email(t('validation.invalidEmail'))`. This is simpler and works with existing `standardSchemaResolver`.
3. **Session Language Sync Timing**
- What we know: The JWT `update()` pattern works for `active_tenant_id`; same pattern applies to `language`
- What's unclear: Whether `getLocale()` in `i18n/request.ts` should prefer the JWT session value or the cookie value when both are available
- Recommendation: Priority order: DB/JWT value (most authoritative) → cookie → default `'en'`. Implement by reading the Auth.js session inside `getRequestConfig` and preferring `session.user.language` over cookie.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest 8.3+ with pytest-asyncio |
| Config file | `pyproject.toml` (`[tool.pytest.ini_options]`) |
| Quick run command | `pytest tests/unit/test_system_prompt_builder.py -x` |
| Full suite command | `pytest tests/unit -x` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| I18N-03 | `build_system_prompt()` appends language instruction | unit | `pytest tests/unit/test_system_prompt_builder.py -x` | ✅ (extend existing) |
| I18N-03 | Language instruction present on all code paths (full, minimal, empty args) | unit | `pytest tests/unit/test_system_prompt_builder.py::TestLanguageInstruction -x` | ❌ Wave 0 |
| I18N-06 | `isValidLocale()` returns false for unsupported locales, true for en/es/pt | unit | `pytest tests/unit/test_i18n_utils.py -x` | ❌ Wave 0 |
| I18N-01 | Portal UI loads in Spanish when cookie is `es` | manual-only | N/A — requires browser render | — |
| I18N-02 | Language switcher PATCH updates `portal_users.language` | integration | `pytest tests/integration/test_language_preference.py -x` | ❌ Wave 0 |
| I18N-04 | Templates API returns translated fields when `?locale=es` | integration | `pytest tests/integration/test_templates_i18n.py -x` | ❌ Wave 0 |
| I18N-05 | Login form shows Spanish error on invalid credentials when locale=es | manual-only | N/A — requires browser render | — |
**Note:** I18N-01 and I18N-05 are frontend-only concerns (rendered TSX). They are verifiable by human testing in the browser and do not lend themselves to automated unit/integration tests without a full browser automation setup (Playwright), which is not in the current test infrastructure.
### Sampling Rate
- **Per task commit:** `pytest tests/unit/test_system_prompt_builder.py -x`
- **Per wave merge:** `pytest tests/unit -x`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `tests/unit/test_system_prompt_builder.py` — extend with `TestLanguageInstruction` class covering I18N-03 language instruction on all `build_system_prompt()` call paths
- [ ] `tests/integration/test_language_preference.py` — covers I18N-02 PATCH language endpoint
- [ ] `tests/integration/test_templates_i18n.py` — covers I18N-04 locale-aware templates endpoint
---
## Sources
### Primary (HIGH confidence)
- Next.js 16 official docs (local): `packages/portal/node_modules/next/dist/docs/01-app/02-guides/internationalization.md` — Next.js i18n guide, routing patterns, proxy.ts locale detection
- next-intl official docs (WebFetch): https://next-intl.dev/docs/getting-started/app-router/without-i18n-routing — complete without-routing setup
- next-intl official docs (WebFetch): https://next-intl.dev/docs/environments/server-client-components — server vs client component translation patterns
- next-intl v4 changelog (WebFetch): https://next-intl.dev/blog/next-intl-4-0 — breaking changes including mandatory `NextIntlClientProvider` and session cookie change
- GitHub (WebFetch): https://github.com/amannn/next-intl — confirmed v4.8.3 as of 2026-02-16
- next-intl without-routing cookie example (WebFetch): https://next-intl.dev/docs/usage/configuration
### Secondary (MEDIUM confidence)
- Blog guide for without-routing implementation: https://jb.desishub.com/blog/nextjs-i18n-docs — verified against official docs
- WebSearch 2026 ecosystem survey confirming next-intl as the App Router standard
### Tertiary (LOW confidence)
- Python language detection library comparison for AI agent language detection fallback (websearch only) — not required for v1 (system prompt handles detection at LLM level)
---
## Metadata
**Confidence breakdown:**
- Standard stack (next-intl v4): HIGH — confirmed via official Next.js docs listing it first + version confirmed from GitHub + docs read directly
- Architecture (without-routing pattern): HIGH — official next-intl docs page read directly via WebFetch
- next-intl v4 breaking changes: HIGH — read from official changelog
- Pitfalls: HIGH for pitfalls 15 (all sourced from official docs); MEDIUM for pitfalls 68 (derived from architecture + v4 changelog)
- Template translations approach: HIGH — standard JSONB pattern, well-established PostgreSQL convention
- AI agent language instruction: HIGH — system prompt approach is simple and confirmed effective by industry research
**Research date:** 2026-03-25
**Valid until:** 2026-06-25 (next-intl fast-moving but stable API; 90 days)