i18n: give bulk-action defs, custom validation messages and dataset labels a bundle key - #14381
Conversation
A `bulkActionDefs` entry is part of the VIEW document, not an action document, so it never reaches `translateAction` and no bundle group addressed it. Measured against a fully translated app the selection bar read `已选择 1 项 · Complete · Skip · 清除` — two English words between two Chinese ones, which reads as a styling quirk rather than a missing translation. Not a drifted key: no key. Adds `objects.<object>._views.<view>.bulkActions.<def_name>` carrying `label` / `confirmText` / `confirmLabel` and per-param `label` / `help` / `placeholder`, resolved in `translateView` against `config.bulkActionDefs` — the one address a served def has (`ViewItemSchema` and `expandViewContainer` both nest the whole ListView under `config`). The def's `label` stays `z.string()` on the authoring side: the bar renders it as a React child, so an inline locale map would be a blank cell rather than a parse error (`ui/bulk-action.zod.ts` module header). Overlaying at the metadata boundary keeps the wire value a plain string and changes only its language. Key face measured against `BulkActionDefSchema`, not mirrored from the report. Two exclusions carry `guidance`: `successMessage` (a def declares none) and per-param `options` (`options[].value` is unconstrained, so a value-keyed map cannot address `true` and `"true"` apart — the same measured reason `FLOW_SCREEN_FIELD_NO_OPTIONS` gives). `help`, not `helpText`: the face follows the authored key and aliases the neighbouring action-param spelling onto it. Part of #14253 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
… key `object.validations[].message` is the sentence a rejected write returns, and the evaluator emitted it verbatim. A deployment with a complete `zh-CN` bundle therefore read platform-generated refusals in Chinese and author-written refusals in English *inside one 400 VALIDATION_FAILED envelope* — the built-in field catalog has resolved through the engine's i18n service since #3957, and only the authored half had nowhere to look. Adds `objects.<object>._validations.<rule_name>.message`, spelled by `objectValidationMessageKey` (the third member of the `objectFieldLabelKey` / `objectLabelKey` family) and read on the write path by a new `authoredRuleMessage` seat in the rule evaluator.⚠️ No second i18n path into objectql. The lookup runs on the SAME `ValidationMessageContext.translate` hook — the engine's `i18nService`, bridged by `ObjectQLPlugin` — that `resolveFieldLabel` and `renderValidationMessage` already use. What was missing was a key shape, not a channel. All five authored-message emitters route through the one seat (`script`/`cross_field`, `state_machine`, `format`, and both `json_schema` arms); a nested `conditional` branch is addressed by the BRANCH's own name, and a PLATFORM-generated rejection (an unevaluable predicate) is deliberately left alone. This is not `validationMessages` (#4667, ADR-0049) coming back. That group was keyed by rule name at the bundle's TOP level — it could not tell two objects' rules apart — and, the reason it was retired, nothing read it. This address is object-scoped, sits beside `_views`/`_actions`/`_tabs`, and ships its reader in the same change. Its retired-key guidance is updated to point here instead of asserting that no route exists, and the `errors` tombstone with it. Key face is one key, measured: a rule also declares `label` (the admin listing entry) and `description` (administrative notes), neither of which reaches a rejected caller — declaring them would parse clean and translate nothing, so both carry `guidance`. Also corrects the `validation.message` liveness row, whose overrides clause named `validationMessages` — a route removed a major version ago. Part of #14253 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
…s group
A dataset reads like a back-office definition, but a measure label is
drawn ON THE DASHBOARD — under every metric tile and on every chart axis.
`dataset` was neither in `TRANSLATABLE_METADATA_TYPES` nor addressed by
any bundle group, so a translated dashboard rendered Chinese tile titles
(those are `dashboards.<name>.widgets.<id>.*`) with `Untouched > 14 days`
directly beneath them. Not a drifted key: no key.
Adds `datasets.<name>.{label,description,dimensions.<d>.label,
measures.<m>.label}` and `translateDataset`, registered in
`METADATA_DOCUMENT_TRANSLATORS`. That registration is the whole wiring:
`TRANSLATABLE_METADATA_TYPES` is DERIVED from the table and
`@objectstack/rest` reads the derived set, so the REST boundary follows
with nothing else to remember (#3786). No second hand-maintained list was
found — the derivation is intact.
Top-level rather than nested under `dashboards` because a dataset is the
ONE definition every presentation binds to by reference (ADR-0021 D1):
the same measure is drawn by N widgets across M dashboards, and a dataset
no dashboard references would otherwise be unaddressable.
The four keys are `I18nLabelSchema` at the authoring site, so a dataset's
copy may already be an inline `{ en, 'zh-CN' }` map (#5728).
`translateDataset` writes ONLY where the bundle answers — the same rule
`translatePage` follows — so an uncovered inline map is left intact
rather than flattened to one language, and the member arrays keep their
identity when nothing matched.
Key face measured against `DatasetSchema`: a dimension and a measure each
declare `label` and nothing else display-shaped, which `dataset.zod.ts`
states at the authoring site too ("its author-facing text is `label`.
`description` is declared on the DATASET itself"). `description` therefore
lives on the dataset and carries `guidance` below it.
Liveness: the `datasets` group is seeded LIVE and DRILLED (label /
description / dimensions / measures) with its reader in the same change.
The ledger's own header sentence is corrected with it — it still called
`validationMessages` "the one dead group" a major version after #4667
removed it.
Part of #14253
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`check:doc-authoring` caught two `#NNNN` citations in the tombstone text this branch rewrote. That text is printed AT the author, verbatim, the moment their bundle is refused — by `os validate`, by a publish gate, by a parse — and none of those readers has a tracker, so the token resolves to nothing in the one place the sentence most needs to be actionable. Maintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成 经验,保留 issue id没有意义」. The customer-resolvable references stay: the protocol version, ADR-0049, the migration command and the replacement key path. Adds the negative pin the gate's own remedy asks for, beside the twin that pins the wording: the tombstone must name the live replacement group AND must not carry an issue id. Part of #14253 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Surface 3's second door, found by measuring where a measure label actually reaches a dashboard rather than assuming it rides the metadata read. `AnalyticsResult.fields[].label` is documented as the display label "for legends/KPIs", and `AnalyticsService` fills it by copying `dataset.measures[].label` off the definition. That definition arrives through `POST /analytics/dataset/query`, which resolves a saved dataset via `getMetaItems` and never passes through `translateMetaItem`. So `translateDataset` alone closes `/meta/datasets` — the door a dashboard does NOT draw through — and leaves the one in the issue's screenshot open: covered at one door, open at the other. Fixed at the metadata boundary, where every other document is localized: the resolved saved definition goes through the existing `translateMetaItem` before it is parsed and compiled, and the existing field enrichment carries the translated label to the wire untouched. Nothing downstream learns about bundles — the analytics service stays free of i18n and there is no second resolution path. ⛔ The INLINE branch is deliberately not translated: a Studio preview posts the draft the designer is editing, which carries no saved name to address a bundle entry with, and overwriting its copy would misreport what is about to be saved. Pinned by test, both ways. Part of #14253 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
📓 Docs Drift CheckThis PR changes 4 package(s): 36 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f4fc60591e522fdb0b8c368bb7eb2d4610723f84 && git checkout f4fc60591e522fdb0b8c368bb7eb2d4610723f84
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5c9e40ad91028b57b0748e3ea0347189bac72ce9 7b1c387bb3769c44f03b6c8b79757d62d98960f2 && git checkout -B drift-repro 5c9e40ad91028b57b0748e3ea0347189bac72ce9 && git merge --no-ff 7b1c387bb3769c44f03b6c8b79757d62d98960f2
node scripts/docs-audit/affected-docs.mjs --json 5c9e40ad91028b57b0748e3ea0347189bac72ce9
|
PM review — the open question is settled as A, and one claim in this PR is deliberately not being madeThe ADR question: land as-is, with the paper trail as a follow-upSurface 2 gives author-written validation-rule messages a translation route that 17.0.0 closed, and Prime Directive #13 says reversing a recorded decision is itself a decision. The judgement: This is ADR-0049 being enforced, not reversed. The policy is enforce-or-remove; Raising it instead of quietly landing it was the right call — a decision recorded in a tombstone rather than an ADR is exactly the shape #13 warns is easy to miss, and the tombstone text does become false with this change. So: A now, B as a follow-up card, not "later if someone remembers". The whole reason this issue existed is that a governance record living only in prose drifts out of true. I would not accept the same fate for the record of this change. Please file the status-line/ADR card before this merges, blocked-by this PR, so the trail is queued rather than intended. C is correctly rejected, and for the reason the card gives: it leaves a deployment reading platform refusals in Chinese and author-written refusals in English inside one The correction to my own scoping is the most valuable thing hereI scoped surface 3 as "add What is NOT proven yet, and I want that on the record before reviewThe external-acceptance section is scrupulous about this and I am restating it so no reviewer reads past it: the red test in The visual proof needs both halves. I have filed objectstack-ai/duly#106 for the application side (retire the three exemptions, author the keys, re-take the two screenshots in this issue), blocked on this PR shipping in a release. Until that lands, this PR's claim is "the route exists and is read", which is exactly what its tests show. Marking ready for review. Not merging: Generated by Claude Code |
…anslation-references #14253 added `objects.<obj>._validations.<rule>.message` to `ObjectTranslationDataSchema`; the lint rule's per-group coverage pin (`classifies every key the schema declares, and no key it does not`) correctly went red because the rule had no leg for it — the exact "second hand-maintained list drifted" class the PR exists to close. The walker now registers `objects[].validations[].name` per object and reports `translation-target-unknown` for a `_validations` key naming a rule the object does not declare, with the same guidance shape as `_tabs`. The coverage pin and the all-real-names control both carry the new group; the fixture declares one rule so the control stays non-vacuous. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
CI red root-caused and fixed —
|
Fixes #14253
Three authored display surfaces carried user-facing text that no key in
TranslationDataSchemacould reach — not a drifted key, no key. Each rendered in the source locale inside an otherwise fully translated screen, which is the bad failure mode: it reads as a styling quirk rather than as a missing translation.Purely additive. Three new translation groups, one new dispatch-table entry, one new lookup on the write path, one localization step at the analytics door. No existing key changes shape, no resolution order changes, and every surface still falls back to the authored literal when the bundle carries nothing.
Five commits, one per surface plus two the work turned up.
1 ·
bulkActionDefson a list viewobjects.OBJECT._views.VIEW.bulkActions.NAME.{label,confirmText,confirmLabel,params.PARAM.{label,help,placeholder}}, resolved intranslateViewagainstconfig.bulkActionDefs.A bulk-action def is part of the view document, not an action document, so it never reached
translateAction. The selection bar read已选择 1 项 · Complete · Skip · 清除.configis the one address a served def has — bothViewItemSchemaandexpandViewContainernest the whole ListView underconfig— so the fixture is derived from the composer rather than transcribed, the #4854 lesson one level in.The def's
labeldeliberately stays a plainz.string()on the authoring side:ui/bulk-action.zod.ts's module header measured that the bar renders it as a React child, so an inline locale map would be a blank cell rather than a parse error. Overlaying at the metadata boundary keeps the wire value a plain string and changes only its language. The documented alternative (bulkActions: ['NAME'], promoting a declared action) is not equivalent: it is N elevated per-record dispatches instead of one data-planeupdateMany.2 · A custom validation rule's
messageobjects.OBJECT._validations.RULE.message, spelled by the newobjectValidationMessageKeyand read on the write path by a newauthoredRuleMessageseat in the rule evaluator.ValidationMessageContext.translatehook — the engine'si18nService, bridged byObjectQLPlugin— thatresolveFieldLabelandrenderValidationMessagehave used since #3957. Before it, a deployment read platform-generated refusals in the caller's language and author-written refusals in the source language inside one400 VALIDATION_FAILEDenvelope.All five authored-message emitters route through the one seat (
script/cross_field,state_machine,format, and bothjson_schemaarms). A nestedconditionalbranch is addressed by the branch's own name; the wrapping conditional'smessagenever reaches a caller. A PLATFORM-generated rejection (an unevaluable predicate) is deliberately left alone — the caller needs to be told the rule is broken, not told the rule's verdict.This is not
validationMessagescoming back. That group (retired 17.0.0, #4667, ADR-0049) was keyed by rule name at the bundle's top level, so it could not tell two objects' rules apart, and — the reason it was retired — nothing read it. Its ADR-0087 conversion still strips it from stored bundles, untouched. The replacement is object-scoped, sits beside_views/_actions/_tabs, and ships its reader in the same change. Its tombstone and theerrorstombstone now point at it instead of asserting no route exists.messages['validation.field.*']is unchanged and still overrides the built-in field catalog only.3 · Dataset labels — and the door the dashboard actually draws through
datasets.NAME.{label,description,dimensions.DIM.label,measures.MEASURE.label}plustranslateDatasetinMETADATA_DOCUMENT_TRANSLATORS. Registering the translator is the whole wiring:TRANSLATABLE_METADATA_TYPESis derived from that table and@objectstack/restreads the derived set (#3786). No second hand-maintained list exists — the derivation is intact; the one historical hand-copy inrest-server.tswas already retired.Top-level rather than nested under
dashboardsbecause a dataset is the ONE definition every presentation binds to by reference (ADR-0021 D1): the same measure is drawn by N widgets across M dashboards, and a dataset no dashboard references would otherwise be unaddressable.The fifth commit exists because
translateDatasetalone would not have fixed the screenshot.AnalyticsResult.fields[].labelis documented as the display label "for legends/KPIs", andAnalyticsServicefills it by copyingdataset.measures[].labeloff the definition. That definition arrives throughPOST /analytics/dataset/query, which resolves a saved dataset viagetMetaItemsand never passes throughtranslateMetaItem. So the metadata read was the door a dashboard does not draw through — covered at one door, open at the other. The saved definition now goes through the existingtranslateMetaItembefore it is parsed and compiled, and the existing enrichment carries the translated label to the wire untouched: nothing downstream learns about bundles, the analytics service stays free of i18n, and there is no second resolution path. ⛔ The inline branch is deliberately not translated — a Studio preview posts the draft the designer is editing, which carries no saved name to address a bundle entry with. Pinned both ways.Key faces are measured, not mirrored
Nothing here parses clean and translates nothing. Every exclusion carries
guidancenaming the right home:optionsBulkActionParamSchema.options[].valueisstring | number | boolean, so a value-keyed map cannot addresstrueand"true"apart — the same reasonFLOW_SCREEN_FIELD_NO_OPTIONSgivessuccessMessagelabel/descriptiondescriptiondataset.zod.tsalready tells authors "its author-facing text islabel.descriptionis declared on the DATASET itself"And a bulk param's hint is
help, not the action-paramhelpText— the face follows the authored key and aliases the neighbouring spelling onto it, so one string keeps one address.Verification
Union re-run after the final commit, at
e865543fe(git rev-parse --short HEAD), which includes the merge oforigin/main@72adb7fcb.Red-first, per surface — each proved by committing the fix, restoring the source from
origin/main/HEAD~1(tree only), confirming the reversion on disk with an anchoredgrep -c, and re-running:i18n-resolver.ts+translation.zod.tsrule-validator.tsonlyi18n-resolver.ts+translation.zod.tsrest-server.tsonlyEach reversion was confirmed by grep before the run (
authoredRuleMessage: 0,message: rule.message: 5,translateDataset: 0,translateMetaItem(req, 'dataset': 0) and each restoration confirmed the same way afterwards, withgit status --porcelainempty.Suites —
pnpm --filter PKG test/typecheck, all at the merged head:@objectstack/spec12 103 passed / 450 files ·@objectstack/objectql4 398 passed / 254 files ·@objectstack/rest2 805 passed / 167 files. Typechecks green for all three.Gates —
node scripts/pm/dispatch-gates.mjs --commandsderives 78 families for this diff (74 by path + 7 by kind, 3 shared). All 78 run ate865543fe: 76 green, 2 NOT MEASURED —check-test-completenessandcheck:pm-half-states, both exit 3 ("nothing was measured", distinct from a finding's 1): the first needs a savedturbo run testlog, the second needs the GitHub PM board. Neither is a finding and neither is evidence about this tree.pnpm lint(eslint . --no-inline-config, whole repo) green — no narrowing to declare.check:nul-bytesgreen, plus a direct control-byte scan over every file in the diff.Two gates were red on first run and are worth recording rather than hiding:
check:doc-authoringcaught two#NNNNcitations this branch had written into customer-facing tombstone text (printed verbatim at an author whose reader has no tracker — maintainer ruling 2026-08-12), and five spec gates refused on a staledist. The first is commit 4, which also adds the negative pin the gate's own remedy asks for; the second was a rebuild.External acceptance — measured from outside the platform
objectstack-ai/dulyrecords all three surfaces as declared, reasoned exemptions insrc/translations/authored-text.ts, and its i18n gate anchors them on the platform's own answer. Run atdd3c619withnode_modules/@objectstack/specrepointed at this branch's build (an untracked symlink, restored immediately and verified):TRANSLATABLE_METADATA_TYPESnow reads["action","app","dashboard","dataset","object","page","view"].Stated plainly, because it is one of two mechanisms and not both: duly's gate has a platform-derived pin (that set) and an app-side pin (an exact list of exemption paths, plus a staleness check). Only the platform-derived pin goes red here, which is the correct behaviour — the exemption-staleness check cannot fire until duly's own walk starts emitting the new keys, since its authored strings are unchanged. So this is end-to-end proof that the platform's answer moved, not proof that duly's three exemptions have been retired; retiring them is duly's follow-up.
Out of scope, filed
check:i18n-coverageis blind to them #14376 — the i18n extractor does not emit the three new key families (packages/cli/src/utils/i18n-extract.ts) — soos i18n extractwill not scaffold them andcheck:i18n-coverageis blind to them. Deliberately separate: it moves nine generated bundles andscripts/i18n-coverage-baseline.json, and that ratchet movement should be readable on its own. Zero platform bundles change today (no extracted package ships abulkActionDefs, avalidations[].messageor a dataset).objectstack-i18nteaches that validation messages are not a translation group — false once #14253 lands #14377 —skills/objectstack-i18n/SKILL.mdteaches "Validation messages are not a translation group" — now false.skills/**is a governed surface (Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14), so it may not ride a mixed diff.translateMetaItem's comment restates the translatable-type set by hand, so it goes stale every time the dispatch table grows #14378 (finding) —rest-server.ts:2963carries an illustrative parenthetical enumerating the translatable set by hand, which this change makes short by one. Left alone deliberately: a one-word comment edit is not worth the conflict risk in that file.Notes for the reviewer
datasetsis seeded live and drilled (label / description / dimensions / measures) with its reader in the same change, not declared first and wired later. Two stale ledger sentences were corrected with it — thetranslationheader still calledvalidationMessages"the one dead group" a major version after 清空剩余 6 条 authorWarn 死键 —— book ×2 / job.id / translation.validationMessages / app.homePageId / app.areas[].order(ADR-0049,v17 限时) #4667 removed it, andvalidation.message's note still namedvalidationMessagesas the override route._validationsand_views.VIEW.bulkActionssit beneath the ledger's walk boundary, so neither adds a row;check:livenessconfirms.origin/main@72adb7fcb. The only overlapping file ispackages/objectql/src/validation/rule-validator.ts(main'sstripReadonlyFieldsusesObject.isto tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088hookWrittenKeyswork, in a different region); both sides verified present after the merge, and nomerge=os-regenpath was touched by both sides, so the driver's silent-drop hazard did not arise.check:generatedreports all 15 artifacts current post-merge.🤖 Generated with Claude Code
https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Generated by Claude Code