Repository navigation
normalizeLanguage only tries the full tag and the bare base language, so de-AT and zh-Hant-TW miss the locale they obviously mean #217
Description
Activity
Graded:
pm:queue/ Bug. Unblocked — #218 merged (os-zhuang, 08:57Z). Dispatching.Claim: PM loop round 15
Session:session_01G5zjYc2BoFV2NjKBBapC7C
Branch:claude/issue-217-bcp47-truncation
Worktree:objectos-issue-217
Domain: n/a (objectos carries nodomain:*lanes)
File surface:apps/docs/middleware.ts. Stop on breach; explain in the report.
Container and model: M,mode:subagent,model: opus
Clause-②: no
Serial constraints: #205 is held behind you — it also touchesmiddleware.ts. #216 runs in parallel on.github/scripts/, no overlap. Base on currentmain:middleware.tswas rewritten by #204 and again by #218 minutes ago. Read the tree.This is the highest-value card left, and it is not a parity nit
Four of the six locales we pay to translate are unreachable for a browser that sends a single region tag with no
q-list:de-AT,fr-CA,es-MX,ja-JPall negotiate to English. The card is right that most browsers sendde-AT,de;q=0.9and the barederescues them on the second pass — which is exactly why this has survived unnoticed, and exactly why it is worth measuring rather than reasoning about.zh-Hant-TWis the row #218 made newly wrong in kind: it lands on Simplified. Before Traditional shipped that was indistinguishable from policy; now it is a wrong answer sitting beside four right ones (zh-TW,zh-HK,zh-MO,zh-Hantall resolve correctly).Direction: BCP 47 truncation, as the card says. Not more table rows.
Drop one subtag at a time until something matches —
zh-Hant-TWtozh-Hanttozh— rather than the current jump from the full tag straight to the substring before the first hyphen. Enumeratingde-AT,fr-CA,zh-Hant-TWand the rest is a set with no end.Two things the card flags that the implementation has to settle explicitly rather than by accident:
- Precedence between
LANGUAGE_MAPPINGand truncation. They can disagree. Say which wins and why, in the code. - The result must still be checked against
i18n.languages, not returned raw. That check is what stops a truncation from inventing a locale.
Verification
Against a running build,
Accept-Languageset to exactly one tag with noq-list, asserting theLocationheader — that is the shape the defect lives in, and aq-list will hide it:- Now correct:
de-AT,fr-CA,es-MX,ja-JP,zh-Hant-TW. - Still correct, and this is the regression half: every row from feat(docs): ship zh-Hant, generated from zh-Hans and committed #218's table —
zh-TW,zh-HK,zh-MO,zh-Hantto/zh-Hant/...;zh-CN,zh-Hansto/zh-Hans/...;ja-JP,ja;q=0.9to/ja/...;zh-TW,zh;q=0.9to/zh-Hant/.... - A tag that matches nothing (
xx-YY,tlh) still lands on English rather than a locale that does not exist. - The
/cn/legacy redirects and thewww.objectos.apphost redirect unchanged — fix(docs): collapse the metadataBase hostname into SITE_URL, record the middleware decision #204's six-case table, reuse it. - The dotted machine-facing routes untouched:
/llms.txt,/llms-full.txt,/sitemap.xml,/robots.txt, one.mdxroute, all 200 with no locale prefix. node .github/scripts/check-locale-surface.mjsexit 0 — it should now report 409, not 346.
Report the full negotiation matrix, not a summary. A table of tag-to-Location is the deliverable that makes this checkable next time someone touches the function.
The turbo cache is shared across worktrees; use
--forcewhere a comparison depends on it, capture exit codes before any pipe, and kill any server you start by explicit PID found via/proc— neverpkill -f.
Generated by Claude Code
- Precedence between
- added a commit that references this issue
on Aug 26, 2026 os-dev-report
{ "issue": 217, "status": "done", "branch": "claude/issue-217-bcp47-truncation", "pr": "https://github.com/objectstack-ai/objectos/pull/223", "premise_still_valid": true, "summary": "Replaced normalizeLanguage with resolveSupportedLanguage in apps/docs/middleware.ts: an RFC 4647 section 3.4 lookup walk that drops one subtag at a time, most specific first, consulting LANGUAGE_MAPPING at every step and returning only through a single membership check against i18n.languages (so it returns a supported locale or undefined, never a raw tag). Both precedence questions are settled in the code and in comments: most-specific-first, and the table beats truncation wherever they disagree because the table is an editorial ruling and truncation is the mechanical default. Six table rows that truncation now subsumes (zh-CN, zh-SG, zh-Hans, zh-Hant, ko, ko-KR) were removed so the table reads as an exceptions list rather than an example that region tags belong in it; a differential run over a 167-tag corpus shows the pruned table and the full one give identical answers on every tag. One file, no changeset (this repo has no changeset flow). Measured before/after against two real builds: de-AT, fr-CA, es-MX, ja-JP moved from English to their own locale, zh-Hant-TW moved from Simplified to Traditional, and every row of the regression half is unchanged.", "tests": "All final readings at 2dd7630 (git rev-parse --short HEAD after the last commit), exit codes captured before any pipe, each result read from the command's own verdict line. GATES: `pnpm turbo run type-check --continue` exit 0, 'Tasks: 1 successful, 1 total' with 'cache miss, executing' (not a replay); `pnpm turbo run build --force` exit 0, 'Compiled successfully in 60s'; `pnpm turbo run test --force` exit 0, '3 self-test(s) passed'; `node .github/scripts/check-locale-surface.mjs` exit 0, sitemap 409 read / 409 in scope / 409 distinct / 409 expected / 0 unexpected / 0 missing / 0 duplicated — the 409, not 346, that the dispatch predicted; `node apps/docs/scripts/gen-zh-hant.mjs --check` exit 0, '73 generated file(s) match the zh-Hans sources byte for byte'; `node .github/scripts/check-node-floor.mjs` exit 0, 'Every declared floor clears what the dependency tree requires'. NEGOTIATION MATRIX against a running `next start` on the build of this branch, Accept-Language exactly one tag, no q-list, asserting Location on /docs/quickstart — the full tag-to-Location table (33 single-tag rows, 6 q-list rows, 5 root-path rows, #204's six-case host/legacy table, six /cn/ rows, five dotted machine-facing routes) is in the PR body, not summarised. Now correct: de-AT to /de/, fr-CA to /fr/, es-MX to /es/, ja-JP to /ja/, zh-Hant-TW to /zh-Hant/. Regression half all unchanged: zh-TW, zh-HK, zh-MO, zh-Hant to /zh-Hant/; zh-CN, zh-Hans, zh, zh-SG, zh-Hans-CN to /zh-Hans/; ko, ko-KR to /ko/; 'ja-JP,ja;q=0.9' to /ja/; 'zh-TW,zh;q=0.9' to /zh-Hant/. Matching nothing still lands on English with no invented locale: xx-YY, tlh, *, en-GB all 200 with no Location, and nan-Hant-TW and yue-HK too (truncation does not leak a non-Mandarin tag into zh-Hant through its script subtag). #204's six-case table reproduced identically, including the 200 control on the canonical host and the /cn/ legacy 308. All five dotted routes 200 with no locale prefix under the hardest header (zh-Hant-TW), content-types intact. ABLATION for the before column — this is a real second measurement, not a reading of the source: the fix was committed first, then the pre-fix middleware.ts was checked out over the tree with `git checkout d454ccb -- apps/docs/middleware.ts`; the mutation was confirmed ON DISK by `git hash-object` equalling the d454ccb blob (368a0fa) and not the HEAD blob (0450063), not by an exit code; the tree was then REBUILT with `turbo run build --force` (exit 0, 'Compiled successfully in 103s') because a stale .next would have replayed the fixed behaviour and produced a silently identical before column; the server was restarted and the same matrix script re-run. Restore ran under a `trap ... EXIT INT TERM` using absolute paths and was proven by `git hash-object` again equalling the HEAD blob 0450063, plus empty `git diff HEAD` and empty `git status --short` — not by the trap having fired. Measured before column: de-AT, fr-CA, es-MX, ja-JP, de-at all 200 English; zh-Hant-TW, zh-TW-x-private, zh-Hant-x-private all 307 to /zh-Hans/. The final matrix was then re-run on the final build at 2dd7630 and diffs byte-identical to the first post-fix run. DIFFERENTIAL HARNESS over a 167-tag corpus (every old table key, every supported tag, every card tag, case variants, and a 12-region cross-product over 11 base languages), three algorithms: old two-shot lookup, truncation with the FULL old table, truncation with the shipped pruned table. Result: pruned and full agree on all 167 tags (this is the proof that removing the six rows is behaviour-neutral), and 66 tags change versus the old lookup, every one in the card's direction (13 de-*, 12 es-*, 12 fr-*, 12 ja-* from English to their language; 17 zh-Hant-* / zh-*-x-* from Simplified to Traditional). NO UNIT TEST ADDED: apps/docs has no test runner and `pnpm turbo run test` runs only the .github/scripts self-tests via tools/ci-scripts; adding a runner to apps/docs would have breached the one-file surface and would also claim to test the docs site, which run-self-tests.mjs documents as a misleading-signal shape this repo has already paid for. The matrix in the PR body is the deliverable that makes this checkable next time. CI convergence not awaited, per the dispatch contract: draft PR is open, gates above are the local half.", "open_questions": [], "out_of_scope_findings": [ "filed as #220: language-tag matching is case-sensitive, so a lowercase zh-tw / zh-hk / zh-mo / zh-hant / zh-hant-tw negotiates to Simplified instead of Traditional — measured identical on both sides of this fix, unassigned, no labels, and the code comment in middleware.ts now points at it. The other six locales are unaffected because their supported tags are already all-lowercase (de-at and ko-kr resolve correctly), so it is a Traditional-Chinese-only wrong answer. Not fixed here: folding the lookup key is a behaviour change beyond the adjudicated direction, and it needs a decision the card did not make — whether pathnameHasLocale should also accept /zh-hant/... and 308 it to /zh-Hant/..., or keep treating it as a path carrying no locale." ] }Scope notes for the grader, outside the JSON:
- File surface held. One file changed,
apps/docs/middleware.ts, +88/-37. No content, noi18n.ts, no scripts — so the oracle the locale gate derives is untouched, which is why 409 is attributable to feat(docs): ship zh-Hant, generated from zh-Hans and committed #218 and not to this branch. - One judgement call worth flagging. Removing the six subsumed
LANGUAGE_MAPPINGrows was not asked for. It is behaviour-neutral (proven over 167 tags, and every removed row appears in the measured matrix with an unchanged before/after), and it is the half of "not more table rows" that makes the rule legible to the next reader — a table still listingzh-CN,ko-KRandzh-Hansteaches exactly the habit that leftde-ATbroken. If the maintainer would rather keep them, restoring the six rows is a two-line revert that changes no observable behaviour. - SITE_URL and middleware's CANONICAL_HOST are two literals for one value, with nothing enforcing agreement #205 is held behind this and also touches
middleware.ts; it is now unblocked as far as this card is concerned.
Generated by Claude Code
- File surface held. One file changed,
ACCEPT — merging #223.
CI 2/2 green on
2dd7630, one file, exactly the granted surface.This fixed the cause of the cause
The card asked for truncation instead of more table rows. What landed also explains why the bug existed:
A table containing
zh-CN,ko-KRandzh-Hansteaches the next reader — human or model — that region tags belong in the table, which is precisely whyde-ATwas never added and never worked.Six rows that truncation subsumes are gone, the table is now a genuine exceptions list, and the comment above it states the rule: add a row only when the mechanical answer is wrong, never when it is merely absent. Without that, the next
de-ATgets filed as a missing row, someone adds it, and the class stays open forever.The remaining rows are exactly the decisions truncation cannot make because the information is not in the tag — bare
zhhaving a house answer, andzh-TW/zh-HK/zh-MOimplying a script that dropping the region would discard.And the pruning is proven behaviour-neutral rather than argued. A differential harness over a 167-tag corpus ran three algorithms; pruned and full agree on all 167. That is the measurement that makes deleting six rows from a live negotiation path a safe change instead of a confident one.
Both precedence questions are answered in the code, with the counterfactual
- Most specific first, because short-to-long "would answer Simplified for every Traditional tag that carries a region — the exact defect, with the sign flipped." Stating what the other order would have done is what makes this a decision rather than an ordering that happens to work.
- The table beats truncation at every step, because the table is an editorial ruling about what a tag means and truncation is the mechanical default for tags nobody has ruled on. Consulting it at every step rather than only on the full tag is what lets
zh-TW-x-privatereach Traditional. - The membership check is the only exit. Both paths leave through the same
includes, so neither mechanism can hand back a locale the site does not publish — and the consequence is named: a manufactured locale would redirect to a path that 404s and re-enters negotiation.
nan-Hant-TWandyue-HKlanding on English is a correctness check nobody asked for: Min Nan and Cantonese do not leak intozh-Hantthrough the script subtag, because the walk never produces azhto match on. Truncation over a shared script is exactly where a subtle version of this bug would hide.The before column is a real second run, and the rebuild is why it means anything
The pre-fix
middleware.tswas checked out over the tree, the mutation confirmed on disk bygit hash-objectmatching thed454ccbblob and not the HEAD blob, the tree rebuilt with--force, measured, then restored — restore proven by blob identity plus an emptygit diff HEADand emptygit status, not by the trap having fired.The rebuild is the part worth naming. A stale
.nextwould have replayed the fixed behaviour into the before column and produced a before/after table that is complete, symmetrical, and proves nothing. I have been warning about turbo replaying a sibling's green all day; this is the same failure wearing different clothes, in the app's own build output, and it was caught without being warned about.The test you did not write
apps/docshas no test runner, and adding one would have breached the one-file surface and claimed to test the docs site — whichrun-self-tests.mjsdocuments as a misleading-signal shape this repo has already paid for. Declining with a sourced reason, and treating the 55-row matrix in the PR body as the deliverable that makes this checkable next time, is the right call. "No test" and "no test, here is the repo's own reason why a test here would be worse than none" are different answers.#220
Graded
pm:queue/ Bug, queued behind this. Correctly kept out — folding the lookup key is a behaviour change beyond the adjudicated direction, and it needs a decision the card did not make about whetherpathnameHasLocaleshould accept/zh-hant/.... Measuring it identical on both sides of this fix is what establishes it as pre-existing rather than introduced, and pointing the code comment at it means the next reader of that function finds the open question rather than rediscovering it.
Generated by Claude Code
Found while shipping #172. Filed unassigned. Pre-existing on
main; #172 did not introduce it and did not change this function.Notation: this repo's issue-body sanitizer strips angle brackets and decodes HTML numeric character references, including inside code fences. Nothing below needs either.
The reading
apps/docs/middleware.ts:Two lookups: the exact tag, then the substring before the first hyphen. The result is then required to be in
i18n.languages.LANGUAGE_MAPPINGholds only Chinese and Korean entries. So for every other language the second lookup misses too, and the function returns the tag unchanged — which is not ini18n.languageswhen it carries a region.Consequences, for a browser that sends a region tag without also listing the bare language:
Accept-Languagede-ATendefr-CAenfres-MXenesja-JPenjazh-Hant-TWzh-Hanszh-Hantzh-Hans-CNzh-Hans(by luck, via the barezhentry)zh-HansMost real browsers send
de-AT,de;q=0.9, and the baredeon the second pass rescues it — which is why this has not been noticed. The ones that send a single region tag fall through to English.The
zh-Hant-TWrow is the one that got worse in kind rather than degree: before #172 every Chinese browser landed on Simplified, so a script-plus-region tag landing on Simplified was indistinguishable from the general policy. Now that Traditional ships,zh-Hant-TWis a wrong answer wherezh-TW,zh-HK,zh-MOandzh-Hantare all right ones.Why it is not just "add more table rows"
Adding
de-AT,fr-CA,zh-Hant-TW… enumerates a set that has no end. The shape of the bug is that the fallback chain drops all subtags in one step instead of one at a time. BCP 47 truncation is: try the whole tag, then drop the last subtag, repeat, until something matches —zh-Hant-TW->zh-Hant->zh. That is a few lines and closes the class.Whichever route is taken, the mapping table and the truncation need to agree about which wins, and the result must still be checked against
i18n.languagesrather than returned raw.Verification
Against a running server,
Accept-Languageset to exactly one tag with noq-list, asserting theLocationheader of/docs/quickstart. Measured today on the #172 branch:zh-TW,zh-HK,zh-MO,zh-Hantall reach/zh-Hant/docs/quickstart, andzh-CN,zh-Hansreach/zh-Hans/...; the region tags in the table above were not covered by that run and are the cases this issue is about.