Found while fixing #3833 (PR #3846). Filing rather than folding in — that one is a correctness fix, this changes the wire shape.
The gap
GetFieldLabelsResponseSchema (packages/spec/src/api/protocol.zod.ts) declares each label as an object:
labels: z.record(z.string(), z.object({
label: z.string().describe('Translated field label'),
help: z.string().optional().describe('Translated help text'),
options: z.record(z.string(), z.string()).optional().describe('Translated option labels'),
})).describe('Field labels keyed by field name'),
Both serving surfaces emit Record<string, string> — a bare label per field:
So a client typed against GetFieldLabelsResponse reads labels[field].label and gets undefined, because the value is the string itself. The declared help and options never appear at all, though the underlying data has them — FieldTranslationSchema carries label, help, placeholder, and options, and bundles populate them. The endpoint drops them on the floor.
Why it survived
The bare-string shape is consistent across both surfaces, so nothing looks inconsistent from the outside — it is only wrong against the schema. And there is currently no consumer to notice: objectui does not call this endpoint, and in-repo callers are limited to the client SDK method plus its wire-dialect tests, which assert URL shape rather than body shape.
Note this is the third declared ≠ enforced gap on this one endpoint family, after #3676 (request filters no server read) and #3833 (a derivation scanning a retired dialect). Whatever check catches this class should probably be pointed at /i18n first.
Options
- Emit the declared shape —
{ label, help?, options? } per field, sourced straight from FieldTranslationSchema, which already has all three. Makes the response match its contract and stops discarding data the bundle carries. Breaking for anyone reading labels[field] as a string; the sweep above suggests that is nobody today, which makes this the cheap moment to do it.
- Narrow the schema to
Record<string, string> — declare what is served. Smaller, but it locks the endpoint out of ever returning help/options without another breaking change, and those are exactly what a localized form needs.
Option 1 looks right on the same reasoning #3676 used in reverse: there the declared thing could not be delivered and no one wanted it, so it was trimmed; here the declared thing can be delivered, the data is already loaded, and it is the more useful surface. But it is a wire-shape change and should be a deliberate call, not a side effect.
Worth deciding alongside whether placeholder (present on FieldTranslationSchema, absent from the response schema) belongs in the response too.
Found while fixing #3833 (PR #3846). Filing rather than folding in — that one is a correctness fix, this changes the wire shape.
The gap
GetFieldLabelsResponseSchema(packages/spec/src/api/protocol.zod.ts) declares each label as an object:Both serving surfaces emit
Record<string, string>— a bare label per field:packages/runtime/src/domains/i18n.ts(after fix(runtime,i18n): field labels read the bundle shape producers write — one shared derivation (#3833) #3846, viaresolveObjectFieldLabels)packages/services/service-i18n/src/i18n-service-plugin.tsSo a client typed against
GetFieldLabelsResponsereadslabels[field].labeland getsundefined, because the value is the string itself. The declaredhelpandoptionsnever appear at all, though the underlying data has them —FieldTranslationSchemacarrieslabel,help,placeholder, andoptions, and bundles populate them. The endpoint drops them on the floor.Why it survived
The bare-string shape is consistent across both surfaces, so nothing looks inconsistent from the outside — it is only wrong against the schema. And there is currently no consumer to notice:
objectuidoes not call this endpoint, and in-repo callers are limited to the client SDK method plus its wire-dialect tests, which assert URL shape rather than body shape.Note this is the third declared ≠ enforced gap on this one endpoint family, after #3676 (request filters no server read) and #3833 (a derivation scanning a retired dialect). Whatever check catches this class should probably be pointed at
/i18nfirst.Options
{ label, help?, options? }per field, sourced straight fromFieldTranslationSchema, which already has all three. Makes the response match its contract and stops discarding data the bundle carries. Breaking for anyone readinglabels[field]as a string; the sweep above suggests that is nobody today, which makes this the cheap moment to do it.Record<string, string>— declare what is served. Smaller, but it locks the endpoint out of ever returninghelp/optionswithout another breaking change, and those are exactly what a localized form needs.Option 1 looks right on the same reasoning #3676 used in reverse: there the declared thing could not be delivered and no one wanted it, so it was trimmed; here the declared thing can be delivered, the data is already loaded, and it is the more useful surface. But it is a wire-shape change and should be a deliberate call, not a side effect.
Worth deciding alongside whether
placeholder(present onFieldTranslationSchema, absent from the response schema) belongs in the response too.