Repository navigation
Description
The API stores form instruments that every client then refuses to render. InstrumentInterpreter.interpret rejects a non-interactive instrument whose bundle imports /runtime/v1/react@* or /runtime/v1/react-dom@* (findReactImport, added in f46a257). That check runs only in the browser. InstrumentsService.create evaluates the same bundle server-side and validates it against $AnyInstrument, but never applies the React rule. So a form whose block imports useState, whether from an instrument repository import or a bundle upload, is accepted, stored and listed. Every attempt to open it in apps/web or the gateway then fails with "Cannot import '/runtime/v1/react@19.x/index.js' in an instrument of kind 'FORM'". The commit message says the browser is "the earliest point the two kinds can be told apart", but the API evaluates the bundle and knows its kind first.
Where
apps/api/src/instruments/instruments.service.ts:94-109:
const result = await this.virtualizationService.eval(bundle);
// ...
const parseResult = await $AnyInstrument.safeParseAsync(result.value);
if (!parseResult.success) {
throw new UnprocessableEntityException({ /* ... */ });
}
const instance = parseResult.data;compared with the client-side rule, packages/instrument-interpreter/src/index.ts:30-37:
if (instrument.kind !== 'INTERACTIVE') {
const reactImport = findReactImport(bundle);
if (reactImport) {
throw new Error(
`Cannot import '${reactImport}' in an instrument of kind '${instrument.kind}': React is available only to interactive instruments, ...`Reproduce
- In an instrument repository, add a form under
lib/forms/whose block imports{ useState }from/runtime/v1/react@19.x. - Add the repository in
apps/web(Admin > Instrument Repositories) and assign the form to a group. - Start a session and open the form.
Actual: the import succeeds and the form is listed, but opening it shows the instrument error page with "React is available only to interactive instruments".
Expected: the API rejects the bundle with 422 and the same message, so the repository import reports that instrument as failed and it is never offered to users.
Tests
apps/api/src/instruments/__tests__/instruments.service.test.ts: it('should reject a non-interactive bundle that imports react, so no stored form fails in every client').
e2e, in testing/src/specs/admin-instrument-repos.spec.ts: a repository fixture form that imports react is reported as failed, not imported.
Suggested fix
Move findReactImport (or the whole kind check) into a shared package the API can import, such as @opendatacapture/instrument-utils or runtime-internal. Call it in InstrumentsService.create after the $AnyInstrument parse and throw UnprocessableEntityException with the interpreter's message. Keep the client-side check for bundles that never pass through the API, such as the playground.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing underapps/api/src/instruments,apps/api/src/instrument-repos,packages/instrument-interpreterorpackages/instrument-utilshas changed since the issue was filed.InstrumentsService.createis the only code inapps/api/srcthat writes a bundle (apps/api/src/instruments/instruments.service.ts:139). Every otherinstrumentModel.updatewrites provenance orarchivedAt. So one check there covers every way in. Two paths can carry a React import:POST /v1/instruments(apps/api/src/instruments/instruments.controller.ts:31-35). The playground's Upload Bundle dialog checks only for type and transpilation errors before posting the bundle (apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx:57-107).apps/api/src/instrument-repos/instrument-repos.service.ts:282).Demo seeding (
apps/api/src/demo/demo.service.ts:61-81) andcreateSeries(instruments.service.ts:199) also go throughcreate, and the check changes neither. No built-in non-interactive instrument imports React.DNP_GENERAL_CONSENT_FORMuses JSX, but that compiles to the exemptjsx-runtime. The series sourcecreateSeriesgenerates has no imports.Corrections
admin-instrument-repos.spec.ts. That suite can't run a real import, because the API must not reach GitHub underNODE_ENV=test(testing/src/specs/admin-instrument-repos.spec.ts:3-6). The tests fix: resolve 26 low-difficulty issues, one commit each #1825 adds there stubPOST /v1/instrument-reposwithpage.route, so the API never handles the request.importInstrumentslogsFailed to Import Instrumentand moves on (instrument-repos.service.ts:333-339), and the response is only the repo record. After the fix the form is just missing from the repository, and the only trace is a server log line. Reporting per-instrument failures would be a separate change.apps/web/src/routes/_app/upload/index.tsx:49-52) and then renders nothing when it is opened, becauseuseInstrumentswallows the interpreter error. That is Assignment lists, dashboard dialogs and the record view show empty, error or blank states while their data is still loading #1757.Plan
Move
packages/instrument-interpreter/src/imports.tsintopackages/instrument-utils. Export one function there that takes the kind and the bundle and returns the rejection message, ornull. The rule, the matcher and the message then live in one place. The API already depends oninstrument-utils, and the interpreter gets aworkspace:*dependency on it. The API should not import the interpreter, because its entry pulls inevaluateInstrument(new Functionin the caller's context).interpret()throwsErrorwith that message.createcalls the function right after the parse (:109), beforegenerateInstrumentIdand theexistscheck (:111-112), and throwsUnprocessableEntityException(message). A broken bundle that is already stored then gets a 422 with the reason when it is re-uploaded, not a 409. The playground dialog already shows the response'smessage(UploadBundleDialog.tsx:119-123).Update the docs:
.agents/docs/architecture/instrument-pipeline.md:104-122packages/instrument-interpreter/AGENTS.md(:26-33,:41)packages/instrument-utils/AGENTS.md:3-6instrument-utilsconsumers in.agents/docs/workspace-map.md:48packages/instrument-guidelines/AGENTS.md:460("rejected when the instrument is loaded") stays true, so the published package needs no release.Forms stored before the fix stay listed and broken. The fix only guards new writes.
Tests
imports.test.tstopackages/instrument-utils/src/__tests__/, and add cases for the new function, including theINTERACTIVEexemption. The interpreter'sindex.test.tskeeps itsinterpret()cases.apps/api/src/instruments/__tests__/instruments.service.test.ts, indescribe('create'): mockevalto returnformInstance('FORM_A', 1), and pass a bundle string containing__import("/runtime/v1/react@19.x/index.js"). Assert a 422 carrying the interpreter's message, and thatinstrumentModel.existsandinstrumentModel.createare never called. A schema failure is also a 422 that skipsexists, so asserting the status alone proves nothing. Then run the same bundle withinteractiveInstrument, which is still stored.testing/src/specs/admin-instruments.spec.ts: withapiRequestContextandadminToken,POST /api/v1/instrumentsa hand-written FORM bundle. Expect a 422 whose body contains "React is available only to interactive instruments". The bundle defines__importthe way the bundler preamble does (packages/instrument-bundler/src/bundle.ts:12-18). It imports zod forvalidationSchema, plus React's index. Instrument upload validation ignores the instrument's language, accepting untranslated bilingual instruments and rejecting language: ['en'] #1712 and FILE instrument schema accepts file groups that can never be completed: min above max, or two groups sharing a basename #1706 plan the same post-a-bundle, expect-422 test, so oneApiClienthelper that posts a raw bundle can serve all three.Triage note written with Claude Code. Code references point at
main@d86740e.