Skip to content

feat(spec): resolveI18nLabel — the shared I18nLabel to string resolver, pinned to objectui's pickLocalized - #6901

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-6765-i18n-label-resolver
Aug 9, 2026
Merged

feat(spec): resolveI18nLabel — the shared I18nLabel to string resolver, pinned to objectui's pickLocalized#6901
os-zhuang merged 2 commits into
mainfrom
claude/issue-6765-i18n-label-resolver

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #6765

Contract half of the #6761 ruling: one shared I18nLabel to string resolver in packages/spec, pinned limb for limb to objectui's pickLocalized by an executed parity table. The consumption half (AnalyticsService.queryDataset's two enrichment sites and dataset-compiler.ts:374/406) is #6761 and is not touched here — no packages/services file is in this diff.

The ruling this implements (maintainer, 2026-08-08, on #6761), quoted verbatim and untranslated:

内联 locale map 由服务端解析成字符串下发,解析器放共享位而非某个服务内部

Premise, verified before implementing

The card's premise is that the backend has no I18nLabel to string resolver today. Confirmed on origin/main by declaration-form grep over packages/spec — the only resolver-shaped declaration anywhere in packages/ is service-analytics/src/dataset-executor.ts:952's this.orderLabels.resolveLabels(...), which resolves dimension row values against a bundle, a different mechanism. I18nLabelSchema exists in packages/spec/src/ui/i18n.zod.ts:164 as #5728 left it. Premise holds.

The rule extracted from pickLocalized

Read at objectui origin/main 50fa3766ebb2ebf2ec78c5d13b1d627e6a91696f, file packages/i18n/src/pickLocalized.ts, blob 9e5d92ae2efe9be62d4d010cb0a26e598211f3ec, last touched by objectui#3278. Line numbers are that file's.

Fallback chain — a six-limb ?? chain at :26-32, in this order:

# limb source
0 plain string returns itself, '' included :17
1 exact tag o[lang] :27
2 base language o[base] (zh-CN reads the key zh) :28
3 first region-qualified sibling sharing the base (zh reads the key zh-CN), in Object.keys insertion order, string values only :25, :29
4 o.default :30
5 o.en :31
6 first string value in Object.values order :32

Locale normalization: (language || 'en').trim() at :21 — a nullish or empty locale becomes en; surrounding whitespace is trimmed. base is lang.split('-')[0] at :22.

Region / case handling: comparisons are case-sensitive throughout; neither the locale nor the keys are folded. The observable effect is asymmetric between the two halves of a tag, and both halves matter to a caller:

  • the region subtag's case does not matter, because limb 3 compares only the language subtag — a map keyed zh-CN answers a zh-cn request through limb 3;
  • the language subtag's case does — ZH-CN has base ZH, which equals no key of that map on limbs 1, 2 or 3, so the request falls through to default / en / any-string and can land in the wrong language entirely.

BCP-47 says subtags are case-insensitive, so a stricter reading would fold case. It is not folded here: parity is the ruled acceptance, and a resolver that folded case would answer differently from the renderer for exactly the inputs where it "improved". Both halves are pinned as vectors; changing it is a two-repo decision.

Both-miss return: '', via pick == null ? '' : String(pick) at :33. Never undefined, never a throw.

Two things in the reference are surprising enough to state rather than inherit silently, both handled below under "deliberate departures": the typeof === 'string' filter is applied on limbs 3 and 6 only (:25, :32) and not on 1/2/4/5; and o[lang] / o[base] are bare bracket accesses, so they walk the prototype chain.

API shape — the trade-offs the card delegates

Candidate in the card: resolveI18nLabel(label: I18nLabel | undefined, locale: string, fallback?: string): string | undefined. What shipped:

export function resolveI18nLabel(
  label: I18nLabel | undefined,
  locale: string | undefined,
): string | undefined

Name. resolveI18nLabel, as proposed — it joins the resolveViewLabel / resolveActionLabel family already in system/i18n-resolver.ts. Deliberately not pickLocalized: an identically-named function whose miss value differs would be a trap, and the difference is exactly what a reader would assume away.

Where it lives. packages/spec/src/ui/i18n-label-resolver.ts, exported from @objectstack/spec/ui. The card allows either side; ui/ won on three measured points: the input type I18nLabel is declared one file away in ui/i18n.zod.ts, so no cross-domain import; the #6761 consumer already imports its label-carrying types (Dataset, DatasetMeasure, DatasetDimension) from @objectstack/spec/ui (service-analytics/src/dataset-compiler.ts:5), so consumption adds no new subpath; and system/i18n-resolver.ts resolves the other form (a plain-string label plus a translation bundle), so they are siblings, not the same file. Both files gained a cross-reference to the other, and I18nLabelSchema's own doc now names the resolver that gives its second form meaning.

undefined vs '' on a miss. This is the one place the function does not spell the outcome the reference's way, and it is deliberate. pickLocalized returns '' because its caller writes into a text node. This resolver's callers are producers filling a label?: string field, and downstream enrichment in that direction is guarded by if (field.label == null) — so a producer that wrote '' would not be saying "no label", it would be permanently displacing a real label a later stage still had. That is #5199 route A, judged harmful rather than redundant, and restated as a hard boundary in #6761. So undefined here means precisely what '' means there, in the spelling each side's callers need, and the bridge is one ??, pinned as an identity in the test:

resolveI18nLabel(label, locale) ?? ''   ===   pickLocalized(label, locale)

No fallback parameter. resolveI18nLabel(l, loc, f) and resolveI18nLabel(l, loc) ?? f are the same expression, so the parameter is pure redundancy — and the ?? spelling keeps the decision about the miss case visible in the file where writing the wrong thing does the damage. It also removes the one call the parameter invites, resolveI18nLabel(x, loc, ''), which silently reintroduces the placeholder harm above. Judged on the three axes: no measured business need the ?? does not already serve; one shape rather than two; and the shape that is harder to get wrong.

locale is positional, not optional. It accepts undefined (and resolves as en, matching the reference), but it cannot be omitted — a producer shipping one audience's language to every audience by forgetting an argument is the defect class #6761 records. A @ts-expect-error pins that the omitting call does not compile.

Input is the declared I18nLabel, not unknown. pickLocalized accepts unknown and stringifies numbers and booleans. Under PD#12 that tolerance belongs at an untrusted boundary, not on an internal contract, so an off-spec scalar takes the miss path instead of being coerced into a visible label.

Two deliberate departures, both narrower than the rule, both pinned with BOTH answers

  1. Own properties only. The reference reads o[lang] / o[base] bare, so a locale that names an Object.prototype member resolves to that member: pickLocalized({ en: 'Pricing' }, 'constructor') returns 'function Object() { [native code] }'. In a browser the locale comes from the app's own language state; on a server it can arrive in an Accept-Language header. No BCP-47 tag is an Object.prototype key, so no in-contract input can tell the two implementations apart. Filed against the reference as objectui#3907.
  2. The string filter applies on every limb. The reference filters on limbs 3 and 6 only, so a non-string value on limbs 1/2/4/5 short-circuits and renders as [object Object]. InlineLocaleMapSchema is a record of locale tags to z.string(), so no in-contract map can hold a non-string and the inconsistency is unobservable inside the declared domain; out of contract, PD#12 says refuse rather than coerce.

Both are asserted in the test with the reference's differing answer alongside, so the divergence stays measured rather than becoming decoration.

Parity pin

packages/spec/src/ui/i18n-label-resolver.test.ts carries a verbatim copy of pickLocalized as pickLocalizedReference, pinned to the revision above. Copied rather than imported because @objectstack/spec must not take a workspace dependency on objectui — spec sits under objectui in the dependency order. The copy is what makes each expectation a measurement instead of the author's recollection: every one of the 26 vectors is asserted against the reference first, then against resolveI18nLabel, then both are compared in one pass. It is not exported and nothing under src/ imports it.

Reverse verification — three directions, each predicted before it was run

Taken out with a file copy restored by a trap, never git stash (shared refs/stash). Final diff against the pre-experiment copy: identical.

(a) Delete limb 3 (the region upgrade). Predicted RED — and red it went: Tests 4 failed | 59 passed (63).

× '3 base → region'
× '3 base → region (ja)'
× '3 runs BEFORE default — a wrong-regio…'
× every vector agrees limb for limb, in one pass

Worth recording that only 3 of the 5 limb-3 vectors discriminate: the two-region map at zh and the zh-CN-only map at zh-cn stay green without limb 3, because limb 6 happens to return the same string. They are kept — they pin the ordering between limbs — but they are not what makes limb 3 falsifiable, and a reader should not think they are.

(b) Return '' on a miss instead of undefined. Predicted INVERTED, and confirmed: the entire 26-row parity table stayed green?? '' collapses exactly this difference — while 5 assertions in the producer-facing suites went red:

× answers `undefined` — not `''` — when the label is absent
× answers `undefined` when no limb matched
× composes with `??` into the producer call shape #6761 needs
× rejects a map whose values are not strings
× accepts both authorized forms, and an absent label
Tests  5 failed | 58 passed (63)

This is the useful finding of the exercise: the parity table alone cannot catch a miss-shape regression. The two suites are not redundant — one pins which entry is chosen, the other pins how "no entry" is spelled — and a reviewer reading only the parity table would over-trust it.

(c) Type level: make locale optional. Predicted: the @ts-expect-error guarding the forgot-the-locale call becomes unused, so tsc reports it. Run against tsconfig.test.json, which really does compile this file (check:test-typecheck runs it), so the directive is not a phantom.

Verification

All commands run in a dedicated worktree on the post-rebase tree, heavy phases serialized on the shared lock.

  • pnpm --filter @objectstack/spec build — PASS (first command in the fresh worktree, and again after the rebase; see the api-surface note below for why that ordering is load-bearing)
  • pnpm --filter @objectstack/spec test — PASS, full suite; the new file alone is Test Files 1 passed (1) / Tests 63 passed (63)
  • pnpm --filter @objectstack/spec typecheck — PASS (tsc --noEmit + check:scripts-typecheck + check:test-typecheck: "OK — the test layer compiles; 58 file(s) / 266 error(s) held in test-typecheck-debt.json", unchanged; the new test file is not in that ledger because it has no errors)
  • pnpm lint — PASS
  • Every gate step enumerated from .github/workflows/lint.yml, run one by one — all PASS. The ESLint job's 33 static gates (check:slot-lookupcheck:spec-parsed-alias), and the type-check job's spec steps (check:authorable-surface, check:docs, check:skill-refs, check:spec-changes, check:upgrade-guide, check:generated --reconcile-only, check:exported-any, check:dual-source-exports, check:skill-examples, check:react-blocks, check:skill-docs), plus check:skill-frame-sync, check:skill-compatibility, check:type-check-coverage, check:driver-conformance, check:stall-guard, check:doc-formula-expressions, check:i18n, check:i18n-coverage, check:app-nav-i18n.
  • Downstream closure, prefix direction: turbo run build --filter='./packages/*' --filter='./packages/*/*' --filter='./examples/*^...' — 71/71 tasks; then turbo run typecheck --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*' — 120/120 tasks. Plus examples typecheck and downstream-contract typecheck, both PASS.
  • pnpm check:type-check-debt (the --re-measure ratchet, run with the closure built) — PASS: "34 ledger entr(ies) re-measured, 1759 raw tsc error(s) total, none above its recorded number". No entry raised, so no note needed rewriting.
  • check:api-surface reports exactly the expected delta, and the regenerated baseline is committed:
  ./ui
    + resolveI18nLabel (function)

@objectstack/spec public API changed: 0 breaking (removed/narrowed), 1 added.

Worth flagging for the next author: regenerating before rebuilding after a rebase is actively wrong here. Run against a dist built from the pre-rebase source, gen:api-surface also dropped AuthoredRowWriteOperation / AuthoredRowWriteVerdict from contracts.json — types #6841 had added to the source in the meantime. That is the AGENTS.md stale-artefact trap in mirror image: the generator reads dist, so a stale dist silently writes a baseline for a tree that no longer exists. Reverted, rebuilt, regenerated; the committed diff is one line.

  • The spec build did not rewrite authorable-surface.base.json — no authorable key changed, check:authorable-surface is green, and git status is empty after the full closure build (no tracked-artefact drift of any kind).

Scope

packages/spec only, plus its regenerated api-surface snapshot and one changeset (additive export, so minor). No packages/services file is touched.


Generated by Claude Code

…lver (#6765)

`I18nLabelSchema` has authorized two forms of a display label since #5728: a
plain string, and an inline locale map. Only ONE end of the platform knew what
the second form means — objectui's `pickLocalized`. Every backend producer that
had to put a label on the wire tested `typeof label === 'string'` and dropped
anything else, so a dataset declaring its dimension label the way the schema
authorizes shipped `fields[]` entries with no label at all (#6761's
measurements).

This adds the missing half in `packages/spec` rather than inside the service
that needed it first (maintainer ruling 2026-08-08, #6761 option B): the backend
had zero inline-map resolvers, and a first one born as a private fork is what
the next producer copies (PD#12).

Rule parity with `pickLocalized` is the contract and it is EXECUTED, not
asserted: a 26-row vector table is checked against a pinned verbatim copy of the
reference implementation first, then against this resolver. The only visible
difference is the spelling of a miss — `undefined` here, `''` there — bridged by
one `??` and pinned as an identity.

Consumption (`AnalyticsService.queryDataset`, `dataset-compiler.ts`) is #6761 and
is deliberately not touched here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 3:39am

Request Review

@github-actions github-actions Bot added the size/l label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec.

113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:system tests protocol:ui tooling labels Aug 9, 2026
…xport

`resolveI18nLabel (function)` on `./ui`. 0 breaking (nothing removed or
narrowed), 1 added — the delta `check:api-surface` asked for.

Regenerated only after a post-rebase `pnpm --filter @objectstack/spec build`:
run against the pre-rebase dist it also DROPPED `AuthoredRowWriteOperation` /
`AuthoredRowWriteVerdict` from `contracts.json`, which #6841 had added to the
source in the meantime — the AGENTS.md §9 stale-artefact trap, in mirror image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants