KgwariC4 Architecture
05

Kgwari LocalizationSeven tags, six languages, three content tiers, three different answers

i18next FormatJS polyfill-force Amazon Bedrock Amazon Translate 7 locales × 9 namespaces 4 lint guardrails

The second capability page. Localization spans @shared/localization and seven catalogues in the client, two build scripts, the text and money contracts in kgwari-shared, and two client packages on the backend — so like search, it has no single repository to belong to.

5A — ContextThree kinds of text, and why one pipeline cannot carry them

Chrome is what the app itself says; curated is the editorial and catalogue writing; free UGC is members' own words. They are authored by different people, translated by different people, on different tracks and at different cost — so each gets a different machine, or none. Conflating them is how a translation budget disappears into text nobody reads.

lo-context diagram
PlantUML source
@startuml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml

LAYOUT_WITH_LEGEND()
HIDE_STEREOTYPE()
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "Inter"
skinparam shadowing false

title Kgwari Localization — System Context (three kinds of text, three different answers)

Person(member, "Member", "Reads the app in one of six languages.\nWrites tasting notes in their own.")
Person_Ext(reviewer, "Native-speaker reviewer", "Gates a locale from draft to reviewed.\nNothing reaches a member before this.")

System_Boundary(tiers, "Three content tiers — never solve one with the other's tools") {
  System(chrome, "Tier 1 — Chrome", "Everything the app itself says.\n7 locales x 9 namespaces of i18next JSON.\n**A finite catalogue.**")
  System(curated, "Tier 2 — Curated", "Editorial and catalogue writing:\nwine descriptions, region write-ups.\n**Human translation, no machine.**")
  System(ugc, "Tier 3 — Free UGC", "Members' own words. Stored original\n+ language tag, permanently.\n**Unbounded member text.**")
}

System_Ext(bedrock, "Amazon Bedrock", "Build time, in the frontend repo.\nGlossary-aware, few-shot, gap-fill only —\nan existing string is never overwritten")
System_Ext(translate, "Amazon Translate", "Runtime, behind the API.\nOn-demand 'See translation', cached by\n(contentId, contentHash, targetLang)")

System(client, "Kgwari Client", "@shared/localization — the ONLY package\nother layers import for i18n")

Rel(member, client, "Reads chrome in", "negotiated UI language")
Rel(chrome, bedrock, "First-pass MT by")
Rel(bedrock, reviewer, "Produces drafts for")
Rel(reviewer, chrome, "Promotes to reviewed")
Rel(ugc, translate, "Translated on demand by")
Rel(member, ugc, "Writes")
Rel(client, chrome, "Renders from")
Rel(curated, client, "Negotiated per request", "Accept-Language / Content-Language")

note bottom of bedrock
  **Two services, and the split is not arbitrary.**
  Chrome is a finite catalogue where an LLM with the
  project glossary beats generic NMT and runs once per
  string, so per-token pricing is noise. UGC is unbounded
  text where per-character pricing, a distinct fr-CA model
  and per-request formality matter — and where quality is
  answered by the affordance itself: original by default,
  machine attribution, toggle back.

  Neither replaces the other. Neither touches tier 2.
end note

note right of ugc
  **Invariant:** the original plus its language tag is
  permanent and always presentable. A translation is a
  derived, cached, optional overlay — never a replacement,
  and never the searchable canonical.
end note

@enduml

5B — ContainerOne source catalogue, six generated from it, none served unread

en is the only hand-authored catalogue. The rest are gap-filled by a glossary-aware model that never overwrites an existing string — which is what makes it safe to re-run after a reviewer has been through a locale, since their corrections are simply keys that are no longer missing. A provenance sidecar records which keys are still machine-written, so a reviewer knows what to read.

lo-container diagram
PlantUML source
@startuml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml

LAYOUT_WITH_LEGEND()
HIDE_STEREOTYPE()
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "Inter"
skinparam shadowing false

title Kgwari Localization — Containers (seven tags, six languages, one source catalogue)

Person(member, "Member")
Person_Ext(reviewer, "Native-speaker reviewer")

System_Boundary(fe, "kgwari-frontend-app") {

  Container_Boundary(locPkg, "@shared/localization — the single source of i18n machinery") {
    Container(resolve, "ResolveLocale", "TS", "Returns a ResolvedLocale, not a string:\nuiLanguage · formattingLocale · region\nnumberingSystem · measurementSystem")
    Container(negotiation, "LocaleNegotiation", "TS", "Ordered device preferences INTERSECT\nserved catalogues. Override -> system -> en")
    Container(formatters, "UseFormatters + FormattersCore", "Hooks + pure fns", "Memoised Intl.NumberFormat · DateTimeFormat\nRelativeTimeFormat · Collator · ListFormat\nCurrency REQUIRES an explicit currencyCode")
    Container(strings, "UseLocalizedStrings", "Hook", "i18next with compile-time typed key paths")
    Container(polyfills, "Polyfills", "FormatJS", "**polyfill-force** — Hermes delegates Intl to\nthe OS, so the same call differs on iOS,\nAndroid and web. Determinism over bundle size")
    Container(pseudo, "PseudoLocalize", "postProcessor", "Accents + 40% expansion + brackets.\nUnbracketed text = a hardcoded string")
    ContainerDb(catalogues, "resources/<locale>/*.json", "9 namespaces x 7 locales", "cellar · common · community · dashboard\ndiscover · onboarding · profile · search · wines")
    ContainerDb(registry, "locales.json", "The launch set + readiness", "status gates NEGOTIATION\nofferInPicker gates the PICKER\nendonym · servesBase")
    ContainerDb(provenance, ".provenance.json", "Sidecar per locale", "Marks which keys are still `mt` —\nso a reviewer can see what to read")
    ContainerDb(glossary, "glossary.json", "Terms that must not drift", "Fed to the model as context")
  }

  Container(i18nTranslate, "scripts/i18n-translate.mjs", "Node · build time", "Fills gaps in every draft locale.\nNEVER overwrites — which is what makes it\nsafe to re-run after a reviewer has been through")
  Container(parity, "scripts/check-locale-parity.mjs", "Node · CI", "Plural-aware; validates per-locale CLDR\ncategories via Intl.PluralRules. Shares its\ndefinition of a key with the translator\nvia scripts/lib/catalogues.mjs")
  Container(lint, "4 ESLint guardrails", "eslint.config.mjs", "No localization import in domain/data\nNo raw Intl outside the package\nNo hardcoded JSX text · no left/right spacing")
}

System_Boundary(be, "kgwari-backend-app") {
  Container(bedrockCore, "packages/core/bedrock", "BedrockRuntimeClient", "Shared with wine-label parsing —\nthe service, not the concern")
  Container(translateCore, "packages/core/translate", "TranslateClient", "Behind EnvironmentConfiguration.translation.enabled")
}

System_Ext(bedrock, "Amazon Bedrock", "eu-west-1")
System_Ext(translateSvc, "Amazon Translate", "eu-west-1")

Rel(member, resolve, "Device preferences read by")
Rel(resolve, negotiation, "Negotiates through")
Rel(negotiation, registry, "Reads served locales from")
Rel(strings, catalogues, "Loads")
Rel(formatters, polyfills, "Formats through")
Rel(i18nTranslate, catalogues, "Gap-fills")
Rel(i18nTranslate, glossary, "Prompted with")
Rel(i18nTranslate, provenance, "Records mt keys in")
Rel(i18nTranslate, bedrock, "InvokeModel", "build time")
Rel(reviewer, catalogues, "Corrects")
Rel(reviewer, registry, "Promotes draft -> reviewed")
Rel(parity, catalogues, "Gates")
Rel(parity, registry, "Reads the reference locale from")
Rel(translateCore, translateSvc, "TranslateText", "runtime")
Rel(bedrockCore, bedrock, "InvokeModel", "label parsing")

note bottom of registry
  **status and offerInPicker are different questions.**
  Landing on a half-filled Afrikaans catalogue because
  your phone is set to Afrikaans is a broken app you did
  not ask for. Choosing AF from the footer and seeing
  English where the catalogue is thin is a different
  transaction, and an acceptable one.

  Today: `en` base; `af`, `fr-FR`, `fr-CA`, `de`, `it`,
  `es` all **draft** — generated, awaiting review.
end note

@enduml

5C — ComponentThree independent axes, resolved once at the edge

UI language, formatting region and commerce currency are genuinely independent — English UI with German formatting and EUR pricing is a valid, common combination. Modelling them as one setting makes that inexpressible and breeds the currency-relabel bug. Domain and data layers stay locale-agnostic, and that boundary is a lint rule rather than a convention because its value collapses the moment one t() leaks into a use case.

lo-component diagram
PlantUML source
@startuml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml

LAYOUT_WITH_LEGEND()
HIDE_STEREOTYPE()
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "Inter"
skinparam shadowing false

title Kgwari Localization — Component view: three independent axes, resolved once

Component(device, "Device", "expo-localization / navigator", "Ordered preferred languages + region")

Container_Boundary(resolution, "Resolution — runs once, at the edge") {
  Component(override, "LocaleOverride", "Persisted slot", "Supersedes system detection.\n'System default' must stay reachable")
  Component(negotiate, "negotiateUiLanguage", "Pure fn", "Ordered preferences INTERSECT served\ncatalogues, else `en`")
  Component(resolved, "ResolvedLocale", "Value object", "uiLanguage · formattingLocale · region\nnumberingSystem · measurementSystem")
}

Container_Boundary(axes, "The three axes — genuinely independent") {
  Component(axis1, "1 · UI language", "Which catalogue renders", "Negotiated. Collapses to base language\nONLY when picking a catalogue")
  Component(axis2, "2 · Region / formatting", "Grouping, dates, separators", "From the device region —\nindependent of UI language")
  Component(axis3, "3 · Commerce currency", "What the wine is priced in", "From **the data**, never the locale.\nA Bordeaux stays EUR in Zurich")
}

Container_Boundary(pres, "-presentation — the only layer that localises") {
  Component(useStrings, "useLocalizedStrings(ns)", "Hook", "Every user-visible string is a key.\nZero literals — enforced by lint")
  Component(useFmt, "useFormatters()", "Hook", "Every number, date and money value.\nNever a raw Intl at a call site")
  Component(money, "formatCurrency(minorUnits, code)", "Fn", "Impossible to call without the\nitem's own currency code")
}

Container_Boundary(lower, "-domain and -data — locale-agnostic by lint rule") {
  Component(entities, "Entities", "TS", "Minor units + ISO 4217 · UTC ISO 8601.\nNo formatted strings, ever")
  Component(ugcEntity, "UGC + language tag", "TS", "{ body, language } — the original is\npermanent and always presentable")
  Component(translatePort, "translate(contentId, targetLang)", "Repository method", "Data-layer I/O. May TAKE a locale;\nmay not FORMAT with one")
}

System_Ext(parse, "parseMoneyAmount", "@edwardseshoka/foundation", "Major -> minor units with no floating point\nand no Intl, so the server re-validates with\nthe same function the client parsed with")

Rel(device, negotiate, "Ordered preferences")
Rel(override, negotiate, "Supersedes")
Rel(negotiate, resolved, "Produces")
Rel(resolved, axis1, "uiLanguage")
Rel(resolved, axis2, "formattingLocale")
Rel(axis1, useStrings, "Selects catalogue")
Rel(axis2, useFmt, "Binds every formatter")
Rel(axis3, money, "Supplies the currency code")
Rel(entities, axis3, "Carry the currency")
Rel(useFmt, money, "Exposes")
Rel(entities, useFmt, "Formatted only here")
Rel(ugcEntity, translatePort, "Translated on demand through")
Rel(money, parse, "Round-trips with")

note bottom of axes
  **Conflating axes 2 and 3 is the classic bug.**
  English UI + German formatting + EUR pricing is a
  valid, common combination. Modelling them as one
  field makes it inexpressible and breeds the
  currency-relabel bug — EUR50 silently becoming $50.
end note

note right of lower
  The value of "domain and data are locale-agnostic"
  collapses the moment one `t()` leaks into a use case.
  A lint rule is the only durable guarantee, which is
  why it is one of four rather than a convention.
end note

@enduml
Technical Summary

The launch set is seven BCP 47 tags across six languages — en, af, fr-FR, fr-CA, de, it, es — and the interesting part of the registry is that it separates two questions most systems conflate. status gates negotiation: what a device is silently placed onto without anyone choosing it. offerInPicker gates the picker: what a member may deliberately select. Landing on a half-filled Afrikaans catalogue because your phone is set to Afrikaans is a broken app you did not ask for; choosing it from the footer and finding English where the catalogue is thin is a different transaction, and an acceptable one while review is pending. Today en is the base and all six others are draft — generated, awaiting a native speaker.

Two machine services do work here and they are not interchangeable. Chrome is a finite catalogue, so an LLM with the project glossary and few-shot examples beats generic neural translation and runs once per string, making per-token pricing noise; it runs at build time in the frontend repo. User-generated text is unbounded, so per-character pricing, a distinct Québécois model and per-request formality all matter; it runs at request time behind the API. Neither touches curated content, which stays human. The same Bedrock service also parses wine labels on the backend — a shared service, not a shared concern.

Formatting is polyfilled rather than delegated. On Hermes the app's JavaScript has no Intl data of its own and falls through to the operating system, so the same Intl.NumberFormat call can produce different output on iOS, Android and web, or throw on a stripped Android build — non-determinism that cannot be caught in tests because it depends on the member's device. Forcing the polyfill costs bundle size and buys identical output everywhere, which is the right trade for a product whose premise is correctness in many languages.

Money is the axis most likely to go wrong, and it is handled in the contracts rather than in the views: integer minor units, scale derived from the currency code and never stored, and no symbol field — because CAD renders as $ in Canada and CA$ elsewhere, and a stored symbol throws that disambiguation away. Members type major units, so a single tested parser converts without floating point and without Intl, which lets the server re-validate a submitted amount with the very function that parsed it on the client.