# i18n Modular Split — `@suaveplan/i18n` → 14 Sibling Packages - **Date:** 2026-05-05 - **Status:** DESIGN APPROVED — pending spec-review loop, then transitions to OpenSpec proposals + implementation plan - **Scope:** Split the current `@suaveplan/i18n` monolith into 14 sibling packages under `packages/i18n/` matching the granularity of the rest of the genesis monorepo (cf. `crypto-*`, `drive-*`, `network-plugin-*`) - **Branch:** `genesis` - **Supersedes / amends (existing OpenSpec changes):** Folds the four already-shipped i18n changes (`i18n-storage-backends`, `i18n-formatters`, `i18n-spec-conformance`, `i18n-completion` — currently misplaced under `openspec/changes/archive/2026-05-04-*/`) into a coherent multi-package layout. Existing capability spec at `openspec/specs/i18n/spec.md` survives but is split across new capability files. --- ## 1. Overview `@suaveplan/i18n` today is a single ~121-source-file package that bundles eight unrelated concerns (catalog, parser, interpolation, pluralization, formatters, backends, detector, observability) behind one entry point. Every other domain in the monorepo with comparable surface area splits into many fine-grained sibling packages — `crypto-hash`, `crypto-curve`, `crypto-cipher`, `crypto-utils`, `crypto-vault`, `crypto-cli`, `crypto-bin`, `crypto-sodium`, `crypto-browser`, `crypto-core`, `crypto`; `drive`, `drive-template`, `drive-mesh-protocol`, `drive-plugin-chunks`, `drive-sharing`, `drive-transport-https`; `network` plus a dozen `network-plugin-*` siblings. The four OpenSpec changes that built the monolith were even **named** like sub-packages (`i18n-storage-backends`, `i18n-formatters`) — strongly implying split intent — but the implementation collapsed them into one package. This change brings i18n in line with the monorepo pattern: 13 new sibling packages plus a slimmed-down core, in dependency order so each layer commits before the next, and the core rewrite is the very last step. ### 1.1. Decision summary | Topic | Decision | Section | |---|---|---| | Number of packages | 14 (1 shared runtime + 1 ICU parser + 8 formatters + 1 detector + 5 backends + 1 slim core; plus types-only additions to `@suaveplan/types`) | §3 | | Granularity for backends | Per-backend (matches monorepo "split when external deps differ" rule — `storage` pulls `@suaveplan/storage`, `fs-multifile` is server-only) | §6 | | Granularity for formatters | Per-formatter (matches user-stated lean toward fine-grained; trivial overhead since each is a thin `Intl.*` wrapper) | §5 | | Where shared atoms live | New `@suaveplan/i18n-shared` (runtime constants + `I18nError` + `I18N_CODES`) plus type-only additions to `@suaveplan/types` | §4 | | Backend interface location | `@suaveplan/types/i18n` (zero runtime — same pattern as the cross-package crypto contract) so backend packages do not depend on `@suaveplan/i18n` core | §4.1, §6 | | Core slim scope | Catalog + plugin slot (`createI18n` / `I18nInstance`) + middleware + observability + key utils + missing-key flow + schemas + conformance harness | §7 | | Back-compat | Slim `@suaveplan/i18n` root barrel re-exports the entire pre-split surface from the new packages — every existing `import { X } from "@suaveplan/i18n"` keeps working | §8 | | Wave order | 1 foundation → 2 parser → 3 formatters → 4 detector → 5 backends → 6 core (LAST) | §9 | | Crash-safety | Waves 1–5 are pure additions (zero edits to `packages/i18n/i18n/src/`). Each wave commits before the next. Only Wave 6 is destructive. | §9 | ### 1.2. Final package layout ```text packages/i18n/ i18n/ ← Wave 6: slim core (catalog + plugin + middleware + observability) i18n-shared/ ← Wave 1: constants + I18N_CODES + I18nError class i18n-icu-parser/ ← Wave 2: parser + interpolation + pluralization i18n-formatter-number/ ← Wave 3: Intl.NumberFormat wrapper i18n-formatter-relative-time/ ← Wave 3: Intl.RelativeTimeFormat wrapper i18n-formatter-list/ ← Wave 3: Intl.ListFormat wrapper i18n-formatter-display-name/ ← Wave 3: Intl.DisplayNames wrapper i18n-formatter-collator/ ← Wave 3: Intl.Collator wrapper (NEW — not in monolith) i18n-formatter-segmenter/ ← Wave 3: Intl.Segmenter wrapper (NEW — not in monolith) i18n-formatter-date/ ← Wave 3: Intl.DateTimeFormat wrapper i18n-formatter-currency/ ← Wave 3: Intl.NumberFormat (currency) wrapper + ISO 4217 metadata i18n-detector/ ← Wave 4: locale detection chain i18n-backend-memory/ ← Wave 5: in-memory backend i18n-backend-http/ ← Wave 5: fetch-based backend i18n-backend-storage/ ← Wave 5: AbstractStorage backend (depends on @suaveplan/storage) i18n-backend-fs-multifile/ ← Wave 5: server-only Node fs backend i18n-backend-chained/ ← Wave 5: chained-fallback backend orchestrator locale-data/ ← UNCHANGED: ISO 3166 / ISO 639 static data ``` --- ## 2. Why now 1. **Architectural drift.** The monolith violates the monorepo's own splitting pattern. Every package audit and parity check has to special-case i18n. 2. **Tier leakage.** `i18n-backend-fs-multifile` uses `node:fs`, but the monolith is a universal-tier package — consumers in browser-tier code can accidentally tree-shake-ineligible imports. Split eliminates this. 3. **Optional-deps awkwardness.** Today `@suaveplan/storage` is a hard dep of `@suaveplan/i18n` because of one backend. Consumers who never touch the storage backend still pay the install cost. 4. **The original change names already implied this layout.** `i18n-storage-backends`, `i18n-formatters`, `i18n-completion`, `i18n-spec-conformance` — every prior change was named per-feature. The implementation simply forgot to split. 5. **Crash-safety for in-flight work.** With the monolith, any session that crashes mid-edit can leave the only i18n package in a half-state. With the split, leaf packages commit independently — a crashed Wave 6 leaves Waves 1–5 fully-shipped on disk. 6. **Downstream React stack.** `@suaveplan/react-i18n` is the binding layer — splitting i18n now (before more downstream React packages land) means React consumers can pull only the formatters / backends they need. --- ## 3. The 14 packages — role and dependency map | # | Package | Tier | Role | Direct deps (within i18n family) | |---|---|---|---|---| | 1 | `@suaveplan/i18n-shared` | universal | Constants, error codes, error class | none | | 2 | `@suaveplan/i18n-icu-parser` | universal | ICU parser, interpolation engine, pluralization rules, ICU render with `FormatBridge` DI seam | `@suaveplan/i18n-shared` | | 3 | `@suaveplan/i18n-formatter-number` | universal | `Intl.NumberFormat` wrapper | `@suaveplan/i18n-shared` | | 4 | `@suaveplan/i18n-formatter-relative-time` | universal | `Intl.RelativeTimeFormat` wrapper | `@suaveplan/i18n-shared` | | 5 | `@suaveplan/i18n-formatter-list` | universal | `Intl.ListFormat` wrapper | `@suaveplan/i18n-shared` | | 6 | `@suaveplan/i18n-formatter-display-name` | universal | `Intl.DisplayNames` wrapper | `@suaveplan/i18n-shared` | | 7 | `@suaveplan/i18n-formatter-collator` | universal | `Intl.Collator` wrapper | `@suaveplan/i18n-shared` | | 8 | `@suaveplan/i18n-formatter-segmenter` | universal | `Intl.Segmenter` wrapper | `@suaveplan/i18n-shared` | | 9 | `@suaveplan/i18n-formatter-date` | universal | `Intl.DateTimeFormat` wrapper | `@suaveplan/i18n-shared` | | 10 | `@suaveplan/i18n-formatter-currency` | universal | Currency wrapper + ISO 4217 metadata | `@suaveplan/i18n-shared`, `@suaveplan/i18n-formatter-number` | | 11 | `@suaveplan/i18n-detector` | universal | Locale detection chain | `@suaveplan/i18n-shared`, `@suaveplan/locale-data` | | 12 | `@suaveplan/i18n-backend-memory` | universal | In-memory `Backend` impl | `@suaveplan/i18n-shared` | | 13 | `@suaveplan/i18n-backend-http` | universal | Fetch-based `Backend` impl | `@suaveplan/i18n-shared` | | 14 | `@suaveplan/i18n-backend-storage` | universal | `AbstractStorage`-backed `Backend` impl | `@suaveplan/i18n-shared`, `@suaveplan/storage` | | 15 | `@suaveplan/i18n-backend-fs-multifile` | server | Node `fs` multi-file `Backend` impl | `@suaveplan/i18n-shared` | | 16 | `@suaveplan/i18n-backend-chained` | universal | Chained-fallback `Backend` orchestrator | `@suaveplan/i18n-shared` | | 17 | `@suaveplan/i18n` | universal | Slim core: catalog + plugin slot + middleware + observability + key utils + missing-key flow | All of 1–16 (re-exported by root barrel) | Plus, types-only additions to `@suaveplan/types` (Wave 1a — not a new package): ```text packages/primitives/types/src/i18n/ i18n.types.ts ← Backend, BackendCapabilities, TranslationRecord, LocaleNamespace, MissingKeyHandler, MessageLoader, NamespacedCatalogMap, TranslationCatalogMap, LookupResult index.ts ``` ### 3.1. Dependency graph (ASCII) ```text @suaveplan/types ──────────┬─── (every package — types-only) │ @suaveplan/i18n-shared ────┼─── i18n-icu-parser ├─── i18n-formatter-{number,relative-time,list,display-name, │ collator,segmenter,date} ├─── i18n-formatter-currency ── i18n-formatter-number ├─── i18n-detector ── @suaveplan/locale-data ├─── i18n-backend-{memory,http,fs-multifile,chained} ├─── i18n-backend-storage ── @suaveplan/storage │ ▼ @suaveplan/i18n (slim core, re-exports everything) ``` No cycles. Every leaf depends only on `@suaveplan/i18n-shared` plus (optionally) one external for tier-specific work. The slim core depends on all 16 leaves. --- ## 4. Wave 1a — `@suaveplan/types/i18n` (types-only) The `Backend` interface and its peers move into `@suaveplan/types` so backend packages can implement it without depending on `@suaveplan/i18n`. This mirrors the cross-package crypto contract already in `@suaveplan/types` (`Authority`, `KeySlotPublic/Private`, `SlotId`, `DomainTag`, `AuthorityVerifier`, `AeadOps`). Add `packages/primitives/types/src/i18n/i18n.types.ts`: ```typescript export interface TranslationRecord { readonly [key: string]: string | TranslationRecord; } export interface LocaleNamespace { readonly locale: string; readonly namespace: string; } export interface BackendCapabilities { readonly supportsBulkRead: boolean; readonly supportsWrite: boolean; readonly supportsLastModified: boolean; } export interface Backend { readonly name: string; readonly capabilities: BackendCapabilities; read(locale: string, namespace: string): Promise; readMulti?( locale: string, namespaces: readonly string[], ): Promise>; save?( locale: string, namespace: string, record: TranslationRecord, ): Promise; lastModified?(locale: string, namespace: string): Promise; init?(): Promise; } export type MessageLoader = ( locale: string, namespace?: string, ) => Promise; export type MissingKeyHandler = (info: { readonly locale: string; readonly namespace: string; readonly key: string; }) => string | undefined; export interface LookupResult { readonly value: string | undefined; readonly fellBackTo: string | undefined; } export type NamespacedCatalogMap = Map; export type TranslationCatalogMap = Map; ``` `packages/primitives/types/src/index.ts` adds: `export type * from "./i18n/i18n.types.js";` This is a zero-runtime, additive change — every existing consumer continues to work unchanged. --- ## 5. Wave 1b — `@suaveplan/i18n-shared` (runtime atoms) `@suaveplan/types` cannot hold runtime — constants and the `I18nError` class need a runtime home. New leaf package: ```text packages/i18n/i18n-shared/src/ constants/ locale-bcp47-map.ts ← LOCALE_BCP47_MAP rtl-locales.ts ← RTL_LOCALES, RTL_LOCALE_LIST locale-descriptors.ts ← LOCALE_DESCRIPTORS, DEFAULT_LOCALES, DefaultLocale interpolation-defaults.ts ← DEFAULT_INTERPOLATION index.ts ← explicit re-exports errors/ i18n-error.ts ← I18nError extends SuaveplanError error-codes.ts ← I18N_CODES via defineCodes (full registry, ~14 codes) index.ts index.ts ← namespace exports index.test.ts ``` Dependencies: `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod` (for any boundary schemas the shared layer needs). Zero deps on other `@suaveplan/i18n*` packages. The full `I18N_CODES` registry moves here in one shot — every leaf package throws codes that already exist before any leaf is implemented. This avoids the chicken-and-egg of "which package owns which code." --- ## 5b. ICU render `FormatBridge` (small architectural wedge) Today `packages/i18n/i18n/src/parser/message/message-render.ts` imports `formatNumber`, `formatCurrency`, `formatDate`, `formatTime` directly to render ICU `{value, number}`, `{amount, number, currency=USD}`, `{date, date, short}` placeholders. That makes the parser depend on the formatters, which would invert Waves 2 and 3. The split introduces a tiny DI seam: `i18n-icu-parser` declares a `FormatBridge` interface and the renderer accepts one; if a bridge is not supplied, the renderer returns the raw value coerced to string (no Intl call). Concretely: ```typescript export interface FormatBridge { number?(value: number, locale: string, style?: NumberFormatStyle): string; currency?(value: number, currency: string, locale: string): string; date?(value: Date | number, locale: string, style?: DateStyle): string; time?(value: Date | number, locale: string, longForm?: boolean): string; relativeTime?(value: number, locale: string, style?: RelativeTimeStyle): string; } export function renderNodes( nodes: readonly MessageNode[], values: InterpolationValues, locale: string, options: RenderOptions & { formatBridge?: FormatBridge }, ): string; ``` The slim core (Wave 6) constructs a default `FormatBridge` by importing the formatter packages and passing it to every `renderNodes` call. Consumers who wire `createI18n({...})` get formatters automatically. Consumers who construct a `MessageParser` directly can pass their own bridge or accept the no-Intl fallback. This is the only API surface change in the entire split; every other module moves byte-identical. Every backend package has the same shape (only `` and the implementation differ): ```text packages/i18n/i18n-backend-/ package.json ← deps: @suaveplan/i18n-shared, @suaveplan/types, @suaveplan/error, @opentelemetry/api, (one external if needed: @suaveplan/storage / node:fs) src/ backend.ts ← implements Backend from @suaveplan/types/i18n backend.types.ts backend.schemas.ts ← Zod for options backend.test.ts backend.spec-conformance.test.ts backend.md factory.ts ← Backend(options): Backend factory function factory.test.ts factory.md index.ts ← explicit named re-exports README.md tsconfig.json ← extends @suaveplan/typescript-config/library.json vite.config.ts ← createTestConfig() from @suaveplan/testing/config ``` Each backend's `factory.ts` exports a lower-case factory (`memoryBackend`, `httpBackend`, etc.) that returns a `Backend`. The backend class itself (`MemoryBackend`, `HttpBackend`, etc.) is also exported for consumers who want subclassing. ### 6.1. Tier split rationale Four backends are universal tier (`memory`, `http`, `storage`, `chained`). One is server tier (`fs-multifile`) because it uses `node:fs/promises`. The split prevents the universal package surface from accidentally pulling Node-only deps when a browser-tier consumer imports `@suaveplan/i18n-backend-storage`. --- ## 7. Formatter package shape (Wave 3 template) ```text packages/i18n/i18n-formatter-/ package.json ← deps: @suaveplan/i18n-shared, @suaveplan/types, @suaveplan/error, @opentelemetry/api, zod src/ formatter.ts ← Formatter class wrapping Intl. formatter.types.ts formatter.schemas.ts ← Zod for options formatter.test.ts formatter.spec-conformance.test.ts formatter.md helpers.ts ← format(value, locale, options?) standalone helpers.test.ts helpers.md index.ts README.md tsconfig.json vite.config.ts ``` Each formatter caches its underlying `Intl.` per `(locale, options-hash)` pair (already proven pattern from current `NumberFormatter`). Each emits an OTel histogram `i18n.format..duration_ms` and a counter `i18n.format.cache_hit{formatter=""}`. ### 7.1. Two new formatters `Collator` and `Segmenter` are listed in the original `i18n-formatters` change scope but not implemented in the current monolith. The split adds them as their own packages — no monolith bloat, just two new sibling packages. --- ## 8. Wave 6 — slim `@suaveplan/i18n` core After Waves 1–5 ship, the core package shrinks dramatically. Final shape: ```text packages/i18n/i18n/src/ catalog/ catalog.ts ← MessageCatalog, deepMerge catalog.types.ts catalog.schemas.ts catalog.test.ts catalog.md index.ts plugin/ instance.ts ← createI18n, I18nInstance instance.types.ts instance.test.ts instance.md index.ts middleware/ ← unchanged from current monolith observability/ ← unchanged from current monolith utils/ keys.ts ← flattenRecord, buildKey ... schemas/ ← package-level boundary schemas conformance.test.ts ← end-to-end spec conformance (KEEPS PASSING) index.ts ← root barrel: re-exports the entire pre-split surface ``` Modules that move OUT (and are deleted from `packages/i18n/i18n/src/`): | Source path (deleted) | New home | |---|---| | `src/constants/` | `@suaveplan/i18n-shared` | | `src/errors/` | `@suaveplan/i18n-shared` | | `src/error-codes.ts` | `@suaveplan/i18n-shared` | | `src/parser/` | `@suaveplan/i18n-icu-parser` | | `src/core/interpolation/` | `@suaveplan/i18n-icu-parser` | | `src/core/pluralization/` | `@suaveplan/i18n-icu-parser` | | `src/formatting/number/` | `@suaveplan/i18n-formatter-number` | | `src/formatting/relative-time/` | `@suaveplan/i18n-formatter-relative-time` | | `src/formatting/list/` | `@suaveplan/i18n-formatter-list` | | `src/formatting/display-name/` | `@suaveplan/i18n-formatter-display-name` | | `src/formatting/date/` | `@suaveplan/i18n-formatter-date` | | `src/formatting/currency/` | `@suaveplan/i18n-formatter-currency` | | `src/detector/` | `@suaveplan/i18n-detector` | | `src/core/backend/memory/` | `@suaveplan/i18n-backend-memory` | | `src/core/backend/http/` | `@suaveplan/i18n-backend-http` | | `src/core/backend/storage/` | `@suaveplan/i18n-backend-storage` | | `src/core/backend/multi-file-fs/` | `@suaveplan/i18n-backend-fs-multifile` | | `src/core/backend/chained/` | `@suaveplan/i18n-backend-chained` | | `src/core/backend/backend.types.ts` | `@suaveplan/types/i18n` (interface) + `@suaveplan/i18n-shared` (any runtime helpers) | Modules that STAY in core: `catalog/`, `plugin/`, `middleware/`, `observability/`, `utils/`, `schemas/`, `conformance.test.ts`, root `index.ts`. ### 8.1. Back-compat — root barrel re-exports The root `packages/i18n/i18n/src/index.ts` becomes a re-export shim that preserves every pre-split import. Concretely: ```typescript // Constants and errors — re-exported from i18n-shared export { DEFAULT_INTERPOLATION, DEFAULT_LOCALES, LOCALE_BCP47_MAP, LOCALE_DESCRIPTORS, RTL_LOCALE_LIST, RTL_LOCALES, I18N_CODES, I18nError, } from "@suaveplan/i18n-shared"; export type { DefaultLocale, I18nErrorCode } from "@suaveplan/i18n-shared"; // Catalog — stays in core export { MessageCatalog, deepMerge } from "./catalog/catalog.js"; export type { CatalogOptions, ExtendedCatalogOptions, LookupResult, MessageLoader, MissingKeyHandler, NamespacedCatalogMap, TranslationCatalogMap, TranslationRecord, } from "@suaveplan/types/i18n"; // Plugin — stays in core export { createI18n, I18nInstance } from "./plugin/instance.js"; export type { I18nInstanceOptions } from "./plugin/instance.js"; // Parser/interpolation/pluralization — re-exported from i18n-icu-parser export { InterpolationEngine, escapeHtmlChars, escapeRegex, MessageParser, PluralizationEngine, arabicPlural, /* ...full plural rule set... */ } from "@suaveplan/i18n-icu-parser"; // Formatters — re-exported from each i18n-formatter-* package export { NumberFormatter, formatNumber } from "@suaveplan/i18n-formatter-number"; export { RelativeTimeFormatter, formatRelativeTime } from "@suaveplan/i18n-formatter-relative-time"; export { ListFormatter, formatList } from "@suaveplan/i18n-formatter-list"; export { DisplayNameFormatter, formatDisplayName } from "@suaveplan/i18n-formatter-display-name"; export { CollatorFormatter, formatCollation } from "@suaveplan/i18n-formatter-collator"; export { SegmenterFormatter, formatSegments } from "@suaveplan/i18n-formatter-segmenter"; export { DateFormatter, formatDate } from "@suaveplan/i18n-formatter-date"; export { CurrencyFormatter, formatCurrency } from "@suaveplan/i18n-formatter-currency"; // Detector — re-exported from i18n-detector export { createDetectorChain, getTextDirection } from "@suaveplan/i18n-detector"; export type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector"; // Backends — re-exported from i18n-backend-* packages export { MemoryBackend, memoryBackend } from "@suaveplan/i18n-backend-memory"; export { HttpBackend, httpBackend } from "@suaveplan/i18n-backend-http"; export { StorageBackend, storageBackend } from "@suaveplan/i18n-backend-storage"; export { ChainedBackend, chainedBackend } from "@suaveplan/i18n-backend-chained"; // (fs-multifile NOT re-exported from universal root — server-tier consumers import directly) ``` Subpath exports also preserved — `@suaveplan/i18n/detector`, `@suaveplan/i18n/plugin`, `@suaveplan/i18n/parser`, `@suaveplan/i18n/errors` continue to resolve, internally forwarding to the relevant new package. ### 8.2. Verification The existing `packages/i18n/i18n/src/conformance.test.ts` is the contract gate. After the slim, **every assertion in that file MUST still pass** — that is the regression bar for the back-compat layer. Downstream consumers verified: - `packages/react/react-i18n/` — 17 distinct imports from `@suaveplan/i18n` (root + subpaths). Re-export bridge covers all of them. - `packages/react/react-ui/` — uses `@suaveplan/i18n` for translation key types and missing-key flow. Bridge covers. - `packages/react/react-forms/` — uses `@suaveplan/i18n` for validation message catalog. Bridge covers. --- ## 9. Wave-by-wave execution plan Crash-safety principle: **every wave commits before the next starts.** Waves 1–5 are pure additions to `packages/i18n/` — they never touch `packages/i18n/i18n/src/`. Only Wave 6 deletes from the monolith. If a session dies mid-Wave-6, the leaf packages (Waves 1–5) are already on disk and the monolith is recoverable from `git`. ### Wave 1 — Foundation | Step | Artifact | Gate | |---|---|---| | 1.1 | `@suaveplan/types/i18n/i18n.types.ts` (zero runtime additions) | `bunx turbo typecheck --filter=@suaveplan/types` green | | 1.2 | `@suaveplan/i18n-shared` package scaffold + constants + errors + error codes | 5-gate triple green; 100/100/100/100 cov | | 1.3 | Commit: `feat(i18n): foundation — types/i18n + i18n-shared (Wave 1)` | — | ### Wave 2 — ICU parser | Step | Artifact | Gate | |---|---|---| | 2.1 | `@suaveplan/i18n-icu-parser` scaffold | — | | 2.2 | Move `parser/`, `core/interpolation/`, `core/pluralization/` source + tests + docs into the new package; rewrite imports to `@suaveplan/i18n-shared` | 5-gate triple green; 100/100/100/100 cov | | 2.3 | Commit: `feat(i18n): icu-parser package (Wave 2)` | — | Note: the source is *copied*, not *moved*, in Wave 2. The monolith retains its copy until Wave 6 deletes it. This keeps the monolith green throughout Waves 2–5. ### Wave 3 — Formatters (8 packages, fully parallel) | Step | Artifact | Gate | |---|---|---| | 3.1 | 8 formatter package scaffolds (parallel agent dispatch) | — | | 3.2 | Copy each `formatting//` → its package; add Collator + Segmenter from scratch | 5-gate triple green per package; 100/100/100/100 cov each | | 3.3 | Commit: `feat(i18n): 8 formatter packages (Wave 3)` | — | ### Wave 4 — Detector | Step | Artifact | Gate | |---|---|---| | 4.1 | `@suaveplan/i18n-detector` scaffold | — | | 4.2 | Copy `detector/` → new package; rewire deps to `@suaveplan/i18n-shared` + `@suaveplan/locale-data` | 5-gate triple green; 100/100/100/100 cov | | 4.3 | Commit: `feat(i18n): detector package (Wave 4)` | — | ### Wave 5 — Backends (5 packages, fully parallel) | Step | Artifact | Gate | |---|---|---| | 5.1 | 5 backend package scaffolds (parallel agent dispatch) | — | | 5.2 | Copy each `core/backend//` → its package; rewire to `Backend` from `@suaveplan/types/i18n` | 5-gate triple green per package; 100/100/100/100 cov each | | 5.3 | Commit: `feat(i18n): 5 backend packages (Wave 5)` | — | ### Wave 6 — Slim core (RISKY) | Step | Artifact | Gate | |---|---|---| | 6.1 | Add Wave 1–5 packages as `workspace:^` deps in `packages/i18n/i18n/package.json` | — | | 6.2 | Rewrite `packages/i18n/i18n/src/index.ts` to re-export from new packages (per §8.1) | `bunx turbo typecheck --filter=@suaveplan/i18n` green | | 6.3 | Verify downstream consumers (`react-i18n`, `react-ui`, `react-forms`) still typecheck and test green | `bunx turbo lint typecheck test --filter='@suaveplan/react-*'` green | | 6.4 | Verify `conformance.test.ts` still passes against re-exported surface | `bunx turbo test --filter=@suaveplan/i18n` — all 745 tests green | | 6.5 | Delete moved-out source from monolith (per §8 table) | `bunx turbo lint typecheck test --filter=@suaveplan/i18n` green; coverage 100/100/100/100 on remaining slim core | | 6.6 | Refresh `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`) | — | | 6.7 | Update `CLAUDE.md` package listing | — | | 6.8 | Archive all 14 change folders + this design doc to `openspec/archive/2026-05-05-i18n-modular-split-*/` | — | | 6.9 | Commit: `feat(i18n): slim core + complete modular split (Wave 6)` | — | --- ## 10. Risk register | Risk | Likelihood | Impact | Mitigation | |---|---|---|---| | Re-export bridge misses an import path | Medium | Downstream consumers break | §8.1 lists every observed downstream import; conformance test + downstream typecheck in Wave 6.3 catches gaps | | Subpath export forwarding loses tree-shaking | Low | Bundle size regression in `react-i18n` consumers | Keep per-package subpath exports (`@suaveplan/i18n-formatter-number/formatter`) so direct importers bypass the bridge | | Plural-rule tests in icu-parser drift from monolith copy | Low | Spec conformance fails | Copy verbatim; plural rules are pure functions; same `it(...)` titles | | Storage backend tests need real `@suaveplan/storage` MemoryStorage | Low | Test infra | Already working in monolith; same pattern | | Wave 6 deletion takes monolith below 100% cov | Medium | Pre-publish gate fails | Delete in surgical commits; run `bunx turbo test:coverage` after each delete batch; restore via `git checkout` if cov drops | | Session crash during Wave 6 leaves monolith in half-deleted state | Low (but the user explicitly flagged it) | Branch needs `git restore` | Waves 1–5 already committed = leaf packages survive; `git restore packages/i18n/i18n/src/` brings the monolith back | | OpenSpec change folder authoring takes longer than implementation | Medium | Pre-implementation effort sunk | Acceptable — folders are durable artifacts; even a crashed session leaves them on disk for the next session to pick up | --- ## 11. Out of scope - **No API changes.** Every public symbol keeps its name and signature. The only observable change for consumers is they *can* now import from the per-package paths if they want narrower trees. - **No new functionality** beyond `Collator` and `Segmenter` formatters (which were in the original `i18n-formatters` change scope). - **No `@suaveplan/react-i18n` changes.** It stays on the slim core's re-export surface; a follow-up change can migrate it to direct per-package imports for tree-shaking. - **No `@suaveplan/locale-data` split.** It is already a separate package and has no need to grow further. - **No registry/distribution changes.** Verdaccio publication is unchanged; each new package publishes independently. --- ## 12. Verification matrix For every wave, the following gates are mandatory before commit: ```bash # Per-package gates (run for each new/touched package) bun run check # Biome 0/0/0 bunx turbo lint typecheck test --filter=@suaveplan/ # green bunx turbo test:coverage --filter=@suaveplan/ # 100/100/100/100 bun scripts/openspec-conformance.ts @suaveplan/ # exit 0 bunx turbo build --filter=@suaveplan/ # green # Wave 6 additional cross-package gate bunx turbo lint typecheck test --filter='@suaveplan/react-*' # downstream stays green bunx turbo build --filter='@suaveplan/i18n*' # entire i18n family builds ``` The OpenSpec conformance gate runs against each new package's spec delta; the `**Verified by:**` lines are checked for existence of the named test cases. --- ## 13. Authoring sequence for OpenSpec change folders The following 14 change folders will be created in `openspec/changes/`: ```text openspec/changes/ i18n-types-extension/ ← Wave 1a (zero-runtime types in @suaveplan/types/i18n) i18n-shared/ ← Wave 1b i18n-icu-parser/ ← Wave 2 i18n-formatter-number/ ← Wave 3 i18n-formatter-relative-time/ ← Wave 3 i18n-formatter-list/ ← Wave 3 i18n-formatter-display-name/ ← Wave 3 i18n-formatter-collator/ ← Wave 3 i18n-formatter-segmenter/ ← Wave 3 i18n-formatter-date/ ← Wave 3 i18n-formatter-currency/ ← Wave 3 i18n-detector/ ← Wave 4 i18n-backend-memory/ ← Wave 5 i18n-backend-http/ ← Wave 5 i18n-backend-storage/ ← Wave 5 i18n-backend-fs-multifile/ ← Wave 5 i18n-backend-chained/ ← Wave 5 i18n-core-slim/ ← Wave 6 ``` (That is actually 18 folders, not 14 — the design doc text in §1.2 lists 14 *new* packages plus the slim core; the change-folder count includes the types extension and the core slim as separate OpenSpec changes.) Each folder contains: - `proposal.md` — Why / What changes / Out of scope / Source mapping / Verification approach / Impact - `tasks.md` — numbered checklist following the Wave 1–5 template (spec authoring → scaffold → implementation → tests → documentation → 5-gate triple → 7-row checklist → archive) - `specs//spec.md` — ADDED Requirements with `**Verified by:**` lines per AGENTS.md §13 Each folder is a durable artifact: even if a future session crashes mid-implementation, the next session can pick up from the tasks.md checklist with full context. --- ## 14. References - `openspec/AGENTS.md` — gate compliance checklist - `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` — pattern reference for cross-cutting designs - `openspec/changes/archive/2026-05-04-i18n-formatters/` — pattern reference for proposal/tasks/spec-delta shape - `packages/i18n/i18n/CLAUDE.md` (root) — current monolith inventory - `packages/primitives/types/src/index.ts` — current `@suaveplan/types` surface (Wave 1a extends this)