You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by en...@apache.org on 2026/08/27 13:13:28 UTC

(superset) 37/46: docs(dashboard-v2): plan the control dependency graph implementation

This is an automated email from the ASF dual-hosted git repository.

EnxDev pushed a commit to branch enxdev/poc/dashboard-v2-editing-ui-flow
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 3514e3b79ac3c995f3d81a2675f9252aa0b0e15a
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Wed Aug 26 23:59:27 2026 +0200

    docs(dashboard-v2): plan the control dependency graph implementation
    
    Corrects three things found while writing the plan, ahead of the spec:
    EnricherFn needs the whole schema (not just its own field) since Balloons'
    real enricher reads a sibling $defs entry; check_dependencies resolves
    x-dependsOn by raw name instead of alias, so it has never actually worked
    (verified directly against a real parsed model); and the x-dependsOn gate
    is coarser than Balloons' actual guard (dimensions non-empty, series
    non-empty) so the enricher keeps its own fine-grained check. Spec gets
    reconciled with these once implementation confirms them.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
---
 .../plans/2026-08-26-control-dependency-graph.md   | 801 +++++++++++++++++++++
 1 file changed, 801 insertions(+)

diff --git a/docs/superpowers/plans/2026-08-26-control-dependency-graph.md b/docs/superpowers/plans/2026-08-26-control-dependency-graph.md
new file mode 100644
index 00000000000..f5db587da16
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-26-control-dependency-graph.md
@@ -0,0 +1,801 @@
+# Control Dependency Graph Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let a widget register more than one dynamic-field enricher, derive their execution order and a dependency graph from the fields' existing `x-dependsOn` declarations, detect a cyclic graph once at widget-registration time, and retire the single `Widget.enrich_schema` hook in favor of this mechanism — retrofitting `Balloons` (the only current dynamic-field widget) as the proof case with zero behavior change.
+
+**Architecture:** A new `superset_core/widgets/enrichment.py` walks a built (pre-enrichment) control schema to find every `x-dynamic` field, builds a dependency graph from each field's `x-dependsOn` list (an entry naming another dynamic field's path is an ordering edge; anything else is a truthiness gate against the parsed control values), topologically sorts it (raising on a cycle), and runs each field's registered enricher in that order, threading prior enrichers' return values forward [...]
+
+**Tech Stack:** Python/Pydantic, pytest.
+
+**Spec:** [docs/superpowers/specs/2026-08-26-control-dependency-graph-design.md](../specs/2026-08-26-control-dependency-graph-design.md)
+
+## Global Constraints
+
+- New Python code needs full type hints and must be mypy-clean (per CLAUDE.md); `pre-commit run mypy` is the only working invocation in this environment (no venv here has a standalone `mypy` module).
+- New files need the standard ASF license header.
+- Run `pre-commit run --files <touched files>` before considering any task done; re-run tests after any auto-fix since files change on disk.
+- No new dynamic-field capability, no frontend changes — Balloons' served schema must remain byte-identical before/after (golden-fixture discipline, same as the composite-control-registry slice).
+- Corrections found while writing this plan, not yet reflected in the spec (fold into the spec-reconciliation step at the end, same as last time): (1) `EnricherFn` must receive the **whole schema**, not just its own field's fragment — Balloons' real enricher reads `schema["$defs"]["SeriesStyle"]`, a sibling `$defs` entry, not something reachable from its own node alone. (2) `check_dependencies` (`superset_core/semantic_layers/config.py`) resolves a dependency name via `getattr(configurat [...]
+
+---
+
+## File Structure
+
+**Backend — new:**
+- `superset-core/src/superset_core/widgets/enrichment.py` — `EnricherFn`, `dynamic_field_paths`, `build_dependency_graph`, `toposort_or_raise`, `run_enrichers`.
+- `tests/unit_tests/widgets/test_enrichment.py` — unit tests for the module above, using synthetic schemas (no real widget needed).
+
+**Backend — modified:**
+- `superset-core/src/superset_core/semantic_layers/config.py` — fix `check_dependencies`'s alias resolution.
+- `superset-core/src/superset_core/widgets/base.py` — `Widget` gains `enrichers: ClassVar[dict[str, EnricherFn]]`; `get_control_schema` drives the new pipeline; `enrich_schema` is removed.
+- `superset-core/src/superset_core/widgets/__init__.py` — re-export `EnricherFn` (widget authors need the type to annotate their enrichers).
+- `superset/core/api/core_api_injection.py` — `widget_impl`'s `decorator` calls `cls.get_control_schema(None, None)` once after registering, so a cyclic graph fails import.
+- `superset/widgets/builtin.py` — `Balloons.enrich_schema` becomes `Balloons._populate_series` registered via `enrichers`.
+- `tests/unit_tests/widgets/test_builtin.py` — unchanged assertions, but now exercised through the new pipeline (regression proof).
+- `tests/unit_tests/widgets/test_registry.py` — add a registration-time cycle-detection test.
+
+---
+
+## Task 1: `enrichment.py` — graph, toposort, and the enricher runner
+
+**Files:**
+- Create: `superset-core/src/superset_core/widgets/enrichment.py`
+- Test: `tests/unit_tests/widgets/test_enrichment.py` (new)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `EnricherFn = Callable[[dict[str, Any], dict[str, Any], BaseModel | None, list[str], dict[str, Any]], Any]` — `(schema, node, parsed, series, upstream_results) -> Any`; `dynamic_field_paths(schema) -> dict[str, dict[str, Any]]`; `build_dependency_graph(fields: dict[str, dict[str, Any]]) -> dict[str, list[str]]`; `toposort_or_raise(graph: dict[str, list[str]], widget_type: str) -> list[str]`; `run_enrichers(schema, fields, order, enrichers, parsed, series) -> None`. Task 2 wir [...]
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/unit_tests/widgets/test_enrichment.py`:
+
+```python
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import pytest
+
+from superset_core.widgets.enrichment import (
+    build_dependency_graph,
+    dynamic_field_paths,
+    run_enrichers,
+    toposort_or_raise,
+)
+
+
+def test_dynamic_field_paths_finds_top_level_field() -> None:
+    schema = {
+        "properties": {
+            "a": {"x-dynamic": True, "x-dependsOn": ["b"]},
+            "b": {"type": "string"},
+        }
+    }
+    fields = dynamic_field_paths(schema)
+    assert list(fields) == ["a"]
+    assert fields["a"] is schema["properties"]["a"]
+
+
+def test_dynamic_field_paths_finds_nested_defs_field() -> None:
+    # Mirrors Customization.series's actual shape: the dynamic field lives
+    # inside a $defs entry referenced by $ref, not directly in properties.
+    schema = {
+        "properties": {"customize": {"$ref": "#/$defs/Customization"}},
+        "$defs": {
+            "Customization": {
+                "properties": {
+                    "series": {"x-dynamic": True, "x-dependsOn": ["dataBinding"]}
+                }
+            }
+        },
+    }
+    fields = dynamic_field_paths(schema)
+    assert list(fields) == ["customize/series"]
+
+
+def test_build_dependency_graph_edge_vs_gate() -> None:
+    fields = {
+        "a": {"x-dynamic": True, "x-dependsOn": ["b", "staticField"]},
+        "b": {"x-dynamic": True},
+    }
+    graph = build_dependency_graph(fields)
+    # "b" is a known dynamic path -> ordering edge; "staticField" isn't -> not an edge.
+    assert graph == {"a": ["b"], "b": []}
+
+
+def test_toposort_orders_a_chain() -> None:
+    graph = {"c": ["b"], "b": ["a"], "a": []}
+    order = toposort_or_raise(graph, "test-widget")
+    assert order.index("a") < order.index("b") < order.index("c")
+
+
+def test_toposort_raises_on_cycle() -> None:
+    graph = {"a": ["b"], "b": ["a"]}
+    with pytest.raises(ValueError, match="a") as exc_info:
+        toposort_or_raise(graph, "test-widget")
+    assert "b" in str(exc_info.value)
+
+
+def test_run_enrichers_threads_upstream_result_forward() -> None:
+    schema = {
+        "properties": {
+            "a": {"x-dynamic": True},
+            "b": {"x-dynamic": True, "x-dependsOn": ["a"]},
+        }
+    }
+    fields = dynamic_field_paths(schema)
+    order = toposort_or_raise(build_dependency_graph(fields), "test-widget")
+
+    seen_upstream = {}
+
+    def enrich_a(schema_arg, node, parsed, series, upstream):
+        node["computed"] = "from-a"
+        return "a-result"
+
+    def enrich_b(schema_arg, node, parsed, series, upstream):
+        seen_upstream.update(upstream)
+
+    run_enrichers(schema, fields, order, {"a": enrich_a, "b": enrich_b}, None, [])
+
+    assert schema["properties"]["a"]["computed"] == "from-a"
+    assert seen_upstream == {"a": "a-result"}
+
+
+def test_run_enrichers_skips_ungated_field_without_error() -> None:
+    # No enricher registered for a discovered dynamic field: no-op, not an error.
+    schema = {"properties": {"a": {"x-dynamic": True}}}
+    fields = dynamic_field_paths(schema)
+    order = toposort_or_raise(build_dependency_graph(fields), "test-widget")
+    run_enrichers(schema, fields, order, {}, None, [])  # must not raise
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/widgets/test_enrichment.py -v`
+Expected: all FAIL with `ModuleNotFoundError` (`superset_core.widgets.enrichment` doesn't exist yet).
+
+- [ ] **Step 3: Implement `superset-core/src/superset_core/widgets/enrichment.py`**
+
+```python
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Dependency graph and execution engine for a widget's dynamic (``x-dynamic``)
+control-schema fields.
+
+A widget registers one enricher callable per dynamic field path (see
+``Widget.enrichers``). Each field's existing ``x-dependsOn`` list (already
+used to gate enrichment via ``check_dependencies``) does double duty here:
+an entry naming another dynamic field's path becomes an ordering edge (that
+field's enricher must run first, and this one receives its result); any
+other entry stays a plain truthiness gate against the parsed control values.
+Field paths use ``a/b`` dot-path notation, the same convention
+``schema_tools.py`` uses for drill-in paths.
+
+This module only computes the graph and runs enrichers in order — it has no
+opinion on where the schema or enrichers come from; ``Widget.get_control_schema``
+wires it up.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable
+
+from pydantic import BaseModel
+
+from superset_core.semantic_layers.config import check_dependencies
+
+# (schema, node, parsed, series, upstream_results) -> Any.
+# `schema` is the full document (for cross-$defs lookups, e.g. a sibling
+# style definition); `node` is this field's own schema fragment, mutated in
+# place. The return value is threaded to enrichers ordered after this one, as
+# `upstream_results[path]`.
+EnricherFn = Callable[
+    [dict[str, Any], dict[str, Any], "BaseModel | None", list[str], dict[str, Any]],
+    Any,
+]
+
+
+def _defs(schema: dict[str, Any]) -> dict[str, Any]:
+    return schema.get("$defs", {}) or {}
+
+
+def _deref(schema: dict[str, Any], node: dict[str, Any]) -> dict[str, Any]:
+    if "$ref" not in node:
+        return node
+    return _defs(schema).get(node["$ref"].split("/")[-1], {})
+
+
+def dynamic_field_paths(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
+    """Walk a built (pre-enrichment) control schema and return
+    ``{path: schema_node}`` for every field carrying ``x-dynamic: true``,
+    using ``a/b`` dot-path notation. Descends into ``properties`` and
+    resolves ``$ref`` against ``$defs`` along the way; does not descend into
+    a discovered dynamic field itself (a dynamic field's own internals are
+    the enricher's concern, not the graph's)."""
+    fields: dict[str, dict[str, Any]] = {}
+
+    def _walk(node: dict[str, Any], prefix: str) -> None:
+        resolved = _deref(schema, node)
+        for name, prop in resolved.get("properties", {}).items():
+            path = f"{prefix}/{name}" if prefix else name
+            prop_resolved = _deref(schema, prop)
+            if prop_resolved.get("x-dynamic"):
+                fields[path] = prop_resolved
+            else:
+                _walk(prop, path)
+
+    _walk(schema, "")
+    return fields
+
+
+def build_dependency_graph(fields: dict[str, dict[str, Any]]) -> dict[str, list[str]]:
+    """``{path: [ordering-edge paths]}`` for every dynamic field in
+    ``fields``. Only ``x-dependsOn`` entries that name another key of
+    ``fields`` become edges; every other entry is left as a gate for
+    ``check_dependencies`` to evaluate at run time, not an edge here."""
+    return {
+        path: [dep for dep in node.get("x-dependsOn", []) if dep in fields]
+        for path, node in fields.items()
+    }
+
+
+def toposort_or_raise(graph: dict[str, list[str]], widget_type: str) -> list[str]:
+    """Kahn's algorithm over ``graph`` (``path -> [paths it depends on]``).
+    Raises ``ValueError`` naming every field on a cycle when the graph isn't
+    a DAG."""
+    in_degree = dict.fromkeys(graph, 0)
+    dependents: dict[str, list[str]] = {path: [] for path in graph}
+    for path, deps in graph.items():
+        in_degree[path] = len(deps)
+        for dep in deps:
+            dependents[dep].append(path)
+
+    ready = sorted(path for path, degree in in_degree.items() if degree == 0)
+    order: list[str] = []
+    while ready:
+        path = ready.pop(0)
+        order.append(path)
+        for dependent in sorted(dependents[path]):
+            in_degree[dependent] -= 1
+            if in_degree[dependent] == 0:
+                ready.append(dependent)
+
+    if len(order) != len(graph):
+        remaining = sorted(set(graph) - set(order))
+        raise ValueError(
+            f"Cyclic control dependency in widget {widget_type!r} among: "
+            f"{', '.join(remaining)}"
+        )
+    return order
+
+
+def run_enrichers(
+    schema: dict[str, Any],
+    fields: dict[str, dict[str, Any]],
+    order: list[str],
+    enrichers: dict[str, "EnricherFn"],
+    parsed: BaseModel | None,
+    series: list[str],
+) -> None:
+    """Run each path's registered enricher (if any) in ``order``, skipping
+    one whose non-edge ``x-dependsOn`` gate(s) aren't satisfied, and
+    threading each enricher's return value forward as
+    ``upstream_results[path]`` for anything ordered after it."""
+    upstream_results: dict[str, Any] = {}
+    for path in order:
+        enricher = enrichers.get(path)
+        if enricher is None:
+            continue
+        node = fields[path]
+        if parsed is not None and not check_dependencies(node, parsed):
+            continue
+        result = enricher(schema, node, parsed, series, upstream_results)
+        upstream_results[path] = result
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/widgets/test_enrichment.py -v`
+Expected: all 7 PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add superset-core/src/superset_core/widgets/enrichment.py tests/unit_tests/widgets/test_enrichment.py
+git commit -m "feat(dashboard-v2): add dependency graph + enricher runner for dynamic control fields"
+```
+
+---
+
+## Task 2: Fix `check_dependencies`'s alias resolution
+
+**Files:**
+- Modify: `superset-core/src/superset_core/semantic_layers/config.py`
+- Test: `tests/unit_tests/semantic_layers/config_test.py` (append)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `check_dependencies` now resolves an `x-dependsOn` entry against the parsed model's *alias* (matching what's actually written in the schema) before falling back to the raw name, instead of only ever trying the raw name directly.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/unit_tests/semantic_layers/config_test.py`:
+
+```python
+from pydantic import ConfigDict, Field
+
+from superset_core.semantic_layers.config import check_dependencies
+
+
+class _Aliased(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    data_binding: int = Field(alias="dataBinding")
+
+
+def test_check_dependencies_resolves_alias() -> None:
+    # x-dependsOn is written using the schema-facing alias ("dataBinding"),
+    # but Pydantic attribute access always uses the Python field name
+    # ("data_binding") -- confirmed directly that getattr(parsed, "dataBinding")
+    # misses even under populate_by_name=True.
+    configuration = _Aliased(dataBinding=1)
+    assert getattr(configuration, "dataBinding", "MISSING") == "MISSING"
+    assert check_dependencies({"x-dependsOn": ["dataBinding"]}, configuration)
+
+
+def test_check_dependencies_false_when_dependency_falsy() -> None:
+    configuration = _Aliased(dataBinding=0)
+    assert not check_dependencies({"x-dependsOn": ["dataBinding"]}, configuration)
+
+
+def test_check_dependencies_true_when_no_dependencies_declared() -> None:
+    configuration = _Aliased(dataBinding=0)
+    assert check_dependencies({}, configuration)
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/semantic_layers/config_test.py -v -k check_dependencies`
+Expected: `test_check_dependencies_resolves_alias` FAILS (`check_dependencies` returns falsy — `getattr(configuration, "dataBinding", None)` is `None`); the other two already pass (they don't exercise the alias bug).
+
+- [ ] **Step 3: Fix `check_dependencies`**
+
+In `superset-core/src/superset_core/semantic_layers/config.py`, replace:
+
+```python
+def check_dependencies(
+    prop_schema: dict[str, Any],
+    configuration: BaseModel,
+) -> bool:
+    """
+    Check whether a dynamic property's dependencies are satisfied.
+
+    Reads the ``x-dependsOn`` list from the property schema and returns ``True``
+    when every referenced attribute on ``configuration`` is truthy.
+    """
+    dependencies = prop_schema.get("x-dependsOn", [])
+    return all(getattr(configuration, dep, None) for dep in dependencies)
+```
+
+with:
+
+```python
+def check_dependencies(
+    prop_schema: dict[str, Any],
+    configuration: BaseModel,
+) -> bool:
+    """
+    Check whether a dynamic property's dependencies are satisfied.
+
+    Reads the ``x-dependsOn`` list from the property schema and returns ``True``
+    when every referenced attribute on ``configuration`` is truthy. Entries are
+    written using the schema-facing alias (e.g. ``"dataBinding"``), so each is
+    resolved to its Pydantic field name before ``getattr`` -- Pydantic attribute
+    access always uses the field name, never the alias, even under
+    ``populate_by_name=True``.
+    """
+    dependencies = prop_schema.get("x-dependsOn", [])
+    alias_to_name = {
+        (field.alias or name): name
+        for name, field in type(configuration).model_fields.items()
+    }
+    return all(
+        getattr(configuration, alias_to_name.get(dep, dep), None)
+        for dep in dependencies
+    )
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/semantic_layers/config_test.py -v`
+Expected: all PASS (8 total: the 5 from the field-order slice plus these 3).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add superset-core/src/superset_core/semantic_layers/config.py tests/unit_tests/semantic_layers/config_test.py
+git commit -m "fix(dashboard-v2): resolve x-dependsOn aliases in check_dependencies"
+```
+
+---
+
+## Task 3: Wire the pipeline into `Widget.get_control_schema`, retire `enrich_schema`
+
+**Files:**
+- Modify: `superset-core/src/superset_core/widgets/base.py`
+- Modify: `superset-core/src/superset_core/widgets/__init__.py`
+
+**Interfaces:**
+- Consumes: `enrichment.py`'s four functions (Task 1).
+- Produces: `Widget.enrichers: ClassVar[dict[str, EnricherFn]] = {}`; `get_control_schema` runs the full pipeline. `enrich_schema` no longer exists on `Widget`. Task 5 (Balloons retrofit) depends on this.
+
+- [ ] **Step 1: Modify `superset-core/src/superset_core/widgets/base.py`**
+
+Replace the imports and the `get_control_schema`/`enrich_schema` methods:
+
+```python
+from __future__ import annotations
+
+import logging
+from typing import Any, ClassVar
+
+from pydantic import BaseModel, ValidationError
+
+from superset_core.semantic_layers.config import build_configuration_schema
+from superset_core.widgets.enrichment import (
+    build_dependency_graph,
+    dynamic_field_paths,
+    EnricherFn,
+    run_enrichers,
+    toposort_or_raise,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class Widget:
+    widget_type: str
+    name: str
+    description: str = ""
+    controls_class: type[BaseModel]
+    enrichers: ClassVar[dict[str, EnricherFn]] = {}
+
+    @classmethod
+    def get_control_schema(
+        cls,
+        control_values: dict[str, Any] | None = None,
+        series: list[str] | None = None,
+    ) -> dict[str, Any]:
+        """
+        Return the JSON Schema for this widget's controls.
+
+        ``control_values`` (the full current ``node.props``) is accepted so
+        dynamic fields can be enriched from it, mirroring the Semantic Layer.
+        ``series`` carries the distinct dimension values the frontend
+        discovered from the query results (they cannot come from
+        ``control_values`` alone, which hold the dimension *name*, not its
+        values). Partial or invalid values during editing are tolerated and
+        fall back to the base schema; enrichment errors propagate so the caller
+        can degrade gracefully.
+
+        Every ``x-dynamic`` field found in the built schema is enriched (if
+        this widget has a registered enricher for its path) in dependency
+        order -- derived from each field's own ``x-dependsOn``, see
+        ``superset_core.widgets.enrichment``. A cyclic dependency raises
+        ``ValueError``; for a built-in or registered widget this is caught
+        once at registration time (``inject_widget_implementations``), not
+        on every request.
+        """
+        parsed: BaseModel | None = None
+        if control_values:
+            try:
+                parsed = cls.controls_class.model_validate(control_values)
+            except Exception:  # pylint: disable=broad-except
+                # Partial control values during editing are expected; fall back
+                # to the base schema.
+                logger.debug(
+                    "Could not validate control values for %s; using base schema",
+                    cls.widget_type,
+                    exc_info=True,
+                )
+        schema = build_configuration_schema(cls.controls_class, parsed)
+        fields = dynamic_field_paths(schema)
+        order = toposort_or_raise(build_dependency_graph(fields), cls.widget_type)
+        run_enrichers(schema, fields, order, cls.enrichers, parsed, series or [])
+        return schema
+
+    @classmethod
+    def validate_control_values(
+        cls,
+        control_values: dict[str, Any] | None,
+    ) -> list[dict[str, Any]]:
+        """
+        Strictly validate ``control_values`` against ``controls_class``,
+        returning a list of ``{"loc", "message"}`` errors (empty when valid).
+
+        This is the **commit-time** gate, distinct from ``get_control_schema``
+        (which tolerates partial/invalid values so a form can be edited without
+        erroring). It runs the model's full validation — including cross-field
+        rules declared as Pydantic ``@model_validator`` / ``@field_validator``
+        on ``controls_class`` — so a caller (or an AI) gets an actionable
+        message instead of a silently-broken widget. It is widget-agnostic:
+        every rule lives declaratively on the model; this method just surfaces
+        whatever the model enforces.
+        """
+        if not control_values:
+            return []
+        try:
+            cls.controls_class.model_validate(control_values)
+        except ValidationError as ex:
+            return [
+                {
+                    "loc": [str(part) for part in error.get("loc", ())],
+                    "message": error.get("msg", ""),
+                }
+                for error in ex.errors()
+            ]
+        return []
+```
+
+(Keep the module and class docstrings as they are today — only the imports
+and the two methods change.)
+
+- [ ] **Step 2: Re-export `EnricherFn` from `superset-core/src/superset_core/widgets/__init__.py`**
+
+```python
+from superset_core.widgets.base import Widget as Widget
+from superset_core.widgets.composites import (
+    composite_control as composite_control,
+    list_composite_controls as list_composite_controls,
+    MetricControl as MetricControl,
+)
+from superset_core.widgets.decorators import widget as widget
+from superset_core.widgets.enrichment import EnricherFn as EnricherFn
+```
+
+- [ ] **Step 3: Run the existing widgets suite — expect Balloons failures**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/widgets/ tests/unit_tests/mcp_service/widgets/ -v`
+Expected: `test_builtin.py`'s Balloons tests now FAIL, because `Balloons` still
+overrides the now-nonexistent `enrich_schema` (dead code — no longer called)
+and has no `enrichers` registered, so `series` stays open-ended in every
+case. Every non-Balloons test should still PASS. This is expected — Task 5
+fixes it; do not treat this as a regression to chase down within this task.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add superset-core/src/superset_core/widgets/base.py superset-core/src/superset_core/widgets/__init__.py
+git commit -m "feat(dashboard-v2): drive get_control_schema through the enricher pipeline"
+```
+
+---
+
+## Task 4: Registration-time cycle detection
+
+**Files:**
+- Modify: `superset/core/api/core_api_injection.py`
+- Test: `tests/unit_tests/widgets/test_registry.py` (append)
+
+**Interfaces:**
+- Consumes: Task 3's `get_control_schema` (which now raises `ValueError` internally on a cyclic graph, via `toposort_or_raise`).
+- Produces: nothing new — this task only changes *when* that `ValueError` can surface (at `@widget` application, not first request).
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/unit_tests/widgets/test_registry.py`:
+
+```python
+def test_widget_registration_raises_on_cyclic_dependency() -> None:
+    from typing import Any, ClassVar
+
+    from pydantic import BaseModel, Field
+    from superset_core.widgets import widget, Widget
+
+    class _CyclicControls(BaseModel):
+        a: dict[str, Any] = Field(
+            default_factory=dict,
+            json_schema_extra={"x-dynamic": True, "x-dependsOn": ["b"]},
+        )
+        b: dict[str, Any] = Field(
+            default_factory=dict,
+            json_schema_extra={"x-dynamic": True, "x-dependsOn": ["a"]},
+        )
+
+    with pytest.raises(ValueError, match="Cyclic control dependency"):
+
+        @widget(widget_type="cyclic-test-widget", name="Cyclic")
+        class _Cyclic(Widget):
+            controls_class = _CyclicControls
+
+    # The widget must not remain half-registered after the failure.
+    assert registry.get("cyclic-test-widget") is None
+```
+
+(Add `import pytest` at the top of the file if not already present — check
+first, since `test_registry.py` may already import it via other tests in
+this suite.)
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/widgets/test_registry.py -v -k cyclic`
+Expected: FAIL — the widget currently registers successfully (no eager
+schema build happens at registration), so no `ValueError` is raised, and the
+final `assert registry.get(...) is None` also fails since it *did* register.
+
+- [ ] **Step 3: Make registration eager, and roll back on failure**
+
+In `superset/core/api/core_api_injection.py`, inside `inject_widget_implementations`'s
+`widget_impl`'s `decorator`, after `registry[key] = cls` and before `return cls`:
+
+```python
+            cls.widget_type = key
+            cls.name = name
+            cls.description = description or ""
+            registry[key] = cls
+            try:
+                # Eagerly build the base (control_values=None) schema once, so
+                # a cyclic x-dependsOn graph among this widget's dynamic
+                # fields fails at import time -- get_control_schema's
+                # toposort_or_raise call is what actually detects the cycle;
+                # this just forces that check to run now instead of on the
+                # widget's first real request.
+                cls.get_control_schema(None, None)
+            except Exception:
+                registry.pop(key, None)
+                raise
+            return cls
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/widgets/test_registry.py -v -k cyclic`
+Expected: PASS.
+
+- [ ] **Step 5: Run the full widgets + MCP suite**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/widgets/ tests/unit_tests/mcp_service/widgets/ -v`
+Expected: same state as Task 3 Step 3 — Balloons tests still failing (Task 5
+fixes them), everything else passing, plus the new cyclic test passing.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add superset/core/api/core_api_injection.py tests/unit_tests/widgets/test_registry.py
+git commit -m "feat(dashboard-v2): detect cyclic control dependencies at widget registration"
+```
+
+---
+
+## Task 5: Retrofit `Balloons` onto the enricher registry
+
+**Files:**
+- Modify: `superset/widgets/builtin.py`
+
+**Interfaces:**
+- Consumes: `Widget.enrichers` (Task 3).
+- Produces: nothing new — `Balloons`'s served schema must be byte-identical to before this whole plan started (golden-fixture proof, `test_builtin.py`/`test_registry.py` already assert this exactly).
+
+- [ ] **Step 1: Replace `Balloons.enrich_schema` with a registered enricher**
+
+In `superset/widgets/builtin.py`, replace the `enrich_schema` classmethod with:
+
+```python
+    @staticmethod
+    def _populate_series(
+        schema: dict[str, Any],
+        node: dict[str, Any],
+        parsed: BaseModel | None,
+        series: list[str],
+        upstream: dict[str, Any],
+    ) -> None:
+        # `node` is Customization.series's own fragment; `SeriesStyle` is a
+        # sibling $defs entry, only reachable via the full `schema`.
+        style_def = schema.get("$defs", {}).get("SeriesStyle")
+        if style_def is None:
+            return
+        # The x-dependsOn: ["dataBinding"] gate (run_enrichers) only confirms
+        # a dataBinding was parsed at all -- it can't express "dimensions is
+        # non-empty" (a nested attribute) or "series is non-empty" (a runtime
+        # parameter, not a field on parsed), so both stay checked here.
+        dimensions = None
+        if parsed is not None:
+            data_binding = getattr(parsed, "data_binding", None)
+            dimensions = getattr(data_binding, "dimensions", None)
+        if not dimensions or not series:
+            return
+        # Dedupe (preserving order) and cap before doing per-series work, so an
+        # oversized/duplicate list can't blow up CPU, memory, or response size.
+        unique_series = list(dict.fromkeys(series))[: Balloons.MAX_SERIES]
+        # Replace the open-ended map with one inlined, pre-colored style per series.
+        node.pop("additionalProperties", None)
+        properties: dict[str, Any] = {}
+        for index, value in enumerate(unique_series):
+            style = deepcopy(style_def)
+            style["properties"]["color"]["default"] = Balloons.PALETTE[
+                index % len(Balloons.PALETTE)
+            ]
+            # Title each group with the series value so the control panel labels
+            # it by series rather than by the shared model name ("SeriesStyle").
+            style["title"] = value
+            properties[value] = style
+        node["properties"] = properties
+
+    enrichers: ClassVar[dict[str, EnricherFn]] = {"customize/series": _populate_series}
+```
+
+Add the necessary imports at the top of `superset/widgets/builtin.py`:
+```python
+from typing import Any, ClassVar
+
+from superset_core.widgets import EnricherFn, Widget, widget
+```
+(replacing the existing `from typing import Any` and `from superset_core.widgets import Widget, widget` lines).
+
+- [ ] **Step 2: Run the Balloons tests to verify they pass again**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/widgets/test_builtin.py -v`
+Expected: all PASS, unchanged assertions — proves the retrofit reproduces
+`enrich_schema`'s exact prior behavior.
+
+- [ ] **Step 3: Run the full widgets + MCP + semantic_layers suite**
+
+Run: `venv/bin/python3 -m pytest tests/unit_tests/widgets/ tests/unit_tests/mcp_service/widgets/ tests/unit_tests/semantic_layers/ -v`
+Expected: all PASS, including the composite-control-registry slice's golden-fixture
+tests for `metric-tile`/`balloons` (this plan must not regress those either).
+
+- [ ] **Step 4: mypy**
+
+Run: `pre-commit run mypy --files superset-core/src/superset_core/widgets/enrichment.py superset-core/src/superset_core/widgets/base.py superset-core/src/superset_core/widgets/__init__.py superset-core/src/superset_core/semantic_layers/config.py superset/core/api/core_api_injection.py superset/widgets/builtin.py`
+Expected: no errors. Fix inline if any surface (the composite-control-registry
+slice hit one narrowing issue mypy flagged that `isinstance`+`issubclass` alone
+didn't resolve — bind to an explicitly-annotated local if something similar
+comes up here).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add superset/widgets/builtin.py
+git commit -m "refactor(dashboard-v2): retrofit Balloons onto the enricher registry"
+```
+
+---
+
+## Final Verification
+
+- [ ] Run `pre-commit run --files <every file touched across all 5 tasks>` and fix anything it flags; re-run the affected tests after any auto-fix.
+- [ ] Run the full backend unit suite once more: `venv/bin/python3 -m pytest tests/unit_tests/widgets/ tests/unit_tests/mcp_service/widgets/ tests/unit_tests/semantic_layers/ -v`
+- [ ] Confirm no frontend files were touched: `git status --porcelain -- superset-frontend/` should be empty.
+- [ ] Reconcile the spec (`docs/superpowers/specs/2026-08-26-control-dependency-graph-design.md`) with anything discovered during implementation that diverged from the plan (`EnricherFn`'s signature, `check_dependencies`'s alias fix, the gate-vs-fine-grained-guard nuance) — same discipline as the composite-control-registry slice's post-implementation doc reconciliation.