Skip to content

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

  1. Add CustomSpec (and the supporting ComponentSpec) to the union with type = "custom". It carries a complete TSX source plus metadata (imports_used, tailwind_classes_used, severity_color_map, assumptions) and the same mock_data and data_intent fields the other variants have.
  2. The Clarifier graph topology is unchanged (the lesson The Clarifier topology is reusable; the schema is not makes this explicit). intentExtractor learns to emit mode: "data" | "custom"; specSynthesizer becomes a router that dispatches to the existing data path or a new custom-path body; gapDetector and questionPrioritizer learn three custom-path fields (data_shape, layout, accent) whose keys match the eval-harness auto_answers table verbatim — so the same target file regression-tests both paths.
  3. The browser compiles component.tsx_source at render time via @babel/standalone inside a sealed scope where React is the only injected global (see frontend/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 (see backend/app/widgets/validators.py, enforced on POST /v1/widgets when spec.type == "custom") BOTH require generated TSX to contain zero import statements; the renderer strips defensively as a third line of defense.
  4. MockLlm returns a canned CustomSpec so 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.
  5. ComponentSpec was promoted from backend/scripts/clarifier_eval/schemas.py into backend/app/widgets/schemas.py; the eval harness re-imports from the production module. Production code never imports from scripts/ — captured as a new lesson in docs/lessons-learned.md and dependency direction is verifiable via rg "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.spec JSONB column carries the new variant unchanged. make demo-reset already truncates widgets (see backend/app/seed.py truncate_all).
  • frontend/package.json gains @babel/standalone (~3 MB; Vite code-splits it out of the initial chunk because it's only imported from CustomWidgetRenderer.tsx).
  • The eval harness keeps working unchanged — its imports of ComponentSpec resolve through the re-export. Verified by re-running make clarifier-eval against the same targets/alerts_feed after 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