Skip to content

i18n: two unbridged bundle shapes — file-authored objects.<name> vs the runtime translation metadata type's o.<object>, so translations cannot be authored in the product #3778

Description

@os-zhuang

Surfaced while closing #3370 and its follow-ups. Filing rather than widening those PRs — this is a contract split, not a fill.

The split

Two translation shapes exist, and nothing bridges them.

File-authored bundles use objects.<name>.… — object / field / option / view / _actions keys, exactly what os i18n extract scaffolds and what packages/spec/src/system/i18n-resolver.ts reads at request time. Every shipped bundle is this shape: platform-objects/src/apps/translations/*, the plugin/service bundles, and any customer bundle listed in translations: [...]. The objectui client hooks (useObjectLabel) read the same shape.

The translation metadata type — the one an admin can author at runtime through the metadata door (allowRuntimeCreate: true) — is registered against AppTranslationBundleSchema (packages/spec/src/kernel/metadata-type-schemas.ts), the object-first o.<object> shape from packages/spec/src/system/translation.zod.ts. No resolver reads that shape, so translations authored in the product never render.

Root-cause finding (2026-07-28 investigation)

The split is narrower than "unbridged" suggests — the plumbing already works; only the registered authoring contract points at the wrong shape:

  • The Authored (Studio) translations never reach the i18n runtime — only static bundles are loaded #2591 authored-translation sync (packages/core/src/fallbacks/authored-translation-sync.ts) is shape-agnostic: it resolves the locale (_meta.locale → top-level locale → BCP-47-looking item name), strips bookkeeping keys, and deep-merges the payload verbatim into the i18n authored layer, which wins over static bundles on read.
  • Save-time validation (metadata-protocol/src/protocol.ts) safeParses but stores the original payload verbatim, and AppTranslationBundleSchema has no required fields — so a file-shape (objects.) payload already passes the door today and renders end-to-end. Nobody knows, because the schema, the Studio editor form derived from it, and the protocol doc all present the o. shape.
  • Inventory of the o. shape's real-world footprint: zero artifacts. All three *.translation.ts files in the tree (platform-objects setup, CRM and todo examples) are already objects.-shape TranslationBundles — contradicting the type's own registered schema. CLI extract and coverage key on objects.. The o. shape lives only in the type registration, two spec schemas, content/docs/protocol/kernel/i18n-standard.mdx (which labels it "Recommended"), the TranslationDiffItem doc examples, and a legacy flat-key fallback in service-i18n (o.<obj>.fields. dotted keys — a third dialect).

Decision: converge on objects. (TranslationData) as the single canonical shape

No bridge/converter will be built. A bridge (normalize o.objects. at ingestion) was considered and rejected: it would be throwaway code, and worse, it would create working o.-shape rows in production — closing the migration-free window that exists today precisely because the feature never functioned. Convergence now is a registration fix, not a migration.

objects.<name> is itself an object-first aggregation — all translatable content for one object under one key — so the workbench/coverage benefits the protocol doc attributes to o. are fully retained; the difference was only spelling and a handful of divergent leaf names (confirmMessage vs confirmText, missing successMessage, dual _options track, app-scoped vs flat nav) which all disappear with the split.

Design decisions (platform coherence + AI-authoring safety)

These were weighed explicitly against two criteria: long-term contract soundness, and minimizing the error surface for AI agents authoring metadata apps (AI copies whatever exemplars it can grep; silent no-ops are traps).

  1. Delete AppTranslationBundleSchema / ObjectTranslationNodeSchema outright — no deprecation cycle. The shapes never worked end-to-end, so there is no functioning consumer to protect. Deprecated-but-present schemas and doc examples are exactly what AI agents pattern-match into new code; every o. exemplar (schemas, doc sections, diff examples) is purged in the same PR. Breaking change, flagged via changeset. FieldTranslationSchema and ActionResultDialogTranslationSchema are shared by the surviving shape and stay.
  2. New TranslationItemSchema for the metadata type: TranslationData groups + a required top-level locale. Explicit locale beats the name-as-locale convention for machine authors — the sync's current "no resolvable locale → warn and skip" path is a silent failure an AI will not notice. (The sync keeps name-as-locale as a legacy fallback; the schema and docs teach only locale.)
  3. Loud rejection of the legacy shape. The item schema carries a superRefine that rejects the known legacy top-level keys (o, app, nav, dashboard, reports, notifications, errors, _globalOptions, _meta, namespace) with an actionable message pointing at objects.. Plain zod would silently ignore unknown keys and reproduce this bug's exact failure mode — save succeeds, nothing renders. A targeted refinement gives a 422 with a message an admin or an AI can act on, without the collateral risk of blanket .strict() (Studio auxiliary fields like isPinned are stored verbatim by design).
  4. ObjectTranslationDataSchema.label becomes optional. Partial translations are the norm (that's why coverage tooling exists); the resolver already treats every key as optional. A required label forces authors — human or AI — to fabricate a copy of the source label just to pass validation, polluting bundles with fake translations that mask real coverage gaps.
  5. Liveness discipline ([P2] Aspirational-config disposition: reconcile-or-prune the still-dead props from the 2026-06 liveness audit (Theme / Translation / Job / Webhook) #3494 precedent): aspirational groups are not carried over. reports, notifications, errors, _globalOptions, _meta.direction, namespace have no consumer; schema-only fields invite AI to author dead data that users then believe is live. They can return as schema + resolver + test together when actually implemented.
  6. The flat-key fallback in service-i18n (i18n-service-plugin.ts, o.<obj>.fields. dotted keys) is fixed in the same PR to walk the nested objects.<obj>.fields.<f>.label shape — it is the third fragment of the same contract split, and the last greppable o. exemplar.

Plan (single PR)

Phase 1 — spec (breaking)

  • Add TranslationItemSchema (+ type, + defineTranslation factory): required locale (BCP-47), all TranslationData groups, superRefine legacy-key rejection with actionable message.
  • Make ObjectTranslationDataSchema.label optional.
  • Delete AppTranslationBundleSchema, ObjectTranslationNodeSchema, and their types; update TranslationDiffItem/coverage doc examples from o.account.… to objects.account.….
  • Swap the translation registration in metadata-type-schemas.ts to TranslationItemSchema.
  • Regenerate api-surface.json; changeset (major).

Phase 2 — core

  • Authored-translation sync: no functional change needed (already reads top-level locale); add a warn when an active row carries legacy top-level keys (pre-existing dead rows surface loudly instead of silently never rendering).

Phase 3 — service-i18n

  • Rewrite the /i18n/labels/:object/:locale fallback to read nested objects.<obj>.fields.<f>.label.

Phase 4 — docs

  • content/docs/protocol/kernel/i18n-standard.mdx: replace the "Object-First Convention (Recommended)" o. section with the objects. shape as the convention (same object-first benefits, one spelling).
  • content/docs/references/system/translation.mdx: regenerate/update for the new schema set.
  • content/docs/ui/translations.mdx: flip the "Two bundle shapes exist" boundary bullet to "runtime authoring uses the same objects. shape as files".

Phase 5 — tests + verification

  • spec: TranslationItemSchema accepts {locale, objects…}; rejects legacy keys with the actionable message; label-optional round-trip.
  • core/service-i18n: end-to-end — save an active translation row {locale: 'zh-CN', objects: {…}} → sync → translateMetadataDocument returns translated labels via REST, both on live mutation and after cold boot; legacy-row warn fires.
  • Browser dogfood: author a translation in Studio on the showcase app, see the label render without restart.

Acceptance criteria

  • An admin (or AI agent) can author a translation item in the product and see it render — the issue title's failure is gone.
  • grep -r "o\." over spec/src/system, content/docs finds no object-first translation exemplars.
  • CLI extract/coverage and objectui remain untouched and green.

Out of scope (recorded, not fixed here)

  • The authored sync is process-wide across organizations (noted in its header) — a tenant-A-authored translation leaks to tenant B. Must be resolved before a multi-tenant translation-workbench story ships.
  • The translation type's filePatterns (**/*.translation.ts …) are vestigial: the three matching files are multi-locale bundles loaded via code/config, not through the metadata door. Verify and either align or drop the patterns separately.
  • Optional follow-up: remove translation from KNOWN_UNSEEDED and add a create seed ({ locale: 'en', objects: {} }) so the Studio create flow starts from a valid skeleton.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions