Repository navigation
Description
$AnyInstrument, the schema that POST /v1/instruments and instrument-interpreter validate bundles against, checks every translatable field (title, description, labels, options, tags) without looking at the instrument's own language. With no language argument, $$InstrumentUIOption accepts either a bare value or an { en, fr } object, whatever language says. So validation goes wrong in both directions.
- Rejects valid instruments.
language: ['en']is a legalInstrumentLanguageinruntime-core, and the specialized factory$$InstrumentUIOption(schema, ['en'])accepts{ en: 'Title' }.$AnyInstrumentrejects the same instrument with "expected string, received undefined" atdetails.title.fr, because its object branch always requires bothenandfr. - Accepts broken instruments. A bilingual instrument (
language: ['en', 'fr']) with an untranslated plain-string label or title passes.translateInstrumentthen reads'Favorite Number'['en'], which isundefined, so the field renders with no label and the instrument card with no title. In the other direction, a unilinguallanguage: 'en'instrument with a{ en, fr }title passes and is returned untranslated, so an object reaches components that render it as text.
instrument-bundler does not type-check, so bundles from the playground's Upload Bundle dialog or a synced instrument repository reach this schema without the compiler having caught the mismatch.
Where
packages/schemas/src/instrument/instrument.base.ts:75-83:
} else if (typeof language === 'undefined') {
resolvedSchema = z.union([
$Schema,
z.object({
en: $Schema,
fr: $Schema
})
]);
}$AnyInstrument = $$AnyInstrument() (instrument.core.ts:30) builds every field from this branch, and apps/api/src/instruments/instruments.service.ts:102 parses uploads with it. The reader that trusts the result is packages/instrument-utils/src/translate.ts:69 (label: field.label[language]) and :252 (title: details.title[language]).
Reproduce
- Take the
bilingualFormInstrumentstub, changedetails.titleto'Plain Title'and thefavoriteNumberlabel to'Favorite Number', then run$AnyInstrument.safeParse(...). - Take the
bilingualFileInstrumentstub and setlanguage: ['en'],details.title: { en: 't' },details.description: { en: 'd' },tags: { en: ['x'] }, and the file group'slabel: { en: 'File' }. Run$AnyInstrument.safeParse(...).
Actual: case 1 succeeds, and translateInstrument produces details.title === undefined and an unlabeled field. Case 2 fails on the missing fr keys.
Expected: case 1 fails, with issues at the plain-string paths. Case 2 succeeds, matching what $$AnyInstrument(['en']) already does.
Tests
packages/schemas/src/instrument/__tests__/instrument.core.test.ts:
it('should reject a bilingual instrument whose title is a plain string, so it cannot reach translation with nothing to translate')it('should accept an instrument authored in a single-language array, as the runtime-core type allows')
E2E in testing/: with the admin token, POST /v1/instruments a bilingual bundle with a plain-string title, and assert a 422.
Suggested fix
Make the no-argument factories language-aware rather than language-agnostic. For example, have $$AnyInstrument() parse language first and then validate against $$AnyInstrument(parsedLanguage), the specialized schema that already behaves correctly. Alternatively, build it as a union of the unilingual and multilingual specializations. apps/outreach always passes a language to the factories when it generates JSON Schema, so changing the no-argument path does not affect its output.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). The cited lines have not moved, and no commit since this was filed touchespackages/schemas/src/instrument.What the suggested fix misses
Accepting
['en']at upload breaks the web's instrument lists unless the info schemas change too.$InstrumentInfoand$SeriesInstrumentOvervieware built from the no-argument$BaseInstrument(instrument.base.ts:166,:224-250,:304-307). The API returnsdetails,tagsandlanguageuntranslated (instruments.service.ts:570), and the web parses each response as one array (apps/web/src/hooks/useInstrumentInfoQuery.ts:28,apps/web/src/hooks/useSeriesInstrumentsOverviewQuery.ts:11). So one storedlanguage: ['en']instrument with a{ en }title empties everyuseInstrumentInfoQuerylist that includes it, not just its own row. If it is a series, the/admin/instrumentsloader awaits the overview parse (apps/web/src/routes/_app/admin/instruments.tsx:392), so the whole page fails. The same object branch makesPOST /v1/instruments/seriesreject['en']in its body ($CreateSeriesInstrumentData,instrument.base.ts:276-291).Do not build it as a union of specializations. In the repo's zod (3.25.76,
zod/v4), a union that fails every branch reports oneinvalid_unionissue at the root with each branch's errors nested inside, so the 422 no longer points atdetails.title. The branch count also grows with each authoring language, which works against #1378.Validate
languagebefore dispatching.$$InstrumentUIOptionthrows a plainErrorfor a non-unique array (instrument.base.ts:70-72). If the dispatch reads the rawvalue.language,['en', 'en']becomes a 500 instead of a 422.Plan
instrument.core.ts:30: define$AnyInstrumentas its own constant instead of$$AnyInstrument(). Make itz.unknown().transform(...): parse{ language: $InstrumentLanguage }, then$$AnyInstrument(language).safeParse(value), forwarding issues withctx.addIssue(their paths survive) and returningz.NEVERon failure. Keep it synchronous sosafeParse(instrument.core.test.ts:18),safeParseAsync(API) andparseAsync(interpreter) all keep working unchanged. Leave the factory alone:z.toJSONSchemathrows on a transform, andapps/outreachconverts the factories.instrument.base.ts:75-83: give the object branch optional keys, so any subset of authoring languages parses. Usez.object, notz.partialRecord:partialRecordrejects unknown keys that today's branch strips, so a stored{ en, fr, es }title would fail a whole list. To derive the keys from$InstrumentAuthoringLanguage, change its annotation atpackages/schemas/src/core/core.ts:96from: z.ZodType<...>tosatisfies. That also removes a hard-codeden/frlist ahead of Allow for languages in instruments not enabled in ODC (and allow for selective enablement of languages in ODC) #1378. Loosening this branch is safe becausecreateSeriessends its generated bundle throughcreate(instruments.service.ts:198-199), so the series route still gets the strict check.apps/web/src/routes/_app/group/manage.tsx:314indexes a duplicate's multilingualexistingTitleby the reader's language. Once['en']series exist, the duplicate prompt names the existing seriesundefinedfor a French reader. Fall back to a language the title has, asdescribeTitledoes (instruments.service.ts:542).packages/schemas/AGENTS.md:68-74calls the no-argument result "the permissive union" and says$AnyInstrumentis$$AnyInstrument(), and:83-85says what is written there must survivez.toJSONSchema. Update both in the same commit, since$AnyInstrumentbecomes the exception.No migration is needed. Stored bundles are read back without the schema (
instruments.service.ts:427-440), and web validates only in dev (apps/web/src/hooks/useInstrument.ts:20). Broken ones will start failing in the playground preview (apps/playground/src/preview/PreviewApp.tsx:67). A repository re-import of one logs a 422 instead of reaching the conflict, because validation (:102) runs before the id check (:112), but sync keeps the association by mergingrepo.instrumentIds(apps/api/src/instrument-repos/instrument-repos.service.ts:126). Labels returned by akind: 'dynamic'render stay unvalidated (instrument.form.ts:111-114,:160-164).Tests
instrument.core.test.ts: the two cases from the issue. Also:language: 'en'with a{ en, fr }title is rejected; a rejection's issue path is['details', 'title'], which pins dispatch over a union; and['en', 'en']gives an issue at['language']without throwing.instrument.base.test.ts:$InstrumentInfoacceptslanguage: ['en']with{ en }title, description and tags.apps/web/src/routes/_app/group/__tests__/manage.test.tsx: a duplicate whoseexistingTitlelacks the reader's language still names the series.testing/src/specs/admin-instruments.spec.ts, through the series route so no bundle is hand-written. ExtendApiClient.createSeries(testing/src/support/api-client.ts:79-92, which hard-codeslanguage: 'en'and a plain title) to takedetailsandlanguage, and check that a series created with['en']and a{ en }title is found by searching the series view. That test catches the first point above.createSeriesexpects a 201, so post['en', 'fr']with a plain title directly throughapiRequestContext, as the archive test does at:63-67, and expect a 422 (it gets a 201 today).Land this before #1378. #1706 adds refinements in the same folder; if they go in the factories, the dispatch picks them up, so the two can land in either order. #1807 also edits
api-client.ts.Triage note written with Claude Code. Code references point at
main@d86740e.