# OpenSpec Change: i18n-spec-conformance - **Change ID**: i18n-spec-conformance - **Package**: `@suaveplan/i18n` - **Status**: PROPOSED - **Date**: 2026-04-26 - **Reference**: `openspec/archive/2026-04-19-enterprise-i18n/specs/i18n/spec.md` ## Summary Close the conformance gaps between the shipped `@suaveplan/i18n` package and its archived enterprise spec, and add type-level guarantees that make the package future-proof for consumers. The original change archived as "all tasks retroactively ticked" but several spec contracts are unimplemented or drifted: OTel instrumentation is absent, `MessageCatalog` lacks strict mode / loader dedup / namespace-aware `set`/`load`/`keys` / `LookupResult.source`, schemas don't match spec field names and aren't enforced at construction, `InterpolationEngine` never throws `I18N.INTERPOLATION_ERROR`, `PluralizationEngine` lacks strict mode and a CLDR-backed fallback, and `LocaleResolver.normalizeLocaleCode` accepts any string instead of validating BCP-47. This change brings the package fully in line with the original spec, adds three type-safety upgrades (typed key inference via `DotPaths`, generic `LookupResult`, `Intl.PluralRules` fallback so new CLDR languages work without a code change), and re-establishes the 100/100/100/100 coverage gate. ## Current-state audit | Spec ID | Requirement | Status | |---------|-------------|--------| | I18N-01 | `I18nError` + 5 codes, side-effect register | ✅ codes registered; ⚠️ no side-effect import in `src/index.ts` | | I18N-02 | Zod schemas match spec shape, parse at boundary | ❌ field names drifted; ❌ never called at construction | | I18N-03 | `MessageCatalog` contract (`source`, `namespace`, strict, dedup, ns-aware `set`/`load`/`keys`) | ❌ all missing | | I18N-04 | `InterpolationEngine` throws on unclosed delim / formatter error | ❌ never throws | | I18N-05 | `PluralizationEngine` strict-mode throw | ❌ missing | | I18N-06 | `MessageParser` AST + `extractVariables` + `renderNodes` | ✅ shipped | | I18N-07 | Pluggable strategy chain, `ResolvedLocale.direction`, `INVALID_LOCALE` throw on garbage input | ⚠️ chain lives in `middleware/server` (acceptable rename); ❌ `normalizeLocaleCode` never throws | | I18N-08 | Middleware attaches `ResolvedLocale`, sets `Content-Language` | ⚠️ attaches result; missing `Content-Language` header path | | I18N-09 | Date formatting | ✅ shipped | | I18N-10 | Currency formatting | ✅ shipped | | I18N-11 | Utility functions | ✅ shipped | | I18N-12 | OTel meter + counters + histogram | ❌ entirely missing | | I18N-13 | Property tests | ⚠️ inline in unit tests instead of `tests/properties/` | | I18N-14 | Per-component benchmarks | ⚠️ bundled into single file | | I18N-15 | Hygiene (sideEffects, 100% coverage, no .skip/.only) | ✅ shipped | ## New additions for this change ### Type-level guarantees (future-proofing) - `DotPaths` template-literal type that recursively projects a nested `TranslationRecord` tree to its `'a.b.c'` leaf paths. - `MessageCatalog.t | (string & {})>(key: K)` — autocomplete + compile-time key safety, with a string escape hatch. - `LookupResult` carries the typed locale through. - `PluralizationEngine` falls back to `Intl.PluralRules(locale).select(n)` after locale-specific rule + base-language rule miss but before strict-mode throw, so CLDR drift no longer requires code changes for newly added languages. ### OTel instrumentation `src/observability/metrics.ts` exporting an `i18nMetrics` singleton built via `getMeter('suaveplan.i18n')`: - Counter `i18n.translate.total` — labels: `outcome` (`'hit' | 'miss'`), `locale` - Counter `i18n.locale.resolve.total` — labels: `strategy`, `locale` - Histogram `i18n.translate.duration_ms` — label: `locale` Wired into `MessageCatalog.get` and `detectLocale` (locale middleware). ### Strict mode + structured throws - `MessageCatalog` accepts `strict?: boolean` (default `false`); when on, missing keys throw `I18nError(I18N.MISSING_KEY)`. - `MessageCatalog.load(locale, namespace?, loader)` now wraps loader rejections as `I18nError(I18N.LOADER_FAILED)` with the original error in `cause`. - `MessageCatalog.load` deduplicates concurrent calls for the same `(locale, namespace)` pair via an in-flight `Map>`. - `MessageCatalog.set(locale, namespace, record)` and `MessageCatalog.keys(locale, namespace?)` accept a namespace argument. - `LookupResult` becomes `{ value: string | undefined; locale: TLocale; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }`. - `InterpolationEngine` throws `I18nError(I18N.INTERPOLATION_ERROR)` on unclosed delimiter and rethrows formatter exceptions with the same code wrapping the cause. - `PluralizationEngine` accepts `strict?: boolean`; when on, throws `I18nError(I18N.PLURAL_RULE_NOT_FOUND)` after rule + base-locale + `Intl.PluralRules` fallback all miss. - `normalizeLocaleCode(code)` validates a BCP-47-shaped tag (`/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/`) and throws `I18nError(I18N.INVALID_LOCALE)` otherwise. ### Schema realignment - `InterpolationConfigSchema` → `{ open, close, escape, maxIterations }` (renamed from `prefix/suffix/escapeHtml/maxDepth`). `InterpolationEngine` constructor accepts both shapes for one minor cycle, with the new shape canonical. - `ResolverConfigSchema` → `{ strategies: ResolutionStrategy[]; fallbackLocale: string; supportedLocales?: string[] }`. - `CatalogOptionsSchema` adds `strict?: boolean`. - All three engines call `.parse(options)` in their constructor; failures throw `I18nError` with the relevant code and the Zod issue list in `context.zodIssues`. ### Side-effect code registration `src/index.ts` adds a top-level `import './error-codes.js';` so consumers that import schemas only still trigger code registration. ## Available workspace deps | Package | Available | |---------|-----------| | `@suaveplan/error` | ✅ | | `@suaveplan/types` | ✅ | | `@opentelemetry/api` (catalog: `observability`) | ✅ | ## Impact - **Breaking**: `LookupResult` shape, `InterpolationEngine` option keys, `MessageCatalog.load` signature, `MessageCatalog.addMessages` renamed to `set` (alias retained for one cycle). - Version stays at `0.1.0` (genesis pre-1.0). - No external consumers on genesis yet; safe to land as-is.