Skip to content

Retire the three i18n exemptions once the platform ships their keys — and re-take the two English-island screenshots - #125

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-106-i18n-exemptions
Sep 5, 2026
Merged

Retire the three i18n exemptions once the platform ships their keys — and re-take the two English-island screenshots#125
os-warren merged 3 commits into
mainfrom
claude/issue-106-i18n-exemptions

Conversation

@os-warren

@os-warren os-warren commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #106
Closes #102

@objectstack/* ^17.2.0^17.3.0 across all 14 packages, the three i18n exemptions retired against the keys that release ships, and the one hook the bump refuses.

Gates on b8bbdb0 (the current head — the union below was re-run after merging main, on that tree): validate ✓ Validation passed · typecheck exit 0 · test 885 passed (885) · build ✓ Build complete.

main advanced to 46394fa (#124) while this was in flight, and #124 touched src/translations/authored-text.ts too. Merged in rather than left for the PM: the overlap auto-merged (its change is in REENTER, mine in the verdict table and key builders), and the union above is the re-run on the merge commit — 873 of those tests are this branch's, 12 are #124's.


1. The upgrade, and the hook it refuses

14 packages bumped. Baseline on 17.3.0 before any fix reproduced the dispatch preflight exactly — 870/872, two reds, both this card's:

FAIL test/i18n-coverage.test.ts > pins the metadata types the PLATFORM says are translatable
FAIL test/task-hook.test.ts    > a predicate write clearing done is NOT refused

The second one is a real contract tightening, not a test that drifted:

MultiUpdateHookKeyDivergenceError: Refusing a multi-record update on 'duly_task': its
'beforeUpdate' handlers wrote 'completed_at', 'completed_late' for some of the 2 matched
records and not for others, and a predicate update has one payload for every record — so
one record's value would have been written to all of them. Nothing was written.

The clearing branch was gated on wasDonethis row's pre-image — so reopening a mixed batch wrote the nulls for the rows that had been done and not for the rows that had not. Different key set per row; the platform is right to refuse it.

Fixed at the cause: the branch is gated on the payload's status instead.

-  } else if (wasDone && !isDone) {
+  } else if ('status' in input && !isDone) {

'status' in input is the load-bearing half. isDone falls back to the stored status when the payload carries none, so testing !isDone alone would reopen the same divergence one shape over — an administrative bulk write with no status (a re-owner, a business-unit backfill) would write nulls on the not-done rows and skip the done ones. Gated on the payload, isDone is read off the one shared payload and is identical for every matched row. Null was already the correct value for every row leaving done, including one never completed, so writing it for all of them is a no-op on the rows that were not done.

The completing direction is untouched — that divergence is the one the platform is right to refuse, and the test above it still pins it. Both clearing tests now run and pass (the second was previously skipped only because the first aborted the file): test/task-hook.test.ts 64 passed (64).

2. The three exemptions, retired

84 strings that reached a zh-CN user in English now do not. Each verdict in authored-text.ts records where its key is resolved, because in two of the three cases it is not the document translator a reader would reach for first — all measured against the installed 17.3.0, not read off the schema:

surface strings key resolved by
dataset label / description / dimension / measure labels 36 datasets.<d>.… translateDataset (new in 17.3.0)
a view's bulkActionDefs copy 35 objects.<o>._views.<v>.bulkActions.<def>.… translateView
custom validation rule messages 13 objects.<o>._validations.<r>.message @objectstack/objectql at throw time

Two measurements worth keeping:

  • translateObject does not rewrite validations[].message. Fed a bundle carrying _validations.<rule>.message it returns the authored English untouched, while the object's own label beside it translates. The resolution happens in authoredRuleMessage, in the locale of whoever was stopped — which is the correct design (one stored rule, every locale), and the bridge is live here: booting logs [ObjectQLPlugin] Bridging i18n service to ObjectQL for validation messages.
  • translateView reads bulkActionDefs off view.config, while this walk reads the authored node where it sits at top level. The bundle key depends on neither — only on object, view key, def name, param name — so both halves address the same slot.

The pin was re-derived, not widened. TRANSLATABLE_METADATA_TYPES gained dataset, and that assertion went red on the bump before any of this file was touched — which is what pinning a set the platform owns is for. Widening the list without extending the walk would have turned the one assertion that noticed into the one that hid it. The three counts it holds are pinned at zero rather than deleted, matching the file's own idiom for the gap #69 closed: a count that is merely deleted cannot notice the gap reopening.

Reverse verification — the issue asked for evidence the exemptions were load-bearing rather than decorative. With the exemptions retired and main's zh-CN bundle (read out of git, no tracked file mutated):

GATE "every declared label the walk can address is carried by zh-CN"
  missing zh-CN keys: 84 => gate RED
  by group: datasets=36 bulkActions=35 _validations=13

Terminology follows the header in zh-CN.ts (职责 vs 任务 above all): a bulk action's label sits next to a task list, so 完成/跳过 match the row actions rather than inventing a second vocabulary. The toolbar copy is one shared TASK_BULK_ACTIONS object rather than five hand-copies — the platform keys it per view, so the same action in five views is five key sets, and five copies is four that eventually go stale.

The exemption list is now designer- and operator-facing text only. No end-user screen is on it.

3. Browser proof — real pnpm demo:zh boot on 17.3.0

Clean .objectstack/data, port 4319, browser context locale: 'zh-CN'. Screenshots in docs/i18n/.

what it shows
01-dashboard-zh.png tile sub-labels Chinese — 超过 14 天无动静 · 最久未动 · 按期率 · 逾期未完成 · 审定率. These are the dataset measure labels: exactly the Untouched > 14 days island the issue describes.
02-bulk-bar-zh.png 已选择 1 项 · 完成 · 跳过 · 清除. Duly is the only Latin run left on the page.
03-validation-refusal-zh.png 请写明打回的原因——负责人需要据此知道该改什么。 — the object's own rule, refused in Chinese.

Two things the browser found that are NOT this card, both filed

a) The work-mix pie legend is still EnglishOne-off / Recurring / Standing. Not a missing key: those are field option labels, already keyed and already translated (the duty list beside it renders 周期性). The server bakes the English into the row:

POST /api/v1/analytics/dataset/query        (only accept-language varies)
  zh-CN -> rows ['One-off','Recurring','Standing']   fields ['形式','清单内职责']
  zh    -> rows ['One-off','Recurring','Standing']   fields ['形式','清单内职责']
  en    -> rows ['One-off','Recurring','Standing']   fields ['Form','Duties on the register']

fields[].label follows the locale — that is this PR's dataset translation working end to end. rows[].form is byte-identical in all three. objectui#4324 fixed charts where labels resolve client-side; objectui#4330/#4388 fixed local select dimensions on table/pivot. A local select dimension on a chart is the uncovered cell. Filed as objectstack-ai/objectui#7696.

b) accept-language: zh refuses in English while the same response's dataset labels are Chinese. resolveBundleLocale in the spec already resolves zhzh-CN by variant match, and every document translator gets it via pickData; the validation-message bridge asks the i18n service with the raw tag and matches exactly, so it misses. zh-CN and zh-CN,zh;q=0.9 (what a Chinese Chrome sends) both give Chinese correctly. Filed as objectstack-ai/objectstack#15757. The screenshots use the real-Chrome header form.

Neither is worked around here.

4. #102 folded in — it was cheap and in this exact assertion

The two directional gates named 'zh-CN' as a literal, so a third locale that existed but was partial went unchecked. They are now driven by the locales dulyTranslations actually ships. Proven rather than assumed — a synthetic three-locale barrel with a ja-JP carrying 2 of the 363 keys:

TRANSLATED_LOCALES = [ 'ja-JP', 'zh-CN' ]
  ja-JP: missing 361 keys => gate RED
  zh-CN: missing   0 keys => gate GREEN

Two guards ship with it, because it.each over an empty array passes with zero cases: zh-CN must be in the derived list, and the barrel's zh-CN entry must be the authored dulyChinese bundle — the refactor moved the key source from the file to the barrel, and that check closes the gap the move opened.

Notes for review

🤖 Generated with Claude Code

https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p

os-warren and others added 3 commits September 5, 2026 06:02
… refuses

`@objectstack/*` ^17.2.0 -> ^17.3.0 across all 14 packages. That release
(objectstack#14381) gives all three of this app's declared i18n exemptions a
bundle key, and tightens one multi-row hook contract this app was on the wrong
side of.

i18n — 84 strings that reached a zh-CN user in English now do not:

  dataset label / description / dimension / measure labels (36) -> the new
    `datasets` group, resolved by `translateDataset`.
  a view's `bulkActionDefs` copy (35) -> `_views.<v>.bulkActions`, resolved by
    `translateView`.
  a custom validation rule's `message` (13) -> `_validations.<r>.message`,
    resolved by `@objectstack/objectql` at THROW time, in the locale of
    whoever was stopped.

Each verdict in `authored-text.ts` records where its key is resolved, because
in two of the three cases it is NOT the document translator a reader would
reach for first: `translateObject` does not rewrite `validations[].message`,
and `translateView` reads `bulkActionDefs` off `view.config`. Measured on the
installed 17.3.0, not read off the schema.

The platform pin in `test/i18n-coverage.test.ts` was RE-DERIVED, not widened —
it went red on the bump, before anything else was touched, which is what it is
for. The three counts it holds are pinned at zero rather than deleted, so the
gap cannot silently reopen.

task.hook.ts — the clearing branch was gated on `wasDone`, this row's
pre-image, so a predicate write reopening a mixed batch wrote the nulls for
some matched rows and not others. 17.3.0 refuses that outright with
`MultiUpdateHookKeyDivergenceError`. Gated on the PAYLOAD's status instead,
the key set is row-invariant and the write goes through; null was already the
correct value for every row leaving `done`, including one never completed.
The completing direction is untouched — that divergence is the one the
platform is right to refuse.

Gates on 17.3.0: validate 0 · typecheck 0 · test 872/872 · build 0.

Closes #106

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
… proof

The two directional coverage gates named `'zh-CN'` as a literal, so a third
locale that EXISTED but was partial went unchecked — the locale-set assertion
catches a declared-and-missing `ja-JP`, but not a `ja-JP.ts` carrying ten of
the keys. They are now driven by the locales `dulyTranslations` actually
ships. Measured on a synthetic three-locale barrel: a `ja-JP` carrying 2 of
the 363 keys reports 361 missing and goes red, where before it was not looked
at.

Two guards come with the generalisation, because `it.each` over an empty array
passes with zero cases:

  - `zh-CN` must be in the derived locale list, so the per-locale gates cannot
    quietly become no gates at all.
  - the barrel's `zh-CN` entry must BE the authored `dulyChinese` bundle — the
    refactor moved the key source from the file to the barrel, and this closes
    the gap that move opened.

docs/i18n/ holds the three screenshots from a real `pnpm demo:zh` boot on
17.3.0, taken with the accept-language a Chinese Chrome sends:

  01  dashboard — tile sub-labels are Chinese (超过 14 天无动静 / 最久未动 /
      按期率 / 逾期未完成 / 审定率), the dataset measure labels that were the
      English island. The pie legend is still English: filed as objectui#7696.
  02  bulk bar — 已选择 1 项 · 完成 · 跳过 · 清除. "Duly" is the only Latin
      run left on the page.
  03  a validation refusal in Chinese, from the object's own rule.

Closes #102

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 5, 2026 06:29
@os-warren
os-warren merged commit 74342a1 into main Sep 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant