Files
suaveplan/openspec/archive/2026-05-09-i18n-core-slim/2026-05-05-i18n-modular-split.md
2026-05-27 23:29:53 +01:00

589 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.
# 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<TranslationRecord>;
readMulti?(
locale: string,
namespaces: readonly string[],
): Promise<Record<string, TranslationRecord>>;
save?(
locale: string,
namespace: string,
record: TranslationRecord,
): Promise<void>;
lastModified?(locale: string, namespace: string): Promise<number | undefined>;
init?(): Promise<void>;
}
export type MessageLoader = (
locale: string,
namespace?: string,
) => Promise<TranslationRecord>;
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<string, TranslationRecord>;
export type TranslationCatalogMap = Map<string, NamespacedCatalogMap>;
```
`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<I18nErrorCode>
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 `<name>` and the implementation differ):
```text
packages/i18n/i18n-backend-<name>/
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 ← <name>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-<name>/
package.json ← deps: @suaveplan/i18n-shared, @suaveplan/types,
@suaveplan/error, @opentelemetry/api, zod
src/
formatter.ts ← <Name>Formatter class wrapping Intl.<Name>
formatter.types.ts
formatter.schemas.ts ← Zod for options
formatter.test.ts
formatter.spec-conformance.test.ts
formatter.md
helpers.ts ← format<Name>(value, locale, options?) standalone
helpers.test.ts
helpers.md
index.ts
README.md
tsconfig.json
vite.config.ts
```
Each formatter caches its underlying `Intl.<Name>` per `(locale, options-hash)` pair (already proven pattern from current `NumberFormatter`). Each emits an OTel histogram `i18n.format.<name>.duration_ms` and a counter `i18n.format.cache_hit{formatter="<name>"}`.
### 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/<name>/` → 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/<name>/` → 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/<pkg> # green
bunx turbo test:coverage --filter=@suaveplan/<pkg> # 100/100/100/100
bun scripts/openspec-conformance.ts @suaveplan/<pkg> # exit 0
bunx turbo build --filter=@suaveplan/<pkg> # 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/<capability>/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)