From 0ed6a9b5d76e0d2159c1e06387864ee66c19c87b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:45:49 +0900 Subject: [PATCH 01/87] feat(i18n): add localization domain policy --- backend/services/ui_localization_policy.py | 271 +++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 backend/services/ui_localization_policy.py diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py new file mode 100644 index 000000000..4cbce05a3 --- /dev/null +++ b/backend/services/ui_localization_policy.py @@ -0,0 +1,271 @@ +"""Pure policy primitives for Naruon's UI Localization Catalog. + +This module intentionally owns no persistence, HTTP routing, browser runtime, or +machine-translation behavior. It defines the fail-closed identities and locale +selection rules that later boundaries consume after their canonical owner +prerequisites are integrated. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from string import Formatter +from typing import Literal, cast + +SupportedLocaleCode = Literal["ko", "en", "ja", "zh", "vi", "es", "de", "fr"] +LocaleSelectionSource = Literal[ + "persisted_preference", + "session_preference", + "accept_language", + "product_default", +] +UiLocalizationValidationCode = Literal[ + "ui_locale_unsupported", + "ui_locale_input_invalid", + "ui_accept_language_invalid", + "ui_screen_key_invalid", + "ui_message_key_invalid", + "ui_placeholder_schema_invalid", + "ui_translation_placeholder_mismatch", +] + +SUPPORTED_LOCALE_CODES: tuple[SupportedLocaleCode, ...] = ( + "ko", + "en", + "ja", + "zh", + "vi", + "es", + "de", + "fr", +) +DEFAULT_LOCALE_CODE: SupportedLocaleCode = "ko" +_SUPPORTED_LOCALE_SET = frozenset(SUPPORTED_LOCALE_CODES) +_LOCALE_TAG_PATTERN = re.compile( + r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$", + flags=re.ASCII, +) +_SCREEN_KEY_PATTERN = re.compile( + r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$", + flags=re.ASCII, +) +_MESSAGE_KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$", flags=re.ASCII) +_PLACEHOLDER_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$", flags=re.ASCII) +_ACCEPT_LANGUAGE_ITEM_PATTERN = re.compile( + r"^(?P\*|[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*)" + r"(?:\s*;\s*q=(?P0(?:\.\d{0,3})?|1(?:\.0{0,3})?))?$", + flags=re.ASCII, +) + + +class UiLocalizationValidationError(ValueError): + """Stable typed validation failure emitted by the localization policy.""" + + def __init__(self, error_code: UiLocalizationValidationCode, message: str) -> None: + """Create a validation failure with a stable machine-readable code.""" + super().__init__(message) + self.error_code = error_code + + +@dataclass(frozen=True, slots=True) +class UiLocaleSelection: + """Resolved locale and the authority tier that selected it.""" + + locale_code: SupportedLocaleCode + selection_source: LocaleSelectionSource + + +def _reject_header_controls(value: str) -> None: + """Reject control characters that could create an additional header line.""" + if any(character in value for character in ("\r", "\n", "\x00")): + raise UiLocalizationValidationError( + "ui_locale_input_invalid", + "locale input contains forbidden control characters", + ) + + +def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: + """Normalize a supported language tag to its release-level product language.""" + if not isinstance(locale_tag, str): + raise UiLocalizationValidationError( + "ui_locale_input_invalid", + "locale input must be a string", + ) + candidate = locale_tag.strip() + _reject_header_controls(candidate) + if not candidate or not _LOCALE_TAG_PATTERN.fullmatch(candidate): + raise UiLocalizationValidationError( + "ui_locale_input_invalid", + "locale input is not a valid bounded language tag", + ) + primary_language = candidate.split("-", 1)[0].lower() + if primary_language not in _SUPPORTED_LOCALE_SET: + raise UiLocalizationValidationError( + "ui_locale_unsupported", + "locale is not supported by this product release", + ) + return cast(SupportedLocaleCode, primary_language) + + +def _accept_language_preferences(header_value: str) -> tuple[SupportedLocaleCode, ...]: + """Return supported preferences ordered by quality and original specificity.""" + if not isinstance(header_value, str): + raise UiLocalizationValidationError( + "ui_accept_language_invalid", + "Accept-Language must be a string", + ) + candidate = header_value.strip() + _reject_header_controls(candidate) + if not candidate: + return () + + explicit_best: dict[SupportedLocaleCode, tuple[float, int]] = {} + wildcard_best: tuple[float, int] | None = None + + for position, raw_item in enumerate(candidate.split(",")): + item = raw_item.strip() + match = _ACCEPT_LANGUAGE_ITEM_PATTERN.fullmatch(item) + if match is None: + raise UiLocalizationValidationError( + "ui_accept_language_invalid", + "Accept-Language contains an invalid language range or quality value", + ) + quality = float(match.group("quality") or "1") + language_range = match.group("range") + if language_range == "*": + candidate_rank = (quality, position) + if wildcard_best is None or candidate_rank[0] > wildcard_best[0]: + wildcard_best = candidate_rank + continue + + primary_language = language_range.split("-", 1)[0].lower() + if primary_language not in _SUPPORTED_LOCALE_SET: + continue + locale_code = cast(SupportedLocaleCode, primary_language) + current = explicit_best.get(locale_code) + if current is None or quality > current[0]: + explicit_best[locale_code] = (quality, position) + + ranked: list[tuple[float, int, int, SupportedLocaleCode]] = [] + for release_order, locale_code in enumerate(SUPPORTED_LOCALE_CODES): + explicit_rank = explicit_best.get(locale_code) + if explicit_rank is not None: + quality, position = explicit_rank + elif wildcard_best is not None: + quality, position = wildcard_best + else: + continue + if quality == 0: + continue + ranked.append((quality, position, release_order, locale_code)) + + ranked.sort(key=lambda item: (-item[0], item[1], item[2])) + return tuple(item[3] for item in ranked) + + +def select_ui_locale( + *, + persisted_preference: str | None = None, + session_preference: str | None = None, + accept_language: str | None = None, + product_default: SupportedLocaleCode = DEFAULT_LOCALE_CODE, +) -> UiLocaleSelection: + """Resolve one supported locale using the product's explicit authority order.""" + if product_default not in _SUPPORTED_LOCALE_SET: + raise UiLocalizationValidationError( + "ui_locale_unsupported", + "product default locale must be supported", + ) + if persisted_preference is not None: + return UiLocaleSelection( + normalize_supported_locale(persisted_preference), + "persisted_preference", + ) + if session_preference is not None: + return UiLocaleSelection( + normalize_supported_locale(session_preference), + "session_preference", + ) + if accept_language is not None: + preferences = _accept_language_preferences(accept_language) + if preferences: + return UiLocaleSelection(preferences[0], "accept_language") + return UiLocaleSelection(product_default, "product_default") + + +def validate_screen_key(screen_key: str) -> str: + """Return a normalized screen identity or fail closed on invalid input.""" + if not isinstance(screen_key, str) or not _SCREEN_KEY_PATTERN.fullmatch(screen_key): + raise UiLocalizationValidationError( + "ui_screen_key_invalid", + "screen_key must be a dotted lowercase product identity", + ) + return screen_key + + +def validate_message_key(message_key: str) -> str: + """Return a normalized message identity or fail closed on invalid input.""" + if not isinstance(message_key, str) or not _MESSAGE_KEY_PATTERN.fullmatch(message_key): + raise UiLocalizationValidationError( + "ui_message_key_invalid", + "message_key must be lowercase snake_case", + ) + return message_key + + +def extract_placeholder_names(message_text: str) -> tuple[str, ...]: + """Extract simple named interpolation fields without evaluating the message.""" + if not isinstance(message_text, str): + raise UiLocalizationValidationError( + "ui_placeholder_schema_invalid", + "translated message must be a string", + ) + names: list[str] = [] + seen: set[str] = set() + try: + parsed = Formatter().parse(message_text) + for _, field_name, format_spec, conversion in parsed: + if field_name is None: + continue + if ( + not _PLACEHOLDER_NAME_PATTERN.fullmatch(field_name) + or format_spec + or conversion is not None + ): + raise UiLocalizationValidationError( + "ui_placeholder_schema_invalid", + "placeholders must be simple named fields without conversion or format specifiers", + ) + if field_name not in seen: + seen.add(field_name) + names.append(field_name) + except ValueError as error: + raise UiLocalizationValidationError( + "ui_placeholder_schema_invalid", + "message contains malformed placeholder syntax", + ) from error + return tuple(names) + + +def validate_translation_placeholders( + translated_message: str, + placeholder_schema: tuple[str, ...] | list[str], +) -> tuple[str, ...]: + """Require the translated message to preserve the exact placeholder schema.""" + schema = tuple(placeholder_schema) + if len(schema) != len(set(schema)) or any( + not isinstance(name, str) or not _PLACEHOLDER_NAME_PATTERN.fullmatch(name) + for name in schema + ): + raise UiLocalizationValidationError( + "ui_placeholder_schema_invalid", + "placeholder schema must contain unique lowercase snake_case names", + ) + actual = extract_placeholder_names(translated_message) + if set(actual) != set(schema): + raise UiLocalizationValidationError( + "ui_translation_placeholder_mismatch", + "translated message placeholders do not match the versioned schema", + ) + return actual From 5070f15538d00fef49df64319d7c31a18d36ea14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:46:09 +0900 Subject: [PATCH 02/87] test(i18n): cover locale and placeholder policy --- backend/tests/test_ui_localization_policy.py | 196 +++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 backend/tests/test_ui_localization_policy.py diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py new file mode 100644 index 000000000..e5de00913 --- /dev/null +++ b/backend/tests/test_ui_localization_policy.py @@ -0,0 +1,196 @@ +"""Regression coverage for the UI Localization Catalog domain policy.""" + +import pytest + +from services.ui_localization_policy import ( + SUPPORTED_LOCALE_CODES, + UiLocalizationValidationError, + extract_placeholder_names, + normalize_supported_locale, + select_ui_locale, + validate_message_key, + validate_screen_key, + validate_translation_placeholders, +) + + +def assert_error(code, callable_): + """Assert a stable localization validation code from one failing call.""" + with pytest.raises(UiLocalizationValidationError) as caught: + callable_() + assert caught.value.error_code == code + + +def test_supported_release_locales_are_fixed(): + """The first catalog release is exactly the eight product languages.""" + assert SUPPORTED_LOCALE_CODES == ("ko", "en", "ja", "zh", "vi", "es", "de", "fr") + + +def test_locale_normalization_preserves_supported_primary_language(): + """Supported regional and script variants resolve to the release language.""" + assert normalize_supported_locale("ko-KR") == "ko" + assert normalize_supported_locale("zh-Hant-TW") == "zh" + assert normalize_supported_locale("FR-fr") == "fr" + + +def test_explicit_unsupported_or_invalid_locale_fails_closed(): + """Explicit preferences never silently turn into a different locale.""" + assert_error("ui_locale_unsupported", lambda: normalize_supported_locale("pt-BR")) + assert_error("ui_locale_input_invalid", lambda: normalize_supported_locale("en_US")) + assert_error( + "ui_locale_input_invalid", + lambda: normalize_supported_locale("en\r\nX-Test: bad"), + ) + + +def test_selection_precedence_is_persisted_session_accept_language_default(): + """Locale authority follows the catalog contract in descending precedence.""" + selected = select_ui_locale( + persisted_preference="de-DE", + session_preference="fr-FR", + accept_language="es;q=1.0", + ) + assert (selected.locale_code, selected.selection_source) == ("de", "persisted_preference") + + selected = select_ui_locale(session_preference="fr-FR", accept_language="es;q=1.0") + assert (selected.locale_code, selected.selection_source) == ("fr", "session_preference") + + selected = select_ui_locale(accept_language="es-MX;q=0.7, ja-JP;q=0.9") + assert (selected.locale_code, selected.selection_source) == ("ja", "accept_language") + + selected = select_ui_locale(accept_language="pt-BR, it;q=0.7") + assert (selected.locale_code, selected.selection_source) == ("ko", "product_default") + + +def test_accept_language_duplicate_quality_is_not_first_occurrence_biased(): + """A later duplicate with higher quality outranks lower-quality peers.""" + selected = select_ui_locale( + accept_language="fr;q=0.7, fr-FR;q=0.9, fr-CA;q=0.6, en;q=0.8" + ) + assert selected.locale_code == "fr" + + +def test_accept_language_zero_quality_excludes_language_from_wildcard(): + """An explicit q=0 exclusion is not reintroduced by a wildcard range.""" + selected = select_ui_locale(accept_language="ko;q=0, *;q=0.8, en;q=0.7") + assert selected.locale_code == "ja" + selected = select_ui_locale(accept_language="fr;q=0, *;q=0") + assert selected.locale_code == "ko" + + +def test_wildcard_quality_and_header_position_are_respected(): + """Wildcard candidates participate at declared quality without hiding specifics.""" + selected = select_ui_locale(accept_language="en;q=0.9, *;q=0.8, fr;q=0.7") + assert selected.locale_code == "en" + selected = select_ui_locale(accept_language="*;q=0.7, *;q=0.9, *;q=0.5, fr;q=0.8") + assert selected.locale_code == "ko" + + +def test_malformed_accept_language_fails_closed(): + """Malformed quality values and header injection do not fall through to defaults.""" + assert_error( + "ui_accept_language_invalid", + lambda: select_ui_locale(accept_language="en;q=1.2"), + ) + assert_error( + "ui_locale_input_invalid", + lambda: select_ui_locale(accept_language="en\r\nX-Test: bad"), + ) + + +def test_non_string_locale_inputs_fail_closed(): + """Non-string boundary values produce typed validation failures.""" + assert_error("ui_locale_input_invalid", lambda: normalize_supported_locale(None)) + assert_error("ui_accept_language_invalid", lambda: select_ui_locale(accept_language=42)) + + +def test_empty_accept_language_and_no_header_use_default(): + """An absent effective negotiation result uses the configured product default.""" + selected = select_ui_locale(accept_language=" ") + assert (selected.locale_code, selected.selection_source) == ("ko", "product_default") + selected = select_ui_locale() + assert (selected.locale_code, selected.selection_source) == ("ko", "product_default") + + +def test_custom_supported_default_and_invalid_default(): + """Deployments may choose another supported default but never an unsupported one.""" + selected = select_ui_locale(accept_language="it", product_default="fr") + assert selected.locale_code == "fr" + assert_error( + "ui_locale_unsupported", + lambda: select_ui_locale(product_default="pt"), + ) + + +def test_screen_and_message_identities_are_bounded(): + """Catalog identifiers stay within stable product-key syntax.""" + assert validate_screen_key("settings.identity") == "settings.identity" + assert validate_message_key("oidc_login_pending") == "oidc_login_pending" + assert_error("ui_screen_key_invalid", lambda: validate_screen_key("settings")) + assert_error("ui_screen_key_invalid", lambda: validate_screen_key("../settings.identity")) + assert_error("ui_message_key_invalid", lambda: validate_message_key("OIDC.Login")) + + +def test_non_string_and_invalid_identity_inputs_fail_closed(): + """Non-string and non-snake-case identities are rejected before persistence.""" + assert_error("ui_screen_key_invalid", lambda: validate_screen_key(None)) + assert_error("ui_message_key_invalid", lambda: validate_message_key(None)) + assert_error("ui_message_key_invalid", lambda: validate_message_key("oidc-login")) + + +def test_placeholder_extraction_is_literal_and_ordered(): + """Interpolation fields are inspected without evaluating format expressions.""" + assert extract_placeholder_names("{account_name}님, {count}건 / {account_name}") == ( + "account_name", + "count", + ) + assert extract_placeholder_names("{{literal}}") == () + + +def test_placeholder_schema_rejects_missing_extra_and_formatter_features(): + """Translations preserve the exact schema and reject formatter field traversal.""" + assert validate_translation_placeholders( + "{count}개의 알림이 {account_name}님에게 있습니다.", + ("account_name", "count"), + ) == ("count", "account_name") + assert_error( + "ui_translation_placeholder_mismatch", + lambda: validate_translation_placeholders( + "{account_name}님", + ("account_name", "count"), + ), + ) + assert_error( + "ui_translation_placeholder_mismatch", + lambda: validate_translation_placeholders("{account_name}님 {extra}", ("account_name",)), + ) + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders("{account_name.value}", ("account_name",)), + ) + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders("{count:03d}", ("count",)), + ) + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders("{account_name!r}", ("account_name",)), + ) + + +def test_placeholder_input_and_schema_must_be_bounded(): + """Malformed text, duplicates, and invalid schema names fail before publication.""" + assert_error("ui_placeholder_schema_invalid", lambda: extract_placeholder_names(None)) + assert_error("ui_placeholder_schema_invalid", lambda: extract_placeholder_names("{missing")) + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders("{name}", ("name", "name")), + ) + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders("{name}", ("Name",)), + ) + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders("{name}", (None,)), + ) From 0ca287d3bf3820447d47ba44ad6e17a21229bbc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:47:08 +0900 Subject: [PATCH 03/87] docs(i18n): record catalog domain boundary and traceability --- .../ui-localization-catalog-domain-policy.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/doctoring/ui-localization-catalog-domain-policy.md diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md new file mode 100644 index 000000000..7f374a208 --- /dev/null +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -0,0 +1,68 @@ +# UI Localization Catalog domain policy + +## Decision status + +This document records the first executable domain slice for issue #1731. It is **Proposed** and deliberately stops before persistence, API delivery, browser caching, catalog publication, or page migration. + +The canonical workspace/Alembic owner is still #1503. Translation persistence must not create a migration from protected `develop` while that lineage is unintegrated. When #1503 or a verified complete successor reaches protected ancestry, the catalog persistence work must create the next ordinary descendant from the then-current single Alembic head. + +Open PRs already contain several unrelated `ADR-0005` proposals. This slice therefore does not allocate another ADR number before a complete live ADR-owner reconciliation. The absence of a new ADR number is intentional, not an implicit acceptance decision. + +## Bounded context and ubiquitous language + +The **UI Localization Catalog** bounded context owns UI-copy identity and selection only. Ontology/concept labels are a separate authority and must not be stored or served through the same resource. + +Current domain objects are: + +- `SupportedLocaleCode`: the release language identity, exactly `ko`, `en`, `ja`, `zh`, `vi`, `es`, `de`, `fr`; +- `UiLocaleSelection`: the selected locale plus the authority tier that selected it; +- `screen_key`: a dotted lowercase product-screen identity such as `settings.identity`; +- `message_key`: a lowercase snake-case message identity within a screen; +- `placeholder_schema`: the immutable set of named interpolation fields a published translation must preserve. + +The locale authority order is persisted preference → session preference → `Accept-Language` → product default. Explicit persisted/session values are validated rather than silently replaced. `Accept-Language` quality values are bounded and malformed/control-character input fails closed. Regional/script variants resolve to one of the release-level product languages; this is intentionally narrower than preserving every BCP 47 variant as a separate catalog identity. + +## Placeholder invariant + +Translation publication must preserve the exact named placeholder schema. The policy accepts only simple lowercase named fields such as `{account_name}`. Attribute/index traversal, positional fields, conversion flags, format specifications, malformed braces, missing placeholders, and extra placeholders fail closed with stable machine-readable error codes. + +This is a catalog integrity boundary, not a rendering engine. It inspects placeholder syntax without evaluating it. + +## Rejected alternatives + +A browser-wide catalog bundle is rejected because the product contract calls for screen-scoped retrieval and version/cache identity. A per-key network fetch is also rejected because it turns normal rendering into repeated network chatter and creates inconsistent version visibility. + +A new translation table or Alembic revision in this PR is rejected because it would create a parallel migration authority ahead of #1503. Locale parsing embedded independently in each route or component is rejected because it would produce inconsistent precedence and fallback rules. + +LLM translation is outside this slice. Future assisted translation must use the released contextual-orchestrator contract and cannot bypass human/publication validation or placeholder integrity. + +## Verification + +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character inputs, screen/message identity, literal braces, and placeholder schema mismatch/formatter features. + +Standalone verification against the exact source/test text before publication: + +```text +PYTHONPATH=. python -m pytest -q -W error tests/test_ui_localization_policy.py +16 passed + +PYTHONPATH=. python -m coverage run --branch -m pytest -q -W error tests/test_ui_localization_policy.py +python -m coverage report -m services/ui_localization_policy.py +125 statements, 0 missed; 60 branches, 0 partial; 100% +``` + +This is focused domain-policy evidence. It is not repository CI, PostgreSQL migration, HTTP/API, Storybook, browser, assistive-technology, eight-locale typography, performance, protected-branch, or release evidence. + +## Next causal work + +After the canonical migration ancestry is protected, add versioned catalog aggregates and an ordinary Alembic descendant with immutable published-version semantics, item-level idempotent UPSERT/authoring behavior, and tenant/workspace authorization where applicable. Only then add a screen-scoped read endpoint with version/ETag cache identity and page composition. UI acceptance must cover normal/loading/empty/error/permission states and KO/EN/JA/ZH/VI/ES/DE/FR text expansion, CJK wrapping/font fallback, keyboard/focus/touch and assistive technology before the UI Delivery Gate can pass. + +## TRACEABILITY + +Phillips, A., & Davis, M. (2009). *Tags for identifying languages* (RFC 5646, BCP 47). RFC Editor. https://doi.org/10.17487/RFC5646 + +Phillips, A., & Davis, M. (2006). *Matching of language tags* (RFC 4647, BCP 47). RFC Editor. https://doi.org/10.17487/RFC4647 + +Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 + +RFC 9110 defines `Accept-Language` as weighted language ranges and delegates language-range matching to RFC 4647; RFC 5646 defines language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. From 859b892e3a1d7fd35b7d1198af0daf55aa7978b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:47:56 +0900 Subject: [PATCH 04/87] test(i18n): require RFC 4647 wildcard lookup behavior --- backend/tests/test_ui_localization_policy.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py index e5de00913..16a084fbd 100644 --- a/backend/tests/test_ui_localization_policy.py +++ b/backend/tests/test_ui_localization_policy.py @@ -70,19 +70,21 @@ def test_accept_language_duplicate_quality_is_not_first_occurrence_biased(): assert selected.locale_code == "fr" -def test_accept_language_zero_quality_excludes_language_from_wildcard(): - """An explicit q=0 exclusion is not reintroduced by a wildcard range.""" +def test_accept_language_zero_quality_and_wildcard_follow_lookup_order(): + """Wildcard lookup defers to a later concrete range and q=0 stays excluded.""" selected = select_ui_locale(accept_language="ko;q=0, *;q=0.8, en;q=0.7") - assert selected.locale_code == "ja" + assert selected.locale_code == "en" selected = select_ui_locale(accept_language="fr;q=0, *;q=0") assert selected.locale_code == "ko" -def test_wildcard_quality_and_header_position_are_respected(): - """Wildcard candidates participate at declared quality without hiding specifics.""" +def test_wildcard_lookup_skips_to_later_concrete_range(): + """RFC 4647 lookup skips wildcard when a later concrete range can be tried.""" selected = select_ui_locale(accept_language="en;q=0.9, *;q=0.8, fr;q=0.7") assert selected.locale_code == "en" selected = select_ui_locale(accept_language="*;q=0.7, *;q=0.9, *;q=0.5, fr;q=0.8") + assert selected.locale_code == "fr" + selected = select_ui_locale(accept_language="*;q=0.9") assert selected.locale_code == "ko" From 6e80999b261610880089a852bc36f6f6ed5fb4da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:51:07 +0900 Subject: [PATCH 05/87] fix(i18n): align wildcard selection with RFC 4647 lookup --- backend/services/ui_localization_policy.py | 45 +++++++++++----------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 4cbce05a3..cad066890 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -77,7 +77,7 @@ class UiLocaleSelection: def _reject_header_controls(value: str) -> None: - """Reject control characters that could create an additional header line.""" + """Reject control characters that could smuggle an additional header line.""" if any(character in value for character in ("\r", "\n", "\x00")): raise UiLocalizationValidationError( "ui_locale_input_invalid", @@ -109,7 +109,7 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: def _accept_language_preferences(header_value: str) -> tuple[SupportedLocaleCode, ...]: - """Return supported preferences ordered by quality and original specificity.""" + """Return supported preferences using weighted RFC 4647 lookup ordering.""" if not isinstance(header_value, str): raise UiLocalizationValidationError( "ui_accept_language_invalid", @@ -134,9 +134,8 @@ def _accept_language_preferences(header_value: str) -> tuple[SupportedLocaleCode quality = float(match.group("quality") or "1") language_range = match.group("range") if language_range == "*": - candidate_rank = (quality, position) - if wildcard_best is None or candidate_rank[0] > wildcard_best[0]: - wildcard_best = candidate_rank + if wildcard_best is None or quality > wildcard_best[0]: + wildcard_best = (quality, position) continue primary_language = language_range.split("-", 1)[0].lower() @@ -147,21 +146,23 @@ def _accept_language_preferences(header_value: str) -> tuple[SupportedLocaleCode if current is None or quality > current[0]: explicit_best[locale_code] = (quality, position) - ranked: list[tuple[float, int, int, SupportedLocaleCode]] = [] - for release_order, locale_code in enumerate(SUPPORTED_LOCALE_CODES): - explicit_rank = explicit_best.get(locale_code) - if explicit_rank is not None: - quality, position = explicit_rank - elif wildcard_best is not None: - quality, position = wildcard_best - else: - continue - if quality == 0: - continue - ranked.append((quality, position, release_order, locale_code)) - - ranked.sort(key=lambda item: (-item[0], item[1], item[2])) - return tuple(item[3] for item in ranked) + ranked: list[tuple[float, int, SupportedLocaleCode | None]] = [ + (quality, position, locale_code) + for locale_code, (quality, position) in explicit_best.items() + if quality > 0 + ] + if wildcard_best is not None and wildcard_best[0] > 0: + ranked.append((wildcard_best[0], wildcard_best[1], None)) + ranked.sort(key=lambda item: (-item[0], item[1])) + + ordered: list[SupportedLocaleCode] = [] + for index, (_, _, locale_code) in enumerate(ranked): + if locale_code is None: + if any(later_locale is not None for _, _, later_locale in ranked[index + 1 :]): + continue + break + ordered.append(locale_code) + return tuple(ordered) def select_ui_locale( @@ -195,7 +196,7 @@ def select_ui_locale( def validate_screen_key(screen_key: str) -> str: - """Return a normalized screen identity or fail closed on invalid input.""" + """Return a normalized screen identity or fail closed on hostile input.""" if not isinstance(screen_key, str) or not _SCREEN_KEY_PATTERN.fullmatch(screen_key): raise UiLocalizationValidationError( "ui_screen_key_invalid", @@ -205,7 +206,7 @@ def validate_screen_key(screen_key: str) -> str: def validate_message_key(message_key: str) -> str: - """Return a normalized message identity or fail closed on invalid input.""" + """Return a normalized message identity or fail closed on hostile input.""" if not isinstance(message_key, str) or not _MESSAGE_KEY_PATTERN.fullmatch(message_key): raise UiLocalizationValidationError( "ui_message_key_invalid", From 849cf063c9bdc8bf9ffefbab816246fd17dfd465 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:58:39 +0900 Subject: [PATCH 06/87] test(i18n): preserve q-zero exclusion through wildcard fallback --- ...test_ui_localization_accept_language_regression.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 backend/tests/test_ui_localization_accept_language_regression.py diff --git a/backend/tests/test_ui_localization_accept_language_regression.py b/backend/tests/test_ui_localization_accept_language_regression.py new file mode 100644 index 000000000..e03a252b3 --- /dev/null +++ b/backend/tests/test_ui_localization_accept_language_regression.py @@ -0,0 +1,11 @@ +"""Regression coverage for Accept-Language wildcard fallback exclusions.""" + +from services.ui_localization_policy import select_ui_locale + + +def test_wildcard_fallback_does_not_return_explicit_q_zero_default(): + """A wildcard keeps unspecified locales acceptable without reviving q=0.""" + selected = select_ui_locale(accept_language="ko;q=0, *;q=0.8") + + assert selected.locale_code == "en" + assert selected.selection_source == "accept_language" From c2f489e1a4452e8a2aab78965d1ce7b4a97d5c1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:02:37 +0900 Subject: [PATCH 07/87] fix(i18n): preserve q-zero exclusion in wildcard fallback --- backend/services/ui_localization_policy.py | 26 +++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index cad066890..bb230b52e 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -77,7 +77,7 @@ class UiLocaleSelection: def _reject_header_controls(value: str) -> None: - """Reject control characters that could smuggle an additional header line.""" + """Reject control characters that could create an additional header line.""" if any(character in value for character in ("\r", "\n", "\x00")): raise UiLocalizationValidationError( "ui_locale_input_invalid", @@ -108,7 +108,10 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: return cast(SupportedLocaleCode, primary_language) -def _accept_language_preferences(header_value: str) -> tuple[SupportedLocaleCode, ...]: +def _accept_language_preferences( + header_value: str, + product_default: SupportedLocaleCode, +) -> tuple[SupportedLocaleCode, ...]: """Return supported preferences using weighted RFC 4647 lookup ordering.""" if not isinstance(header_value, str): raise UiLocalizationValidationError( @@ -155,11 +158,24 @@ def _accept_language_preferences(header_value: str) -> tuple[SupportedLocaleCode ranked.append((wildcard_best[0], wildcard_best[1], None)) ranked.sort(key=lambda item: (-item[0], item[1])) + excluded_locales = { + locale_code + for locale_code, (quality, _) in explicit_best.items() + if quality == 0 + } ordered: list[SupportedLocaleCode] = [] for index, (_, _, locale_code) in enumerate(ranked): if locale_code is None: if any(later_locale is not None for _, _, later_locale in ranked[index + 1 :]): continue + wildcard_candidates = (product_default, *SUPPORTED_LOCALE_CODES) + for wildcard_candidate in wildcard_candidates: + if ( + wildcard_candidate not in excluded_locales + and wildcard_candidate not in ordered + ): + ordered.append(wildcard_candidate) + break break ordered.append(locale_code) return tuple(ordered) @@ -189,14 +205,14 @@ def select_ui_locale( "session_preference", ) if accept_language is not None: - preferences = _accept_language_preferences(accept_language) + preferences = _accept_language_preferences(accept_language, product_default) if preferences: return UiLocaleSelection(preferences[0], "accept_language") return UiLocaleSelection(product_default, "product_default") def validate_screen_key(screen_key: str) -> str: - """Return a normalized screen identity or fail closed on hostile input.""" + """Return a normalized screen identity or fail closed on invalid input.""" if not isinstance(screen_key, str) or not _SCREEN_KEY_PATTERN.fullmatch(screen_key): raise UiLocalizationValidationError( "ui_screen_key_invalid", @@ -206,7 +222,7 @@ def validate_screen_key(screen_key: str) -> str: def validate_message_key(message_key: str) -> str: - """Return a normalized message identity or fail closed on hostile input.""" + """Return a normalized message identity or fail closed on invalid input.""" if not isinstance(message_key, str) or not _MESSAGE_KEY_PATTERN.fullmatch(message_key): raise UiLocalizationValidationError( "ui_message_key_invalid", From ff4da1861ef0708634514bd0e613f1e98dc68395 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:40:47 +0900 Subject: [PATCH 08/87] test(i18n): distinguish Accept-Language control failures --- ...ocalization_accept_language_error_codes.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 backend/tests/test_ui_localization_accept_language_error_codes.py diff --git a/backend/tests/test_ui_localization_accept_language_error_codes.py b/backend/tests/test_ui_localization_accept_language_error_codes.py new file mode 100644 index 000000000..b1f8c767a --- /dev/null +++ b/backend/tests/test_ui_localization_accept_language_error_codes.py @@ -0,0 +1,25 @@ +"""Regression coverage for typed Accept-Language control-character failures.""" + +import pytest + +from services.ui_localization_policy import ( + UiLocalizationValidationError, + normalize_supported_locale, + select_ui_locale, +) + + +def test_accept_language_controls_report_header_validation_code(): + """Header injection syntax stays distinguishable from explicit locale input.""" + with pytest.raises(UiLocalizationValidationError) as caught: + select_ui_locale(accept_language="en\r\nX-Test: bad") + + assert caught.value.error_code == "ui_accept_language_invalid" + + +def test_explicit_locale_controls_keep_locale_validation_code(): + """The same control characters retain the explicit-locale boundary code.""" + with pytest.raises(UiLocalizationValidationError) as caught: + normalize_supported_locale("en\r\nX-Test: bad") + + assert caught.value.error_code == "ui_locale_input_invalid" From bd4eeef77228e5f7252b6cf5513a1e628821a1b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:41:26 +0900 Subject: [PATCH 09/87] fix(i18n): preserve validation boundary for header controls --- backend/services/ui_localization_policy.py | 26 +++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index bb230b52e..466825d85 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -76,13 +76,15 @@ class UiLocaleSelection: selection_source: LocaleSelectionSource -def _reject_header_controls(value: str) -> None: - """Reject control characters that could create an additional header line.""" +def _reject_control_characters( + value: str, + *, + error_code: UiLocalizationValidationCode, + message: str, +) -> None: + """Reject header-breaking controls while preserving the caller's boundary code.""" if any(character in value for character in ("\r", "\n", "\x00")): - raise UiLocalizationValidationError( - "ui_locale_input_invalid", - "locale input contains forbidden control characters", - ) + raise UiLocalizationValidationError(error_code, message) def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: @@ -93,7 +95,11 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: "locale input must be a string", ) candidate = locale_tag.strip() - _reject_header_controls(candidate) + _reject_control_characters( + candidate, + error_code="ui_locale_input_invalid", + message="locale input contains forbidden control characters", + ) if not candidate or not _LOCALE_TAG_PATTERN.fullmatch(candidate): raise UiLocalizationValidationError( "ui_locale_input_invalid", @@ -119,7 +125,11 @@ def _accept_language_preferences( "Accept-Language must be a string", ) candidate = header_value.strip() - _reject_header_controls(candidate) + _reject_control_characters( + candidate, + error_code="ui_accept_language_invalid", + message="Accept-Language contains forbidden control characters", + ) if not candidate: return () From 3c6e6a7ecd9f1f0e4a08e09c811907e2ce12dc5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:42:12 +0900 Subject: [PATCH 10/87] test(i18n): align Accept-Language boundary code --- backend/tests/test_ui_localization_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py index 16a084fbd..9100db35d 100644 --- a/backend/tests/test_ui_localization_policy.py +++ b/backend/tests/test_ui_localization_policy.py @@ -95,7 +95,7 @@ def test_malformed_accept_language_fails_closed(): lambda: select_ui_locale(accept_language="en;q=1.2"), ) assert_error( - "ui_locale_input_invalid", + "ui_accept_language_invalid", lambda: select_ui_locale(accept_language="en\r\nX-Test: bad"), ) From 5c87649e2b2dfd76f2b1bae8eb099ee5ab27222a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:42:28 +0900 Subject: [PATCH 11/87] test(i18n): fold boundary regression into domain suite --- ...ocalization_accept_language_error_codes.py | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 backend/tests/test_ui_localization_accept_language_error_codes.py diff --git a/backend/tests/test_ui_localization_accept_language_error_codes.py b/backend/tests/test_ui_localization_accept_language_error_codes.py deleted file mode 100644 index b1f8c767a..000000000 --- a/backend/tests/test_ui_localization_accept_language_error_codes.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Regression coverage for typed Accept-Language control-character failures.""" - -import pytest - -from services.ui_localization_policy import ( - UiLocalizationValidationError, - normalize_supported_locale, - select_ui_locale, -) - - -def test_accept_language_controls_report_header_validation_code(): - """Header injection syntax stays distinguishable from explicit locale input.""" - with pytest.raises(UiLocalizationValidationError) as caught: - select_ui_locale(accept_language="en\r\nX-Test: bad") - - assert caught.value.error_code == "ui_accept_language_invalid" - - -def test_explicit_locale_controls_keep_locale_validation_code(): - """The same control characters retain the explicit-locale boundary code.""" - with pytest.raises(UiLocalizationValidationError) as caught: - normalize_supported_locale("en\r\nX-Test: bad") - - assert caught.value.error_code == "ui_locale_input_invalid" From f07de3fce6b61afd8fe9dc5b72352a9b184ab854 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:45:27 +0900 Subject: [PATCH 12/87] docs(i18n): record typed header validation boundary --- .../ui-localization-catalog-domain-policy.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index 7f374a208..07a94fa85 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -22,6 +22,8 @@ Current domain objects are: The locale authority order is persisted preference → session preference → `Accept-Language` → product default. Explicit persisted/session values are validated rather than silently replaced. `Accept-Language` quality values are bounded and malformed/control-character input fails closed. Regional/script variants resolve to one of the release-level product languages; this is intentionally narrower than preserving every BCP 47 variant as a separate catalog identity. +Stable machine-readable validation codes belong to the boundary that rejected the input. An explicit locale value with CR/LF/NUL remains `ui_locale_input_invalid`; the same control characters arriving through `Accept-Language` are `ui_accept_language_invalid`. This distinction is part of the public domain contract so later HTTP/API adapters can map failures without inferring their origin from human-readable text. + ## Placeholder invariant Translation publication must preserve the exact named placeholder schema. The policy accepts only simple lowercase named fields such as `{account_name}`. Attribute/index traversal, positional fields, conversion flags, format specifications, malformed braces, missing placeholders, and extra placeholders fail closed with stable machine-readable error codes. @@ -40,18 +42,9 @@ LLM translation is outside this slice. Future assisted translation must use the The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character inputs, screen/message identity, literal braces, and placeholder schema mismatch/formatter features. -Standalone verification against the exact source/test text before publication: - -```text -PYTHONPATH=. python -m pytest -q -W error tests/test_ui_localization_policy.py -16 passed - -PYTHONPATH=. python -m coverage run --branch -m pytest -q -W error tests/test_ui_localization_policy.py -python -m coverage report -m services/ui_localization_policy.py -125 statements, 0 missed; 60 branches, 0 partial; 100% -``` +The current branch contains two ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned the typed validation boundary for control characters: a RED required `Accept-Language` CR/LF input to return `ui_accept_language_invalid`, while explicit locale input retains `ui_locale_input_invalid`; the causal repair parameterized the shared control-character helper by the caller's stable validation code and folded the regression into the canonical policy suite. -This is focused domain-policy evidence. It is not repository CI, PostgreSQL migration, HTTP/API, Storybook, browser, assistive-technology, eight-locale typography, performance, protected-branch, or release evidence. +Predecessor local verification receipts are historical evidence only after the current head moved. This document does not transfer the earlier `PYTHONPATH=.` test/coverage commands or claim current-head full-suite, coverage, hosted CI, PostgreSQL migration, HTTP/API, Storybook, browser, assistive-technology, eight-locale typography, performance, protected-branch, or release acceptance. Current-head hosted evidence and independent post-last-push review must be reacquired without a source-neutral wake commit. ## Next causal work From de6bbfaf0d37011efaae77c8142c2ca87dda8e7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:40:33 +0900 Subject: [PATCH 13/87] test(i18n): reject edge CRLF before normalization --- backend/tests/test_ui_localization_policy.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py index 9100db35d..801b7c4ed 100644 --- a/backend/tests/test_ui_localization_policy.py +++ b/backend/tests/test_ui_localization_policy.py @@ -43,6 +43,12 @@ def test_explicit_unsupported_or_invalid_locale_fails_closed(): ) +@pytest.mark.parametrize("locale_tag", ("\r\nen-US", "en-US\r\n")) +def test_locale_normalization_rejects_edge_crlf_before_trimming(locale_tag): + """CR/LF at either edge is rejected rather than normalized away by whitespace trimming.""" + assert_error("ui_locale_input_invalid", lambda: normalize_supported_locale(locale_tag)) + + def test_selection_precedence_is_persisted_session_accept_language_default(): """Locale authority follows the catalog contract in descending precedence.""" selected = select_ui_locale( @@ -100,6 +106,15 @@ def test_malformed_accept_language_fails_closed(): ) +@pytest.mark.parametrize("header_value", ("\r\nen-US", "en-US\r\n")) +def test_accept_language_rejects_edge_crlf_before_trimming(header_value): + """Header-edge CR/LF cannot disappear before the injection guard runs.""" + assert_error( + "ui_accept_language_invalid", + lambda: select_ui_locale(accept_language=header_value), + ) + + def test_non_string_locale_inputs_fail_closed(): """Non-string boundary values produce typed validation failures.""" assert_error("ui_locale_input_invalid", lambda: normalize_supported_locale(None)) From 2d84ee69476a60641eefe14f8a22bf452b6c6bb1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:41:00 +0900 Subject: [PATCH 14/87] fix(i18n): validate CRLF before locale trimming --- backend/services/ui_localization_policy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 466825d85..2998b22fb 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -94,12 +94,12 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: "ui_locale_input_invalid", "locale input must be a string", ) - candidate = locale_tag.strip() _reject_control_characters( - candidate, + locale_tag, error_code="ui_locale_input_invalid", message="locale input contains forbidden control characters", ) + candidate = locale_tag.strip() if not candidate or not _LOCALE_TAG_PATTERN.fullmatch(candidate): raise UiLocalizationValidationError( "ui_locale_input_invalid", @@ -124,12 +124,12 @@ def _accept_language_preferences( "ui_accept_language_invalid", "Accept-Language must be a string", ) - candidate = header_value.strip() _reject_control_characters( - candidate, + header_value, error_code="ui_accept_language_invalid", message="Accept-Language contains forbidden control characters", ) + candidate = header_value.strip() if not candidate: return () From da5288ce4da34c4a0e3740abc088b6bc01097a4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:41:46 +0900 Subject: [PATCH 15/87] test(i18n): fold wildcard exclusion regression into policy suite --- backend/tests/test_ui_localization_policy.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py index 801b7c4ed..3e351c334 100644 --- a/backend/tests/test_ui_localization_policy.py +++ b/backend/tests/test_ui_localization_policy.py @@ -77,9 +77,11 @@ def test_accept_language_duplicate_quality_is_not_first_occurrence_biased(): def test_accept_language_zero_quality_and_wildcard_follow_lookup_order(): - """Wildcard lookup defers to a later concrete range and q=0 stays excluded.""" + """Wildcard lookup defers to concrete ranges and never revives a q=0 locale.""" selected = select_ui_locale(accept_language="ko;q=0, *;q=0.8, en;q=0.7") assert selected.locale_code == "en" + selected = select_ui_locale(accept_language="ko;q=0, *;q=0.8") + assert (selected.locale_code, selected.selection_source) == ("en", "accept_language") selected = select_ui_locale(accept_language="fr;q=0, *;q=0") assert selected.locale_code == "ko" From 3d27ddc914e90ecc74d092b85bf9408cc5dd2b35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:41:50 +0900 Subject: [PATCH 16/87] test(i18n): remove superseded wildcard regression file --- ...test_ui_localization_accept_language_regression.py | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 backend/tests/test_ui_localization_accept_language_regression.py diff --git a/backend/tests/test_ui_localization_accept_language_regression.py b/backend/tests/test_ui_localization_accept_language_regression.py deleted file mode 100644 index e03a252b3..000000000 --- a/backend/tests/test_ui_localization_accept_language_regression.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Regression coverage for Accept-Language wildcard fallback exclusions.""" - -from services.ui_localization_policy import select_ui_locale - - -def test_wildcard_fallback_does_not_return_explicit_q_zero_default(): - """A wildcard keeps unspecified locales acceptable without reviving q=0.""" - selected = select_ui_locale(accept_language="ko;q=0, *;q=0.8") - - assert selected.locale_code == "en" - assert selected.selection_source == "accept_language" From ae34dee31fdef2e59359ec532fcc9865f4994566 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:42:18 +0900 Subject: [PATCH 17/87] docs(i18n): record pre-normalization CRLF validation --- docs/doctoring/ui-localization-catalog-domain-policy.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index 07a94fa85..2fbf282c8 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -22,7 +22,7 @@ Current domain objects are: The locale authority order is persisted preference → session preference → `Accept-Language` → product default. Explicit persisted/session values are validated rather than silently replaced. `Accept-Language` quality values are bounded and malformed/control-character input fails closed. Regional/script variants resolve to one of the release-level product languages; this is intentionally narrower than preserving every BCP 47 variant as a separate catalog identity. -Stable machine-readable validation codes belong to the boundary that rejected the input. An explicit locale value with CR/LF/NUL remains `ui_locale_input_invalid`; the same control characters arriving through `Accept-Language` are `ui_accept_language_invalid`. This distinction is part of the public domain contract so later HTTP/API adapters can map failures without inferring their origin from human-readable text. +Stable machine-readable validation codes belong to the boundary that rejected the input. An explicit locale value with CR/LF/NUL remains `ui_locale_input_invalid`; the same control characters arriving through `Accept-Language` are `ui_accept_language_invalid`. Control-character rejection runs against the original boundary value before benign outer whitespace normalization, so leading or trailing CR/LF cannot be stripped away before validation. This distinction and ordering are part of the public domain contract so later HTTP/API adapters can map failures without inferring their origin from human-readable text. ## Placeholder invariant @@ -40,9 +40,9 @@ LLM translation is outside this slice. Future assisted translation must use the ## Verification -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character inputs, screen/message identity, literal braces, and placeholder schema mismatch/formatter features. +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character inputs including leading/trailing CRLF, screen/message identity, literal braces, and placeholder schema mismatch/formatter features. -The current branch contains two ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned the typed validation boundary for control characters: a RED required `Accept-Language` CR/LF input to return `ui_accept_language_invalid`, while explicit locale input retains `ui_locale_input_invalid`; the causal repair parameterized the shared control-character helper by the caller's stable validation code and folded the regression into the canonical policy suite. +The current branch contains three ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned the typed validation boundary for control characters: a RED required `Accept-Language` CR/LF input to return `ui_accept_language_invalid`, while explicit locale input retains `ui_locale_input_invalid`; the causal repair parameterized the shared control-character helper by the caller's stable validation code. The third found that both locale and `Accept-Language` values were stripped before that helper ran, so edge CR/LF could disappear before validation. Exact regression cases now require edge CR/LF to fail with the appropriate boundary-specific code, and the repair validates the original value before whitespace normalization. The wildcard q=0 regression is folded into the canonical policy suite; no separate duplicate regression file remains. Predecessor local verification receipts are historical evidence only after the current head moved. This document does not transfer the earlier `PYTHONPATH=.` test/coverage commands or claim current-head full-suite, coverage, hosted CI, PostgreSQL migration, HTTP/API, Storybook, browser, assistive-technology, eight-locale typography, performance, protected-branch, or release acceptance. Current-head hosted evidence and independent post-last-push review must be reacquired without a source-neutral wake commit. From 8265e9bb08cc407c860f148f1823dd0f4ab6d13d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 01:45:11 +0900 Subject: [PATCH 18/87] test(i18n): pin RFC 9110 whitespace boundary RED --- .../test_ui_localization_http_whitespace.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 backend/tests/test_ui_localization_http_whitespace.py diff --git a/backend/tests/test_ui_localization_http_whitespace.py b/backend/tests/test_ui_localization_http_whitespace.py new file mode 100644 index 000000000..d587d8da0 --- /dev/null +++ b/backend/tests/test_ui_localization_http_whitespace.py @@ -0,0 +1,54 @@ +"""HTTP/locale whitespace regressions for the UI Localization Catalog policy.""" + +import pytest + +from services.ui_localization_policy import ( + UiLocalizationValidationError, + normalize_supported_locale, + select_ui_locale, +) + + +def assert_error(code, callable_): + """Assert a stable localization validation code from one failing call.""" + with pytest.raises(UiLocalizationValidationError) as caught: + callable_() + assert caught.value.error_code == code + + +@pytest.mark.parametrize( + "locale_tag", + ( + "\ten-US", + "en-US\v", + "\fen-US", + "\u00a0en-US", + ), +) +def test_explicit_locale_rejects_non_space_boundary_whitespace(locale_tag): + """Only ASCII SP is benign outer whitespace for stored/session locale values.""" + assert_error("ui_locale_input_invalid", lambda: normalize_supported_locale(locale_tag)) + + +@pytest.mark.parametrize( + "header_value", + ( + "\ven-US", + "en-US\f", + "en-US\v;q=0.8", + "en-US;\fq=0.8", + "\u00a0en-US", + ), +) +def test_accept_language_rejects_non_http_whitespace(header_value): + """VT, FF, and Unicode whitespace are not RFC 9110 OWS and fail closed.""" + assert_error( + "ui_accept_language_invalid", + lambda: select_ui_locale(accept_language=header_value), + ) + + +def test_accept_language_allows_only_sp_and_htab_as_ows(): + """RFC 9110 OWS remains accepted around list members and q-weights.""" + selected = select_ui_locale(accept_language="\ten-US\t,\tfr ;\tq=0.5\t") + assert (selected.locale_code, selected.selection_source) == ("en", "accept_language") From 9a6c8bb04be237c968b58987ca6ac4efb789a482 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 01:45:39 +0900 Subject: [PATCH 19/87] fix(i18n): enforce RFC 9110 CTL and OWS semantics --- backend/services/ui_localization_policy.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 2998b22fb..1ac8c0c36 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -54,7 +54,7 @@ _PLACEHOLDER_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$", flags=re.ASCII) _ACCEPT_LANGUAGE_ITEM_PATTERN = re.compile( r"^(?P\*|[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*)" - r"(?:\s*;\s*q=(?P0(?:\.\d{0,3})?|1(?:\.0{0,3})?))?$", + r"(?:[ \t]*;[ \t]*q=(?P0(?:\.\d{0,3})?|1(?:\.0{0,3})?))?$", flags=re.ASCII, ) @@ -81,10 +81,15 @@ def _reject_control_characters( *, error_code: UiLocalizationValidationCode, message: str, + allow_horizontal_tab: bool = False, ) -> None: - """Reject header-breaking controls while preserving the caller's boundary code.""" - if any(character in value for character in ("\r", "\n", "\x00")): - raise UiLocalizationValidationError(error_code, message) + """Reject C0/DEL controls while preserving the caller's boundary code.""" + for character in value: + code_point = ord(character) + if code_point == 0x7F or ( + code_point < 0x20 and not (allow_horizontal_tab and character == "\t") + ): + raise UiLocalizationValidationError(error_code, message) def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: @@ -99,7 +104,7 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: error_code="ui_locale_input_invalid", message="locale input contains forbidden control characters", ) - candidate = locale_tag.strip() + candidate = locale_tag.strip(" ") if not candidate or not _LOCALE_TAG_PATTERN.fullmatch(candidate): raise UiLocalizationValidationError( "ui_locale_input_invalid", @@ -128,8 +133,9 @@ def _accept_language_preferences( header_value, error_code="ui_accept_language_invalid", message="Accept-Language contains forbidden control characters", + allow_horizontal_tab=True, ) - candidate = header_value.strip() + candidate = header_value.strip(" \t") if not candidate: return () @@ -137,7 +143,7 @@ def _accept_language_preferences( wildcard_best: tuple[float, int] | None = None for position, raw_item in enumerate(candidate.split(",")): - item = raw_item.strip() + item = raw_item.strip(" \t") match = _ACCEPT_LANGUAGE_ITEM_PATTERN.fullmatch(item) if match is None: raise UiLocalizationValidationError( From 41aeb4ea59b7a2e24bc326b3239c434c3e135edf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 01:46:26 +0900 Subject: [PATCH 20/87] docs(i18n): record RFC 9110 OWS canonicalization repair --- .../ui-localization-catalog-domain-policy.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index 2fbf282c8..1f205b38c 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -22,7 +22,7 @@ Current domain objects are: The locale authority order is persisted preference → session preference → `Accept-Language` → product default. Explicit persisted/session values are validated rather than silently replaced. `Accept-Language` quality values are bounded and malformed/control-character input fails closed. Regional/script variants resolve to one of the release-level product languages; this is intentionally narrower than preserving every BCP 47 variant as a separate catalog identity. -Stable machine-readable validation codes belong to the boundary that rejected the input. An explicit locale value with CR/LF/NUL remains `ui_locale_input_invalid`; the same control characters arriving through `Accept-Language` are `ui_accept_language_invalid`. Control-character rejection runs against the original boundary value before benign outer whitespace normalization, so leading or trailing CR/LF cannot be stripped away before validation. This distinction and ordering are part of the public domain contract so later HTTP/API adapters can map failures without inferring their origin from human-readable text. +Stable machine-readable validation codes belong to the boundary that rejected the input. Explicit locale input rejects every C0 control and DEL before normalization and permits only ASCII SP as benign outer whitespace. `Accept-Language` applies the HTTP field-value boundary instead: C0/DEL controls are rejected except HTAB where RFC 9110 permits it as OWS, and only SP/HTAB are normalized as HTTP optional whitespace. Unicode whitespace, VT and FF are not treated as OWS. This distinction prevents Python's broader `str.strip()`/`\s` semantics from silently canonicalizing bytes that are outside the HTTP grammar, while preserving boundary-specific errors (`ui_locale_input_invalid` versus `ui_accept_language_invalid`). ## Placeholder invariant @@ -36,15 +36,17 @@ A browser-wide catalog bundle is rejected because the product contract calls for A new translation table or Alembic revision in this PR is rejected because it would create a parallel migration authority ahead of #1503. Locale parsing embedded independently in each route or component is rejected because it would produce inconsistent precedence and fallback rules. +Using Python `str.strip()` and regex `\s` as protocol whitespace is rejected. Python intentionally recognizes a broader Unicode whitespace set than HTTP OWS, while RFC 9110 defines OWS exactly as SP/HTAB. Protocol parsing therefore uses explicit ASCII whitespace sets rather than language-runtime whitespace classes. + LLM translation is outside this slice. Future assisted translation must use the released contextual-orchestrator contract and cannot bypass human/publication validation or placeholder integrity. ## Verification -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character inputs including leading/trailing CRLF, screen/message identity, literal braces, and placeholder schema mismatch/formatter features. +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, and placeholder schema mismatch/formatter features. The dedicated HTTP-whitespace regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, and valid SP/HTAB OWS around `Accept-Language` list members and q-weights. -The current branch contains three ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned the typed validation boundary for control characters: a RED required `Accept-Language` CR/LF input to return `ui_accept_language_invalid`, while explicit locale input retains `ui_locale_input_invalid`; the causal repair parameterized the shared control-character helper by the caller's stable validation code. The third found that both locale and `Accept-Language` values were stripped before that helper ran, so edge CR/LF could disappear before validation. Exact regression cases now require edge CR/LF to fail with the appropriate boundary-specific code, and the repair validates the original value before whitespace normalization. The wildcard q=0 regression is folded into the canonical policy suite; no separate duplicate regression file remains. +The branch now contains four ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. -Predecessor local verification receipts are historical evidence only after the current head moved. This document does not transfer the earlier `PYTHONPATH=.` test/coverage commands or claim current-head full-suite, coverage, hosted CI, PostgreSQL migration, HTTP/API, Storybook, browser, assistive-technology, eight-locale typography, performance, protected-branch, or release acceptance. Current-head hosted evidence and independent post-last-push review must be reacquired without a source-neutral wake commit. +No local/container PASS is claimed for the fourth repair because this automation environment cannot execute the repository test stack. The exact source and regression are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. ## Next causal work @@ -58,4 +60,4 @@ Phillips, A., & Davis, M. (2006). *Matching of language tags* (RFC 4647, BCP 47) Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 -RFC 9110 defines `Accept-Language` as weighted language ranges and delegates language-range matching to RFC 4647; RFC 5646 defines language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. +RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, and defines OWS exactly as zero or more SP/HTAB octets. It also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. From 962e250c54d0da324ec29b21283ab0b02cd55e8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 02:45:14 +0900 Subject: [PATCH 21/87] test(i18n): pin case-insensitive Accept-Language q parameter --- backend/tests/test_ui_localization_http_whitespace.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/tests/test_ui_localization_http_whitespace.py b/backend/tests/test_ui_localization_http_whitespace.py index d587d8da0..4edcd1737 100644 --- a/backend/tests/test_ui_localization_http_whitespace.py +++ b/backend/tests/test_ui_localization_http_whitespace.py @@ -52,3 +52,12 @@ def test_accept_language_allows_only_sp_and_htab_as_ows(): """RFC 9110 OWS remains accepted around list members and q-weights.""" selected = select_ui_locale(accept_language="\ten-US\t,\tfr ;\tq=0.5\t") assert (selected.locale_code, selected.selection_source) == ("en", "accept_language") + + +def test_accept_language_quality_parameter_name_is_case_insensitive(): + """RFC 9110 defines the q parameter name as case-insensitive.""" + selected = select_ui_locale(accept_language="fr;Q=0.4, en;q=0.8") + assert (selected.locale_code, selected.selection_source) == ("en", "accept_language") + + selected = select_ui_locale(accept_language="fr;Q=0.9, en;q=0.8") + assert (selected.locale_code, selected.selection_source) == ("fr", "accept_language") From b20b5593498c09855e6ef6fb213d9bf6ea764e15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 02:45:43 +0900 Subject: [PATCH 22/87] fix(i18n): accept case-insensitive HTTP quality parameter --- backend/services/ui_localization_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 1ac8c0c36..f8bf57bb9 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -54,7 +54,7 @@ _PLACEHOLDER_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$", flags=re.ASCII) _ACCEPT_LANGUAGE_ITEM_PATTERN = re.compile( r"^(?P\*|[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*)" - r"(?:[ \t]*;[ \t]*q=(?P0(?:\.\d{0,3})?|1(?:\.0{0,3})?))?$", + r"(?:[ \t]*;[ \t]*[qQ]=(?P0(?:\.\d{0,3})?|1(?:\.0{0,3})?))?$", flags=re.ASCII, ) From 2bc067e7b0a98b1d35630e77623e55bc9594992b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 02:46:09 +0900 Subject: [PATCH 23/87] docs(i18n): record case-insensitive HTTP q parameter --- .../ui-localization-catalog-domain-policy.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index 1f205b38c..a38f2e9b6 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -24,6 +24,8 @@ The locale authority order is persisted preference → session preference → `A Stable machine-readable validation codes belong to the boundary that rejected the input. Explicit locale input rejects every C0 control and DEL before normalization and permits only ASCII SP as benign outer whitespace. `Accept-Language` applies the HTTP field-value boundary instead: C0/DEL controls are rejected except HTAB where RFC 9110 permits it as OWS, and only SP/HTAB are normalized as HTTP optional whitespace. Unicode whitespace, VT and FF are not treated as OWS. This distinction prevents Python's broader `str.strip()`/`\s` semantics from silently canonicalizing bytes that are outside the HTTP grammar, while preserving boundary-specific errors (`ui_locale_input_invalid` versus `ui_accept_language_invalid`). +The HTTP quality parameter name is case-insensitive. `q=` and `Q=` therefore carry identical weight semantics; rejecting `Q=` would incorrectly turn a valid `Accept-Language` field value into `ui_accept_language_invalid`. The policy keeps the value grammar strict while matching the parameter name case-insensitively. + ## Placeholder invariant Translation publication must preserve the exact named placeholder schema. The policy accepts only simple lowercase named fields such as `{account_name}`. Attribute/index traversal, positional fields, conversion flags, format specifications, malformed braces, missing placeholders, and extra placeholders fail closed with stable machine-readable error codes. @@ -38,15 +40,17 @@ A new translation table or Alembic revision in this PR is rejected because it wo Using Python `str.strip()` and regex `\s` as protocol whitespace is rejected. Python intentionally recognizes a broader Unicode whitespace set than HTTP OWS, while RFC 9110 defines OWS exactly as SP/HTAB. Protocol parsing therefore uses explicit ASCII whitespace sets rather than language-runtime whitespace classes. +Treating the literal spelling `q` as case-sensitive is also rejected. RFC 9110 defines the content-negotiation weight parameter name `q` as case-insensitive; only its qvalue grammar remains strict. + LLM translation is outside this slice. Future assisted translation must use the released contextual-orchestrator contract and cannot bypass human/publication validation or placeholder integrity. ## Verification -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, and placeholder schema mismatch/formatter features. The dedicated HTTP-whitespace regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, and valid SP/HTAB OWS around `Accept-Language` list members and q-weights. +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, and placeholder schema mismatch/formatter features. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. -The branch now contains four ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. +The branch now contains five ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. -No local/container PASS is claimed for the fourth repair because this automation environment cannot execute the repository test stack. The exact source and regression are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. +No local/container PASS is claimed for the fourth or fifth repair because this automation environment cannot execute the repository test stack. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. ## Next causal work @@ -60,4 +64,4 @@ Phillips, A., & Davis, M. (2006). *Matching of language tags* (RFC 4647, BCP 47) Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 -RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, and defines OWS exactly as zero or more SP/HTAB octets. It also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. +RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, defines OWS exactly as zero or more SP/HTAB octets, and explicitly states that the `q` parameter name is case-insensitive. It also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. From e299d29b14943c368e581bcb3d35303a1ba3fb8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 03:43:13 +0900 Subject: [PATCH 24/87] test(i18n): pin placeholder schema container boundary --- backend/tests/test_ui_localization_policy.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py index 3e351c334..5dbbdb482 100644 --- a/backend/tests/test_ui_localization_policy.py +++ b/backend/tests/test_ui_localization_policy.py @@ -213,3 +213,18 @@ def test_placeholder_input_and_schema_must_be_bounded(): "ui_placeholder_schema_invalid", lambda: validate_translation_placeholders("{name}", (None,)), ) + + +@pytest.mark.parametrize( + "placeholder_schema", + ( + None, + {"name": "ignored-value"}, + ), +) +def test_placeholder_schema_container_type_fails_closed(placeholder_schema): + """Only the declared tuple/list schema boundary is accepted at runtime.""" + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders("{name}", placeholder_schema), + ) From 92a50acef8c7a3fbb38a926994add1641ac6c49d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 03:44:25 +0900 Subject: [PATCH 25/87] fix(i18n): reject non-list placeholder schemas --- backend/services/ui_localization_policy.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index f8bf57bb9..45541ed4d 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -286,6 +286,11 @@ def validate_translation_placeholders( placeholder_schema: tuple[str, ...] | list[str], ) -> tuple[str, ...]: """Require the translated message to preserve the exact placeholder schema.""" + if not isinstance(placeholder_schema, (tuple, list)): + raise UiLocalizationValidationError( + "ui_placeholder_schema_invalid", + "placeholder schema must be a tuple or list of names", + ) schema = tuple(placeholder_schema) if len(schema) != len(set(schema)) or any( not isinstance(name, str) or not _PLACEHOLDER_NAME_PATTERN.fullmatch(name) @@ -301,4 +306,4 @@ def validate_translation_placeholders( "ui_translation_placeholder_mismatch", "translated message placeholders do not match the versioned schema", ) - return actual + return actual \ No newline at end of file From b1808593ca9ac147c06d8c6d1cc3ac0d0ed5efb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 03:45:01 +0900 Subject: [PATCH 26/87] docs(i18n): trace placeholder schema container repair --- .../ui-localization-catalog-domain-policy.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index a38f2e9b6..e10f826d8 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -18,7 +18,7 @@ Current domain objects are: - `UiLocaleSelection`: the selected locale plus the authority tier that selected it; - `screen_key`: a dotted lowercase product-screen identity such as `settings.identity`; - `message_key`: a lowercase snake-case message identity within a screen; -- `placeholder_schema`: the immutable set of named interpolation fields a published translation must preserve. +- `placeholder_schema`: the immutable ordered collection of named interpolation fields a published translation must preserve. The locale authority order is persisted preference → session preference → `Accept-Language` → product default. Explicit persisted/session values are validated rather than silently replaced. `Accept-Language` quality values are bounded and malformed/control-character input fails closed. Regional/script variants resolve to one of the release-level product languages; this is intentionally narrower than preserving every BCP 47 variant as a separate catalog identity. @@ -30,6 +30,8 @@ The HTTP quality parameter name is case-insensitive. `q=` and `Q=` therefore car Translation publication must preserve the exact named placeholder schema. The policy accepts only simple lowercase named fields such as `{account_name}`. Attribute/index traversal, positional fields, conversion flags, format specifications, malformed braces, missing placeholders, and extra placeholders fail closed with stable machine-readable error codes. +The schema container itself is part of the boundary. Runtime callers may provide only the declared tuple/list collection. A mapping, generator, scalar, `None`, or other iterable is not silently coerced into a schema. This prevents Python container iteration semantics from changing the meaning of publication metadata and guarantees `ui_placeholder_schema_invalid` instead of raw `TypeError` or accidental acceptance. + This is a catalog integrity boundary, not a rendering engine. It inspects placeholder syntax without evaluating it. ## Rejected alternatives @@ -42,15 +44,17 @@ Using Python `str.strip()` and regex `\s` as protocol whitespace is rejected. Py Treating the literal spelling `q` as case-sensitive is also rejected. RFC 9110 defines the content-negotiation weight parameter name `q` as case-insensitive; only its qvalue grammar remains strict. +Coercing an arbitrary iterable with `tuple(placeholder_schema)` is rejected. A one-key mapping can otherwise be accepted accidentally because iteration yields keys, while `None` raises a raw runtime exception before the localization policy can emit its stable boundary code. + LLM translation is outside this slice. Future assisted translation must use the released contextual-orchestrator contract and cannot bypass human/publication validation or placeholder integrity. ## Verification -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, and placeholder schema mismatch/formatter features. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, and runtime schema-container rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. -The branch now contains five ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. +The branch now contains six ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. -No local/container PASS is claimed for the fourth or fifth repair because this automation environment cannot execute the repository test stack. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. +No local/container PASS is claimed for the fourth, fifth, or sixth repair because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. ## Next causal work @@ -65,3 +69,5 @@ Phillips, A., & Davis, M. (2006). *Matching of language tags* (RFC 4647, BCP 47) Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, defines OWS exactly as zero or more SP/HTAB octets, and explicitly states that the `q` parameter name is case-insensitive. It also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. + +The placeholder-container repair is an internal product invariant rather than a new standards interpretation: public/runtime boundaries must preserve stable localization validation codes and must not rely on incidental Python iteration behavior to define a versioned schema. \ No newline at end of file From 3e251cc253d2ccf62ec1a3209ab629c6cffbe149 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 04:43:09 +0900 Subject: [PATCH 27/87] test(i18n): pin translation text persistence boundary --- .../test_ui_localization_translation_text.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 backend/tests/test_ui_localization_translation_text.py diff --git a/backend/tests/test_ui_localization_translation_text.py b/backend/tests/test_ui_localization_translation_text.py new file mode 100644 index 000000000..c3d78cb52 --- /dev/null +++ b/backend/tests/test_ui_localization_translation_text.py @@ -0,0 +1,46 @@ +"""Translation-text boundary regressions for the UI Localization Catalog policy.""" + +import pytest + +from services.ui_localization_policy import ( + UiLocalizationValidationError, + extract_placeholder_names, + validate_translation_placeholders, +) + + +def assert_error(code, callable_): + """Assert a stable localization validation code from one failing call.""" + with pytest.raises(UiLocalizationValidationError) as caught: + callable_() + assert caught.value.error_code == code + + +@pytest.mark.parametrize( + "message_text", + ( + "\x00{name}", + "{name}\x00", + "\ud800{name}", + "{name}\udfff", + ), +) +def test_translation_text_rejects_non_persistable_unicode(message_text): + """NUL and surrogate code points fail at the translation boundary before publication.""" + assert_error( + "ui_translation_input_invalid", + lambda: validate_translation_placeholders(message_text, ("name",)), + ) + + +def test_non_string_translation_text_keeps_translation_boundary_identity(): + """A non-string translation reports the translation boundary, not a schema failure.""" + assert_error("ui_translation_input_invalid", lambda: extract_placeholder_names(None)) + + +def test_translation_text_allows_normal_multiline_unicode(): + """The boundary does not reject ordinary Unicode, newlines, or tabs used by UI copy.""" + assert validate_translation_placeholders( + "안녕하세요, {name}님.\n\t다음 일정이 있습니다.", + ("name",), + ) == ("name",) From dec6c694dcf930ef184b158de04260d8db433bec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 04:43:36 +0900 Subject: [PATCH 28/87] fix(i18n): validate translation text persistence boundary --- backend/services/ui_localization_policy.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 45541ed4d..603ecd31a 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -26,6 +26,7 @@ "ui_accept_language_invalid", "ui_screen_key_invalid", "ui_message_key_invalid", + "ui_translation_input_invalid", "ui_placeholder_schema_invalid", "ui_translation_placeholder_mismatch", ] @@ -247,13 +248,25 @@ def validate_message_key(message_key: str) -> str: return message_key -def extract_placeholder_names(message_text: str) -> tuple[str, ...]: - """Extract simple named interpolation fields without evaluating the message.""" +def _validate_translation_text(message_text: str) -> None: + """Reject text that cannot cross the catalog's UTF-8/PostgreSQL boundary.""" if not isinstance(message_text, str): raise UiLocalizationValidationError( - "ui_placeholder_schema_invalid", + "ui_translation_input_invalid", "translated message must be a string", ) + if "\x00" in message_text or any( + 0xD800 <= ord(character) <= 0xDFFF for character in message_text + ): + raise UiLocalizationValidationError( + "ui_translation_input_invalid", + "translated message must contain persistable Unicode scalar text without NUL", + ) + + +def extract_placeholder_names(message_text: str) -> tuple[str, ...]: + """Extract simple named interpolation fields without evaluating the message.""" + _validate_translation_text(message_text) names: list[str] = [] seen: set[str] = set() try: @@ -306,4 +319,4 @@ def validate_translation_placeholders( "ui_translation_placeholder_mismatch", "translated message placeholders do not match the versioned schema", ) - return actual \ No newline at end of file + return actual From 8d7bae267c9b7d3cd640fece9d2d43173ebfc867 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 04:44:10 +0900 Subject: [PATCH 29/87] docs(i18n): trace translation text persistence boundary --- .../ui-localization-catalog-domain-policy.md | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index e10f826d8..35c06395c 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -34,6 +34,12 @@ The schema container itself is part of the boundary. Runtime callers may provide This is a catalog integrity boundary, not a rendering engine. It inspects placeholder syntax without evaluating it. +## Translation text persistence boundary + +A Python `str` is not by itself proof that catalog text is suitable for the product's UTF-8/PostgreSQL persistence path. PostgreSQL character types cannot store U+0000, and Unicode encoding forms operate on Unicode scalar values, which exclude surrogate code points U+D800–U+DFFF. The pure policy therefore rejects NUL and surrogate code points before placeholder parsing with the stable boundary code `ui_translation_input_invalid`. + +The rule is intentionally narrow. Ordinary Unicode text, combining marks, CJK text, newlines and horizontal tabs remain valid translation content. This is not a generic control-character scrubber and does not rewrite user-facing copy; it prevents persistence/encoding failures from escaping later as database or codec exceptions. + ## Rejected alternatives A browser-wide catalog bundle is rejected because the product contract calls for screen-scoped retrieval and version/cache identity. A per-key network fetch is also rejected because it turns normal rendering into repeated network chatter and creates inconsistent version visibility. @@ -46,15 +52,17 @@ Treating the literal spelling `q` as case-sensitive is also rejected. RFC 9110 d Coercing an arbitrary iterable with `tuple(placeholder_schema)` is rejected. A one-key mapping can otherwise be accepted accidentally because iteration yields keys, while `None` raises a raw runtime exception before the localization policy can emit its stable boundary code. +Deferring NUL/surrogate rejection to PostgreSQL, JSON serialization, or an implicit UTF-8 encode is rejected. That would make publication correctness depend on whichever downstream sink is reached first and would replace a stable product error with driver/codec-specific failures. + LLM translation is outside this slice. Future assisted translation must use the released contextual-orchestrator contract and cannot bypass human/publication validation or placeholder integrity. ## Verification -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, and runtime schema-container rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, and runtime schema-container rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. -The branch now contains six ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. +The branch now contains seven ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. -No local/container PASS is claimed for the fourth, fifth, or sixth repair because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. +No local/container PASS is claimed for the fourth through seventh repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. ## Next causal work @@ -68,6 +76,12 @@ Phillips, A., & Davis, M. (2006). *Matching of language tags* (RFC 4647, BCP 47) Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Character types*. https://www.postgresql.org/docs/18/datatype-character.html + +The Unicode Consortium. (2026). *The Unicode Standard, Version 18.0: Core specification, Chapter 3*. https://www.unicode.org/versions/Unicode18.0.0/core-spec/chapter-3/ + RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, defines OWS exactly as zero or more SP/HTAB octets, and explicitly states that the `q` parameter name is case-insensitive. It also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. -The placeholder-container repair is an internal product invariant rather than a new standards interpretation: public/runtime boundaries must preserve stable localization validation codes and must not rely on incidental Python iteration behavior to define a versioned schema. \ No newline at end of file +PostgreSQL 18 documents that character zero cannot be stored in character types. Unicode 18 defines Unicode scalar values as all Unicode code points except the high- and low-surrogate ranges and defines the Unicode encoding forms over scalar values. The seventh repair binds those external persistence/encoding constraints to the product's typed translation-input boundary without banning legitimate multiline or tabbed UI copy. + +The placeholder-container repair is an internal product invariant rather than a new standards interpretation: public/runtime boundaries must preserve stable localization validation codes and must not rely on incidental Python iteration behavior to define a versioned schema. From 5d7056b15041e254bf2a6e33c70272534af7a7ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 04:44:35 +0900 Subject: [PATCH 30/87] test(i18n): align canonical translation input error code --- backend/tests/test_ui_localization_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py index 5dbbdb482..09acc2fdf 100644 --- a/backend/tests/test_ui_localization_policy.py +++ b/backend/tests/test_ui_localization_policy.py @@ -199,7 +199,7 @@ def test_placeholder_schema_rejects_missing_extra_and_formatter_features(): def test_placeholder_input_and_schema_must_be_bounded(): """Malformed text, duplicates, and invalid schema names fail before publication.""" - assert_error("ui_placeholder_schema_invalid", lambda: extract_placeholder_names(None)) + assert_error("ui_translation_input_invalid", lambda: extract_placeholder_names(None)) assert_error("ui_placeholder_schema_invalid", lambda: extract_placeholder_names("{missing")) assert_error( "ui_placeholder_schema_invalid", From 489bfd4fa2d6f3630cf33412f6d910d73c47e110 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:41:19 +0900 Subject: [PATCH 31/87] test(i18n): expose unhashable placeholder schema RED --- backend/tests/test_ui_localization_policy.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py index 09acc2fdf..66f136fe3 100644 --- a/backend/tests/test_ui_localization_policy.py +++ b/backend/tests/test_ui_localization_policy.py @@ -228,3 +228,18 @@ def test_placeholder_schema_container_type_fails_closed(placeholder_schema): "ui_placeholder_schema_invalid", lambda: validate_translation_placeholders("{name}", placeholder_schema), ) + + +@pytest.mark.parametrize( + "placeholder_schema", + ( + (["name"],), + ({"name": "ignored-value"},), + ), +) +def test_placeholder_schema_unhashable_element_fails_closed(placeholder_schema): + """Nested mutable values fail with the catalog error instead of raw TypeError.""" + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders("{name}", placeholder_schema), + ) From 244cff4f8d8805f2b3eacf91a75c7da84052bfc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:42:30 +0900 Subject: [PATCH 32/87] fix(i18n): validate placeholder elements before deduplication --- backend/services/ui_localization_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 603ecd31a..8edac5534 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -305,10 +305,10 @@ def validate_translation_placeholders( "placeholder schema must be a tuple or list of names", ) schema = tuple(placeholder_schema) - if len(schema) != len(set(schema)) or any( + if any( not isinstance(name, str) or not _PLACEHOLDER_NAME_PATTERN.fullmatch(name) for name in schema - ): + ) or len(schema) != len(set(schema)): raise UiLocalizationValidationError( "ui_placeholder_schema_invalid", "placeholder schema must contain unique lowercase snake_case names", From 05c00ca9c48ba995e531200405e30f6eb0fb5f6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:42:58 +0900 Subject: [PATCH 33/87] docs(i18n): record placeholder element validation boundary --- .../ui-localization-catalog-domain-policy.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index 35c06395c..5afe9b0dc 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -30,7 +30,7 @@ The HTTP quality parameter name is case-insensitive. `q=` and `Q=` therefore car Translation publication must preserve the exact named placeholder schema. The policy accepts only simple lowercase named fields such as `{account_name}`. Attribute/index traversal, positional fields, conversion flags, format specifications, malformed braces, missing placeholders, and extra placeholders fail closed with stable machine-readable error codes. -The schema container itself is part of the boundary. Runtime callers may provide only the declared tuple/list collection. A mapping, generator, scalar, `None`, or other iterable is not silently coerced into a schema. This prevents Python container iteration semantics from changing the meaning of publication metadata and guarantees `ui_placeholder_schema_invalid` instead of raw `TypeError` or accidental acceptance. +The schema container itself is part of the boundary. Runtime callers may provide only the declared tuple/list collection. A mapping, generator, scalar, `None`, or other iterable is not silently coerced into a schema. Each collection element is validated as a string matching the placeholder-name grammar before duplicate detection. This ordering matters: nested mutable values such as lists or mappings are unhashable, so applying `set(schema)` first would leak a raw Python `TypeError` instead of the stable `ui_placeholder_schema_invalid` contract. Container shape, element type/syntax, and uniqueness are therefore checked in that order. This is a catalog integrity boundary, not a rendering engine. It inspects placeholder syntax without evaluating it. @@ -52,17 +52,19 @@ Treating the literal spelling `q` as case-sensitive is also rejected. RFC 9110 d Coercing an arbitrary iterable with `tuple(placeholder_schema)` is rejected. A one-key mapping can otherwise be accepted accidentally because iteration yields keys, while `None` raises a raw runtime exception before the localization policy can emit its stable boundary code. +Running duplicate detection with `set(schema)` before element validation is also rejected. An unhashable nested list or mapping would otherwise escape as a raw `TypeError`; the domain boundary must reject the element itself before any hash-based uniqueness operation. + Deferring NUL/surrogate rejection to PostgreSQL, JSON serialization, or an implicit UTF-8 encode is rejected. That would make publication correctness depend on whichever downstream sink is reached first and would replace a stable product error with driver/codec-specific failures. LLM translation is outside this slice. Future assisted translation must use the released contextual-orchestrator contract and cannot bypass human/publication validation or placeholder integrity. ## Verification -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, and runtime schema-container rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, and unhashable schema-element rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. -The branch now contains seven ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. +The branch now contains eight ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. The eighth RED `489bfd4fa2d6f3630cf33412f6d910d73c47e110` proves that a tuple/list containing an unhashable nested list or mapping reaches `set(schema)` and escapes as raw `TypeError`; causal fix `244cff4f8d8805f2b3eacf91a75c7da84052bfc2` validates every element as a valid string name before duplicate detection. -No local/container PASS is claimed for the fourth through seventh repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. +No local/container PASS is claimed for the fourth through eighth repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. ## Next causal work @@ -84,4 +86,4 @@ RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight PostgreSQL 18 documents that character zero cannot be stored in character types. Unicode 18 defines Unicode scalar values as all Unicode code points except the high- and low-surrogate ranges and defines the Unicode encoding forms over scalar values. The seventh repair binds those external persistence/encoding constraints to the product's typed translation-input boundary without banning legitimate multiline or tabbed UI copy. -The placeholder-container repair is an internal product invariant rather than a new standards interpretation: public/runtime boundaries must preserve stable localization validation codes and must not rely on incidental Python iteration behavior to define a versioned schema. +The placeholder-container and element-order repairs are internal product invariants rather than new standards interpretations: public/runtime boundaries must preserve stable localization validation codes and must not rely on incidental Python iteration or hashability behavior to define a versioned schema. From 652137bd5b4fae436af207d8823a4e6e5122b8a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 06:41:47 +0900 Subject: [PATCH 34/87] test(i18n): pin product-default runtime boundary --- .../tests/test_ui_localization_product_default.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 backend/tests/test_ui_localization_product_default.py diff --git a/backend/tests/test_ui_localization_product_default.py b/backend/tests/test_ui_localization_product_default.py new file mode 100644 index 000000000..b17a84024 --- /dev/null +++ b/backend/tests/test_ui_localization_product_default.py @@ -0,0 +1,14 @@ +"""Runtime-boundary regression for the UI Localization Catalog product default.""" + +import pytest + +from services.ui_localization_policy import UiLocalizationValidationError, select_ui_locale + + +@pytest.mark.parametrize("product_default", ([], {})) +def test_unhashable_product_default_fails_with_typed_locale_error(product_default): + """Malformed runtime defaults never leak Python hashability errors.""" + with pytest.raises(UiLocalizationValidationError) as caught: + select_ui_locale(product_default=product_default) + + assert caught.value.error_code == "ui_locale_unsupported" From f079bb270aadb2748937d5491b718e87b4198834 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 06:42:15 +0900 Subject: [PATCH 35/87] fix(i18n): validate product default before set membership --- backend/services/ui_localization_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 8edac5534..d32acf603 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -206,7 +206,7 @@ def select_ui_locale( product_default: SupportedLocaleCode = DEFAULT_LOCALE_CODE, ) -> UiLocaleSelection: """Resolve one supported locale using the product's explicit authority order.""" - if product_default not in _SUPPORTED_LOCALE_SET: + if not isinstance(product_default, str) or product_default not in _SUPPORTED_LOCALE_SET: raise UiLocalizationValidationError( "ui_locale_unsupported", "product default locale must be supported", From cf10a222d55fc27e49b28b7a9fb55f4d31a349aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 06:43:11 +0900 Subject: [PATCH 36/87] docs(i18n): record product-default runtime boundary --- .../ui-localization-catalog-domain-policy.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index 5afe9b0dc..b6ae35daa 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -26,6 +26,8 @@ Stable machine-readable validation codes belong to the boundary that rejected th The HTTP quality parameter name is case-insensitive. `q=` and `Q=` therefore carry identical weight semantics; rejecting `Q=` would incorrectly turn a valid `Accept-Language` field value into `ui_accept_language_invalid`. The policy keeps the value grammar strict while matching the parameter name case-insensitively. +The product default is a runtime boundary too. Although the type annotation is `SupportedLocaleCode`, callers can still supply arbitrary Python objects. The policy validates that the value is a string before set membership so an unhashable list or mapping cannot escape as a raw `TypeError`. Unsupported or malformed defaults retain the stable `ui_locale_unsupported` product-contract failure rather than exposing Python hashability semantics. + ## Placeholder invariant Translation publication must preserve the exact named placeholder schema. The policy accepts only simple lowercase named fields such as `{account_name}`. Attribute/index traversal, positional fields, conversion flags, format specifications, malformed braces, missing placeholders, and extra placeholders fail closed with stable machine-readable error codes. @@ -50,6 +52,8 @@ Using Python `str.strip()` and regex `\s` as protocol whitespace is rejected. Py Treating the literal spelling `q` as case-sensitive is also rejected. RFC 9110 defines the content-negotiation weight parameter name `q` as case-insensitive; only its qvalue grammar remains strict. +Checking `product_default` directly with `value in _SUPPORTED_LOCALE_SET` before validating its runtime type is rejected. Type annotations do not constrain arbitrary runtime callers, and unhashable inputs such as lists or mappings would otherwise leak raw Python `TypeError` instead of the localization contract's stable `ui_locale_unsupported` failure. + Coercing an arbitrary iterable with `tuple(placeholder_schema)` is rejected. A one-key mapping can otherwise be accepted accidentally because iteration yields keys, while `None` raises a raw runtime exception before the localization policy can emit its stable boundary code. Running duplicate detection with `set(schema)` before element validation is also rejected. An unhashable nested list or mapping would otherwise escape as a raw `TypeError`; the domain boundary must reject the element itself before any hash-based uniqueness operation. @@ -60,11 +64,11 @@ LLM translation is outside this slice. Future assisted translation must use the ## Verification -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, and unhashable schema-element rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, and unhashable schema-element rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. The dedicated product-default regression covers unhashable list/mapping runtime values so Python set-membership semantics cannot escape the typed locale boundary. -The branch now contains eight ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. The eighth RED `489bfd4fa2d6f3630cf33412f6d910d73c47e110` proves that a tuple/list containing an unhashable nested list or mapping reaches `set(schema)` and escapes as raw `TypeError`; causal fix `244cff4f8d8805f2b3eacf91a75c7da84052bfc2` validates every element as a valid string name before duplicate detection. +The branch now contains nine ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. The eighth RED `489bfd4fa2d6f3630cf33412f6d910d73c47e110` proves that a tuple/list containing an unhashable nested list or mapping reaches `set(schema)` and escapes as raw `TypeError`; causal fix `244cff4f8d8805f2b3eacf91a75c7da84052bfc2` validates every element as a valid string name before duplicate detection. The ninth RED `652137bd5b4fae436af207d8823a4e6e5122b8a0` proves that an unhashable `product_default` reaches frozenset membership and escapes as raw `TypeError`; causal fix `f079bb270aadb2748937d5491b718e87b4198834` validates the runtime string type before membership while preserving `ui_locale_unsupported` for invalid defaults. -No local/container PASS is claimed for the fourth through eighth repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. +No local/container PASS is claimed for the fourth through ninth repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. ## Next causal work @@ -86,4 +90,4 @@ RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight PostgreSQL 18 documents that character zero cannot be stored in character types. Unicode 18 defines Unicode scalar values as all Unicode code points except the high- and low-surrogate ranges and defines the Unicode encoding forms over scalar values. The seventh repair binds those external persistence/encoding constraints to the product's typed translation-input boundary without banning legitimate multiline or tabbed UI copy. -The placeholder-container and element-order repairs are internal product invariants rather than new standards interpretations: public/runtime boundaries must preserve stable localization validation codes and must not rely on incidental Python iteration or hashability behavior to define a versioned schema. +The product-default, placeholder-container and element-order repairs are internal product invariants rather than new standards interpretations: public/runtime boundaries must preserve stable localization validation codes and must not rely on incidental Python hashability or iteration behavior to define versioned product semantics. From 20a7475c190585a1f94d34bd19c363f97dcc4e81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:41:59 +0900 Subject: [PATCH 37/87] test(i18n): reproduce Accept-Language empty-list rejection --- backend/tests/test_ui_localization_http_whitespace.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/tests/test_ui_localization_http_whitespace.py b/backend/tests/test_ui_localization_http_whitespace.py index 4edcd1737..1e9e6d14c 100644 --- a/backend/tests/test_ui_localization_http_whitespace.py +++ b/backend/tests/test_ui_localization_http_whitespace.py @@ -61,3 +61,12 @@ def test_accept_language_quality_parameter_name_is_case_insensitive(): selected = select_ui_locale(accept_language="fr;Q=0.9, en;q=0.8") assert (selected.locale_code, selected.selection_source) == ("fr", "accept_language") + + +def test_accept_language_ignores_reasonable_empty_list_elements(): + """RFC 9110 recipients ignore empty members introduced while list values are merged.""" + selected = select_ui_locale(accept_language=", fr;q=0.4, , en;q=0.8,") + assert (selected.locale_code, selected.selection_source) == ("en", "accept_language") + + selected = select_ui_locale(accept_language=",") + assert (selected.locale_code, selected.selection_source) == ("ko", "product_default") From eff473a04fd8cca5d76396c36ff0c1dbf3a033f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:42:24 +0900 Subject: [PATCH 38/87] fix(i18n): ignore empty Accept-Language list elements --- backend/services/ui_localization_policy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index d32acf603..7858afe49 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -145,6 +145,8 @@ def _accept_language_preferences( for position, raw_item in enumerate(candidate.split(",")): item = raw_item.strip(" \t") + if not item: + continue match = _ACCEPT_LANGUAGE_ITEM_PATTERN.fullmatch(item) if match is None: raise UiLocalizationValidationError( From 8450a94d8f67ee704f126daca8fc838a3c5e5981 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:43:06 +0900 Subject: [PATCH 39/87] docs(i18n): trace HTTP list-recipient semantics --- .../ui-localization-catalog-domain-policy.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index b6ae35daa..6e76129ed 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -26,6 +26,8 @@ Stable machine-readable validation codes belong to the boundary that rejected th The HTTP quality parameter name is case-insensitive. `q=` and `Q=` therefore carry identical weight semantics; rejecting `Q=` would incorrectly turn a valid `Accept-Language` field value into `ui_accept_language_invalid`. The policy keeps the value grammar strict while matching the parameter name case-insensitively. +`Accept-Language` is also an HTTP list field. RFC 9110 §5.6.1.2 requires recipients to parse and ignore a reasonable number of empty list elements that can arise when field values are merged. Empty comma members therefore carry no language preference and are skipped before language-range validation; they are not reinterpreted as wildcards or product-default votes. An all-empty present field resolves exactly like an empty preference list and falls through to the product default. + The product default is a runtime boundary too. Although the type annotation is `SupportedLocaleCode`, callers can still supply arbitrary Python objects. The policy validates that the value is a string before set membership so an unhashable list or mapping cannot escape as a raw `TypeError`. Unsupported or malformed defaults retain the stable `ui_locale_unsupported` product-contract failure rather than exposing Python hashability semantics. ## Placeholder invariant @@ -52,6 +54,8 @@ Using Python `str.strip()` and regex `\s` as protocol whitespace is rejected. Py Treating the literal spelling `q` as case-sensitive is also rejected. RFC 9110 defines the content-negotiation weight parameter name `q` as case-insensitive; only its qvalue grammar remains strict. +Rejecting an empty comma member in `Accept-Language` as a malformed language range is rejected. RFC 9110's recipient-side `#rule` semantics require a reasonable number of empty list elements to be ignored; treating those members as product syntax errors makes normal merged-header mistakes fail unnecessarily. + Checking `product_default` directly with `value in _SUPPORTED_LOCALE_SET` before validating its runtime type is rejected. Type annotations do not constrain arbitrary runtime callers, and unhashable inputs such as lists or mappings would otherwise leak raw Python `TypeError` instead of the localization contract's stable `ui_locale_unsupported` failure. Coercing an arbitrary iterable with `tuple(placeholder_schema)` is rejected. A one-key mapping can otherwise be accepted accidentally because iteration yields keys, while `None` raises a raw runtime exception before the localization policy can emit its stable boundary code. @@ -64,11 +68,11 @@ LLM translation is outside this slice. Future assisted translation must use the ## Verification -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, and unhashable schema-element rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, and case-insensitive `q` parameter names. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. The dedicated product-default regression covers unhashable list/mapping runtime values so Python set-membership semantics cannot escape the typed locale boundary. +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, and unhashable schema-element rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, case-insensitive `q` parameter names, and recipient-side empty list elements including an all-empty field value. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. The dedicated product-default regression covers unhashable list/mapping runtime values so Python set-membership semantics cannot escape the typed locale boundary. -The branch now contains nine ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. The eighth RED `489bfd4fa2d6f3630cf33412f6d910d73c47e110` proves that a tuple/list containing an unhashable nested list or mapping reaches `set(schema)` and escapes as raw `TypeError`; causal fix `244cff4f8d8805f2b3eacf91a75c7da84052bfc2` validates every element as a valid string name before duplicate detection. The ninth RED `652137bd5b4fae436af207d8823a4e6e5122b8a0` proves that an unhashable `product_default` reaches frozenset membership and escapes as raw `TypeError`; causal fix `f079bb270aadb2748937d5491b718e87b4198834` validates the runtime string type before membership while preserving `ui_locale_unsupported` for invalid defaults. +The branch now contains ten ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. The eighth RED `489bfd4fa2d6f3630cf33412f6d910d73c47e110` proves that a tuple/list containing an unhashable nested list or mapping reaches `set(schema)` and escapes as raw `TypeError`; causal fix `244cff4f8d8805f2b3eacf91a75c7da84052bfc2` validates every element as a valid string name before duplicate detection. The ninth RED `652137bd5b4fae436af207d8823a4e6e5122b8a0` proves that an unhashable `product_default` reaches frozenset membership and escapes as raw `TypeError`; causal fix `f079bb270aadb2748937d5491b718e87b4198834` validates the runtime string type before membership while preserving `ui_locale_unsupported` for invalid defaults. The tenth RED `20a7475c190585a1f94d34bd19c363f97dcc4e81` proves that RFC 9110-valid recipient inputs such as `", fr;q=0.4, , en;q=0.8,"` and an all-empty `","` field were rejected as invalid ranges; causal fix `eff473a04fd8cca5d76396c36ff0c1dbf3a033f6` skips empty members before language-range matching so they contribute no preference while valid members preserve their original list positions. -No local/container PASS is claimed for the fourth through ninth repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. +No local/container PASS is claimed for the fourth through tenth repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. ## Next causal work @@ -86,7 +90,7 @@ PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Chara The Unicode Consortium. (2026). *The Unicode Standard, Version 18.0: Core specification, Chapter 3*. https://www.unicode.org/versions/Unicode18.0.0/core-spec/chapter-3/ -RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, defines OWS exactly as zero or more SP/HTAB octets, and explicitly states that the `q` parameter name is case-insensitive. It also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. +RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, defines OWS exactly as zero or more SP/HTAB octets, explicitly states that the `q` parameter name is case-insensitive, and in §5.6.1.2 requires recipients of list-based fields to parse and ignore a reasonable number of empty list elements. It also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. PostgreSQL 18 documents that character zero cannot be stored in character types. Unicode 18 defines Unicode scalar values as all Unicode code points except the high- and low-surrogate ranges and defines the Unicode encoding forms over scalar values. The seventh repair binds those external persistence/encoding constraints to the product's typed translation-input boundary without banning legitimate multiline or tabbed UI copy. From 7a68f034ef817abdad2c5744d8cbf4d0cf65ce03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 08:43:54 +0900 Subject: [PATCH 40/87] test(i18n): bound empty Accept-Language members --- .../tests/test_ui_localization_http_whitespace.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/tests/test_ui_localization_http_whitespace.py b/backend/tests/test_ui_localization_http_whitespace.py index 1e9e6d14c..5292e6f29 100644 --- a/backend/tests/test_ui_localization_http_whitespace.py +++ b/backend/tests/test_ui_localization_http_whitespace.py @@ -70,3 +70,16 @@ def test_accept_language_ignores_reasonable_empty_list_elements(): selected = select_ui_locale(accept_language=",") assert (selected.locale_code, selected.selection_source) == ("ko", "product_default") + + +def test_accept_language_bounds_empty_list_elements_against_dos(): + """Only a bounded reasonable number of empty RFC 9110 list members is ignored.""" + accepted = ",".join([""] * 32 + ["en"]) + selected = select_ui_locale(accept_language=accepted) + assert (selected.locale_code, selected.selection_source) == ("en", "accept_language") + + excessive = ",".join([""] * 33 + ["en"]) + assert_error( + "ui_accept_language_invalid", + lambda: select_ui_locale(accept_language=excessive), + ) From b88aff67897bed9a533d21987d2db2618144b850 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 08:44:20 +0900 Subject: [PATCH 41/87] fix(i18n): bound empty Accept-Language members --- backend/services/ui_localization_policy.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 7858afe49..78765c1b8 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -43,6 +43,7 @@ ) DEFAULT_LOCALE_CODE: SupportedLocaleCode = "ko" _SUPPORTED_LOCALE_SET = frozenset(SUPPORTED_LOCALE_CODES) +_MAX_ACCEPT_LANGUAGE_EMPTY_MEMBERS = 32 _LOCALE_TAG_PATTERN = re.compile( r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$", flags=re.ASCII, @@ -142,10 +143,17 @@ def _accept_language_preferences( explicit_best: dict[SupportedLocaleCode, tuple[float, int]] = {} wildcard_best: tuple[float, int] | None = None + empty_members = 0 for position, raw_item in enumerate(candidate.split(",")): item = raw_item.strip(" \t") if not item: + empty_members += 1 + if empty_members > _MAX_ACCEPT_LANGUAGE_EMPTY_MEMBERS: + raise UiLocalizationValidationError( + "ui_accept_language_invalid", + "Accept-Language contains too many empty list members", + ) continue match = _ACCEPT_LANGUAGE_ITEM_PATTERN.fullmatch(item) if match is None: From d5588459d21b82fb1b84032001c0997b632507e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 08:45:23 +0900 Subject: [PATCH 42/87] docs(i18n): record bounded empty-list recipient policy --- .../ui-localization-catalog-domain-policy.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index 6e76129ed..856175f8a 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -26,7 +26,7 @@ Stable machine-readable validation codes belong to the boundary that rejected th The HTTP quality parameter name is case-insensitive. `q=` and `Q=` therefore carry identical weight semantics; rejecting `Q=` would incorrectly turn a valid `Accept-Language` field value into `ui_accept_language_invalid`. The policy keeps the value grammar strict while matching the parameter name case-insensitively. -`Accept-Language` is also an HTTP list field. RFC 9110 §5.6.1.2 requires recipients to parse and ignore a reasonable number of empty list elements that can arise when field values are merged. Empty comma members therefore carry no language preference and are skipped before language-range validation; they are not reinterpreted as wildcards or product-default votes. An all-empty present field resolves exactly like an empty preference list and falls through to the product default. +`Accept-Language` is also an HTTP list field. RFC 9110 §5.6.1.2 requires recipients to parse and ignore a **reasonable** number of empty list elements that can arise when field values are merged, while explicitly warning that recipients must not accept so many empty elements that the construct can be used as a denial-of-service mechanism. Empty comma members therefore carry no language preference and are skipped before language-range validation; they are not reinterpreted as wildcards or product-default votes. An all-empty present field resolves exactly like an empty preference list and falls through to the product default. Naruon's pure policy caps ignored empty members at 32 per field value. The 33rd empty member fails closed with `ui_accept_language_invalid`; this is a product safety limit over RFC 9110's intentionally implementation-defined "reasonable number", not a claim that the RFC specifies 32. The product default is a runtime boundary too. Although the type annotation is `SupportedLocaleCode`, callers can still supply arbitrary Python objects. The policy validates that the value is a string before set membership so an unhashable list or mapping cannot escape as a raw `TypeError`. Unsupported or malformed defaults retain the stable `ui_locale_unsupported` product-contract failure rather than exposing Python hashability semantics. @@ -56,6 +56,8 @@ Treating the literal spelling `q` as case-sensitive is also rejected. RFC 9110 d Rejecting an empty comma member in `Accept-Language` as a malformed language range is rejected. RFC 9110's recipient-side `#rule` semantics require a reasonable number of empty list elements to be ignored; treating those members as product syntax errors makes normal merged-header mistakes fail unnecessarily. +Ignoring an unbounded number of empty `Accept-Language` members is also rejected. RFC 9110 §5.6.1.2 explicitly qualifies recipient tolerance so empty members cannot become a denial-of-service mechanism. Because HTTP intentionally leaves the exact reasonable count to implementations, Naruon fixes a reproducible product bound of 32 ignored empty members and rejects the 33rd with the existing `ui_accept_language_invalid` boundary code. This preserves robustness for common merged-header mistakes without turning parser work into an attacker-controlled unbounded empty-list loop. + Checking `product_default` directly with `value in _SUPPORTED_LOCALE_SET` before validating its runtime type is rejected. Type annotations do not constrain arbitrary runtime callers, and unhashable inputs such as lists or mappings would otherwise leak raw Python `TypeError` instead of the localization contract's stable `ui_locale_unsupported` failure. Coercing an arbitrary iterable with `tuple(placeholder_schema)` is rejected. A one-key mapping can otherwise be accepted accidentally because iteration yields keys, while `None` raises a raw runtime exception before the localization policy can emit its stable boundary code. @@ -68,11 +70,11 @@ LLM translation is outside this slice. Future assisted translation must use the ## Verification -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, and unhashable schema-element rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, case-insensitive `q` parameter names, and recipient-side empty list elements including an all-empty field value. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. The dedicated product-default regression covers unhashable list/mapping runtime values so Python set-membership semantics cannot escape the typed locale boundary. +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, and unhashable schema-element rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, case-insensitive `q` parameter names, recipient-side empty list elements including an all-empty field value, acceptance through 32 empty members, and fail-closed rejection at the 33rd empty member. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. The dedicated product-default regression covers unhashable list/mapping runtime values so Python set-membership semantics cannot escape the typed locale boundary. -The branch now contains ten ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. The eighth RED `489bfd4fa2d6f3630cf33412f6d910d73c47e110` proves that a tuple/list containing an unhashable nested list or mapping reaches `set(schema)` and escapes as raw `TypeError`; causal fix `244cff4f8d8805f2b3eacf91a75c7da84052bfc2` validates every element as a valid string name before duplicate detection. The ninth RED `652137bd5b4fae436af207d8823a4e6e5122b8a0` proves that an unhashable `product_default` reaches frozenset membership and escapes as raw `TypeError`; causal fix `f079bb270aadb2748937d5491b718e87b4198834` validates the runtime string type before membership while preserving `ui_locale_unsupported` for invalid defaults. The tenth RED `20a7475c190585a1f94d34bd19c363f97dcc4e81` proves that RFC 9110-valid recipient inputs such as `", fr;q=0.4, , en;q=0.8,"` and an all-empty `","` field were rejected as invalid ranges; causal fix `eff473a04fd8cca5d76396c36ff0c1dbf3a033f6` skips empty members before language-range matching so they contribute no preference while valid members preserve their original list positions. +The branch now contains eleven ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. The eighth RED `489bfd4fa2d6f3630cf33412f6d910d73c47e110` proves that a tuple/list containing an unhashable nested list or mapping reaches `set(schema)` and escapes as raw `TypeError`; causal fix `244cff4f8d8805f2b3eacf91a75c7da84052bfc2` validates every element as a valid string name before duplicate detection. The ninth RED `652137bd5b4fae436af207d8823a4e6e5122b8a0` proves that an unhashable `product_default` reaches frozenset membership and escapes as raw `TypeError`; causal fix `f079bb270aadb2748937d5491b718e87b4198834` validates the runtime string type before membership while preserving `ui_locale_unsupported` for invalid defaults. The tenth RED `20a7475c190585a1f94d34bd19c363f97dcc4e81` proves that RFC 9110-valid recipient inputs such as `", fr;q=0.4, , en;q=0.8,"` and an all-empty `","` field were rejected as invalid ranges; causal fix `eff473a04fd8cca5d76396c36ff0c1dbf3a033f6` skips empty members before language-range matching so they contribute no preference while valid members preserve their original list positions. The eleventh RED `7a68f034ef817abdad2c5744d8cbf4d0cf65ce03` proves the tenth repair was still unbounded: 33 empty list members were ignored exactly like common merge mistakes even though RFC 9110 warns against tolerating enough empty elements to create a denial-of-service mechanism. Causal fix `b88aff67897bed9a533d21987d2db2618144b850` counts empty members, accepts through 32, and rejects the 33rd with `ui_accept_language_invalid` before continuing language-range processing. -No local/container PASS is claimed for the fourth through tenth repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. +No local/container PASS is claimed for the fourth through eleventh repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. ## Next causal work @@ -90,7 +92,7 @@ PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Chara The Unicode Consortium. (2026). *The Unicode Standard, Version 18.0: Core specification, Chapter 3*. https://www.unicode.org/versions/Unicode18.0.0/core-spec/chapter-3/ -RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, defines OWS exactly as zero or more SP/HTAB octets, explicitly states that the `q` parameter name is case-insensitive, and in §5.6.1.2 requires recipients of list-based fields to parse and ignore a reasonable number of empty list elements. It also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. +RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, defines OWS exactly as zero or more SP/HTAB octets, explicitly states that the `q` parameter name is case-insensitive, and in §5.6.1.2 requires recipients of list-based fields to parse and ignore a reasonable number of empty list elements while not accepting enough empty elements to create a denial-of-service mechanism. RFC 9110 intentionally leaves that reasonable count to the implementation; Naruon's 32-member threshold is therefore a product bound with an executable edge, not a standards-defined magic number. RFC 9110 also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. PostgreSQL 18 documents that character zero cannot be stored in character types. Unicode 18 defines Unicode scalar values as all Unicode code points except the high- and low-surrogate ranges and defines the Unicode encoding forms over scalar values. The seventh repair binds those external persistence/encoding constraints to the product's typed translation-input boundary without banning legitimate multiline or tabbed UI copy. From 2ebe9758cb915841d86330bbabcd618f3f091d71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 09:40:02 +0900 Subject: [PATCH 43/87] test(i18n): RED wildcard provenance after unsupported range --- backend/tests/test_ui_localization_http_whitespace.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/tests/test_ui_localization_http_whitespace.py b/backend/tests/test_ui_localization_http_whitespace.py index 5292e6f29..7f8558780 100644 --- a/backend/tests/test_ui_localization_http_whitespace.py +++ b/backend/tests/test_ui_localization_http_whitespace.py @@ -83,3 +83,9 @@ def test_accept_language_bounds_empty_list_elements_against_dos(): "ui_accept_language_invalid", lambda: select_ui_locale(accept_language=excessive), ) + + +def test_wildcard_before_unsupported_range_does_not_claim_negotiation_source(): + """RFC 4647 lookup skips a wildcard when any later concrete range remains to try.""" + selected = select_ui_locale(accept_language="*;q=0.9, pt-BR;q=0.8") + assert (selected.locale_code, selected.selection_source) == ("ko", "product_default") From 1664d9707d4b5b9366b422f8faf612418ecd7789 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 09:40:35 +0900 Subject: [PATCH 44/87] fix(i18n): preserve RFC lookup wildcard ordering across unsupported ranges --- backend/services/ui_localization_policy.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 78765c1b8..9d6ea256d 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -142,6 +142,7 @@ def _accept_language_preferences( return () explicit_best: dict[SupportedLocaleCode, tuple[float, int]] = {} + concrete_priorities: list[tuple[float, int]] = [] wildcard_best: tuple[float, int] | None = None empty_members = 0 @@ -168,6 +169,8 @@ def _accept_language_preferences( wildcard_best = (quality, position) continue + if quality > 0: + concrete_priorities.append((quality, position)) primary_language = language_range.split("-", 1)[0].lower() if primary_language not in _SUPPORTED_LOCALE_SET: continue @@ -191,9 +194,13 @@ def _accept_language_preferences( if quality == 0 } ordered: list[SupportedLocaleCode] = [] - for index, (_, _, locale_code) in enumerate(ranked): + for quality, position, locale_code in ranked: if locale_code is None: - if any(later_locale is not None for _, _, later_locale in ranked[index + 1 :]): + wildcard_priority = (-quality, position) + if any( + (-concrete_quality, concrete_position) > wildcard_priority + for concrete_quality, concrete_position in concrete_priorities + ): continue wildcard_candidates = (product_default, *SUPPORTED_LOCALE_CODES) for wildcard_candidate in wildcard_candidates: From 91d0b0a17a0492231e0d5bdfa163eb746837e8da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 09:40:55 +0900 Subject: [PATCH 45/87] docs(i18n): trace RFC 4647 wildcard provenance repair --- ...-localization-rfc4647-wildcard-ordering.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/doctoring/ui-localization-rfc4647-wildcard-ordering.md diff --git a/docs/doctoring/ui-localization-rfc4647-wildcard-ordering.md b/docs/doctoring/ui-localization-rfc4647-wildcard-ordering.md new file mode 100644 index 000000000..c78f93e35 --- /dev/null +++ b/docs/doctoring/ui-localization-rfc4647-wildcard-ordering.md @@ -0,0 +1,37 @@ +# UI localization RFC 4647 wildcard ordering repair + +## Decision status + +**Proposed.** This note extends the pure UI Localization Catalog policy owned by #1740 and does not create persistence, API, browser, Storybook, ontology-label, or LLM-translation ownership. + +## Verified finding + +The prior policy retained only supported concrete language ranges before applying RFC 4647 lookup wildcard ordering. That changed the meaning of a valid priority list such as: + +```text +Accept-Language: *;q=0.9, pt-BR;q=0.8 +``` + +RFC 4647 lookup says `*` is skipped when another language range follows in the language priority list. The later `pt-BR` range still exists even though Naruon's current release has no Portuguese resource. Lookup therefore tries the later concrete range, finds no supported resource, and reaches the product default. The prior implementation discarded `pt-BR` before deciding whether the wildcard had a later concrete range, so it incorrectly attributed the selected default to `accept_language` rather than `product_default`. + +This is an authority/provenance defect, not a change to the selected locale value. `UiLocaleSelection.selection_source` is product state used to explain which authority tier selected the locale, so unsupported concrete ranges cannot be erased before wildcard lookup control flow is resolved. + +## RED and causal repair + +- RED `2ebe9758cb915841d86330bbabcd618f3f091d71` adds `*;q=0.9, pt-BR;q=0.8` and requires `(ko, product_default)`. +- Causal fix `1664d9707d4b5b9366b422f8faf612418ecd7789` records every positive-quality concrete range's priority before filtering against the eight supported product locales. Wildcard handling then skips the wildcard whenever any later positive concrete range remains in the RFC lookup priority order. Unsupported ranges still never become supported locale candidates. +- Existing positive wildcard behavior remains unchanged when the wildcard is the final effective range, and q=0 exclusions remain non-candidates. + +The repair deliberately does not generalize Naruon into a full BCP 47 registry. Release resources remain exactly `ko/en/ja/zh/vi/es/de/fr`; the change preserves protocol ordering metadata long enough to compute the correct selection authority. + +## Verification boundary + +The source and focused regression are committed to the exact #1740 branch. This runtime cannot obtain an exact repository checkout, so no local/container PASS is claimed. Hosted checks and an independent post-last-push review must be reacquired on the final exact head; predecessor receipts do not transfer. + +## TRACEABILITY + +Phillips, A., & Davis, M. (2006). *Matching of language tags* (RFC 4647, BCP 47). RFC Editor. https://doi.org/10.17487/RFC4647 + +Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 + +RFC 4647 §3.4.1 specifies that in lookup the special range `*` is skipped when followed by other language ranges; if no later range matches, the default value is returned. RFC 9110 supplies the weighted `Accept-Language` language-priority list. Naruon preserves that ordering rule while reducing successful matches to its eight release locales. From 8b19c7dfe43e3dca3c1e4b8980c0a9998a3aec43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 10:40:15 +0900 Subject: [PATCH 46/87] test(i18n): reject empty placeholder format specifier --- backend/tests/test_ui_localization_policy.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py index 66f136fe3..6dcd28d1f 100644 --- a/backend/tests/test_ui_localization_policy.py +++ b/backend/tests/test_ui_localization_policy.py @@ -195,6 +195,10 @@ def test_placeholder_schema_rejects_missing_extra_and_formatter_features(): "ui_placeholder_schema_invalid", lambda: validate_translation_placeholders("{account_name!r}", ("account_name",)), ) + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders("{account_name:}", ("account_name",)), + ) def test_placeholder_input_and_schema_must_be_bounded(): From 6ed310e1065940fa14c258b48412490e7b98d751 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 10:40:48 +0900 Subject: [PATCH 47/87] fix(i18n): preserve canonical placeholder syntax --- backend/services/ui_localization_policy.py | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 9d6ea256d..5ab0ed086 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -281,9 +281,36 @@ def _validate_translation_text(message_text: str) -> None: ) +def _reject_placeholder_operators(message_text: str) -> None: + """Reject formatter operators that Formatter.parse normalizes away when empty.""" + index = 0 + while index < len(message_text): + character = message_text[index] + if character == "{": + if index + 1 < len(message_text) and message_text[index + 1] == "{": + index += 2 + continue + closing_index = message_text.find("}", index + 1) + if closing_index == -1: + return + field_source = message_text[index + 1 : closing_index] + if ":" in field_source or "!" in field_source: + raise UiLocalizationValidationError( + "ui_placeholder_schema_invalid", + "placeholders must be simple named fields without conversion or format specifiers", + ) + index = closing_index + 1 + continue + if character == "}" and index + 1 < len(message_text) and message_text[index + 1] == "}": + index += 2 + continue + index += 1 + + def extract_placeholder_names(message_text: str) -> tuple[str, ...]: """Extract simple named interpolation fields without evaluating the message.""" _validate_translation_text(message_text) + _reject_placeholder_operators(message_text) names: list[str] = [] seen: set[str] = set() try: From eb8fe8c43a017fed0ea029623d1380d824fd3150 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 10:41:21 +0900 Subject: [PATCH 48/87] docs(i18n): trace canonical placeholder syntax repair --- ...calization-placeholder-canonical-syntax.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/doctoring/ui-localization-placeholder-canonical-syntax.md diff --git a/docs/doctoring/ui-localization-placeholder-canonical-syntax.md b/docs/doctoring/ui-localization-placeholder-canonical-syntax.md new file mode 100644 index 000000000..0cb464359 --- /dev/null +++ b/docs/doctoring/ui-localization-placeholder-canonical-syntax.md @@ -0,0 +1,39 @@ +# UI Localization Catalog: canonical placeholder syntax + +## Decision status + +**Proposed.** This note records the thirteenth ordinary-forward repair in #1740. It changes only the pure placeholder-policy boundary; persistence, API delivery, browser rendering, catalog publication and authoring remain outside this PR. + +## Finding + +The catalog contract already says that published placeholders are simple named fields such as `{account_name}` and that conversion flags and format specifications are forbidden. The implementation used `string.Formatter.parse()` and rejected a field when `format_spec` was truthy or `conversion` was non-null. + +That was insufficient for the source form `{account_name:}`. Python's format-string grammar treats the colon as the delimiter that introduces a `format_spec`, but an empty format specification has the same formatting result as the default. `Formatter.parse()` therefore yields an empty `format_spec` for both `{account_name}` and `{account_name:}`. The previous policy lost the lexical distinction and silently accepted a non-canonical spelling that its own contract said was forbidden. + +This matters even though both forms render the same value. Catalog source is versioned publication data. If alternate lexical spellings are accepted as equivalent after parsing, source identity and validation semantics depend on lossy runtime normalization instead of the product contract. + +## Decision + +The policy now validates the replacement-field source before `Formatter.parse()` normalizes it. Escaped literal braces remain literals. For a real replacement field, the presence of `:` or `!` is rejected with `ui_placeholder_schema_invalid` before the parsed field-name/schema comparison runs. + +The canonical accepted form remains exactly a simple named field such as `{account_name}`. `{account_name:}`, `{count:03d}`, `{account_name!r}`, attribute/index traversal and malformed fields are not alternate spellings of the schema; they are invalid publication input. + +RED `8b19c7dfe43e3dca3c1e4b8980c0a9998a3aec43` adds the empty-format-specifier regression. Causal fix `6ed310e1065940fa14c258b48412490e7b98d751` preserves lexical operator information before the standard-library parser erases the empty-spec distinction. + +## Rejected alternatives + +Treating `{name:}` as equivalent to `{name}` is rejected because the product boundary explicitly forbids format-specifier syntax and published source identity should not depend on an implementation detail of `Formatter.parse()`. + +Replacing the full parser with a bespoke interpolation engine is also rejected. Python's parser remains the authority for brace balancing and field decomposition; the product adds only the narrow lexical check required to preserve the stricter catalog grammar. + +## Verification + +The canonical policy suite now requires `{account_name:}` to fail with `ui_placeholder_schema_invalid`, alongside existing rejection of non-empty format specifications and conversions. Existing escaped-literal behavior remains owned by the same suite. + +No local/container PASS or predecessor hosted receipt is claimed in this runtime. Current-head full-suite, coverage, security and independent review evidence must be reacquired on the exact post-repair head. + +## TRACEABILITY + +Python Software Foundation. (2026). *string — Common string operations: Format string syntax* (Python 3.14 documentation). https://docs.python.org/3.14/library/string.html + +Python 3.14 defines a replacement field as a `field_name` optionally followed by `! conversion` and/or `: format_spec`, and documents that an empty format specification produces the same result as the default formatting behavior. `Formatter.parse()` returns the parsed `format_spec` value rather than preserving whether an empty `:` delimiter was present in the original source. Naruon therefore retains a stricter, source-aware catalog grammar on top of the standard parser rather than equating semantically similar but lexically different publication inputs. From 978fdbbc6135fa88a8d43e0971ed075ae546f9de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 11:45:05 +0900 Subject: [PATCH 49/87] test(i18n): bound locale negotiation work --- .../test_ui_localization_http_whitespace.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/backend/tests/test_ui_localization_http_whitespace.py b/backend/tests/test_ui_localization_http_whitespace.py index 7f8558780..70cdc878d 100644 --- a/backend/tests/test_ui_localization_http_whitespace.py +++ b/backend/tests/test_ui_localization_http_whitespace.py @@ -85,6 +85,40 @@ def test_accept_language_bounds_empty_list_elements_against_dos(): ) +def test_locale_and_accept_language_have_explicit_resource_bounds(): + """Valid basic ranges cannot create unbounded parser work at product boundaries.""" + locale_at_limit = "en-" + "-".join(["abcdefgh"] * 14) + assert len(locale_at_limit) == 128 + assert normalize_supported_locale(locale_at_limit) == "en" + + locale_over_limit = locale_at_limit + "-abcdefgh" + assert_error( + "ui_locale_input_invalid", + lambda: normalize_supported_locale(locale_over_limit), + ) + + accepted_members = ",".join(["pt"] * 64) + selected = select_ui_locale(accept_language=accepted_members) + assert (selected.locale_code, selected.selection_source) == ("ko", "product_default") + + excessive_members = ",".join(["pt"] * 65) + assert_error( + "ui_accept_language_invalid", + lambda: select_ui_locale(accept_language=excessive_members), + ) + + header_at_limit = "en-" + "-".join(["abcdefgh"] * 910) + assert len(header_at_limit) == 8192 + selected = select_ui_locale(accept_language=header_at_limit) + assert (selected.locale_code, selected.selection_source) == ("en", "accept_language") + + header_over_limit = header_at_limit + "-abcdefgh" + assert_error( + "ui_accept_language_invalid", + lambda: select_ui_locale(accept_language=header_over_limit), + ) + + def test_wildcard_before_unsupported_range_does_not_claim_negotiation_source(): """RFC 4647 lookup skips a wildcard when any later concrete range remains to try.""" selected = select_ui_locale(accept_language="*;q=0.9, pt-BR;q=0.8") From 0fdb5371eef63d6d7abe87ac14b69269c8519ff8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 11:45:44 +0900 Subject: [PATCH 50/87] fix(i18n): cap locale negotiation inputs --- backend/services/ui_localization_policy.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 5ab0ed086..bce37dc85 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -43,6 +43,9 @@ ) DEFAULT_LOCALE_CODE: SupportedLocaleCode = "ko" _SUPPORTED_LOCALE_SET = frozenset(SUPPORTED_LOCALE_CODES) +_MAX_LOCALE_TAG_CHARS = 128 +_MAX_ACCEPT_LANGUAGE_CHARS = 8192 +_MAX_ACCEPT_LANGUAGE_MEMBERS = 64 _MAX_ACCEPT_LANGUAGE_EMPTY_MEMBERS = 32 _LOCALE_TAG_PATTERN = re.compile( r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$", @@ -101,6 +104,11 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: "ui_locale_input_invalid", "locale input must be a string", ) + if len(locale_tag) > _MAX_LOCALE_TAG_CHARS: + raise UiLocalizationValidationError( + "ui_locale_input_invalid", + "locale input exceeds the 128-character product limit", + ) _reject_control_characters( locale_tag, error_code="ui_locale_input_invalid", @@ -131,6 +139,11 @@ def _accept_language_preferences( "ui_accept_language_invalid", "Accept-Language must be a string", ) + if len(header_value) > _MAX_ACCEPT_LANGUAGE_CHARS: + raise UiLocalizationValidationError( + "ui_accept_language_invalid", + "Accept-Language exceeds the 8192-character product limit", + ) _reject_control_characters( header_value, error_code="ui_accept_language_invalid", @@ -144,6 +157,7 @@ def _accept_language_preferences( explicit_best: dict[SupportedLocaleCode, tuple[float, int]] = {} concrete_priorities: list[tuple[float, int]] = [] wildcard_best: tuple[float, int] | None = None + non_empty_members = 0 empty_members = 0 for position, raw_item in enumerate(candidate.split(",")): @@ -156,6 +170,12 @@ def _accept_language_preferences( "Accept-Language contains too many empty list members", ) continue + non_empty_members += 1 + if non_empty_members > _MAX_ACCEPT_LANGUAGE_MEMBERS: + raise UiLocalizationValidationError( + "ui_accept_language_invalid", + "Accept-Language contains too many language ranges", + ) match = _ACCEPT_LANGUAGE_ITEM_PATTERN.fullmatch(item) if match is None: raise UiLocalizationValidationError( From d25c7a627af720242c9503f8f2b90553c7c94405 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 11:46:41 +0900 Subject: [PATCH 51/87] docs(i18n): trace negotiation resource bounds --- ...ocalization-negotiation-resource-bounds.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/doctoring/ui-localization-negotiation-resource-bounds.md diff --git a/docs/doctoring/ui-localization-negotiation-resource-bounds.md b/docs/doctoring/ui-localization-negotiation-resource-bounds.md new file mode 100644 index 000000000..7055a08d7 --- /dev/null +++ b/docs/doctoring/ui-localization-negotiation-resource-bounds.md @@ -0,0 +1,56 @@ +# UI localization negotiation resource bounds + +## Status and owned boundary + +This record belongs to #1740's pure UI Localization Catalog policy. It does not add an HTTP route, persistence, browser cache, or a new transport error mapping. The purpose is to keep locale negotiation work bounded before later API owners expose the policy on a request path. + +## Verified finding + +The earlier policy bounded only RFC 9110 empty-list tolerance. Valid non-empty input remained attacker-controlled in three dimensions: + +- an explicit persisted/session locale could contain an arbitrarily long syntactically valid basic language range; +- one `Accept-Language` field value could be arbitrarily long before normalization and comma splitting; +- one bounded-size field could still contain an unnecessarily large number of non-empty language ranges. + +That leaves parser CPU/allocation proportional to unconstrained input. In particular, `candidate.split(",")` materializes the whole list after the raw string has already been accepted, so bounding only ignored empty members is not a complete resource boundary. + +## RED and causal repair + +RED `978fdbbc6135fa88a8d43e0971ed075ae546f9de` adds executable edges for inputs that were previously accepted: + +- explicit locale length 128 characters is accepted and the next 9-character subtag is rejected with `ui_locale_input_invalid`; +- 64 non-empty `Accept-Language` members are accepted, while the 65th fails with `ui_accept_language_invalid`; +- an 8192-character syntactically valid `Accept-Language` range is accepted, while extending it by another 9-character subtag fails with `ui_accept_language_invalid`. + +Causal fix `0fdb5371eef63d6d7abe87ac14b69269c8519ff8` introduces explicit product limits before expensive normalization/list processing: + +- `_MAX_LOCALE_TAG_CHARS = 128`; +- `_MAX_ACCEPT_LANGUAGE_CHARS = 8192`; +- `_MAX_ACCEPT_LANGUAGE_MEMBERS = 64`; +- the existing `_MAX_ACCEPT_LANGUAGE_EMPTY_MEMBERS = 32` remains unchanged. + +The policy rejects over-limit values; it never truncates them. The 128/8192/64 values are Naruon product limits, not IETF constants. They are intentionally explicit so later transport, observability, load-test, and security evidence can verify the same boundary instead of relying on framework defaults. + +## Standards rationale + +RFC 9110 §5.4 does not define a universal field-size limit. It explicitly allows a server to refuse a request field value larger than it is willing to process and requires an appropriate 4xx response rather than silently ignoring an oversized request field. The future HTTP owner must map the stable policy failure consistently with that transport contract; this pure-policy slice does not choose the endpoint/status code. + +RFC 4647 §4.4 allows language-range length restrictions comparable to language-tag restrictions. RFC 5646 §4.4.1 states that implementations may refuse language tags above a specified length, requires such limits and their failure behavior to be documented, and requires limited-buffer protocols to support at least 35 characters. Naruon's 128-character explicit-locale budget exceeds that minimum and rejects rather than truncates. + +The 8192-character `Accept-Language` value budget and 64 non-empty-member budget are application resource limits. They preserve ample interoperability headroom for ordinary preference lists while preventing one localization request from controlling unbounded split, regex, ranking, and duplicate-processing work. Outer HTTP servers/proxies may impose stricter limits; this policy does not weaken those boundaries. + +## Rejected alternatives + +Relying only on reverse-proxy, ASGI-server, or browser header limits is rejected because the pure policy is callable outside the eventual HTTP route and framework limits vary by deployment. Truncation is rejected because it can change language preference semantics. Bounding only empty list members is rejected because valid non-empty ranges and one very long range still leave work attacker-controlled. + +## Verification boundary + +The source and focused regression are committed. This automation environment does not contain an exact repository checkout, so no full-suite/local-container PASS is claimed here. Hosted checks and qualifying independent review must be reacquired on the final exact head after this documentation commit. Future HTTP/k6 evidence should additionally verify that an over-limit request is rejected before buyer-path work and that normal negotiation remains within the product p95 budget. + +## TRACEABILITY + +Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 + +Phillips, A., & Davis, M. (2006). *Matching of language tags* (RFC 4647, BCP 47). RFC Editor. https://doi.org/10.17487/RFC4647 + +Phillips, A., & Davis, M. (2009). *Tags for identifying languages* (RFC 5646, BCP 47). RFC Editor. https://doi.org/10.17487/RFC5646 From 30957821d00fa5e3eebc6cad5ed70820da8ac755 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 11:47:23 +0900 Subject: [PATCH 52/87] docs(i18n): bring domain policy current --- .../ui-localization-catalog-domain-policy.md | 107 +++++++++++------- 1 file changed, 63 insertions(+), 44 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index 856175f8a..8a5208b84 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -2,98 +2,117 @@ ## Decision status -This document records the first executable domain slice for issue #1731. It is **Proposed** and deliberately stops before persistence, API delivery, browser caching, catalog publication, or page migration. +This document records the executable pure-domain slice for issue #1731. It remains **Proposed** and deliberately stops before persistence, API delivery, browser caching, catalog publication, Storybook composition, or page migration. The canonical workspace/Alembic owner is still #1503. Translation persistence must not create a migration from protected `develop` while that lineage is unintegrated. When #1503 or a verified complete successor reaches protected ancestry, the catalog persistence work must create the next ordinary descendant from the then-current single Alembic head. -Open PRs already contain several unrelated `ADR-0005` proposals. This slice therefore does not allocate another ADR number before a complete live ADR-owner reconciliation. The absence of a new ADR number is intentional, not an implicit acceptance decision. +Open work already contains unrelated `ADR-0005` proposals. This slice therefore does not allocate another ADR number before live ADR-owner reconciliation. ## Bounded context and ubiquitous language -The **UI Localization Catalog** bounded context owns UI-copy identity and selection only. Ontology/concept labels are a separate authority and must not be stored or served through the same resource. +The **UI Localization Catalog** bounded context owns UI-copy identity and locale selection only. Ontology/concept labels are a separate authority and must not be stored or served through the same resource. Current domain objects are: -- `SupportedLocaleCode`: the release language identity, exactly `ko`, `en`, `ja`, `zh`, `vi`, `es`, `de`, `fr`; -- `UiLocaleSelection`: the selected locale plus the authority tier that selected it; -- `screen_key`: a dotted lowercase product-screen identity such as `settings.identity`; -- `message_key`: a lowercase snake-case message identity within a screen; -- `placeholder_schema`: the immutable ordered collection of named interpolation fields a published translation must preserve. +- `SupportedLocaleCode`: exactly `ko`, `en`, `ja`, `zh`, `vi`, `es`, `de`, `fr`; +- `UiLocaleSelection`: selected locale plus the authority tier that selected it; +- `screen_key`: dotted lowercase product-screen identity such as `settings.identity`; +- `message_key`: lowercase snake-case message identity within a screen; +- `placeholder_schema`: immutable named interpolation fields a published translation must preserve. -The locale authority order is persisted preference → session preference → `Accept-Language` → product default. Explicit persisted/session values are validated rather than silently replaced. `Accept-Language` quality values are bounded and malformed/control-character input fails closed. Regional/script variants resolve to one of the release-level product languages; this is intentionally narrower than preserving every BCP 47 variant as a separate catalog identity. +Locale authority is persisted preference → session preference → weighted `Accept-Language` → product default. Explicit persisted/session values are validated rather than silently replaced. Regional/script variants resolve to one release-level product language; this is intentionally narrower than a general-purpose BCP 47 registry. -Stable machine-readable validation codes belong to the boundary that rejected the input. Explicit locale input rejects every C0 control and DEL before normalization and permits only ASCII SP as benign outer whitespace. `Accept-Language` applies the HTTP field-value boundary instead: C0/DEL controls are rejected except HTAB where RFC 9110 permits it as OWS, and only SP/HTAB are normalized as HTTP optional whitespace. Unicode whitespace, VT and FF are not treated as OWS. This distinction prevents Python's broader `str.strip()`/`\s` semantics from silently canonicalizing bytes that are outside the HTTP grammar, while preserving boundary-specific errors (`ui_locale_input_invalid` versus `ui_accept_language_invalid`). +## Locale and HTTP negotiation boundary -The HTTP quality parameter name is case-insensitive. `q=` and `Q=` therefore carry identical weight semantics; rejecting `Q=` would incorrectly turn a valid `Accept-Language` field value into `ui_accept_language_invalid`. The policy keeps the value grammar strict while matching the parameter name case-insensitively. +Stable machine-readable validation codes belong to the boundary that rejects the input. -`Accept-Language` is also an HTTP list field. RFC 9110 §5.6.1.2 requires recipients to parse and ignore a **reasonable** number of empty list elements that can arise when field values are merged, while explicitly warning that recipients must not accept so many empty elements that the construct can be used as a denial-of-service mechanism. Empty comma members therefore carry no language preference and are skipped before language-range validation; they are not reinterpreted as wildcards or product-default votes. An all-empty present field resolves exactly like an empty preference list and falls through to the product default. Naruon's pure policy caps ignored empty members at 32 per field value. The 33rd empty member fails closed with `ui_accept_language_invalid`; this is a product safety limit over RFC 9110's intentionally implementation-defined "reasonable number", not a claim that the RFC specifies 32. +Explicit locale input rejects every C0 control and DEL before normalization. Only ASCII SP is benign outer whitespace. `Accept-Language` applies HTTP field semantics instead: C0/DEL controls are rejected except HTAB where RFC 9110 permits it as OWS, and only SP/HTAB are normalized as optional whitespace. Python `str.strip()` and regex `\s` are intentionally not protocol authority. -The product default is a runtime boundary too. Although the type annotation is `SupportedLocaleCode`, callers can still supply arbitrary Python objects. The policy validates that the value is a string before set membership so an unhashable list or mapping cannot escape as a raw `TypeError`. Unsupported or malformed defaults retain the stable `ui_locale_unsupported` product-contract failure rather than exposing Python hashability semantics. +The quality parameter name is case-insensitive, so `q=` and `Q=` carry identical parameter-name semantics while qvalue syntax remains strict. -## Placeholder invariant +`Accept-Language` is an HTTP list field. RFC 9110 §5.6.1.2 requires recipients to ignore a reasonable number of empty list elements while not tolerating enough to create a denial-of-service mechanism. Naruon therefore ignores at most 32 empty members and rejects the 33rd with `ui_accept_language_invalid`. Empty members never become wildcard/default votes. -Translation publication must preserve the exact named placeholder schema. The policy accepts only simple lowercase named fields such as `{account_name}`. Attribute/index traversal, positional fields, conversion flags, format specifications, malformed braces, missing placeholders, and extra placeholders fail closed with stable machine-readable error codes. +RFC 4647 lookup ordering metadata is preserved even for release-unsupported ranges. For `*;q=0.9, pt-BR;q=0.8`, the later concrete range still exists in the language priority list, so the wildcard does not claim the selection. Portuguese then fails to match the eight release locales and the result reaches `product_default`. -The schema container itself is part of the boundary. Runtime callers may provide only the declared tuple/list collection. A mapping, generator, scalar, `None`, or other iterable is not silently coerced into a schema. Each collection element is validated as a string matching the placeholder-name grammar before duplicate detection. This ordering matters: nested mutable values such as lists or mappings are unhashable, so applying `set(schema)` first would leak a raw Python `TypeError` instead of the stable `ui_placeholder_schema_invalid` contract. Container shape, element type/syntax, and uniqueness are therefore checked in that order. +The product default is also runtime-validated before set membership. Type annotations cannot prevent an arbitrary caller from supplying an unhashable object, so invalid defaults fail with `ui_locale_unsupported` rather than raw Python `TypeError`. -This is a catalog integrity boundary, not a rendering engine. It inspects placeholder syntax without evaluating it. +### Explicit parser resource limits -## Translation text persistence boundary +The policy now bounds valid as well as malformed negotiation input before expensive normalization/list work: -A Python `str` is not by itself proof that catalog text is suitable for the product's UTF-8/PostgreSQL persistence path. PostgreSQL character types cannot store U+0000, and Unicode encoding forms operate on Unicode scalar values, which exclude surrogate code points U+D800–U+DFFF. The pure policy therefore rejects NUL and surrogate code points before placeholder parsing with the stable boundary code `ui_translation_input_invalid`. +- explicit persisted/session locale value: at most **128 characters**; +- raw `Accept-Language` field value: at most **8192 characters**; +- non-empty `Accept-Language` members: at most **64**; +- ignored empty members: at most **32**. -The rule is intentionally narrow. Ordinary Unicode text, combining marks, CJK text, newlines and horizontal tabs remain valid translation content. This is not a generic control-character scrubber and does not rewrite user-facing copy; it prevents persistence/encoding failures from escaping later as database or codec exceptions. +These are Naruon product limits, not IETF constants. Over-limit values are rejected; they are never truncated. RFC 9110 §5.4 permits implementations to refuse field values larger than they are willing to process, while RFC 4647 §4.4 and RFC 5646 §4.4.1 permit documented language range/tag length limits. RFC 5646 requires limited-buffer protocols to support at least 35 characters; the 128-character explicit-locale budget exceeds that minimum. Outer proxies/HTTP servers may impose stricter limits and are not weakened by this policy. -## Rejected alternatives +The focused resource-bound record is `docs/doctoring/ui-localization-negotiation-resource-bounds.md`. -A browser-wide catalog bundle is rejected because the product contract calls for screen-scoped retrieval and version/cache identity. A per-key network fetch is also rejected because it turns normal rendering into repeated network chatter and creates inconsistent version visibility. +## Placeholder invariant -A new translation table or Alembic revision in this PR is rejected because it would create a parallel migration authority ahead of #1503. Locale parsing embedded independently in each route or component is rejected because it would produce inconsistent precedence and fallback rules. +Translation publication preserves the exact named placeholder schema. Only simple lowercase named fields such as `{account_name}` are accepted. Attribute/index traversal, positional fields, conversion flags, format-specifier syntax, malformed braces, missing placeholders, and extra placeholders fail closed. -Using Python `str.strip()` and regex `\s` as protocol whitespace is rejected. Python intentionally recognizes a broader Unicode whitespace set than HTTP OWS, while RFC 9110 defines OWS exactly as SP/HTAB. Protocol parsing therefore uses explicit ASCII whitespace sets rather than language-runtime whitespace classes. +The schema container is itself part of the boundary. Runtime callers may provide only tuple/list collections. A mapping, generator, scalar, `None`, or arbitrary iterable is not silently coerced. Elements are validated as strings matching the placeholder-name grammar before duplicate detection so nested mutable values cannot leak raw hashability errors. -Treating the literal spelling `q` as case-sensitive is also rejected. RFC 9110 defines the content-negotiation weight parameter name `q` as case-insensitive; only its qvalue grammar remains strict. +Python's `Formatter.parse()` normalizes an explicit empty format specifier: `{name}` and `{name:}` both expose an empty parsed `format_spec`. Because Naruon's catalog contract forbids format-specifier syntax entirely, the policy performs a narrow lexical check for real replacement fields containing `:` or `!` before `Formatter.parse()`, while doubled braces remain literals. See `docs/doctoring/ui-localization-placeholder-canonical-syntax.md`. -Rejecting an empty comma member in `Accept-Language` as a malformed language range is rejected. RFC 9110's recipient-side `#rule` semantics require a reasonable number of empty list elements to be ignored; treating those members as product syntax errors makes normal merged-header mistakes fail unnecessarily. +This is catalog integrity validation, not a rendering engine. -Ignoring an unbounded number of empty `Accept-Language` members is also rejected. RFC 9110 §5.6.1.2 explicitly qualifies recipient tolerance so empty members cannot become a denial-of-service mechanism. Because HTTP intentionally leaves the exact reasonable count to implementations, Naruon fixes a reproducible product bound of 32 ignored empty members and rejects the 33rd with the existing `ui_accept_language_invalid` boundary code. This preserves robustness for common merged-header mistakes without turning parser work into an attacker-controlled unbounded empty-list loop. +## Translation text persistence boundary + +A Python `str` is not sufficient proof that catalog text can cross the UTF-8/PostgreSQL boundary. PostgreSQL character types cannot store U+0000, and Unicode scalar values exclude surrogate code points U+D800–U+DFFF. The policy therefore rejects NUL and isolated surrogate code points before placeholder parsing with `ui_translation_input_invalid`. + +Ordinary Unicode text, combining marks, CJK text, newlines, and horizontal tabs remain valid. The policy does not rewrite user-facing copy. + +## Rejected alternatives -Checking `product_default` directly with `value in _SUPPORTED_LOCALE_SET` before validating its runtime type is rejected. Type annotations do not constrain arbitrary runtime callers, and unhashable inputs such as lists or mappings would otherwise leak raw Python `TypeError` instead of the localization contract's stable `ui_locale_unsupported` failure. +A browser-wide catalog bundle and per-key network fetches are rejected in favor of future screen-scoped versioned resources. A new translation table/Alembic revision in this PR is rejected because it would create a parallel migration authority ahead of #1503. Locale parsing duplicated inside routes/components is rejected because it would fragment precedence and fallback semantics. -Coercing an arbitrary iterable with `tuple(placeholder_schema)` is rejected. A one-key mapping can otherwise be accepted accidentally because iteration yields keys, while `None` raises a raw runtime exception before the localization policy can emit its stable boundary code. +Generic runtime whitespace, case-sensitive `q`, unconditional rejection of recipient-side empty list members, unbounded empty-member tolerance, truncation of oversized locale/header values, and dependence on reverse-proxy/framework header limits are rejected. The pure policy is callable outside the future HTTP route, so its resource and syntax boundaries must be explicit and deterministic. -Running duplicate detection with `set(schema)` before element validation is also rejected. An unhashable nested list or mapping would otherwise escape as a raw `TypeError`; the domain boundary must reject the element itself before any hash-based uniqueness operation. +Coercing arbitrary placeholder iterables, running `set(schema)` before element validation, and deferring NUL/surrogate rejection to downstream drivers/codecs are rejected because they leak implementation-specific runtime failures instead of stable product errors. -Deferring NUL/surrogate rejection to PostgreSQL, JSON serialization, or an implicit UTF-8 encode is rejected. That would make publication correctness depend on whichever downstream sink is reached first and would replace a stable product error with driver/codec-specific failures. +LLM translation is outside this slice. Future assisted translation must use a released contextual-orchestrator contract and cannot bypass deterministic publication/completeness/placeholder validation. -LLM translation is outside this slice. Future assisted translation must use the released contextual-orchestrator contract and cannot bypass human/publication validation or placeholder integrity. +## Verification and repair history -## Verification +The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard/q=0 handling, malformed/control input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, unhashable schema elements, translation-text persistence, product-default runtime typing, and explicit parser resource limits. -The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard handling, q=0 exclusion, malformed/control-character input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, and unhashable schema-element rejection. The dedicated HTTP-boundary regression covers VT/FF/Unicode-whitespace rejection, explicit-locale HTAB rejection, valid SP/HTAB OWS around `Accept-Language` list members and q-weights, case-insensitive `q` parameter names, recipient-side empty list elements including an all-empty field value, acceptance through 32 empty members, and fail-closed rejection at the 33rd empty member. The dedicated translation-text regression covers leading/trailing NUL, high/low surrogate code points, non-string translation input, and a valid multiline Korean message so the repair does not collapse legitimate copy. The dedicated product-default regression covers unhashable list/mapping runtime values so Python set-membership semantics cannot escape the typed locale boundary. +The branch now contains **fourteen ordinary-forward RED→repair sequences**. The first three established wildcard/q=0 basics, boundary-specific control codes, and control rejection before whitespace normalization. Subsequent repairs are: -The branch now contains eleven ordinary-forward RED→repair sequences. The first corrected wildcard lookup and q=0 fallback semantics. The second pinned boundary-specific validation codes for control characters. The third proved that CR/LF could disappear when `.strip()` ran before validation and moved control rejection ahead of normalization. The fourth RED `8265e9bb...` demonstrated that the remaining `.strip()` and regex `\s` semantics still accepted VT/FF, explicit-locale HTAB, and edge Unicode whitespace that are not part of the intended boundary; causal fix `9a6c8bb0...` rejects C0/DEL at the locale boundary, permits only HTTP HTAB where appropriate, restricts protocol OWS to `[ SP / HTAB ]`, and replaces generic stripping with explicit ASCII normalization. The fifth RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` pins RFC 9110's case-insensitive quality-parameter name with `Q=` examples; causal fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15` changes only the parameter-name matcher from literal `q` to `[qQ]`, preserving the existing qvalue and OWS grammar. The sixth RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` proves that `None` escapes as raw `TypeError` and a one-key mapping is silently accepted when the implementation blindly applies `tuple(...)`; causal fix `92a50acef8c7a3fbb38a926994add1641ac6c49d` validates the tuple/list container before conversion and preserves `ui_placeholder_schema_invalid` for invalid containers. The seventh RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` proves that NUL, isolated surrogate code points, and non-string translation values are not rejected at the translation boundary; causal fix `dec6c694dcf930ef184b158de04260d8db433bec` adds `ui_translation_input_invalid` and validates persistable Unicode scalar text before `Formatter` parsing. The eighth RED `489bfd4fa2d6f3630cf33412f6d910d73c47e110` proves that a tuple/list containing an unhashable nested list or mapping reaches `set(schema)` and escapes as raw `TypeError`; causal fix `244cff4f8d8805f2b3eacf91a75c7da84052bfc2` validates every element as a valid string name before duplicate detection. The ninth RED `652137bd5b4fae436af207d8823a4e6e5122b8a0` proves that an unhashable `product_default` reaches frozenset membership and escapes as raw `TypeError`; causal fix `f079bb270aadb2748937d5491b718e87b4198834` validates the runtime string type before membership while preserving `ui_locale_unsupported` for invalid defaults. The tenth RED `20a7475c190585a1f94d34bd19c363f97dcc4e81` proves that RFC 9110-valid recipient inputs such as `", fr;q=0.4, , en;q=0.8,"` and an all-empty `","` field were rejected as invalid ranges; causal fix `eff473a04fd8cca5d76396c36ff0c1dbf3a033f6` skips empty members before language-range matching so they contribute no preference while valid members preserve their original list positions. The eleventh RED `7a68f034ef817abdad2c5744d8cbf4d0cf65ce03` proves the tenth repair was still unbounded: 33 empty list members were ignored exactly like common merge mistakes even though RFC 9110 warns against tolerating enough empty elements to create a denial-of-service mechanism. Causal fix `b88aff67897bed9a533d21987d2db2618144b850` counts empty members, accepts through 32, and rejects the 33rd with `ui_accept_language_invalid` before continuing language-range processing. +4. RED `8265e9bb08cc407c860f148f1823dd0f4ab6d13d` → fix `9a6c8bb04be237c968b58987ca6ac4efb789a482`: replace generic Python whitespace semantics with boundary-specific SP/HTAB/C0 rules. +5. RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` → fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15`: case-insensitive `q`/`Q` parameter name. +6. RED `e299d29b14943c368e581bcb3d35303a1ba3fb8f` → fix `92a50acef8c7a3fbb38a926994add1641ac6c49d`: tuple/list-only placeholder schema container. +7. RED `3e251cc253d2ccf62ec1a3209ab629c6cffbe149` → fix `dec6c694dcf930ef184b158de04260d8db433bec`: reject NUL/surrogate/non-string translation input. +8. RED `489bfd4fa2d6f3630cf33412f6d910d73c47e110` → fix `244cff4f8d8805f2b3eacf91a75c7da84052bfc2`: validate schema elements before hash-based uniqueness. +9. RED `652137bd5b4fae436af207d8823a4e6e5122b8a0` → fix `f079bb270aadb2748937d5491b718e87b4198834`: runtime product-default type validation before set membership. +10. RED `20a7475c190585a1f94d34bd19c363f97dcc4e81` → fix `eff473a04fd8cca5d76396c36ff0c1dbf3a033f6`: RFC 9110 recipient-side empty-list semantics. +11. RED `7a68f034ef817abdad2c5744d8cbf4d0cf65ce03` → fix `b88aff67897bed9a533d21987d2db2618144b850`: bound ignored empty members at 32. +12. RED `2ebe9758cb915841d86330bbabcd618f3f091d71` → fix `1664d9707d4b5b9366b422f8faf612418ecd7789`: preserve later unsupported concrete ranges for truthful RFC 4647 wildcard control flow and `selection_source`. +13. RED `8b19c7dfe43e3dca3c1e4b8980c0a9998a3aec43` → fix `6ed310e1065940fa14c258b48412490e7b98d751`: distinguish canonical `{name}` from forbidden `{name:}` before parser normalization. +14. RED `978fdbbc6135fa88a8d43e0971ed075ae546f9de` → fix `0fdb5371eef63d6d7abe87ac14b69269c8519ff8`: bound explicit locale length, raw `Accept-Language` length, and non-empty member count before parser work. -No local/container PASS is claimed for the fourth through eleventh repairs because this automation environment does not contain the repository checkout needed for exact-head execution. The exact source and regressions are committed, but current-head full-suite/coverage/hosted evidence and independent post-last-push review must be reacquired naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. +Focused traceability for repair 14 is commit `d25c7a627af720242c9503f8f2b90553c7c94405`. + +This automation environment does not contain an exact repository checkout, so no full-suite/local-container PASS is claimed for the current head. Exact-head hosted CI/security and qualifying independent post-last-push review must settle naturally. No source-neutral wake commit or predecessor receipt transfer is authorized. ## Next causal work -After the canonical migration ancestry is protected, add versioned catalog aggregates and an ordinary Alembic descendant with immutable published-version semantics, item-level idempotent UPSERT/authoring behavior, and tenant/workspace authorization where applicable. Only then add a screen-scoped read endpoint with version/ETag cache identity and page composition. UI acceptance must cover normal/loading/empty/error/permission states and KO/EN/JA/ZH/VI/ES/DE/FR text expansion, CJK wrapping/font fallback, keyboard/focus/touch and assistive technology before the UI Delivery Gate can pass. +After canonical migration ancestry is protected, add versioned catalog aggregates and an ordinary Alembic descendant with immutable published-version semantics, item-level idempotent authoring, and tenant/workspace authorization. Then add a screen-scoped async read endpoint with version/ETag identity and realistic k6/E2E evidence, followed by page composition and KO/EN/JA/ZH/VI/ES/DE/FR Storybook/browser/a11y acceptance covering normal/loading/empty/error/permission states, CJK wrapping/font fallback, text expansion, keyboard/focus/touch, and assistive technology. ## TRACEABILITY -Phillips, A., & Davis, M. (2009). *Tags for identifying languages* (RFC 5646, BCP 47). RFC Editor. https://doi.org/10.17487/RFC5646 +Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 Phillips, A., & Davis, M. (2006). *Matching of language tags* (RFC 4647, BCP 47). RFC Editor. https://doi.org/10.17487/RFC4647 -Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 +Phillips, A., & Davis, M. (2009). *Tags for identifying languages* (RFC 5646, BCP 47). RFC Editor. https://doi.org/10.17487/RFC5646 + +Python Software Foundation. (2026). *string — Common string operations: Format string syntax* (Python 3.14 documentation). https://docs.python.org/3.14/library/string.html PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Character types*. https://www.postgresql.org/docs/18/datatype-character.html The Unicode Consortium. (2026). *The Unicode Standard, Version 18.0: Core specification, Chapter 3*. https://www.unicode.org/versions/Unicode18.0.0/core-spec/chapter-3/ -RFC 9110 defines `Accept-Language` as weighted language ranges, defines `weight = OWS ";" OWS "q=" qvalue`, defines OWS exactly as zero or more SP/HTAB octets, explicitly states that the `q` parameter name is case-insensitive, and in §5.6.1.2 requires recipients of list-based fields to parse and ignore a reasonable number of empty list elements while not accepting enough empty elements to create a denial-of-service mechanism. RFC 9110 intentionally leaves that reasonable count to the implementation; Naruon's 32-member threshold is therefore a product bound with an executable edge, not a standards-defined magic number. RFC 9110 also states that field values containing CTL characters are invalid, with CR/LF/NUL specifically dangerous. RFC 4647 supplies language-range matching and RFC 5646 supplies language-tag structure. Naruon's first release intentionally reduces supported variants to eight product-language identities, so this module is a bounded product policy over those standards rather than a general-purpose BCP 47 library. - -PostgreSQL 18 documents that character zero cannot be stored in character types. Unicode 18 defines Unicode scalar values as all Unicode code points except the high- and low-surrogate ranges and defines the Unicode encoding forms over scalar values. The seventh repair binds those external persistence/encoding constraints to the product's typed translation-input boundary without banning legitimate multiline or tabbed UI copy. - -The product-default, placeholder-container and element-order repairs are internal product invariants rather than new standards interpretations: public/runtime boundaries must preserve stable localization validation codes and must not rely on incidental Python hashability or iteration behavior to define versioned product semantics. +RFC 9110 defines `Accept-Language` as weighted language ranges, OWS as SP/HTAB, recipient-side list behavior, and implementation-specific field processing limits. RFC 4647 supplies lookup and range-length considerations. RFC 5646 supplies language-tag length guidance and explicitly permits documented refusal above an implementation limit rather than semantic-changing truncation. PostgreSQL/Unicode references define the persistence/scalar-value boundary. Python format-string documentation explains why canonical placeholder source requires a lexical check before parsed field semantics. From c0d802d33bf68ca6a8fbe70e4aaee5726b6d4a65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 12:39:51 +0900 Subject: [PATCH 53/87] test(i18n): RED bound catalog identity lengths --- .../test_ui_localization_identity_bounds.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 backend/tests/test_ui_localization_identity_bounds.py diff --git a/backend/tests/test_ui_localization_identity_bounds.py b/backend/tests/test_ui_localization_identity_bounds.py new file mode 100644 index 000000000..02bfad941 --- /dev/null +++ b/backend/tests/test_ui_localization_identity_bounds.py @@ -0,0 +1,41 @@ +"""Resource-bound regressions for UI Localization Catalog identities.""" + +import pytest + +from services.ui_localization_policy import ( + UiLocalizationValidationError, + validate_message_key, + validate_screen_key, +) + + +def assert_error(code, callable_): + """Assert a stable localization validation code from one failing call.""" + with pytest.raises(UiLocalizationValidationError) as caught: + callable_() + assert caught.value.error_code == code + + +def test_screen_key_has_explicit_product_length_bound(): + """A syntactically valid screen identity cannot create unbounded catalog/index work.""" + screen_key_at_limit = "a." + ("b" * 126) + assert len(screen_key_at_limit) == 128 + assert validate_screen_key(screen_key_at_limit) == screen_key_at_limit + + screen_key_over_limit = screen_key_at_limit + "b" + assert_error( + "ui_screen_key_invalid", + lambda: validate_screen_key(screen_key_over_limit), + ) + + +def test_message_key_has_explicit_product_length_bound(): + """A syntactically valid message identity cannot grow without a product ceiling.""" + message_key_at_limit = "m" * 128 + assert validate_message_key(message_key_at_limit) == message_key_at_limit + + message_key_over_limit = message_key_at_limit + "m" + assert_error( + "ui_message_key_invalid", + lambda: validate_message_key(message_key_over_limit), + ) From c40e2aa244249878405896fc1255d8c1df9d9dc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 12:40:48 +0900 Subject: [PATCH 54/87] fix(i18n): bound catalog identity lengths --- backend/services/ui_localization_policy.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index bce37dc85..71c6070b0 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -47,6 +47,8 @@ _MAX_ACCEPT_LANGUAGE_CHARS = 8192 _MAX_ACCEPT_LANGUAGE_MEMBERS = 64 _MAX_ACCEPT_LANGUAGE_EMPTY_MEMBERS = 32 +_MAX_SCREEN_KEY_CHARS = 128 +_MAX_MESSAGE_KEY_CHARS = 128 _LOCALE_TAG_PATTERN = re.compile( r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$", flags=re.ASCII, @@ -267,20 +269,28 @@ def select_ui_locale( def validate_screen_key(screen_key: str) -> str: """Return a normalized screen identity or fail closed on invalid input.""" - if not isinstance(screen_key, str) or not _SCREEN_KEY_PATTERN.fullmatch(screen_key): + if ( + not isinstance(screen_key, str) + or len(screen_key) > _MAX_SCREEN_KEY_CHARS + or not _SCREEN_KEY_PATTERN.fullmatch(screen_key) + ): raise UiLocalizationValidationError( "ui_screen_key_invalid", - "screen_key must be a dotted lowercase product identity", + "screen_key must be a dotted lowercase product identity up to 128 characters", ) return screen_key def validate_message_key(message_key: str) -> str: """Return a normalized message identity or fail closed on invalid input.""" - if not isinstance(message_key, str) or not _MESSAGE_KEY_PATTERN.fullmatch(message_key): + if ( + not isinstance(message_key, str) + or len(message_key) > _MAX_MESSAGE_KEY_CHARS + or not _MESSAGE_KEY_PATTERN.fullmatch(message_key) + ): raise UiLocalizationValidationError( "ui_message_key_invalid", - "message_key must be lowercase snake_case", + "message_key must be lowercase snake_case up to 128 characters", ) return message_key From e91445f55c0355423d4475bbd7a8f5ca3de7465c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 12:41:10 +0900 Subject: [PATCH 55/87] docs(i18n): trace catalog identity bounds --- ...i-localization-identity-resource-bounds.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/doctoring/ui-localization-identity-resource-bounds.md diff --git a/docs/doctoring/ui-localization-identity-resource-bounds.md b/docs/doctoring/ui-localization-identity-resource-bounds.md new file mode 100644 index 000000000..f493c4fb0 --- /dev/null +++ b/docs/doctoring/ui-localization-identity-resource-bounds.md @@ -0,0 +1,38 @@ +# UI Localization Catalog identity resource bounds + +## Status and owned boundary + +**Proposed.** This record belongs to #1740's pure UI Localization Catalog policy. It changes only validation of product-owned `screen_key` and `message_key` identities; persistence, Alembic ownership, HTTP routing, browser caching and UI composition remain outside this PR. + +## Verified finding + +The policy described `screen_key` and `message_key` as stable bounded product identities, but the implementation bounded only their character grammar. Any syntactically valid lowercase identifier could therefore grow without limit before later persistence, indexing, cache-key construction and observability boundaries. + +That was a contract mismatch. The future catalog schema uses `(screen_key, message_key)` as durable identity, so accepting unbounded valid identifiers in the pure policy would push resource control downstream and make database/index/API behavior deployment-dependent. + +## RED and causal repair + +RED `c0d802d33bf68ca6a8fbe70e4aaee5726b6d4a65` adds executable boundaries for both identities: + +- a valid 128-character `screen_key` is accepted and the 129th character is rejected with `ui_screen_key_invalid`; +- a valid 128-character `message_key` is accepted and the 129th character is rejected with `ui_message_key_invalid`. + +Causal fix `c40e2aa244249878405896fc1255d8c1df9d9dc5` introduces `_MAX_SCREEN_KEY_CHARS = 128` and `_MAX_MESSAGE_KEY_CHARS = 128` before the existing regex acceptance. Over-limit values fail closed and are never truncated. + +The 128-character limits are Naruon product constraints, not PostgreSQL or IETF constants. They are intentionally aligned with the existing explicit-locale ceiling so product-owned textual identifiers have a deterministic upper bound before persistence and cache/index composition. The future persistence owner may choose narrower physical column/index constraints only if the API/domain contract is updated first; it must not silently truncate these identities. + +## Rejected alternatives + +Relying on PostgreSQL `text`, B-tree implementation limits, reverse proxies, or application-memory pressure is rejected because none of those is the domain contract. Truncation is rejected because `(screen_key, message_key)` is semantic identity: shortening it can alias two different product messages. Bounding only the eventual database column is also rejected because this pure policy is callable before persistence exists. + +## Verification boundary + +The RED and causal fix are committed. This runtime could not obtain an exact repository checkout because outbound Git resolution is unavailable, so no local/container PASS is claimed. Current-head hosted CI/security and qualifying independent post-last-push review remain the acceptance authority. + +Future persistence/API tests must prove that the same 128/128 contract is preserved at write/read boundaries, that no transport or migration silently truncates values, and that indexed screen-scoped lookup remains within the buyer-path performance budget. + +## TRACEABILITY + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Character types*. https://www.postgresql.org/docs/18/datatype-character.html + +Naruon's limits are product architecture decisions layered above PostgreSQL. PostgreSQL `text` does not supply the semantic identity ceiling required by the product; the domain therefore owns and tests the limit explicitly. From e1bdccc3b95bc41a8e03d29f3c581457cf586da5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 13:44:42 +0900 Subject: [PATCH 56/87] test(i18n): RED bound translation and placeholder resources --- ...ui_localization_content_resource_bounds.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 backend/tests/test_ui_localization_content_resource_bounds.py diff --git a/backend/tests/test_ui_localization_content_resource_bounds.py b/backend/tests/test_ui_localization_content_resource_bounds.py new file mode 100644 index 000000000..4a75c67e5 --- /dev/null +++ b/backend/tests/test_ui_localization_content_resource_bounds.py @@ -0,0 +1,67 @@ +"""Resource-bound regressions for translation text and placeholder metadata.""" + +import pytest + +from services.ui_localization_policy import ( + UiLocalizationValidationError, + extract_placeholder_names, + validate_translation_placeholders, +) + + +def assert_error(code, callable_): + """Assert one stable localization validation code from a failing call.""" + with pytest.raises(UiLocalizationValidationError) as caught: + callable_() + assert caught.value.error_code == code + + +def test_translation_text_character_budget_is_bounded_before_placeholder_work(): + """UI copy accepts 16,384 characters and rejects the next character without truncation.""" + assert extract_placeholder_names("가" * 16_384) == () + assert_error( + "ui_translation_input_invalid", + lambda: extract_placeholder_names("가" * 16_385), + ) + + +def test_placeholder_name_character_budget_is_bounded(): + """Versioned placeholder identities accept 64 characters and reject the 65th.""" + accepted_name = "a" + ("b" * 63) + rejected_name = accepted_name + "c" + + assert validate_translation_placeholders( + "{" + accepted_name + "}", + (accepted_name,), + ) == (accepted_name,) + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders( + "{" + rejected_name + "}", + (rejected_name,), + ), + ) + + +def test_placeholder_schema_item_budget_is_bounded_before_collection_copy(): + """A catalog message accepts 32 unique placeholders and rejects the 33rd.""" + accepted_schema = tuple(f"field_{index}" for index in range(32)) + accepted_message = " ".join(f"{{{name}}}" for name in accepted_schema) + rejected_schema = (*accepted_schema, "field_32") + rejected_message = accepted_message + " {field_32}" + + assert validate_translation_placeholders( + accepted_message, + accepted_schema, + ) == accepted_schema + assert_error( + "ui_placeholder_schema_invalid", + lambda: validate_translation_placeholders( + rejected_message, + rejected_schema, + ), + ) + assert_error( + "ui_placeholder_schema_invalid", + lambda: extract_placeholder_names(rejected_message), + ) From 8cf8880fb86a87af22b9f18afd36f45acfb0e70d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 13:45:40 +0900 Subject: [PATCH 57/87] fix(i18n): bound translation and placeholder parser resources --- backend/services/ui_localization_policy.py | 35 +++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 71c6070b0..65b239630 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -49,6 +49,9 @@ _MAX_ACCEPT_LANGUAGE_EMPTY_MEMBERS = 32 _MAX_SCREEN_KEY_CHARS = 128 _MAX_MESSAGE_KEY_CHARS = 128 +_MAX_TRANSLATION_MESSAGE_CHARS = 16_384 +_MAX_PLACEHOLDER_NAME_CHARS = 64 +_MAX_PLACEHOLDER_SCHEMA_ITEMS = 32 _LOCALE_TAG_PATTERN = re.compile( r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$", flags=re.ASCII, @@ -296,12 +299,17 @@ def validate_message_key(message_key: str) -> str: def _validate_translation_text(message_text: str) -> None: - """Reject text that cannot cross the catalog's UTF-8/PostgreSQL boundary.""" + """Reject text that cannot cross the catalog's bounded UTF-8/PostgreSQL boundary.""" if not isinstance(message_text, str): raise UiLocalizationValidationError( "ui_translation_input_invalid", "translated message must be a string", ) + if len(message_text) > _MAX_TRANSLATION_MESSAGE_CHARS: + raise UiLocalizationValidationError( + "ui_translation_input_invalid", + "translated message exceeds the 16384-character product limit", + ) if "\x00" in message_text or any( 0xD800 <= ord(character) <= 0xDFFF for character in message_text ): @@ -338,7 +346,7 @@ def _reject_placeholder_operators(message_text: str) -> None: def extract_placeholder_names(message_text: str) -> tuple[str, ...]: - """Extract simple named interpolation fields without evaluating the message.""" + """Extract bounded simple named interpolation fields without evaluating the message.""" _validate_translation_text(message_text) _reject_placeholder_operators(message_text) names: list[str] = [] @@ -349,15 +357,21 @@ def extract_placeholder_names(message_text: str) -> tuple[str, ...]: if field_name is None: continue if ( - not _PLACEHOLDER_NAME_PATTERN.fullmatch(field_name) + len(field_name) > _MAX_PLACEHOLDER_NAME_CHARS + or not _PLACEHOLDER_NAME_PATTERN.fullmatch(field_name) or format_spec or conversion is not None ): raise UiLocalizationValidationError( "ui_placeholder_schema_invalid", - "placeholders must be simple named fields without conversion or format specifiers", + "placeholders must be bounded simple named fields without conversion or format specifiers", ) if field_name not in seen: + if len(names) >= _MAX_PLACEHOLDER_SCHEMA_ITEMS: + raise UiLocalizationValidationError( + "ui_placeholder_schema_invalid", + "translated message contains too many unique placeholders", + ) seen.add(field_name) names.append(field_name) except ValueError as error: @@ -372,20 +386,27 @@ def validate_translation_placeholders( translated_message: str, placeholder_schema: tuple[str, ...] | list[str], ) -> tuple[str, ...]: - """Require the translated message to preserve the exact placeholder schema.""" + """Require translated text to preserve one bounded versioned placeholder schema.""" if not isinstance(placeholder_schema, (tuple, list)): raise UiLocalizationValidationError( "ui_placeholder_schema_invalid", "placeholder schema must be a tuple or list of names", ) + if len(placeholder_schema) > _MAX_PLACEHOLDER_SCHEMA_ITEMS: + raise UiLocalizationValidationError( + "ui_placeholder_schema_invalid", + "placeholder schema exceeds the 32-name product limit", + ) schema = tuple(placeholder_schema) if any( - not isinstance(name, str) or not _PLACEHOLDER_NAME_PATTERN.fullmatch(name) + not isinstance(name, str) + or len(name) > _MAX_PLACEHOLDER_NAME_CHARS + or not _PLACEHOLDER_NAME_PATTERN.fullmatch(name) for name in schema ) or len(schema) != len(set(schema)): raise UiLocalizationValidationError( "ui_placeholder_schema_invalid", - "placeholder schema must contain unique lowercase snake_case names", + "placeholder schema must contain unique lowercase snake_case names up to 64 characters", ) actual = extract_placeholder_names(translated_message) if set(actual) != set(schema): From 7f7adef82bc1da611013989bd6b5b65050aefa7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 13:46:06 +0900 Subject: [PATCH 58/87] docs(i18n): trace translation content resource bounds --- ...ui-localization-content-resource-bounds.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/doctoring/ui-localization-content-resource-bounds.md diff --git a/docs/doctoring/ui-localization-content-resource-bounds.md b/docs/doctoring/ui-localization-content-resource-bounds.md new file mode 100644 index 000000000..4c4475f23 --- /dev/null +++ b/docs/doctoring/ui-localization-content-resource-bounds.md @@ -0,0 +1,78 @@ +# UI localization content resource bounds + +Status: Proposed with PR #1740. This record describes the pure-domain contract only; it does not claim that persistence, HTTP admission, browser rendering, or publication workflows are shipped. + +## Problem + +The localization policy already bounded locale tags, `Accept-Language`, `screen_key`, and `message_key`, but translation content and placeholder metadata remained syntactically constrained and operationally unbounded. A caller could submit an arbitrarily large translated message, a placeholder name of arbitrary length, or a tuple/list containing an arbitrary number of placeholder identities. The policy would then scan the whole message, run formatter parsing, allocate placeholder collections, and copy the complete schema before any downstream persistence or transport boundary had an opportunity to refuse the work. + +That is inconsistent with the catalog's fail-closed resource policy. PostgreSQL `text` being variable-length does not define a safe product message budget, and an outer HTTP/proxy field or body limit cannot be the only guard for code paths that may also be called from offline authoring, tests, migrations, or future publication jobs. + +MITRE CWE-400 describes uncontrolled resource consumption as an architecture/design weakness class and explicitly recommends placing specific limits of scale on protocols and resource-triggering inputs. This record uses that guidance only as resource-design traceability; it does not label this PR as a CVE or assign security severity from CWE-400 alone. + +## RED + +Commit `e1bdccc3b95bc41a8e03d29f3c581457cf586da5` adds focused boundary cases that are false on the predecessor policy: + +- translation text accepts exactly 16,384 Python characters and rejects 16,385 with `ui_translation_input_invalid`; +- one placeholder name accepts exactly 64 characters and rejects 65 with `ui_placeholder_schema_invalid`; +- one message/schema accepts 32 unique placeholder names and rejects the 33rd with `ui_placeholder_schema_invalid`; +- direct placeholder extraction also rejects a 33rd unique placeholder, so callers cannot bypass the versioned-schema guard by invoking the extractor directly. + +These are product limits, not values supplied by PostgreSQL, Unicode, Python, or an IETF specification. + +## Decision + +Causal fix `8cf8880fb86a87af22b9f18afd36f45acfb0e70d` adds three domain ceilings: + +- `_MAX_TRANSLATION_MESSAGE_CHARS = 16_384`; +- `_MAX_PLACEHOLDER_NAME_CHARS = 64`; +- `_MAX_PLACEHOLDER_SCHEMA_ITEMS = 32`. + +The checks are intentionally ordered to bound work before expensive or allocating steps: + +1. translation text type and character count are checked before scanning Unicode scalar validity, lexical placeholder checks, or `Formatter.parse()`; +2. tuple/list schema length is checked before copying it to a tuple; +3. placeholder-name length is checked before accepting the identifier as catalog metadata; +4. direct extraction refuses a new unique placeholder once 32 names are already retained. + +Over-limit input is rejected and never truncated. Truncation could alias distinct catalog content or placeholder identity and would make the immutable publication record dishonest. + +The 16,384-character text ceiling is deliberately generous for a single UI message while making parser work finite. It is a character/code-point boundary in Python rather than a byte budget. Transport, persistence, export, or rendering owners may impose a stricter byte-size limit when their own contract requires it, but they must not silently accept a larger value by bypassing this domain policy. + +## Rejected alternatives + +### Leave content unbounded because the future API can cap request size + +Rejected. The policy is callable outside HTTP, and resource safety would depend on every future caller reproducing the same limit correctly. + +### Rely on PostgreSQL `text` + +Rejected. PostgreSQL describes `text` as variable-length character data; that storage type is not a Naruon semantic or parser-work budget. Letting storage failure or memory pressure define the product limit would also move the failure away from the typed catalog boundary. + +### Truncate text or placeholder metadata + +Rejected. Truncation can change rendered meaning, collapse distinct placeholder identities, or publish a resource different from the reviewed source. + +### Count UTF-8 bytes in this pure policy + +Not selected for this layer. Python parsing cost here is driven primarily by the in-memory string and placeholder structure. A later transport/persistence owner can add an explicit byte budget if required, but that must be a separately tested contract rather than an implicit codec side effect. + +## Downstream contract + +Future persistence/publication and screen-scoped HTTP owners must preserve the 16,384/64/32 limits or enforce stricter documented limits without silent truncation. Publication completeness, immutable resource versions, placeholder equality, and the existing KO/EN/JA/ZH/VI/ES/DE/FR release set remain unchanged. + +Realistic API/k6/E2E acceptance should cover normal payloads, exact-boundary payloads, and over-limit rejection on the final endpoint without sample shrinking or cache-only measurement. This pure-policy PR does not claim those runtime receipts. + +## Traceability + +- RED: `e1bdccc3b95bc41a8e03d29f3c581457cf586da5`. +- Causal source fix: `8cf8880fb86a87af22b9f18afd36f45acfb0e70d`. +- Canonical product Gap: #1731. +- Product/technical Gap ledger owner: #1602. + +## References + +MITRE. (2026). *CWE-400: Uncontrolled resource consumption (Version 4.20)*. Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/400.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Data types*. https://www.postgresql.org/docs/18/datatype.html From 478bd95fdbd2b6601fae1ab70c3513abf0f985fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 14:42:31 +0900 Subject: [PATCH 59/87] test(i18n): reject malformed BCP 47 explicit locale suffixes --- ...t_ui_localization_bcp47_well_formedness.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 backend/tests/test_ui_localization_bcp47_well_formedness.py diff --git a/backend/tests/test_ui_localization_bcp47_well_formedness.py b/backend/tests/test_ui_localization_bcp47_well_formedness.py new file mode 100644 index 000000000..6af1cda59 --- /dev/null +++ b/backend/tests/test_ui_localization_bcp47_well_formedness.py @@ -0,0 +1,22 @@ +"""BCP 47 well-formedness regressions for explicit locale preferences.""" + +import pytest + +from services.ui_localization_policy import ( + UiLocalizationValidationError, + normalize_supported_locale, +) + + +@pytest.mark.parametrize("locale_tag", ("en-x", "en-u")) +def test_explicit_locale_rejects_terminal_extension_or_private_use_singletons(locale_tag): + """A supported primary language does not make a malformed RFC 5646 suffix valid.""" + with pytest.raises(UiLocalizationValidationError) as caught: + normalize_supported_locale(locale_tag) + assert caught.value.error_code == "ui_locale_input_invalid" + + +@pytest.mark.parametrize("locale_tag", ("en-x-private", "en-u-ca-gregory")) +def test_explicit_locale_accepts_well_formed_private_use_and_extension_suffixes(locale_tag): + """Valid RFC 5646 suffixes remain compatible with release-level locale normalization.""" + assert normalize_supported_locale(locale_tag) == "en" From 3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 14:43:33 +0900 Subject: [PATCH 60/87] fix(i18n): enforce RFC 5646 explicit locale well-formedness --- backend/services/ui_localization_policy.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 65b239630..b06914efa 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -53,8 +53,16 @@ _MAX_PLACEHOLDER_NAME_CHARS = 64 _MAX_PLACEHOLDER_SCHEMA_ITEMS = 32 _LOCALE_TAG_PATTERN = re.compile( - r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$", - flags=re.ASCII, + r"^(?:" + r"(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4}|[A-Za-z]{5,8})" + r"(?:-[A-Za-z]{4})?" + r"(?:-(?:[A-Za-z]{2}|[0-9]{3}))?" + r"(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*" + r"(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*" + r"(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?" + r"|en-GB-oed" + r")$", + flags=re.ASCII | re.IGNORECASE, ) _SCREEN_KEY_PATTERN = re.compile( r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$", @@ -123,7 +131,7 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: if not candidate or not _LOCALE_TAG_PATTERN.fullmatch(candidate): raise UiLocalizationValidationError( "ui_locale_input_invalid", - "locale input is not a valid bounded language tag", + "locale input is not a well-formed bounded RFC 5646 language tag", ) primary_language = candidate.split("-", 1)[0].lower() if primary_language not in _SUPPORTED_LOCALE_SET: From 60fef97a6eab8ce2322f0920aecce3040ef35661 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 14:44:04 +0900 Subject: [PATCH 61/87] docs(i18n): trace explicit locale BCP 47 well-formedness --- ...n-explicit-locale-bcp47-well-formedness.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md diff --git a/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md new file mode 100644 index 000000000..a08cca281 --- /dev/null +++ b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md @@ -0,0 +1,57 @@ +# UI localization explicit-locale BCP 47 well-formedness + +Status: Proposed with PR #1740. This record covers the pure explicit-locale policy only; it does not change the broader RFC 4647 `Accept-Language` language-range grammar. + +## Problem + +`normalize_supported_locale()` previously used the broad pattern `[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*`. That shape was adequate to recover a primary language but was not the RFC 5646 `Language-Tag` grammar. It accepted malformed explicit preferences such as `en-x` and `en-u`: both have a supported primary language, but RFC 5646 requires private-use `x` to be followed by at least one private-use subtag and an extension singleton such as `u` to be followed by at least one 2-8 character extension subtag. + +Accepting those values as persisted/session locale identities made the explicit preference boundary weaker than its own documentation and blurred two different standards surfaces: RFC 5646 language tags for explicit stored/session identities versus RFC 4647 basic language ranges used inside `Accept-Language`. + +## RED + +Commit `478bd95fdbd2b6601fae1ab70c3513abf0f985fe` adds focused cases that fail on the predecessor policy: + +- `en-x` must fail with `ui_locale_input_invalid`; +- `en-u` must fail with `ui_locale_input_invalid`; +- `en-x-private` remains accepted and normalizes to `en`; +- `en-u-ca-gregory` remains accepted and normalizes to `en`. + +The regression is intentionally scoped to explicit locale values. It does not tighten RFC 4647 language-range parsing in `Accept-Language`, where the grammar is different. + +## Decision + +Causal fix `3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a` replaces the permissive explicit-locale pattern with an RFC 5646 structural `langtag` pattern covering language/extlang, optional script and region, variants, extensions, and optional private-use suffix. The one supported-primary irregular grandfathered tag that does not fit `langtag`, `en-GB-oed`, remains syntactically accepted so the policy does not reject an RFC 5646 language tag merely because it is grandfathered. + +The product continues to normalize a well-formed supported tag to its release-level primary language (`ko/en/ja/zh/vi/es/de/fr`). This is a syntax/well-formedness gate, not an IANA registry snapshot validator. RFC 5646 distinguishes well-formed tags from registry-valid tags; requiring a moving registry snapshot would create a separate versioned dependency that this pure policy does not currently own. + +## Rejected alternatives + +### Keep the broad subtag regex because only the primary language is used + +Rejected. Persisted/session preference is itself product data. A malformed suffix should not be accepted simply because the first subtag happens to map to a supported release locale. + +### Reuse the `Accept-Language` language-range regex + +Rejected. RFC 4647 basic language ranges deliberately have a broader structural grammar than RFC 5646 language tags. Sharing the same regex would preserve the original boundary confusion. + +### Validate every subtag against the live IANA Language Subtag Registry + +Not selected for this slice. RFC 5646 defines well-formedness separately from registry validity. A registry-validity guarantee would require a pinned registry version, update/release process, reproducibility evidence, and failure policy; none belongs implicitly inside this bounded pure-domain repair. + +## Downstream contract + +Persistence and future authoring/API boundaries must keep explicit locale values on this RFC 5646 well-formedness path and must not substitute the broader `Accept-Language` range grammar. They may add a pinned registry-validity requirement later only as an explicit versioned contract. + +## Traceability + +- RED: `478bd95fdbd2b6601fae1ab70c3513abf0f985fe`. +- Causal source fix: `3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a`. +- Canonical product Gap: #1731. +- Product/technical Gap ledger owner: #1602. + +## References + +Phillips, A., & Davis, M. (2009). *Tags for identifying languages* (BCP 47; RFC 5646). RFC Editor. https://doi.org/10.17487/RFC5646 + +Phillips, A., & Davis, M. (2006). *Matching of language tags* (BCP 47; RFC 4647). RFC Editor. https://doi.org/10.17487/RFC4647 From 8d9a0f67a47b26eb1e36a7bc65681470033c6f74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 14:51:17 +0900 Subject: [PATCH 62/87] test(i18n): preserve supported grandfathered BCP 47 tags --- ...test_ui_localization_bcp47_well_formedness.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/tests/test_ui_localization_bcp47_well_formedness.py b/backend/tests/test_ui_localization_bcp47_well_formedness.py index 6af1cda59..2df4753c3 100644 --- a/backend/tests/test_ui_localization_bcp47_well_formedness.py +++ b/backend/tests/test_ui_localization_bcp47_well_formedness.py @@ -20,3 +20,19 @@ def test_explicit_locale_rejects_terminal_extension_or_private_use_singletons(lo def test_explicit_locale_accepts_well_formed_private_use_and_extension_suffixes(locale_tag): """Valid RFC 5646 suffixes remain compatible with release-level locale normalization.""" assert normalize_supported_locale(locale_tag) == "en" + + +@pytest.mark.parametrize( + ("locale_tag", "expected_locale"), + ( + ("en-GB-oed", "en"), + ("zh-min", "zh"), + ("zh-min-nan", "zh"), + ), +) +def test_explicit_locale_accepts_supported_primary_grandfathered_language_tags( + locale_tag, + expected_locale, +): + """RFC 5646 grandfathered tags remain Language-Tag values even outside langtag ABNF.""" + assert normalize_supported_locale(locale_tag) == expected_locale From aceef1b282d948411fe9b04b8cd08750fcdacfe8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 15:44:30 +0900 Subject: [PATCH 63/87] fix(i18n): accept supported grandfathered zh tags --- backend/services/ui_localization_policy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index b06914efa..05c2b92c7 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -61,6 +61,7 @@ r"(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*" r"(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?" r"|en-GB-oed" + r"|zh-min(?:-nan)?" r")$", flags=re.ASCII | re.IGNORECASE, ) From 502669e233a500f98bc311bf181d56445fd23e59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 15:44:54 +0900 Subject: [PATCH 64/87] docs(i18n): trace grandfathered tag repair --- ...n-explicit-locale-bcp47-well-formedness.md | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md index a08cca281..6880b3c9f 100644 --- a/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md +++ b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md @@ -8,6 +8,8 @@ Status: Proposed with PR #1740. This record covers the pure explicit-locale poli Accepting those values as persisted/session locale identities made the explicit preference boundary weaker than its own documentation and blurred two different standards surfaces: RFC 5646 language tags for explicit stored/session identities versus RFC 4647 basic language ranges used inside `Accept-Language`. +A second standards sweep found the first structural repair incomplete. RFC 5646 defines `Language-Tag = langtag / privateuse / grandfathered`; some grandfathered values are intentionally outside the `langtag` production. For the current release-primary set, `en-GB-oed`, `zh-min`, and `zh-min-nan` therefore require explicit structural acceptance before release-level normalization. `zh-guoyu`, `zh-hakka`, and `zh-xiang` are grandfathered registry entries too, but their literal forms already satisfy the structural `langtag` pattern and do not need a separate alternation. + ## RED Commit `478bd95fdbd2b6601fae1ab70c3513abf0f985fe` adds focused cases that fail on the predecessor policy: @@ -17,11 +19,19 @@ Commit `478bd95fdbd2b6601fae1ab70c3513abf0f985fe` adds focused cases that fail o - `en-x-private` remains accepted and normalizes to `en`; - `en-u-ca-gregory` remains accepted and normalizes to `en`. -The regression is intentionally scoped to explicit locale values. It does not tighten RFC 4647 language-range parsing in `Accept-Language`, where the grammar is different. +Commit `8d9a0f67a47b26eb1e36a7bc65681470033c6f74` adds the follow-up grandfathered-tag regression: + +- `en-GB-oed` normalizes to `en`; +- `zh-min` normalizes to `zh`; +- `zh-min-nan` normalizes to `zh`. + +The regressions are intentionally scoped to explicit locale values. They do not tighten RFC 4647 language-range parsing in `Accept-Language`, where the grammar is different. ## Decision -Causal fix `3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a` replaces the permissive explicit-locale pattern with an RFC 5646 structural `langtag` pattern covering language/extlang, optional script and region, variants, extensions, and optional private-use suffix. The one supported-primary irregular grandfathered tag that does not fit `langtag`, `en-GB-oed`, remains syntactically accepted so the policy does not reject an RFC 5646 language tag merely because it is grandfathered. +Causal fix `3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a` replaces the permissive explicit-locale pattern with an RFC 5646 structural `langtag` pattern covering language/extlang, optional script and region, variants, extensions, and optional private-use suffix. + +Follow-up causal fix `aceef1b282d948411fe9b04b8cd08750fcdacfe8` completes the supported-primary grandfathered-tag path by explicitly accepting `zh-min` and `zh-min-nan` alongside the already explicit `en-GB-oed`. This is a finite product-owned syntax compatibility set, not a live registry lookup. Grandfathered forms that already satisfy the structural `langtag` pattern need no separate exception. The product continues to normalize a well-formed supported tag to its release-level primary language (`ko/en/ja/zh/vi/es/de/fr`). This is a syntax/well-formedness gate, not an IANA registry snapshot validator. RFC 5646 distinguishes well-formed tags from registry-valid tags; requiring a moving registry snapshot would create a separate versioned dependency that this pure policy does not currently own. @@ -39,14 +49,20 @@ Rejected. RFC 4647 basic language ranges deliberately have a broader structural Not selected for this slice. RFC 5646 defines well-formedness separately from registry validity. A registry-validity guarantee would require a pinned registry version, update/release process, reproducibility evidence, and failure policy; none belongs implicitly inside this bounded pure-domain repair. +### Treat grandfathered values as malformed because they do not fit `langtag` + +Rejected. RFC 5646 defines grandfathered forms as part of `Language-Tag`. Rejecting `zh-min` or `zh-min-nan` merely because they sit outside the `langtag` branch would make the explicit-locale boundary contradict the standard it claims to implement. + ## Downstream contract Persistence and future authoring/API boundaries must keep explicit locale values on this RFC 5646 well-formedness path and must not substitute the broader `Accept-Language` range grammar. They may add a pinned registry-validity requirement later only as an explicit versioned contract. ## Traceability -- RED: `478bd95fdbd2b6601fae1ab70c3513abf0f985fe`. -- Causal source fix: `3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a`. +- Seventeenth RED: `478bd95fdbd2b6601fae1ab70c3513abf0f985fe`. +- Seventeenth causal source fix: `3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a`. +- Eighteenth RED: `8d9a0f67a47b26eb1e36a7bc65681470033c6f74`. +- Eighteenth causal source fix: `aceef1b282d948411fe9b04b8cd08750fcdacfe8`. - Canonical product Gap: #1731. - Product/technical Gap ledger owner: #1602. From d23d4095d1c2ac8542f1ac247b6fee87fcb9485c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 15:48:18 +0900 Subject: [PATCH 65/87] test(i18n): classify well-formed unsupported BCP 47 tags --- .../tests/test_ui_localization_bcp47_well_formedness.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/tests/test_ui_localization_bcp47_well_formedness.py b/backend/tests/test_ui_localization_bcp47_well_formedness.py index 2df4753c3..fa44ab0da 100644 --- a/backend/tests/test_ui_localization_bcp47_well_formedness.py +++ b/backend/tests/test_ui_localization_bcp47_well_formedness.py @@ -36,3 +36,11 @@ def test_explicit_locale_accepts_supported_primary_grandfathered_language_tags( ): """RFC 5646 grandfathered tags remain Language-Tag values even outside langtag ABNF.""" assert normalize_supported_locale(locale_tag) == expected_locale + + +@pytest.mark.parametrize("locale_tag", ("x-private", "i-klingon", "sgn-BE-FR")) +def test_well_formed_language_tags_without_supported_primary_are_unsupported(locale_tag): + """Well-formed private-use/irregular tags are unsupported, not syntactically invalid.""" + with pytest.raises(UiLocalizationValidationError) as caught: + normalize_supported_locale(locale_tag) + assert caught.value.error_code == "ui_locale_unsupported" From dc077bec9ae47e1421fa678ae43451f65784c410 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 15:49:02 +0900 Subject: [PATCH 66/87] fix(i18n): classify complete RFC 5646 syntax before support --- backend/services/ui_localization_policy.py | 45 ++++++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 05c2b92c7..5faac7c89 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -52,19 +52,43 @@ _MAX_TRANSLATION_MESSAGE_CHARS = 16_384 _MAX_PLACEHOLDER_NAME_CHARS = 64 _MAX_PLACEHOLDER_SCHEMA_ITEMS = 32 -_LOCALE_TAG_PATTERN = re.compile( - r"^(?:" - r"(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4}|[A-Za-z]{5,8})" +_LANGTAG_PATTERN = re.compile( + r"^(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4}|[A-Za-z]{5,8})" r"(?:-[A-Za-z]{4})?" r"(?:-(?:[A-Za-z]{2}|[0-9]{3}))?" r"(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*" r"(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*" - r"(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?" - r"|en-GB-oed" - r"|zh-min(?:-nan)?" - r")$", + r"(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?$", flags=re.ASCII | re.IGNORECASE, ) +_PRIVATEUSE_TAG_PATTERN = re.compile( + r"^[xX](?:-[A-Za-z0-9]{1,8})+$", + flags=re.ASCII, +) +_IRREGULAR_GRANDFATHERED_TAGS = frozenset( + tag.lower() + for tag in ( + "en-GB-oed", + "i-ami", + "i-bnn", + "i-default", + "i-enochian", + "i-hak", + "i-klingon", + "i-lux", + "i-mingo", + "i-navajo", + "i-pwn", + "i-tao", + "i-tay", + "i-tsu", + "sgn-BE-FR", + "sgn-BE-NL", + "sgn-CH-DE", + "zh-min", + "zh-min-nan", + ) +) _SCREEN_KEY_PATTERN = re.compile( r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$", flags=re.ASCII, @@ -129,7 +153,12 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: message="locale input contains forbidden control characters", ) candidate = locale_tag.strip(" ") - if not candidate or not _LOCALE_TAG_PATTERN.fullmatch(candidate): + candidate_lower = candidate.lower() + if not candidate or not ( + _LANGTAG_PATTERN.fullmatch(candidate) + or _PRIVATEUSE_TAG_PATTERN.fullmatch(candidate) + or candidate_lower in _IRREGULAR_GRANDFATHERED_TAGS + ): raise UiLocalizationValidationError( "ui_locale_input_invalid", "locale input is not a well-formed bounded RFC 5646 language tag", From 71d5043f3189ac3e613466a094c8a0577d24923f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 15:49:39 +0900 Subject: [PATCH 67/87] docs(i18n): trace syntax versus support classification --- ...n-explicit-locale-bcp47-well-formedness.md | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md index 6880b3c9f..f9660e66d 100644 --- a/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md +++ b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md @@ -4,11 +4,13 @@ Status: Proposed with PR #1740. This record covers the pure explicit-locale poli ## Problem -`normalize_supported_locale()` previously used the broad pattern `[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*`. That shape was adequate to recover a primary language but was not the RFC 5646 `Language-Tag` grammar. It accepted malformed explicit preferences such as `en-x` and `en-u`: both have a supported primary language, but RFC 5646 requires private-use `x` to be followed by at least one private-use subtag and an extension singleton such as `u` to be followed by at least one 2-8 character extension subtag. +`normalize_supported_locale()` originally used the broad pattern `[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*`. That shape was adequate to recover a primary language but was not the RFC 5646 `Language-Tag` grammar. It accepted malformed explicit preferences such as `en-x` and `en-u`: both have a supported primary language, but RFC 5646 requires private-use `x` to be followed by at least one private-use subtag and an extension singleton such as `u` to be followed by at least one 2-8 character extension subtag. Accepting those values as persisted/session locale identities made the explicit preference boundary weaker than its own documentation and blurred two different standards surfaces: RFC 5646 language tags for explicit stored/session identities versus RFC 4647 basic language ranges used inside `Accept-Language`. -A second standards sweep found the first structural repair incomplete. RFC 5646 defines `Language-Tag = langtag / privateuse / grandfathered`; some grandfathered values are intentionally outside the `langtag` production. For the current release-primary set, `en-GB-oed`, `zh-min`, and `zh-min-nan` therefore require explicit structural acceptance before release-level normalization. `zh-guoyu`, `zh-hakka`, and `zh-xiang` are grandfathered registry entries too, but their literal forms already satisfy the structural `langtag` pattern and do not need a separate alternation. +A second standards sweep found the first structural repair incomplete. RFC 5646 defines `Language-Tag = langtag / privateuse / grandfathered`; some grandfathered values are intentionally outside the `langtag` production. For the current release-primary set, `en-GB-oed`, `zh-min`, and `zh-min-nan` therefore require explicit structural acceptance before release-level normalization. `zh-guoyu`, `zh-hakka`, and `zh-xiang` are grandfathered registry entries too, but their literal forms already satisfy the structural `langtag` pattern and do not need a separate exception. + +A third sweep found a typed-boundary defect left by treating only supported-primary tags as syntactically relevant. RFC 5646 permits a language tag to consist entirely of private-use subtags, and irregular grandfathered tags remain well-formed language tags. Values such as `x-private`, `i-klingon`, and `sgn-BE-FR` are therefore syntactically well-formed but do not map to one of Naruon's eight release primaries. They belong to `ui_locale_unsupported`, not `ui_locale_input_invalid`. ## RED @@ -19,21 +21,29 @@ Commit `478bd95fdbd2b6601fae1ab70c3513abf0f985fe` adds focused cases that fail o - `en-x-private` remains accepted and normalizes to `en`; - `en-u-ca-gregory` remains accepted and normalizes to `en`. -Commit `8d9a0f67a47b26eb1e36a7bc65681470033c6f74` adds the follow-up grandfathered-tag regression: +Commit `8d9a0f67a47b26eb1e36a7bc65681470033c6f74` adds the grandfathered-tag regression: - `en-GB-oed` normalizes to `en`; - `zh-min` normalizes to `zh`; - `zh-min-nan` normalizes to `zh`. +Commit `d23d4095d1c2ac8542f1ac247b6fee87fcb9485c` adds the syntax-versus-support classification regression: + +- `x-private` is well-formed private-use-only syntax and must fail with `ui_locale_unsupported`; +- `i-klingon` is an irregular grandfathered language tag and must fail with `ui_locale_unsupported`; +- `sgn-BE-FR` is an irregular grandfathered language tag and must fail with `ui_locale_unsupported`. + The regressions are intentionally scoped to explicit locale values. They do not tighten RFC 4647 language-range parsing in `Accept-Language`, where the grammar is different. ## Decision Causal fix `3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a` replaces the permissive explicit-locale pattern with an RFC 5646 structural `langtag` pattern covering language/extlang, optional script and region, variants, extensions, and optional private-use suffix. -Follow-up causal fix `aceef1b282d948411fe9b04b8cd08750fcdacfe8` completes the supported-primary grandfathered-tag path by explicitly accepting `zh-min` and `zh-min-nan` alongside the already explicit `en-GB-oed`. This is a finite product-owned syntax compatibility set, not a live registry lookup. Grandfathered forms that already satisfy the structural `langtag` pattern need no separate exception. +Follow-up causal fix `aceef1b282d948411fe9b04b8cd08750fcdacfe8` restores the supported-primary grandfathered forms `zh-min` and `zh-min-nan` alongside `en-GB-oed` without introducing a moving IANA-registry dependency. + +Causal fix `dc077bec9ae47e1421fa678ae43451f65784c410` separates three syntax branches before release support is evaluated: structural `langtag`, private-use-only tags, and RFC 5646's fixed irregular-grandfathered set. Only after a candidate is established as well-formed does the policy inspect the first subtag for membership in `ko/en/ja/zh/vi/es/de/fr`. This preserves the stable semantic distinction between malformed input and well-formed-but-unsupported input. -The product continues to normalize a well-formed supported tag to its release-level primary language (`ko/en/ja/zh/vi/es/de/fr`). This is a syntax/well-formedness gate, not an IANA registry snapshot validator. RFC 5646 distinguishes well-formed tags from registry-valid tags; requiring a moving registry snapshot would create a separate versioned dependency that this pure policy does not currently own. +The irregular grandfathered set is fixed by RFC 5646 and therefore does not create a moving registry dependency. Regular grandfathered forms that already satisfy `langtag` continue through the structural branch. Registry validity beyond this fixed syntax exception remains outside this pure policy. ## Rejected alternatives @@ -49,13 +59,13 @@ Rejected. RFC 4647 basic language ranges deliberately have a broader structural Not selected for this slice. RFC 5646 defines well-formedness separately from registry validity. A registry-validity guarantee would require a pinned registry version, update/release process, reproducibility evidence, and failure policy; none belongs implicitly inside this bounded pure-domain repair. -### Treat grandfathered values as malformed because they do not fit `langtag` +### Treat private-use-only or irregular grandfathered values as malformed because Naruon cannot render them -Rejected. RFC 5646 defines grandfathered forms as part of `Language-Tag`. Rejecting `zh-min` or `zh-min-nan` merely because they sit outside the `langtag` branch would make the explicit-locale boundary contradict the standard it claims to implement. +Rejected. Support is a product-release question after syntax classification. Collapsing well-formed unsupported values into `ui_locale_input_invalid` would make error semantics depend on the current release set and contradict the RFC 5646 syntax contract. ## Downstream contract -Persistence and future authoring/API boundaries must keep explicit locale values on this RFC 5646 well-formedness path and must not substitute the broader `Accept-Language` range grammar. They may add a pinned registry-validity requirement later only as an explicit versioned contract. +Persistence and future authoring/API boundaries must keep explicit locale values on this RFC 5646 well-formedness path and must not substitute the broader `Accept-Language` range grammar. A well-formed tag that lacks a supported release primary fails as unsupported; malformed syntax fails as input-invalid. They may add a pinned registry-validity requirement later only as an explicit versioned contract. ## Traceability @@ -63,6 +73,8 @@ Persistence and future authoring/API boundaries must keep explicit locale values - Seventeenth causal source fix: `3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a`. - Eighteenth RED: `8d9a0f67a47b26eb1e36a7bc65681470033c6f74`. - Eighteenth causal source fix: `aceef1b282d948411fe9b04b8cd08750fcdacfe8`. +- Nineteenth RED: `d23d4095d1c2ac8542f1ac247b6fee87fcb9485c`. +- Nineteenth causal source fix: `dc077bec9ae47e1421fa678ae43451f65784c410`. - Canonical product Gap: #1731. - Product/technical Gap ledger owner: #1602. From da7e2653a158a259f4d327f1cf017674c7721064 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 16:43:56 +0900 Subject: [PATCH 68/87] test(i18n): RED repeated RFC 5646 extension singleton --- ...localization_bcp47_extension_singletons.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 backend/tests/test_ui_localization_bcp47_extension_singletons.py diff --git a/backend/tests/test_ui_localization_bcp47_extension_singletons.py b/backend/tests/test_ui_localization_bcp47_extension_singletons.py new file mode 100644 index 000000000..49d286db7 --- /dev/null +++ b/backend/tests/test_ui_localization_bcp47_extension_singletons.py @@ -0,0 +1,21 @@ +"""RFC 5646 extension-singleton regressions for explicit locale preferences.""" + +import pytest + +from services.ui_localization_policy import ( + UiLocalizationValidationError, + normalize_supported_locale, +) + + +@pytest.mark.parametrize("locale_tag", ("en-a-bbb-a-ccc", "EN-A-bbb-a-ccc")) +def test_explicit_locale_rejects_repeated_extension_singleton(locale_tag): + """RFC 5646 forbids repeating an extension singleton outside private use.""" + with pytest.raises(UiLocalizationValidationError) as caught: + normalize_supported_locale(locale_tag) + assert caught.value.error_code == "ui_locale_input_invalid" + + +def test_private_use_may_repeat_extension_letter_after_x(): + """Singleton-shaped private-use data is not a repeated extension singleton.""" + assert normalize_supported_locale("en-a-bbb-x-a-ccc") == "en" From 81e061c000a13261ced87b7d6f71fbce64637821 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 16:45:09 +0900 Subject: [PATCH 69/87] fix(i18n): reject repeated RFC 5646 extension singleton --- backend/services/ui_localization_policy.py | 23 +++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index 5faac7c89..d32925d50 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -135,6 +135,21 @@ def _reject_control_characters( raise UiLocalizationValidationError(error_code, message) +def _has_repeated_extension_singleton(locale_tag: str) -> bool: + """Return whether a structural langtag repeats an extension singleton before private use.""" + seen_singletons: set[str] = set() + for subtag in locale_tag.split("-")[1:]: + normalized = subtag.lower() + if normalized == "x": + break + if len(subtag) != 1: + continue + if normalized in seen_singletons: + return True + seen_singletons.add(normalized) + return False + + def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: """Normalize a supported language tag to its release-level product language.""" if not isinstance(locale_tag, str): @@ -154,8 +169,14 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: ) candidate = locale_tag.strip(" ") candidate_lower = candidate.lower() + is_structural_langtag = _LANGTAG_PATTERN.fullmatch(candidate) is not None + if is_structural_langtag and _has_repeated_extension_singleton(candidate): + raise UiLocalizationValidationError( + "ui_locale_input_invalid", + "locale input repeats an RFC 5646 extension singleton", + ) if not candidate or not ( - _LANGTAG_PATTERN.fullmatch(candidate) + is_structural_langtag or _PRIVATEUSE_TAG_PATTERN.fullmatch(candidate) or candidate_lower in _IRREGULAR_GRANDFATHERED_TAGS ): From 81bbbcf86a0ebbb56176c6aa86c9d37c7b5651f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 16:45:51 +0900 Subject: [PATCH 70/87] docs(i18n): trace RFC 5646 singleton validity repair --- ...ion-bcp47-registry-independent-validity.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/doctoring/ui-localization-bcp47-registry-independent-validity.md diff --git a/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md b/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md new file mode 100644 index 000000000..f3fc571c6 --- /dev/null +++ b/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md @@ -0,0 +1,51 @@ +# UI localization: BCP 47 registry-independent validity guard + +## Decision + +Naruon's explicit-locale policy distinguishes three boundaries: + +1. structural `Language-Tag` syntax accepted by RFC 5646 ABNF, including private-use-only and grandfathered forms; +2. registry-independent RFC 5646 validity constraints that can be checked without consulting a moving IANA Language Subtag Registry snapshot; +3. Naruon's release support set (`ko`, `en`, `ja`, `zh`, `vi`, `es`, `de`, `fr`). + +The policy now rejects a structural `langtag` that repeats the same extension singleton before the private-use `x` boundary. RFC 5646 §2.2.6 requires each singleton subtag to appear at most once outside private use and gives `en-a-bbb-a-ccc` as an invalid example while allowing `en-a-bbb-x-a-ccc` because the second `a` is private-use data. + +This is intentionally narrower than full BCP 47 validity. RFC 5646 validity also depends on a dated IANA Language Subtag Registry and prohibits duplicate variant subtags. This bounded policy does not claim live registry validation or canonicalization. A future owner may add registry-backed validity only with an explicit version/update contract and reproducible evidence. + +## Finding + +The existing `_LANGTAG_PATTERN` mirrors the structural ABNF closely enough that it accepted `en-a-bbb-a-ccc`: each `a` independently satisfied the extension-singleton production. That made an RFC-invalid tag appear to be a supported `en` locale. + +The failure is product-significant even though Naruon ultimately collapses supported tags to their primary release language. Persisted/session locale input is an authority boundary; accepting a tag that violates a normative RFC invariant makes validation semantics depend on which downstream component revalidates the original tag. + +## RED and causal repair + +RED commit `da7e2653a158a259f4d327f1cf017674c7721064` adds an executable regression requiring repeated extension singleton tags to fail with `ui_locale_input_invalid`, while preserving `en-a-bbb-x-a-ccc -> en`. + +Causal fix `81e061c000a13261ced87b7d6f71fbce64637821` adds a bounded structural check after the existing `langtag` regex match. It tracks one-character extension singletons case-insensitively, stops at private-use `x`, and rejects the first repeat. Private-use-only and grandfathered paths are unchanged, as are Naruon's release-support semantics and 128-character input ceiling. + +Rejected alternatives: + +- **Treat ABNF match as sufficient validity.** Rejected because RFC 5646 explicitly adds non-ABNF validity constraints. +- **Reject every repeated one-character token including private use.** Rejected because RFC 5646 explicitly permits the singleton-shaped token after `x` as private-use data. +- **Introduce live IANA registry validation in this repair.** Rejected because it creates a moving external authority and versioning/update problem outside this pure-policy slice. +- **Canonicalize or silently repair the tag.** Rejected because explicit preferences fail closed; mutation would hide invalid caller input. + +## Acceptance and follow-up + +The focused regression is necessary but not sufficient for merge. The unchanged exact head still requires repository full-suite/coverage/security execution and qualifying independent post-last-push review. Future persistence and HTTP owners must preserve the same typed error boundary without truncation or silent canonical repair. + +Duplicate variant subtags are another RFC 5646 validity condition. They remain a separate review target because identifying variants without a registry snapshot requires a deliberately scoped parser contract rather than broadening this singleton fix by assumption. + +## Traceability + +- Product Gap: #1731 +- Pure policy owner: #1740 +- Source: `backend/services/ui_localization_policy.py` +- Focused regression: `backend/tests/test_ui_localization_bcp47_extension_singletons.py` +- RED: `da7e2653a158a259f4d327f1cf017674c7721064` +- Causal fix: `81e061c000a13261ced87b7d6f71fbce64637821` + +## Reference + +Phillips, A., & Davis, M. (2009). *Tags for identifying languages* (BCP 47, RFC 5646). RFC Editor. https://doi.org/10.17487/RFC5646 From c34b45957799575ea7e11c84992fdf84461b783f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 17:42:41 +0900 Subject: [PATCH 71/87] test(i18n): pin duplicate BCP47 variant rejection --- .../test_ui_localization_bcp47_well_formedness.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/tests/test_ui_localization_bcp47_well_formedness.py b/backend/tests/test_ui_localization_bcp47_well_formedness.py index fa44ab0da..b4ef3ca8a 100644 --- a/backend/tests/test_ui_localization_bcp47_well_formedness.py +++ b/backend/tests/test_ui_localization_bcp47_well_formedness.py @@ -44,3 +44,16 @@ def test_well_formed_language_tags_without_supported_primary_are_unsupported(loc with pytest.raises(UiLocalizationValidationError) as caught: normalize_supported_locale(locale_tag) assert caught.value.error_code == "ui_locale_unsupported" + + +@pytest.mark.parametrize("locale_tag", ("en-1901-1901", "en-oxendict-OXENDICT")) +def test_explicit_locale_rejects_duplicate_variant_subtags(locale_tag): + """RFC 5646 validity forbids duplicate variant subtags case-insensitively.""" + with pytest.raises(UiLocalizationValidationError) as caught: + normalize_supported_locale(locale_tag) + assert caught.value.error_code == "ui_locale_input_invalid" + + +def test_duplicate_variant_spelling_inside_private_use_is_not_a_variant_duplicate(): + """Private-use subtags do not participate in the duplicate-variant validity rule.""" + assert normalize_supported_locale("en-1901-x-1901") == "en" From 96adc96dd5fab6e31734c2c23257b0f0f40447ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 17:43:46 +0900 Subject: [PATCH 72/87] fix(i18n): reject duplicate BCP47 variants --- backend/services/ui_localization_policy.py | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index d32925d50..f153792da 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -150,6 +150,50 @@ def _has_repeated_extension_singleton(locale_tag: str) -> bool: return False +def _has_repeated_variant_subtag(locale_tag: str) -> bool: + """Return whether a structural langtag repeats a variant before extensions/private use.""" + subtags = locale_tag.split("-") + index = 1 + primary_language = subtags[0] + + if len(primary_language) in (2, 3): + extlang_count = 0 + while ( + index < len(subtags) + and extlang_count < 3 + and len(subtags[index]) == 3 + and subtags[index].isalpha() + ): + index += 1 + extlang_count += 1 + + if index < len(subtags) and len(subtags[index]) == 4 and subtags[index].isalpha(): + index += 1 + + if index < len(subtags) and ( + (len(subtags[index]) == 2 and subtags[index].isalpha()) + or (len(subtags[index]) == 3 and subtags[index].isdigit()) + ): + index += 1 + + seen_variants: set[str] = set() + while index < len(subtags): + subtag = subtags[index] + if len(subtag) == 1: + break + is_variant = (5 <= len(subtag) <= 8 and subtag.isalnum()) or ( + len(subtag) == 4 and subtag[0].isdigit() and subtag[1:].isalnum() + ) + if not is_variant: + break + normalized = subtag.lower() + if normalized in seen_variants: + return True + seen_variants.add(normalized) + index += 1 + return False + + def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: """Normalize a supported language tag to its release-level product language.""" if not isinstance(locale_tag, str): @@ -175,6 +219,11 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: "ui_locale_input_invalid", "locale input repeats an RFC 5646 extension singleton", ) + if is_structural_langtag and _has_repeated_variant_subtag(candidate): + raise UiLocalizationValidationError( + "ui_locale_input_invalid", + "locale input repeats an RFC 5646 variant subtag", + ) if not candidate or not ( is_structural_langtag or _PRIVATEUSE_TAG_PATTERN.fullmatch(candidate) From c49c80904ae1c38ee529a4b65e2fdb6612863c47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 17:45:03 +0900 Subject: [PATCH 73/87] test(i18n): pin single-extlang BCP47 validity --- .../test_ui_localization_bcp47_well_formedness.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/tests/test_ui_localization_bcp47_well_formedness.py b/backend/tests/test_ui_localization_bcp47_well_formedness.py index b4ef3ca8a..16f37fef6 100644 --- a/backend/tests/test_ui_localization_bcp47_well_formedness.py +++ b/backend/tests/test_ui_localization_bcp47_well_formedness.py @@ -57,3 +57,15 @@ def test_explicit_locale_rejects_duplicate_variant_subtags(locale_tag): def test_duplicate_variant_spelling_inside_private_use_is_not_a_variant_duplicate(): """Private-use subtags do not participate in the duplicate-variant validity rule.""" assert normalize_supported_locale("en-1901-x-1901") == "en" + + +def test_explicit_locale_rejects_more_than_one_extlang_subtag(): + """RFC 5646 reserves second and third extlang positions as permanently invalid.""" + with pytest.raises(UiLocalizationValidationError) as caught: + normalize_supported_locale("zh-cmn-yue") + assert caught.value.error_code == "ui_locale_input_invalid" + + +def test_explicit_locale_accepts_one_extlang_with_following_structural_subtags(): + """One extlang remains valid before script and region subtags.""" + assert normalize_supported_locale("zh-cmn-Hans-CN") == "zh" From 983194dd129cc593a4caa3c8531fb669986e4262 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 17:46:50 +0900 Subject: [PATCH 74/87] fix(i18n): reject reserved repeated extlang positions --- backend/services/ui_localization_policy.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index f153792da..a253ef7ea 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -150,6 +150,19 @@ def _has_repeated_extension_singleton(locale_tag: str) -> bool: return False +def _has_multiple_extlang_subtags(locale_tag: str) -> bool: + """Return whether a structural langtag occupies a reserved second extlang position.""" + subtags = locale_tag.split("-") + if len(subtags[0]) not in (2, 3): + return False + extlang_count = 0 + for subtag in subtags[1:4]: + if len(subtag) != 3 or not subtag.isalpha(): + break + extlang_count += 1 + return extlang_count > 1 + + def _has_repeated_variant_subtag(locale_tag: str) -> bool: """Return whether a structural langtag repeats a variant before extensions/private use.""" subtags = locale_tag.split("-") @@ -214,6 +227,11 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: candidate = locale_tag.strip(" ") candidate_lower = candidate.lower() is_structural_langtag = _LANGTAG_PATTERN.fullmatch(candidate) is not None + if is_structural_langtag and _has_multiple_extlang_subtags(candidate): + raise UiLocalizationValidationError( + "ui_locale_input_invalid", + "locale input contains more than one RFC 5646 extended language subtag", + ) if is_structural_langtag and _has_repeated_extension_singleton(candidate): raise UiLocalizationValidationError( "ui_locale_input_invalid", From 91a0e8c402be8338b917feabfaa7e9872f76467f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 17:47:23 +0900 Subject: [PATCH 75/87] docs(i18n): trace registry-independent BCP47 validity --- ...ion-bcp47-registry-independent-validity.md | 62 ++++++++++++------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md b/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md index f3fc571c6..503b71c24 100644 --- a/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md +++ b/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md @@ -1,51 +1,71 @@ -# UI localization: BCP 47 registry-independent validity guard +# UI localization: BCP 47 registry-independent validity guards ## Decision -Naruon's explicit-locale policy distinguishes three boundaries: +Naruon's explicit-locale policy separates three boundaries: 1. structural `Language-Tag` syntax accepted by RFC 5646 ABNF, including private-use-only and grandfathered forms; 2. registry-independent RFC 5646 validity constraints that can be checked without consulting a moving IANA Language Subtag Registry snapshot; 3. Naruon's release support set (`ko`, `en`, `ja`, `zh`, `vi`, `es`, `de`, `fr`). -The policy now rejects a structural `langtag` that repeats the same extension singleton before the private-use `x` boundary. RFC 5646 §2.2.6 requires each singleton subtag to appear at most once outside private use and gives `en-a-bbb-a-ccc` as an invalid example while allowing `en-a-bbb-x-a-ccc` because the second `a` is private-use data. +The structural `langtag` branch now enforces three registry-independent validity rules in addition to ABNF shape: -This is intentionally narrower than full BCP 47 validity. RFC 5646 validity also depends on a dated IANA Language Subtag Registry and prohibits duplicate variant subtags. This bounded policy does not claim live registry validation or canonicalization. A future owner may add registry-backed validity only with an explicit version/update contract and reproducible evidence. +- an extension singleton may appear at most once before the private-use `x` boundary; +- a variant subtag may not repeat case-insensitively before extensions/private use; +- despite the compatibility ABNF reserving up to three extlang positions, only the first extlang position may be occupied. -## Finding +These checks intentionally remain narrower than full BCP 47 validity. Registry membership, variant `Prefix` relationships, deprecation, preferred values, canonicalization, and other registry-dated semantics still require a versioned IANA Language Subtag Registry authority and are not inferred here. -The existing `_LANGTAG_PATTERN` mirrors the structural ABNF closely enough that it accepted `en-a-bbb-a-ccc`: each `a` independently satisfied the extension-singleton production. That made an RFC-invalid tag appear to be a supported `en` locale. +## Findings -The failure is product-significant even though Naruon ultimately collapses supported tags to their primary release language. Persisted/session locale input is an authority boundary; accepting a tag that violates a normative RFC invariant makes validation semantics depend on which downstream component revalidates the original tag. +### Repeated extension singleton -## RED and causal repair +The existing `_LANGTAG_PATTERN` accepted `en-a-bbb-a-ccc`: each `a` independently satisfied the extension-singleton production. RFC 5646 §2.2.6 requires each singleton subtag to appear at most once outside private use and explicitly makes that form invalid, while `en-a-bbb-x-a-ccc` remains valid because the second `a` is private-use data. -RED commit `da7e2653a158a259f4d327f1cf017674c7721064` adds an executable regression requiring repeated extension singleton tags to fail with `ui_locale_input_invalid`, while preserving `en-a-bbb-x-a-ccc -> en`. +RED `da7e2653a158a259f4d327f1cf017674c7721064` pinned the failure. Causal fix `81e061c000a13261ced87b7d6f71fbce64637821` added the bounded case-insensitive singleton check and stopped evaluation at private-use `x`. -Causal fix `81e061c000a13261ced87b7d6f71fbce64637821` adds a bounded structural check after the existing `langtag` regex match. It tracks one-character extension singletons case-insensitively, stops at private-use `x`, and rejects the first repeat. Private-use-only and grandfathered paths are unchanged, as are Naruon's release-support semantics and 128-character input ceiling. +### Duplicate variant subtag -Rejected alternatives: +A structural ABNF match also accepted duplicate variants such as `en-1901-1901` and case variants such as `en-oxendict-OXENDICT`. RFC 5646 §2.2.9 states that a valid tag contains no duplicate variant subtags. This condition is registry-independent once the structural parser has identified the variant segment. -- **Treat ABNF match as sufficient validity.** Rejected because RFC 5646 explicitly adds non-ABNF validity constraints. -- **Reject every repeated one-character token including private use.** Rejected because RFC 5646 explicitly permits the singleton-shaped token after `x` as private-use data. -- **Introduce live IANA registry validation in this repair.** Rejected because it creates a moving external authority and versioning/update problem outside this pure-policy slice. -- **Canonicalize or silently repair the tag.** Rejected because explicit preferences fail closed; mutation would hide invalid caller input. +RED `c34b45957799575ea7e11c84992fdf84461b783f` requires both duplicate forms to fail with `ui_locale_input_invalid`, while preserving `en-1901-x-1901 -> en` because the second spelling is private-use data. Causal fix `96adc96dd5fab6e31734c2c23257b0f0f40447ee` walks the structural positions after primary/extlang/script/region, compares variant spellings case-insensitively, and stops before extensions/private use. + +### Reserved second and third extlang positions + +RFC 5646 §2.2.2 explains that the `extlang` ABNF retains up to three positions for compatibility, but the second and third positions are permanently reserved: tags occupying them are and will always remain invalid. The RFC 5646 errata record also notes that §2.2.9's compact validity list omitted this condition even though §2.2.2 makes it normative. + +The previous regex therefore accepted `zh-cmn-yue` and normalized it to `zh`. RED `c49c80904ae1c38ee529a4b65e2fdb6612863c47` requires that form to fail with `ui_locale_input_invalid` while preserving the single-extlang form `zh-cmn-Hans-CN -> zh`. Causal fix `983194dd129cc593a4caa3c8531fb669986e4262` rejects structural tags that occupy more than one three-letter extlang position immediately after a two- or three-letter primary language subtag. + +## Product significance + +Naruon ultimately collapses a supported explicit tag to one release language, but persisted/session locale input is still an authority boundary. Accepting an RFC-invalid tag here allows downstream systems to disagree about whether the original identity was admissible, which weakens persistence, cache identity, auditability, and future API interoperability. The policy therefore fails closed on these stable registry-independent invariants before applying the eight-locale support check. + +## Rejected alternatives + +- **Treat ABNF match as sufficient validity.** Rejected because RFC 5646 explicitly adds validity rules outside the ABNF. +- **Treat every repeated spelling after `x` as a duplicate.** Rejected because all subtags after the private-use singleton are private-use data. +- **Use live IANA lookup for this repair.** Rejected because that would introduce a moving external authority without a registry version/update/reproducibility contract. +- **Canonicalize or silently repair invalid caller input.** Rejected because explicit locale preferences are an authority boundary and fail closed. +- **Ban extlang entirely.** Rejected because RFC 5646 still permits one extlang position and Naruon's release-level normalization can preserve that structural compatibility without claiming registry canonicalization. ## Acceptance and follow-up -The focused regression is necessary but not sufficient for merge. The unchanged exact head still requires repository full-suite/coverage/security execution and qualifying independent post-last-push review. Future persistence and HTTP owners must preserve the same typed error boundary without truncation or silent canonical repair. +The focused regressions and causal source repairs are necessary but not sufficient for merge. The exact current head still requires repository full-suite/coverage/security execution and qualifying independent post-last-push review. Persistence and HTTP owners must preserve the typed invalid-versus-unsupported boundary without truncation or silent canonical repair. -Duplicate variant subtags are another RFC 5646 validity condition. They remain a separate review target because identifying variants without a registry snapshot requires a deliberately scoped parser contract rather than broadening this singleton fix by assumption. +Full registry validity remains deliberately out of scope until an owner defines a dated IANA Language Subtag Registry version, update cadence, provenance, rollback behavior, and reproducible tests. Variant-prefix recommendations and canonical preferred-value replacement must not be guessed into this pure-policy slice. ## Traceability - Product Gap: #1731 - Pure policy owner: #1740 - Source: `backend/services/ui_localization_policy.py` -- Focused regression: `backend/tests/test_ui_localization_bcp47_extension_singletons.py` -- RED: `da7e2653a158a259f4d327f1cf017674c7721064` -- Causal fix: `81e061c000a13261ced87b7d6f71fbce64637821` +- Focused regressions: `backend/tests/test_ui_localization_bcp47_extension_singletons.py`, `backend/tests/test_ui_localization_bcp47_well_formedness.py` +- Repeated-singleton RED/fix: `da7e2653a158a259f4d327f1cf017674c7721064` -> `81e061c000a13261ced87b7d6f71fbce64637821` +- Duplicate-variant RED/fix: `c34b45957799575ea7e11c84992fdf84461b783f` -> `96adc96dd5fab6e31734c2c23257b0f0f40447ee` +- Multi-extlang RED/fix: `c49c80904ae1c38ee529a4b65e2fdb6612863c47` -> `983194dd129cc593a4caa3c8531fb669986e4262` -## Reference +## References Phillips, A., & Davis, M. (2009). *Tags for identifying languages* (BCP 47, RFC 5646). RFC Editor. https://doi.org/10.17487/RFC5646 + +RFC Editor. (2018). *Errata ID 5457: RFC 5646, Section 2.2.9*. https://www.rfc-editor.org/errata/eid5457 From deaf07941cfc2b607c2148f434ce1196a3db29d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 19:41:07 +0900 Subject: [PATCH 76/87] test(i18n): pin wildcard-only lookup provenance --- backend/tests/test_ui_localization_policy.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_ui_localization_policy.py b/backend/tests/test_ui_localization_policy.py index 6dcd28d1f..1f4a897d9 100644 --- a/backend/tests/test_ui_localization_policy.py +++ b/backend/tests/test_ui_localization_policy.py @@ -87,13 +87,15 @@ def test_accept_language_zero_quality_and_wildcard_follow_lookup_order(): def test_wildcard_lookup_skips_to_later_concrete_range(): - """RFC 4647 lookup skips wildcard when a later concrete range can be tried.""" + """RFC 4647 lookup skips wildcard and attributes wildcard-only fallback to product default.""" selected = select_ui_locale(accept_language="en;q=0.9, *;q=0.8, fr;q=0.7") assert selected.locale_code == "en" selected = select_ui_locale(accept_language="*;q=0.7, *;q=0.9, *;q=0.5, fr;q=0.8") assert selected.locale_code == "fr" selected = select_ui_locale(accept_language="*;q=0.9") - assert selected.locale_code == "ko" + assert (selected.locale_code, selected.selection_source) == ("ko", "product_default") + selected = select_ui_locale(accept_language="*;q=0.9", product_default="fr") + assert (selected.locale_code, selected.selection_source) == ("fr", "product_default") def test_malformed_accept_language_fails_closed(): From 1ce7b6ed5883beda305ea2552ae5af00509510c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 19:42:31 +0900 Subject: [PATCH 77/87] fix(i18n): preserve wildcard lookup default provenance --- backend/services/ui_localization_policy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index a253ef7ea..fb2acbcc6 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -353,6 +353,8 @@ def _accept_language_preferences( for concrete_quality, concrete_position in concrete_priorities ): continue + if not excluded_locales: + break wildcard_candidates = (product_default, *SUPPORTED_LOCALE_CODES) for wildcard_candidate in wildcard_candidates: if ( From d48e9e6f91d922e6b0dbf7497832836254c29eb9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 19:42:49 +0900 Subject: [PATCH 78/87] docs(i18n): trace wildcard-only default provenance repair --- ...-localization-rfc4647-wildcard-ordering.md | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/doctoring/ui-localization-rfc4647-wildcard-ordering.md b/docs/doctoring/ui-localization-rfc4647-wildcard-ordering.md index c78f93e35..a307d1d15 100644 --- a/docs/doctoring/ui-localization-rfc4647-wildcard-ordering.md +++ b/docs/doctoring/ui-localization-rfc4647-wildcard-ordering.md @@ -4,9 +4,9 @@ **Proposed.** This note extends the pure UI Localization Catalog policy owned by #1740 and does not create persistence, API, browser, Storybook, ontology-label, or LLM-translation ownership. -## Verified finding +## Verified findings -The prior policy retained only supported concrete language ranges before applying RFC 4647 lookup wildcard ordering. That changed the meaning of a valid priority list such as: +The first repair found that the policy retained only supported concrete language ranges before applying RFC 4647 lookup wildcard ordering. That changed the meaning of a valid priority list such as: ```text Accept-Language: *;q=0.9, pt-BR;q=0.8 @@ -14,19 +14,22 @@ Accept-Language: *;q=0.9, pt-BR;q=0.8 RFC 4647 lookup says `*` is skipped when another language range follows in the language priority list. The later `pt-BR` range still exists even though Naruon's current release has no Portuguese resource. Lookup therefore tries the later concrete range, finds no supported resource, and reaches the product default. The prior implementation discarded `pt-BR` before deciding whether the wildcard had a later concrete range, so it incorrectly attributed the selected default to `accept_language` rather than `product_default`. -This is an authority/provenance defect, not a change to the selected locale value. `UiLocaleSelection.selection_source` is product state used to explain which authority tier selected the locale, so unsupported concrete ranges cannot be erased before wildcard lookup control flow is resolved. +A later standards recheck found the symmetric provenance defect when `*` is the only effective positive range. RFC 4647 §3.4 states that `*` does not convey enough information to choose a language tag: when it is the only range, or when no language range follows it, lookup computes the default value. The policy instead materialized the configured product default as an `accept_language` result, so `Accept-Language: *;q=0.9` selected the same locale value but reported the wrong authority tier. The defect also appeared with a non-Korean configured default. -## RED and causal repair +These are authority/provenance defects, not necessarily selected-locale defects. `UiLocaleSelection.selection_source` is product state used to explain which authority tier selected the locale, so wildcard control flow and unsupported concrete ranges cannot be collapsed into a fabricated header-selected locale. + +## RED and causal repairs - RED `2ebe9758cb915841d86330bbabcd618f3f091d71` adds `*;q=0.9, pt-BR;q=0.8` and requires `(ko, product_default)`. -- Causal fix `1664d9707d4b5b9366b422f8faf612418ecd7789` records every positive-quality concrete range's priority before filtering against the eight supported product locales. Wildcard handling then skips the wildcard whenever any later positive concrete range remains in the RFC lookup priority order. Unsupported ranges still never become supported locale candidates. -- Existing positive wildcard behavior remains unchanged when the wildcard is the final effective range, and q=0 exclusions remain non-candidates. +- Causal fix `1664d9707d4b5b9366b422f8faf612418ecd7789` records every positive-quality concrete range's priority before filtering against the eight supported product locales. Wildcard handling then skips the wildcard whenever a later positive concrete range remains in the RFC lookup priority order. Unsupported ranges still never become supported locale candidates. +- RED `deaf07941cfc2b607c2148f434ce1196a3db29d9` pins wildcard-only provenance for both the ordinary Korean default and a custom supported default: `*;q=0.9` must return `(ko, product_default)`, and the same input with `product_default="fr"` must return `(fr, product_default)`. +- Causal fix `1ce7b6ed5883beda305ea2552ae5af00509510c7` stops a wildcard from manufacturing an `accept_language` selection when no supported q=0 exclusion changes the fallback set. The caller therefore computes the configured product default and records `selection_source=product_default`. Existing supported q=0 exclusions remain meaningful: when the header explicitly excludes the configured default, wildcard fallback may still choose the first non-excluded release locale and truthfully remain header-dependent. -The repair deliberately does not generalize Naruon into a full BCP 47 registry. Release resources remain exactly `ko/en/ja/zh/vi/es/de/fr`; the change preserves protocol ordering metadata long enough to compute the correct selection authority. +The repair deliberately does not generalize Naruon into a full BCP 47 registry. Release resources remain exactly `ko/en/ja/zh/vi/es/de/fr`; the change preserves protocol ordering and exclusion metadata long enough to compute the correct selection authority. ## Verification boundary -The source and focused regression are committed to the exact #1740 branch. This runtime cannot obtain an exact repository checkout, so no local/container PASS is claimed. Hosted checks and an independent post-last-push review must be reacquired on the final exact head; predecessor receipts do not transfer. +The source and focused regressions are committed to the exact #1740 branch. This runtime cannot obtain an exact repository checkout, so no local/container PASS is claimed. Hosted checks and an independent post-last-push review must be reacquired on the final exact head; predecessor receipts do not transfer. ## TRACEABILITY @@ -34,4 +37,4 @@ Phillips, A., & Davis, M. (2006). *Matching of language tags* (RFC 4647, BCP 47) Fielding, R. T., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 -RFC 4647 §3.4.1 specifies that in lookup the special range `*` is skipped when followed by other language ranges; if no later range matches, the default value is returned. RFC 9110 supplies the weighted `Accept-Language` language-priority list. Naruon preserves that ordering rule while reducing successful matches to its eight release locales. +RFC 4647 §3.4 specifies that the special range `*` is skipped when later language ranges remain, and that when `*` is the only range or no range follows it, the default value is computed. RFC 4647 §3.4.1 requires the application to define that defaulting behavior. RFC 9110 supplies the weighted `Accept-Language` language-priority list. Naruon preserves those ordering and defaulting rules while reducing successful matches to its eight release locales. From 2ff28c208b8fd5a83bee3e179f4d66b31831be43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 00:46:02 +0900 Subject: [PATCH 79/87] test(i18n): reject surrounding SP in explicit locale tags --- .../tests/test_ui_localization_bcp47_well_formedness.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/tests/test_ui_localization_bcp47_well_formedness.py b/backend/tests/test_ui_localization_bcp47_well_formedness.py index 16f37fef6..52bcedb54 100644 --- a/backend/tests/test_ui_localization_bcp47_well_formedness.py +++ b/backend/tests/test_ui_localization_bcp47_well_formedness.py @@ -16,6 +16,14 @@ def test_explicit_locale_rejects_terminal_extension_or_private_use_singletons(lo assert caught.value.error_code == "ui_locale_input_invalid" +@pytest.mark.parametrize("locale_tag", (" en-US", "en-US ", " en-US ")) +def test_explicit_locale_rejects_surrounding_spaces(locale_tag): + """RFC 5646 language-tag syntax does not permit SP around an explicit locale value.""" + with pytest.raises(UiLocalizationValidationError) as caught: + normalize_supported_locale(locale_tag) + assert caught.value.error_code == "ui_locale_input_invalid" + + @pytest.mark.parametrize("locale_tag", ("en-x-private", "en-u-ca-gregory")) def test_explicit_locale_accepts_well_formed_private_use_and_extension_suffixes(locale_tag): """Valid RFC 5646 suffixes remain compatible with release-level locale normalization.""" From c94c9345008cf6e339a9f90f59afccc18adea39f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 00:47:27 +0900 Subject: [PATCH 80/87] fix(i18n): reject whitespace around explicit locale tags --- backend/services/ui_localization_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index fb2acbcc6..b127fb561 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -224,7 +224,7 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: error_code="ui_locale_input_invalid", message="locale input contains forbidden control characters", ) - candidate = locale_tag.strip(" ") + candidate = locale_tag candidate_lower = candidate.lower() is_structural_langtag = _LANGTAG_PATTERN.fullmatch(candidate) is not None if is_structural_langtag and _has_multiple_extlang_subtags(candidate): @@ -542,4 +542,4 @@ def validate_translation_placeholders( "ui_translation_placeholder_mismatch", "translated message placeholders do not match the versioned schema", ) - return actual + return actual \ No newline at end of file From 97279b9dfd8d47d3033007d36398ccf7aa9e6943 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 00:48:20 +0900 Subject: [PATCH 81/87] docs(i18n): trace explicit locale whitespace repair --- ...n-explicit-locale-bcp47-well-formedness.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md index f9660e66d..c545d0771 100644 --- a/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md +++ b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md @@ -12,6 +12,8 @@ A second standards sweep found the first structural repair incomplete. RFC 5646 A third sweep found a typed-boundary defect left by treating only supported-primary tags as syntactically relevant. RFC 5646 permits a language tag to consist entirely of private-use subtags, and irregular grandfathered tags remain well-formed language tags. Values such as `x-private`, `i-klingon`, and `sgn-BE-FR` are therefore syntactically well-formed but do not map to one of Naruon's eight release primaries. They belong to `ui_locale_unsupported`, not `ui_locale_input_invalid`. +A later boundary sweep found a separate whitespace-normalization defect. The explicit persisted/session path called `locale_tag.strip(" ")` before RFC 5646 validation, so `" en-US"`, `"en-US "`, and `" en-US "` were silently accepted as `en-US`. RFC 5646 states that whitespace is not permitted in a language tag. That differs from the HTTP `Accept-Language` surface, where HTTP list syntax deliberately permits optional whitespace around field-value list elements. Explicit stored/session locale identity therefore must not borrow HTTP OWS normalization. + ## RED Commit `478bd95fdbd2b6601fae1ab70c3513abf0f985fe` adds focused cases that fail on the predecessor policy: @@ -33,7 +35,13 @@ Commit `d23d4095d1c2ac8542f1ac247b6fee87fcb9485c` adds the syntax-versus-support - `i-klingon` is an irregular grandfathered language tag and must fail with `ui_locale_unsupported`; - `sgn-BE-FR` is an irregular grandfathered language tag and must fail with `ui_locale_unsupported`. -The regressions are intentionally scoped to explicit locale values. They do not tighten RFC 4647 language-range parsing in `Accept-Language`, where the grammar is different. +Commit `2ff28c208b8fd5a83bee3e179f4d66b31831be43` adds the explicit-boundary whitespace regression: + +- leading SP before `en-US` must fail with `ui_locale_input_invalid`; +- trailing SP after `en-US` must fail with `ui_locale_input_invalid`; +- leading and trailing SP together must fail with `ui_locale_input_invalid`. + +The regressions are intentionally scoped to explicit locale values. They do not tighten RFC 4647 language-range parsing in `Accept-Language`, where the grammar and HTTP OWS boundary are different. ## Decision @@ -45,6 +53,8 @@ Causal fix `dc077bec9ae47e1421fa678ae43451f65784c410` separates three syntax bra The irregular grandfathered set is fixed by RFC 5646 and therefore does not create a moving registry dependency. Regular grandfathered forms that already satisfy `langtag` continue through the structural branch. Registry validity beyond this fixed syntax exception remains outside this pure policy. +Causal fix `c94c9345008cf6e339a9f90f59afccc18adea39f` removes SP trimming from the explicit locale path. The raw persisted/session value now reaches the RFC 5646 structural checks unchanged, so surrounding SP is rejected as malformed. The `Accept-Language` parser keeps its SP/HTAB handling because that is an HTTP field-value concern rather than a language-tag normalization rule. + ## Rejected alternatives ### Keep the broad subtag regex because only the primary language is used @@ -55,6 +65,10 @@ Rejected. Persisted/session preference is itself product data. A malformed suffi Rejected. RFC 4647 basic language ranges deliberately have a broader structural grammar than RFC 5646 language tags. Sharing the same regex would preserve the original boundary confusion. +### Trim explicit locale strings before validation + +Rejected. Trimming converts malformed persisted/session identity data into a different valid language tag and hides the rejecting boundary. HTTP OWS handling belongs only to the `Accept-Language` parser; RFC 5646 language-tag syntax itself does not permit whitespace. + ### Validate every subtag against the live IANA Language Subtag Registry Not selected for this slice. RFC 5646 defines well-formedness separately from registry validity. A registry-validity guarantee would require a pinned registry version, update/release process, reproducibility evidence, and failure policy; none belongs implicitly inside this bounded pure-domain repair. @@ -65,7 +79,7 @@ Rejected. Support is a product-release question after syntax classification. Col ## Downstream contract -Persistence and future authoring/API boundaries must keep explicit locale values on this RFC 5646 well-formedness path and must not substitute the broader `Accept-Language` range grammar. A well-formed tag that lacks a supported release primary fails as unsupported; malformed syntax fails as input-invalid. They may add a pinned registry-validity requirement later only as an explicit versioned contract. +Persistence and future authoring/API boundaries must keep explicit locale values on this RFC 5646 well-formedness path and must not substitute the broader `Accept-Language` range grammar. They also must not trim or otherwise rewrite explicit locale identity before validation. A well-formed tag that lacks a supported release primary fails as unsupported; malformed syntax fails as input-invalid. They may add a pinned registry-validity requirement later only as an explicit versioned contract. ## Traceability @@ -75,6 +89,8 @@ Persistence and future authoring/API boundaries must keep explicit locale values - Eighteenth causal source fix: `aceef1b282d948411fe9b04b8cd08750fcdacfe8`. - Nineteenth RED: `d23d4095d1c2ac8542f1ac247b6fee87fcb9485c`. - Nineteenth causal source fix: `dc077bec9ae47e1421fa678ae43451f65784c410`. +- Twenty-fourth RED: `2ff28c208b8fd5a83bee3e179f4d66b31831be43`. +- Twenty-fourth causal source fix: `c94c9345008cf6e339a9f90f59afccc18adea39f`. - Canonical product Gap: #1731. - Product/technical Gap ledger owner: #1602. From 8666ef7bf2451f4e4ca8e93c69d24d5a9fb6436c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 01:44:59 +0900 Subject: [PATCH 82/87] fix(i18n): exempt fixed grandfathered tags from structural guards --- backend/services/ui_localization_policy.py | 30 ++++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/backend/services/ui_localization_policy.py b/backend/services/ui_localization_policy.py index b127fb561..a0821ae6f 100644 --- a/backend/services/ui_localization_policy.py +++ b/backend/services/ui_localization_policy.py @@ -65,7 +65,7 @@ r"^[xX](?:-[A-Za-z0-9]{1,8})+$", flags=re.ASCII, ) -_IRREGULAR_GRANDFATHERED_TAGS = frozenset( +_GRANDFATHERED_TAGS = frozenset( tag.lower() for tag in ( "en-GB-oed", @@ -85,8 +85,15 @@ "sgn-BE-FR", "sgn-BE-NL", "sgn-CH-DE", + "art-lojban", + "cel-gaulish", + "no-bok", + "no-nyn", + "zh-guoyu", + "zh-hakka", "zh-min", "zh-min-nan", + "zh-xiang", ) ) _SCREEN_KEY_PATTERN = re.compile( @@ -226,18 +233,31 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: ) candidate = locale_tag candidate_lower = candidate.lower() + is_grandfathered = candidate_lower in _GRANDFATHERED_TAGS is_structural_langtag = _LANGTAG_PATTERN.fullmatch(candidate) is not None - if is_structural_langtag and _has_multiple_extlang_subtags(candidate): + if ( + is_structural_langtag + and not is_grandfathered + and _has_multiple_extlang_subtags(candidate) + ): raise UiLocalizationValidationError( "ui_locale_input_invalid", "locale input contains more than one RFC 5646 extended language subtag", ) - if is_structural_langtag and _has_repeated_extension_singleton(candidate): + if ( + is_structural_langtag + and not is_grandfathered + and _has_repeated_extension_singleton(candidate) + ): raise UiLocalizationValidationError( "ui_locale_input_invalid", "locale input repeats an RFC 5646 extension singleton", ) - if is_structural_langtag and _has_repeated_variant_subtag(candidate): + if ( + is_structural_langtag + and not is_grandfathered + and _has_repeated_variant_subtag(candidate) + ): raise UiLocalizationValidationError( "ui_locale_input_invalid", "locale input repeats an RFC 5646 variant subtag", @@ -245,7 +265,7 @@ def normalize_supported_locale(locale_tag: str) -> SupportedLocaleCode: if not candidate or not ( is_structural_langtag or _PRIVATEUSE_TAG_PATTERN.fullmatch(candidate) - or candidate_lower in _IRREGULAR_GRANDFATHERED_TAGS + or is_grandfathered ): raise UiLocalizationValidationError( "ui_locale_input_invalid", From ad091f7bbf95f781a509e39c0f4fcf73696480f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 01:45:35 +0900 Subject: [PATCH 83/87] docs(i18n): record grandfathered-tag precedence repair --- ...ion-bcp47-registry-independent-validity.md | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md b/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md index 503b71c24..edba6ba2b 100644 --- a/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md +++ b/docs/doctoring/ui-localization-bcp47-registry-independent-validity.md @@ -14,6 +14,8 @@ The structural `langtag` branch now enforces three registry-independent validity - a variant subtag may not repeat case-insensitively before extensions/private use; - despite the compatibility ABNF reserving up to three extlang positions, only the first extlang position may be occupied. +Those structural guards apply only to ordinary `langtag` values. RFC 5646's fixed `grandfathered` alternatives are complete registered tags whose apparent subtags cannot be reinterpreted as extlang/variant structure. The fixed regular and irregular grandfathered lists are therefore recognized before registry-independent structural guards are applied. + These checks intentionally remain narrower than full BCP 47 validity. Registry membership, variant `Prefix` relationships, deprecation, preferred values, canonicalization, and other registry-dated semantics still require a versioned IANA Language Subtag Registry authority and are not inferred here. ## Findings @@ -36,21 +38,31 @@ RFC 5646 §2.2.2 explains that the `extlang` ABNF retains up to three positions The previous regex therefore accepted `zh-cmn-yue` and normalized it to `zh`. RED `c49c80904ae1c38ee529a4b65e2fdb6612863c47` requires that form to fail with `ui_locale_input_invalid` while preserving the single-extlang form `zh-cmn-Hans-CN -> zh`. Causal fix `983194dd129cc593a4caa3c8531fb669986e4262` rejects structural tags that occupy more than one three-letter extlang position immediately after a two- or three-letter primary language subtag. +### Grandfathered tags are not ordinary structural subtags + +Fresh code-current review of predecessor `97279b9dfd8d47d3033007d36398ccf7aa9e6943` found a contradiction between the fixed grandfathered-tag regression and production control flow. `backend/tests/test_ui_localization_bcp47_well_formedness.py` already requires `zh-min-nan -> zh`, but `_LANGTAG_PATTERN` also matches that spelling as `zh` plus two apparent three-letter extlang positions. The generic multi-extlang guard therefore rejected the RFC-defined grandfathered tag before the grandfathered alternative could be considered. + +RFC 5646 §2.1 lists `zh-min-nan` in the fixed `regular` grandfathered production and explicitly states that regular grandfathered tags can appear to match `langtag` even though their apparent subtags do not carry the ordinary extlang/variant semantics. This is a fixed standards exception, not a moving IANA-registry lookup. + +The realistic RED already existed on the predecessor exact head: the focused test expected `normalize_supported_locale("zh-min-nan") == "zh"`, while the source raised `ui_locale_input_invalid`. Causal fix `8666ef7bf2451f4e4ca8e93c69d24d5a9fb6436c` replaces the partial/misnamed grandfathered set with RFC 5646's complete fixed regular+irregular list and exempts exact grandfathered values from the ordinary structural duplicate/extlang guards. Unsupported grandfathered tags still fail at the product-support boundary as `ui_locale_unsupported`; no preferred-value rewriting or registry-date semantics are introduced. + ## Product significance -Naruon ultimately collapses a supported explicit tag to one release language, but persisted/session locale input is still an authority boundary. Accepting an RFC-invalid tag here allows downstream systems to disagree about whether the original identity was admissible, which weakens persistence, cache identity, auditability, and future API interoperability. The policy therefore fails closed on these stable registry-independent invariants before applying the eight-locale support check. +Naruon ultimately collapses a supported explicit tag to one release language, but persisted/session locale input is still an authority boundary. Accepting an RFC-invalid tag here allows downstream systems to disagree about whether the original identity was admissible, while rejecting an RFC-defined grandfathered tag creates the opposite interoperability failure. The policy therefore recognizes the fixed RFC grandfathered alternatives first, applies stable registry-independent structural invariants only to ordinary `langtag` values, and then applies the eight-locale product-support check. ## Rejected alternatives - **Treat ABNF match as sufficient validity.** Rejected because RFC 5646 explicitly adds validity rules outside the ABNF. +- **Treat regular grandfathered tags as ordinary extlang/variant sequences.** Rejected because RFC 5646 says their meaning is defined by the complete grandfathered registration even when they appear to match `langtag`. - **Treat every repeated spelling after `x` as a duplicate.** Rejected because all subtags after the private-use singleton are private-use data. -- **Use live IANA lookup for this repair.** Rejected because that would introduce a moving external authority without a registry version/update/reproducibility contract. +- **Use live IANA lookup for this repair.** Rejected because the grandfathered alternatives are a fixed RFC list and a live registry would introduce a moving external authority without a version/update/reproducibility contract. +- **Canonicalize grandfathered tags to Preferred-Value.** Rejected because that is a registry/canonicalization policy outside this pure admission boundary and would rewrite caller identity. - **Canonicalize or silently repair invalid caller input.** Rejected because explicit locale preferences are an authority boundary and fail closed. - **Ban extlang entirely.** Rejected because RFC 5646 still permits one extlang position and Naruon's release-level normalization can preserve that structural compatibility without claiming registry canonicalization. ## Acceptance and follow-up -The focused regressions and causal source repairs are necessary but not sufficient for merge. The exact current head still requires repository full-suite/coverage/security execution and qualifying independent post-last-push review. Persistence and HTTP owners must preserve the typed invalid-versus-unsupported boundary without truncation or silent canonical repair. +The focused regressions and causal source repairs are necessary but not sufficient for merge. The exact current head still requires repository full-suite/coverage/security execution and qualifying independent post-last-push review. Persistence and HTTP owners must preserve the typed invalid-versus-unsupported boundary without truncation, silent canonical repair, or reinterpretation of fixed grandfathered tags as ordinary subtag structure. Full registry validity remains deliberately out of scope until an owner defines a dated IANA Language Subtag Registry version, update cadence, provenance, rollback behavior, and reproducible tests. Variant-prefix recommendations and canonical preferred-value replacement must not be guessed into this pure-policy slice. @@ -63,6 +75,8 @@ Full registry validity remains deliberately out of scope until an owner defines - Repeated-singleton RED/fix: `da7e2653a158a259f4d327f1cf017674c7721064` -> `81e061c000a13261ced87b7d6f71fbce64637821` - Duplicate-variant RED/fix: `c34b45957799575ea7e11c84992fdf84461b783f` -> `96adc96dd5fab6e31734c2c23257b0f0f40447ee` - Multi-extlang RED/fix: `c49c80904ae1c38ee529a4b65e2fdb6612863c47` -> `983194dd129cc593a4caa3c8531fb669986e4262` +- Grandfathered-structure RED: predecessor `97279b9dfd8d47d3033007d36398ccf7aa9e6943`, `test_explicit_locale_accepts_supported_primary_grandfathered_language_tags` +- Grandfathered-structure fix: `8666ef7bf2451f4e4ca8e93c69d24d5a9fb6436c` ## References From b4b8774b189abfd847fd1b6f60e8ae478d02240f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 01:59:40 +0900 Subject: [PATCH 84/87] test(i18n): correct explicit whitespace boundary wording --- backend/tests/test_ui_localization_http_whitespace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_ui_localization_http_whitespace.py b/backend/tests/test_ui_localization_http_whitespace.py index 70cdc878d..8f9574459 100644 --- a/backend/tests/test_ui_localization_http_whitespace.py +++ b/backend/tests/test_ui_localization_http_whitespace.py @@ -25,8 +25,8 @@ def assert_error(code, callable_): "\u00a0en-US", ), ) -def test_explicit_locale_rejects_non_space_boundary_whitespace(locale_tag): - """Only ASCII SP is benign outer whitespace for stored/session locale values.""" +def test_explicit_locale_rejects_control_and_unicode_boundary_whitespace(locale_tag): + """HTAB, VT, FF, and NBSP cannot surround an explicit locale identity.""" assert_error("ui_locale_input_invalid", lambda: normalize_supported_locale(locale_tag)) From e76f7b074e4d32ae4ebaea53456760125f7cec04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 02:00:18 +0900 Subject: [PATCH 85/87] docs(i18n): correct explicit locale whitespace contract --- docs/doctoring/ui-localization-catalog-domain-policy.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/ui-localization-catalog-domain-policy.md b/docs/doctoring/ui-localization-catalog-domain-policy.md index 8a5208b84..a4b77d592 100644 --- a/docs/doctoring/ui-localization-catalog-domain-policy.md +++ b/docs/doctoring/ui-localization-catalog-domain-policy.md @@ -26,7 +26,7 @@ Locale authority is persisted preference → session preference → weighted `Ac Stable machine-readable validation codes belong to the boundary that rejects the input. -Explicit locale input rejects every C0 control and DEL before normalization. Only ASCII SP is benign outer whitespace. `Accept-Language` applies HTTP field semantics instead: C0/DEL controls are rejected except HTAB where RFC 9110 permits it as OWS, and only SP/HTAB are normalized as optional whitespace. Python `str.strip()` and regex `\s` are intentionally not protocol authority. +Explicit persisted/session locale input accepts no surrounding whitespace. C0 controls and DEL are rejected before structural parsing; ASCII SP and non-control Unicode whitespace such as NBSP are rejected because the anchored RFC 5646 language-tag alternatives do not admit them. The explicit-locale path does not trim or rewrite caller identity before validation. `Accept-Language` applies HTTP field semantics instead: C0/DEL controls are rejected except HTAB where RFC 9110 permits it as OWS, and only SP/HTAB are normalized as optional whitespace. Python generic `str.strip()` and regex `\s` are intentionally not protocol authority. The quality parameter name is case-insensitive, so `q=` and `Q=` carry identical parameter-name semantics while qvalue syntax remains strict. @@ -79,7 +79,7 @@ LLM translation is outside this slice. Future assisted translation must use a re The focused policy suite covers supported-language identity, precedence, regional/script normalization, weighted `Accept-Language`, duplicate ranges, wildcard/q=0 handling, malformed/control input, screen/message identity, literal braces, placeholder schema mismatch/formatter features, runtime schema-container rejection, unhashable schema elements, translation-text persistence, product-default runtime typing, and explicit parser resource limits. -The branch now contains **fourteen ordinary-forward RED→repair sequences**. The first three established wildcard/q=0 basics, boundary-specific control codes, and control rejection before whitespace normalization. Subsequent repairs are: +This document records the first **fourteen** ordinary-forward RED→repair sequences that established the core catalog policy. Later RFC 5646/RFC 4647 validity, resource-bound, explicit-locale no-rewrite, wildcard-provenance, and fixed-grandfathered repairs are recorded in their focused doctoring files and current #1740 authority rather than being backfilled into this historical sequence count. The first three established wildcard/q=0 basics, boundary-specific control codes, and control rejection before whitespace normalization. Subsequent repairs recorded here are: 4. RED `8265e9bb08cc407c860f148f1823dd0f4ab6d13d` → fix `9a6c8bb04be237c968b58987ca6ac4efb789a482`: replace generic Python whitespace semantics with boundary-specific SP/HTAB/C0 rules. 5. RED `962e250c54d0da324ec29b21283ab0b02cd55e8c` → fix `b20b5593498c09855e6ef6fb213d9bf6ea764e15`: case-insensitive `q`/`Q` parameter name. From 80b889fc62f8e6b7df55467df8e25147dcb23682 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 02:00:52 +0900 Subject: [PATCH 86/87] docs(i18n): document complete grandfathered tag precedence --- ...on-explicit-locale-bcp47-well-formedness.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md index c545d0771..8a6772cf8 100644 --- a/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md +++ b/docs/doctoring/ui-localization-explicit-locale-bcp47-well-formedness.md @@ -8,9 +8,9 @@ Status: Proposed with PR #1740. This record covers the pure explicit-locale poli Accepting those values as persisted/session locale identities made the explicit preference boundary weaker than its own documentation and blurred two different standards surfaces: RFC 5646 language tags for explicit stored/session identities versus RFC 4647 basic language ranges used inside `Accept-Language`. -A second standards sweep found the first structural repair incomplete. RFC 5646 defines `Language-Tag = langtag / privateuse / grandfathered`; some grandfathered values are intentionally outside the `langtag` production. For the current release-primary set, `en-GB-oed`, `zh-min`, and `zh-min-nan` therefore require explicit structural acceptance before release-level normalization. `zh-guoyu`, `zh-hakka`, and `zh-xiang` are grandfathered registry entries too, but their literal forms already satisfy the structural `langtag` pattern and do not need a separate exception. +A second standards sweep found the first structural repair incomplete. RFC 5646 defines `Language-Tag = langtag / privateuse / grandfathered`; some grandfathered values are intentionally outside the `langtag` production, while regular grandfathered values may also look structurally like `langtag`. For the current release-primary set, `en-GB-oed`, `zh-min`, and `zh-min-nan` require recognition as complete fixed grandfathered tags before ordinary structural guards reinterpret apparent subtags. Other fixed grandfathered values remain syntactically well formed even when Naruon does not support their primary language. -A third sweep found a typed-boundary defect left by treating only supported-primary tags as syntactically relevant. RFC 5646 permits a language tag to consist entirely of private-use subtags, and irregular grandfathered tags remain well-formed language tags. Values such as `x-private`, `i-klingon`, and `sgn-BE-FR` are therefore syntactically well-formed but do not map to one of Naruon's eight release primaries. They belong to `ui_locale_unsupported`, not `ui_locale_input_invalid`. +A third sweep found a typed-boundary defect left by treating only supported-primary tags as syntactically relevant. RFC 5646 permits a language tag to consist entirely of private-use subtags, and grandfathered tags remain well-formed language tags. Values such as `x-private`, `i-klingon`, and `sgn-BE-FR` are therefore syntactically well-formed but do not map to one of Naruon's eight release primaries. They belong to `ui_locale_unsupported`, not `ui_locale_input_invalid`. A later boundary sweep found a separate whitespace-normalization defect. The explicit persisted/session path called `locale_tag.strip(" ")` before RFC 5646 validation, so `" en-US"`, `"en-US "`, and `" en-US "` were silently accepted as `en-US`. RFC 5646 states that whitespace is not permitted in a language tag. That differs from the HTTP `Accept-Language` surface, where HTTP list syntax deliberately permits optional whitespace around field-value list elements. Explicit stored/session locale identity therefore must not borrow HTTP OWS normalization. @@ -47,11 +47,13 @@ The regressions are intentionally scoped to explicit locale values. They do not Causal fix `3a87209306ad1b3dee67cb9e0e9e513dfb7a4f3a` replaces the permissive explicit-locale pattern with an RFC 5646 structural `langtag` pattern covering language/extlang, optional script and region, variants, extensions, and optional private-use suffix. -Follow-up causal fix `aceef1b282d948411fe9b04b8cd08750fcdacfe8` restores the supported-primary grandfathered forms `zh-min` and `zh-min-nan` alongside `en-GB-oed` without introducing a moving IANA-registry dependency. +Follow-up causal fix `aceef1b282d948411fe9b04b8cd08750fcdacfe8` restored the supported-primary grandfathered forms `zh-min` and `zh-min-nan` alongside `en-GB-oed` without introducing a moving IANA-registry dependency. -Causal fix `dc077bec9ae47e1421fa678ae43451f65784c410` separates three syntax branches before release support is evaluated: structural `langtag`, private-use-only tags, and RFC 5646's fixed irregular-grandfathered set. Only after a candidate is established as well-formed does the policy inspect the first subtag for membership in `ko/en/ja/zh/vi/es/de/fr`. This preserves the stable semantic distinction between malformed input and well-formed-but-unsupported input. +Causal fix `dc077bec9ae47e1421fa678ae43451f65784c410` established separate admission branches for structural `langtag`, private-use-only tags, and the then-explicit fixed grandfathered exceptions before release support is evaluated. Only after a candidate is established as well-formed does the policy inspect the first subtag for membership in `ko/en/ja/zh/vi/es/de/fr`. This preserves the stable semantic distinction between malformed input and well-formed-but-unsupported input. -The irregular grandfathered set is fixed by RFC 5646 and therefore does not create a moving registry dependency. Regular grandfathered forms that already satisfy `langtag` continue through the structural branch. Registry validity beyond this fixed syntax exception remains outside this pure policy. +Current source repair `8666ef7bf2451f4e4ca8e93c69d24d5a9fb6436c` completes that boundary: both RFC 5646 regular and irregular grandfathered tags are recognized from the RFC's fixed list before ordinary `langtag` structural guards run. This matters for `zh-min-nan`, whose spelling also matches the structural pattern and would otherwise be misread as occupying two extlang positions. Fixed-grandfathered recognition establishes syntax only; unsupported primary languages still fail afterward as `ui_locale_unsupported`. Ordinary non-grandfathered `langtag` values continue through the multi-extlang, repeated-singleton, and duplicate-variant validity guards. + +The complete regular+irregular grandfathered list is fixed by RFC 5646, so this admission exception does not create a moving registry dependency. Registry validity, Prefix relationships, deprecation, Preferred-Value replacement, and canonicalization beyond that fixed RFC list remain outside this pure policy until a dated/versioned IANA-registry contract exists. Causal fix `c94c9345008cf6e339a9f90f59afccc18adea39f` removes SP trimming from the explicit locale path. The raw persisted/session value now reaches the RFC 5646 structural checks unchanged, so surrounding SP is rejected as malformed. The `Accept-Language` parser keeps its SP/HTAB handling because that is an HTTP field-value concern rather than a language-tag normalization rule. @@ -73,13 +75,13 @@ Rejected. Trimming converts malformed persisted/session identity data into a dif Not selected for this slice. RFC 5646 defines well-formedness separately from registry validity. A registry-validity guarantee would require a pinned registry version, update/release process, reproducibility evidence, and failure policy; none belongs implicitly inside this bounded pure-domain repair. -### Treat private-use-only or irregular grandfathered values as malformed because Naruon cannot render them +### Treat private-use-only or grandfathered values as malformed because Naruon cannot render them Rejected. Support is a product-release question after syntax classification. Collapsing well-formed unsupported values into `ui_locale_input_invalid` would make error semantics depend on the current release set and contradict the RFC 5646 syntax contract. ## Downstream contract -Persistence and future authoring/API boundaries must keep explicit locale values on this RFC 5646 well-formedness path and must not substitute the broader `Accept-Language` range grammar. They also must not trim or otherwise rewrite explicit locale identity before validation. A well-formed tag that lacks a supported release primary fails as unsupported; malformed syntax fails as input-invalid. They may add a pinned registry-validity requirement later only as an explicit versioned contract. +Persistence and future authoring/API boundaries must keep explicit locale values on this RFC 5646 well-formedness path and must not substitute the broader `Accept-Language` range grammar. They also must not trim or otherwise rewrite explicit locale identity before validation. A well-formed tag that lacks a supported release primary fails as unsupported; malformed syntax fails as input-invalid. Fixed regular/irregular grandfathered values must be recognized before ordinary structural guards rather than reinterpreted from apparent subtags. A pinned registry-validity requirement may be added later only as an explicit versioned contract. ## Traceability @@ -91,6 +93,8 @@ Persistence and future authoring/API boundaries must keep explicit locale values - Nineteenth causal source fix: `dc077bec9ae47e1421fa678ae43451f65784c410`. - Twenty-fourth RED: `2ff28c208b8fd5a83bee3e179f4d66b31831be43`. - Twenty-fourth causal source fix: `c94c9345008cf6e339a9f90f59afccc18adea39f`. +- Twenty-fifth pre-existing RED: predecessor exact `97279b9dfd8d47d3033007d36398ccf7aa9e6943`, focused `zh-min-nan` regression. +- Twenty-fifth causal source fix: `8666ef7bf2451f4e4ca8e93c69d24d5a9fb6436c`. - Canonical product Gap: #1731. - Product/technical Gap ledger owner: #1602. From a2ed58691b8cb98abae9ee9acb51a2e4b4877cf9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 05:45:57 +0900 Subject: [PATCH 87/87] test(localization): keep resource-bound fixture well formed --- backend/tests/test_ui_localization_http_whitespace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_ui_localization_http_whitespace.py b/backend/tests/test_ui_localization_http_whitespace.py index 8f9574459..396f3ac28 100644 --- a/backend/tests/test_ui_localization_http_whitespace.py +++ b/backend/tests/test_ui_localization_http_whitespace.py @@ -87,7 +87,7 @@ def test_accept_language_bounds_empty_list_elements_against_dos(): def test_locale_and_accept_language_have_explicit_resource_bounds(): """Valid basic ranges cannot create unbounded parser work at product boundaries.""" - locale_at_limit = "en-" + "-".join(["abcdefgh"] * 14) + locale_at_limit = "en-" + "-".join(f"var{index:05d}" for index in range(14)) assert len(locale_at_limit) == 128 assert normalize_supported_locale(locale_at_limit) == "en"