fix(runtime,i18n)!: /i18n/locales answers in one shape, plus the success-envelope conformance gate that found it - #3870
Merged
os-zhuang merged 1 commit intoJul 28, 2026
Conversation
…ccess-envelope conformance gate that found it Follow-up to #3676 / #3833 / #3847. Those three were each a body that did not match the schema declaring it, and each survived a green suite because every test asserted the emitted body against a hand-written literal. Comparing output to a literal proves the code does what the test author believed; it cannot prove the code does what the contract declares. Nothing had ever put the emitted value and the declared schema in the same assertion. This adds that assertion as a suite — `i18n-success-envelope.conformance.test.ts` in runtime, the missing success-path twin of service-i18n's `error-envelope.conformance.test.ts` and the same pairing storage got in #3689. Every `/i18n` success body is parsed against `BaseResponseSchema` and against the schema `plugin-rest-api` names for that route, imported rather than restated. It found a fourth gap on its first run. `GET /i18n/locales` passed `getLocales()`'s raw `string[]` straight through the dispatcher, while `GetLocalesResponseSchema` declares `{ code, label, isDefault }[]` — and service-i18n, the OTHER provider of this identical route, already emitted descriptors. One endpoint, two shapes, decided by which plugin mounted it, with the dispatcher's form contradicting the SDK's own `GetLocalesResponse` type. That is the same split #3833 found in the field-labels derivation, one route over, and for the same reason: two surfaces, one mapping, kept twice. The mapping is now shared as `toLocaleDescriptors` next to `resolveObjectFieldLabels`, and both surfaces call it. `label` is the locale code — no display-name source exists in the tree and the schema requires the field; inventing an ICU display-name table here would be a product decision, not an implementation detail. The gate was verified the way #3833's was: the fix was reverted and the suite confirmed to FAIL on it ("expected object, received string" at locales[0]), rather than merely passing once written. Five existing tests pinned the bare `string[]`; they now assert on `.map(l => l.code)`, so the codes stay pinned while the shape is owned by the schema. BREAKING: `GET /i18n/locales` served by the dispatcher now returns `[{ code, label, isDefault }]` instead of `['en', ...]`. Callers on the service-i18n mount already received this shape, and the SDK's published `GetLocalesResponse` type has always described it, so this ends a divergence rather than starting one. Worth generalizing beyond `/i18n`: `plugin-rest-api.zod.ts` already carries a `responseSchema` name on essentially every route (29 declarations across 28 handlers), so the route -> declaring-schema mapping needed to run this check repo-wide exists today and is unused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0196gwfwMK5vW8RToPyzGMJo
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 28, 2026 13:31
os-zhuang
deleted the
claude/gettranslationsrequest-namespace-keys-rv08r7
branch
July 28, 2026 13:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #3676 / #3833 / #3847 — the generalizable part of that trilogy, plus the fourth gap it immediately caught.
Why a gate, not another one-off fix
Those three defects were each a body that did not match the schema declaring it. All three survived a green suite, for one shared reason:
Every test asserted the emitted body against a hand-written literal. Comparing output to a literal proves the code does what the test author believed. It cannot prove the code does what the contract declares. Nothing had ever put the emitted value and the declared schema in the same assertion.
That is not three bugs, it is one missing check.
The gate
packages/runtime/src/i18n-success-envelope.conformance.test.ts— the missing success-path twin of service-i18n'serror-envelope.conformance.test.ts, and the same pairing storage got in #3689 (success-envelope.conformance.test.ts, whose header notes i18n went through the error half in #3675 and never the success half).Every
/i18nsuccess body is parsed againstBaseResponseSchemaand against the schemaplugin-rest-apinames for that route —responseSchema: 'GetLocalesResponseSchema', etc. — imported rather than restated. A body that parses is a body the SDK's published return type does not lie about.It found a fourth gap on its first run
GET /i18n/localespassedgetLocales()'s rawstring[]straight through the dispatcher, whileGetLocalesResponseSchemadeclares{ code, label, isDefault }[]— and service-i18n, the other provider of this identical route, already emitted descriptors.One endpoint, two shapes, decided by which plugin mounted it, with the dispatcher's form contradicting the SDK's own
GetLocalesResponsetype.That is the same split #3833 found in the field-labels derivation, one route over, and for the same reason: two surfaces, one mapping, kept twice. So the mapping is now shared as
toLocaleDescriptorsinpackages/spec/src/system/i18n-resolver.ts, next toresolveObjectFieldLabels, and both surfaces call it.labelis the locale code. No display-name source exists in the tree and the schema requires the field; inventing an ICU display-name table here would be a product decision, not an implementation detail.Verified by reverting, not by passing
Same discipline as #3846. The fix was reverted and the suite confirmed to fail on it:
A regression test never observed failing is just another assertion.
Five existing tests pinned the bare
string[]. They now assert on.map(l => l.code)— the codes stay pinned, the shape is owned by the schema. That is the intended division: literals for values, schemas for shapes.Verification
@objectstack/spec— 262 files / 6822 tests pass@objectstack/runtime— 48 files / 702 tests pass@objectstack/service-i18n— 5 files / 63 tests passcheck:api-surface— regenerated (0 breaking,toLocaleDescriptors+LocaleDescriptoradded), re-verified cleancheck:docs— 250 generated files in sync@objectstack/clientbuilds cleanBreaking
GET /i18n/localesserved by the dispatcher now returns[{ code, label, isDefault }]instead of['en', …]. Callers on the service-i18n mount already received this shape, and the SDK's publishedGetLocalesResponsetype has always described it — this ends a divergence rather than starting one.Worth generalizing
This suite covers one route family. The same check is mechanically available repo-wide today:
plugin-rest-api.zod.tsalready carries aresponseSchemaname on essentially every route (29 declarations across 28 handlers), so the route → declaring-schema mapping needed to drive it exists and is currently unused by any check.Note the existing
check:livenessgate does not cover this: it is registry-rooted over authorable metadata types, not API request/response schemas. That is exactly the blind spot all four gaps lived in.Also swept while here, per #3833's follow-up: every remaining reader of a translation bundle (
rest-server.ts:1681, the two i18n surfaces, the two adapters) is accounted for — no third consumer is still on the retiredo.dialect.Generated by Claude Code