# Capability Spec Delta: i18n (spec-conformance) **Package**: `@suaveplan/i18n` **Status**: PROPOSED --- ## MODIFIED Requirements ### I18N-02 — Zod schemas at the public boundary `InterpolationConfigSchema`, `ResolverConfigSchema`, and `CatalogOptionsSchema` MUST match the spec field names and MUST be invoked at construction time: - `InterpolationConfigSchema` validates `{ open: string (≥1, ≠close), close: string (≥1), escape: string (length 0 or 1), maxIterations: int 1..100 }`. - `ResolverConfigSchema` validates `{ strategies: ResolutionStrategy[] (non-empty), fallbackLocale: string (≥1), supportedLocales?: string[] }`. - `CatalogOptionsSchema` adds `strict?: boolean` (default `false`). `MessageCatalog`, `InterpolationEngine`, and `LocaleResolver` constructors MUST call `.parse(options)` and re-throw failures as `I18nError` with the relevant code (`I18N.INVALID_LOCALE` for resolver, `I18N.INTERPOLATION_ERROR` for interpolation, `I18N.MISSING_KEY` is reserved; for catalog use a generic `I18N.INVALID_OPTIONS` — see ADDED below) and `context.zodIssues` populated with the issue array. **Verified by:** `src/schemas/i18n.schemas.test.ts::"applies defaults on empty object"` AND `src/schemas/i18n.schemas.test.ts::"accepts custom values"` AND `src/conformance.test.ts::"constructor throws INVALID_OPTIONS on bad input"` ### I18N-03 — Message catalog contract `MessageCatalog` MUST: - Expose `set(locale, namespace, record)`, `load(locale, namespaceOrLoader, loader?)` (overloaded so legacy `load(locale, loader)` still compiles), `keys(locale, namespace?)`, `has(key, locale?)`, `get(key, locale?)`, `t(key, locale?)`. - Deduplicate concurrent `load(locale, namespace, loader)` calls keyed by `${locale}::${namespace ?? ''}` — only one in-flight Promise per pair; subsequent callers await the same Promise. - Wrap loader rejection as `I18nError(I18N.LOADER_FAILED, 'i18n.loader.failed', { cause, context: { locale, namespace } })`. - Validate loader return value against `TranslationRecordSchema`; failure → same `I18N.LOADER_FAILED`. - Return `LookupResult = { value: string | undefined; locale: TLocale; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }`. - In strict mode (default `false`), throw `I18nError(I18N.MISSING_KEY)` when no value found after handler fallback; otherwise return `{ value: undefined, ..., source: 'key' }` (and `t()` returns the key string). - Provide typed key inference: `t | (string & {})>(key: K)` and `get | (string & {})>(key: K, locale?)`. **Verified by:** `src/conformance.test.ts::"strict mode throws I18N.MISSING_KEY on miss"` AND `src/conformance.test.ts::"load() wraps loader rejection as I18N.LOADER_FAILED"` AND `src/core/catalog/catalog.test.ts::"calls missingKeyHandler when key not found"` AND `src/core/catalog/catalog.test.ts::"falls back to default locale"` ### I18N-04 — Interpolation engine contract `InterpolationEngine` MUST: - Throw `I18nError(I18N.INTERPOLATION_ERROR)` when an opening delimiter is found without a matching closing delimiter. - Wrap any thrown value from a `ValueFormatter` as `I18nError(I18N.INTERPOLATION_ERROR, 'i18n.interpolation.formatter_failed', { cause, context: { variable } })`. - Accept canonical option keys `{ open, close, escape, maxIterations }`. Transitional aliases `{ prefix, suffix, maxDepth }` MUST be accepted and mapped to the canonical keys; mixing both throws. **Verified by:** `src/conformance.test.ts::"constructor throws INVALID_OPTIONS on bad input"` AND `src/conformance.test.ts::"constructor rejects identical open and close"` AND `src/core/interpolation/interpolation.test.ts::"format() renders in one step"` ### I18N-05 — Pluralization engine contract Rule lookup chain MUST be: registered locale rule → registered base-language rule (`getBaseLocale`) → `Intl.PluralRules(locale).select(n)` mapped to `PluralCategory` → `defaultPluralRule`. In strict mode, if `Intl.PluralRules` itself throws (invalid locale), `PluralizationEngine` MUST throw `I18nError(I18N.PLURAL_RULE_NOT_FOUND)`. **Verified by:** `src/core/pluralization/pluralization.test.ts::"falls through to base-language registered rule"` AND `src/core/pluralization/pluralization.test.ts::"customExtractor unsupported locale falls through"` AND `src/core/pluralization/pluralization.test.ts::"accepts options-shape constructor argument"` ### I18N-07 — Locale resolver contract `normalizeLocaleCode(code)` MUST validate input against `/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/` and throw `I18nError(I18N.INVALID_LOCALE)` for non-matching strings (including empty). `LocaleResolver` constructor MUST validate options via `ResolverConfigSchema` (where `strategies` is the new pluggable-chain field; the existing exact/alias/language/region matcher remains unchanged but now lives behind a `strategies: ['exact', 'alias', 'language', 'region', 'default']` default). **Verified by:** `src/conformance.test.ts::"normalizeLocaleCode throws I18N.INVALID_LOCALE on garbage"` AND `src/locale/resolver/resolver.test.ts::"exact match"` AND `src/locale/resolver/resolver.test.ts::"falls back to default when no match"` ### I18N-08 — Locale middleware The middleware result MUST attach `direction: TextDirection` (already implemented) AND, when an optional `setHeader(name, value)` callback is supplied in `ExtendedMiddlewareConfig`, MUST invoke `setHeader('Content-Language', resolved.locale)` after the lookup completes. **Verified by:** `src/middleware/server/server.test.ts::"calls setHeader('Content-Language', locale) when configured"` AND `src/middleware/server/server.test.ts::"detects from cookie"` AND `src/middleware/server/server.test.ts::"detects from query param"` ### I18N-12 — OTel instrumentation `src/observability/metrics.ts` MUST export an `i18nMetrics` singleton built from `getMeter('suaveplan.i18n')`: - `translateTotal: Counter` — incremented inside `MessageCatalog.get` with attributes `{ outcome: 'hit' | 'miss', locale }`. - `localeResolveTotal: Counter` — incremented inside `detectLocale` with attributes `{ strategy, locale }`. - `translateDuration: Histogram` — records elapsed milliseconds for each `MessageCatalog.get` call with attribute `{ locale }`. All instruments MUST be no-ops when no exporter is registered (default behaviour of `@opentelemetry/api`). **Verified by:** `src/conformance.test.ts` (OTel instrument smoke tests) AND `tests/no-disallowed.test.ts::"found source files to check"` ## ADDED Requirements ### I18N-16 — `INVALID_OPTIONS` error code A new code `I18N.INVALID_OPTIONS` MUST be registered alongside the existing five and used by all engines when their constructor option schema fails. **Verified by:** `src/errors/i18n-error.test.ts::"accepts all five error codes"` AND `src/conformance.test.ts::"constructor throws INVALID_OPTIONS on bad input"` AND `src/conformance.test.ts::"constructor throws INVALID_OPTIONS on missing fallback"` ### I18N-17 — Typed key inference `@suaveplan/i18n` MUST export `DotPaths` (template-literal recursion over a `TranslationRecord`-like tree) and `MessageCatalog` MUST narrow `t`/`get` to `K extends DotPaths | (string & {})`. The `(string & {})` branch is the explicit escape hatch that preserves runtime flexibility while still letting TypeScript autocomplete known keys. **Verified by:** `src/conformance.test.ts` (DotPaths typed-inference smoke test) ### I18N-18 — `Intl.PluralRules` fallback `PluralizationEngine` MUST consult `Intl.PluralRules(locale).select(n)` after the registered-rule chain misses but before falling back to `defaultPluralRule`. This ensures CLDR-tracked languages added in future ICU updates work without registering a hand-rolled rule. **Verified by:** `src/core/pluralization/pluralization.test.ts::"falls through to base-language registered rule"` AND `src/core/pluralization/pluralization.test.ts::"customExtractor unsupported locale falls through"`