ADR-006: WidgetSpec gains a custom variant; in-browser TSX evaluation¶
Status: Accepted Source: prd.md §19
Context¶
ADR-005 declared WidgetSpec a closed discriminated union of kpi | chart | table. The codegen-path eval (see docs/plans/active/test-clarifier-codegen-on-alerts-feed.md, receipts under artifacts/clarifier-eval/<run-id>/) proved Bedrock can synthesize complete React + Tailwind components that recreate dashboard tiles. To finish the loop — get those components in front of the user without rebuilding the frontend image per widget — WidgetSpec must accept a fourth variant. The follow-up plan is docs/plans/active/onboard-generated-widgets.md.
Decision¶
- Add
CustomSpec(and the supportingComponentSpec) to the union withtype = "custom". It carries a complete TSX source plus metadata (imports_used,tailwind_classes_used,severity_color_map,assumptions) and the samemock_dataanddata_intentfields the other variants have. - The Clarifier graph topology is unchanged (the lesson The Clarifier topology is reusable; the schema is not makes this explicit).
intentExtractorlearns to emitmode: "data" | "custom";specSynthesizerbecomes a router that dispatches to the existing data path or a new custom-path body;gapDetectorandquestionPrioritizerlearn three custom-path fields (data_shape,layout,accent) whose keys match the eval-harnessauto_answerstable verbatim — so the same target file regression-tests both paths. - The browser compiles
component.tsx_sourceat render time via@babel/standaloneinside a sealed scope whereReactis the only injected global (seefrontend/src/widgets/CustomWidgetRenderer.tsx). No server-side compile, no Node toolchain on the API, no per-widget frontend rebuild. The synthesizer prompt and a server-side static-check gate (seebackend/app/widgets/validators.py, enforced onPOST /v1/widgetswhenspec.type == "custom") BOTH require generated TSX to contain zeroimportstatements; the renderer strips defensively as a third line of defense. MockLlmreturns a cannedCustomSpecso the offline demo path (USE_BEDROCK=false,make up-offline) remains intact. Per ADR-002 / ADR-005, the demo must run without AWS — non-negotiable.ComponentSpecwas promoted frombackend/scripts/clarifier_eval/schemas.pyintobackend/app/widgets/schemas.py; the eval harness re-imports from the production module. Production code never imports fromscripts/— captured as a new lesson in docs/lessons-learned.md and dependency direction is verifiable viarg "from scripts" backend/app/.
Consequences¶
- Browser-side
eval-equivalent execution of LLM-generated code is a security trade-off. Acceptable in the hackathon scope (single-user demo, PRD §3.2 explicitly excludes AuthN/AuthZ); explicitly not acceptable for any multi-tenant deployment that follows. Future production hardening would require either: (a) server-side esbuild/SWC compilation with strict AST inspection, or (b) iframe sandboxing with a strict CSP. Either is a separate ADR. (ADR-V2-002 proposes the iframe sandbox; not extracted here per the rollout plan's 16-ADR scope.) - db/init.sql does not change — the
widgets.specJSONB column carries the new variant unchanged.make demo-resetalready truncateswidgets(see backend/app/seed.pytruncate_all). - frontend/package.json gains
@babel/standalone(~3 MB; Vite code-splits it out of the initial chunk because it's only imported fromCustomWidgetRenderer.tsx). - The eval harness keeps working unchanged — its imports of
ComponentSpecresolve through the re-export. Verified by re-runningmake clarifier-evalagainst the sametargets/alerts_feedafter this change. - If a future deployment adds a CSP,
'unsafe-eval'will be required for the custom path. Documented here so the next maintainer doesn't have to guess.
Cross-references¶
- ADR-005 — original closed union.
- ADR-007 — replaces
metric_label: stringon every variant includingcustom. - ADR-010 — adds the second sealed global (
Icon) at the same axis asReact. - Lessons-learned: docs/lessons-learned.md § Promote eval schemas.
- Implementation: frontend/src/widgets/CustomWidgetRenderer.tsx, backend/app/widgets/validators.py.