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

(superset) branch master updated: feat(mcp): add typed Bullet chart support (#43770)

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

aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 2747094d4c2 feat(mcp): add typed Bullet chart support (#43770)
2747094d4c2 is described below

commit 2747094d4c2799214d15c5158274423151c904f7
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Mon Oct 12 23:15:00 2026 +1000

    feat(mcp): add typed Bullet chart support (#43770)
    
    Co-authored-by: Claude Opus 5 <no...@anthropic.com>
---
 UPDATING.md                                        |    2 +
 docs/admin_docs/configuration/mcp-server.mdx       |   67 +-
 .../docs/using-superset/using-ai-with-superset.mdx |    5 +
 .../test/Bullet/buildQuery.test.ts                 |   15 +
 .../test/Bullet/transformProps.test.ts             |  172 +-
 .../src/layers/Geojson/Geojson.test.tsx            |   22 +
 superset/dataframe.py                              |    4 +
 superset/mcp_service/CLAUDE.md                     |    5 +-
 superset/mcp_service/app.py                        |    4 +-
 superset/mcp_service/caching.py                    |    2 +-
 superset/mcp_service/chart/chart_helpers.py        |   63 +
 superset/mcp_service/chart/chart_utils.py          |  750 ++-
 superset/mcp_service/chart/compile.py              |  303 +-
 superset/mcp_service/chart/plugin.py               |   38 +-
 superset/mcp_service/chart/plugins/__init__.py     |    3 +
 superset/mcp_service/chart/plugins/bullet.py       |  596 +++
 superset/mcp_service/chart/plugins/gantt.py        |    1 +
 superset/mcp_service/chart/plugins/geographic.py   |    2 +
 superset/mcp_service/chart/preview_utils.py        | 1098 +++-
 superset/mcp_service/chart/query_result.py         |  218 +-
 .../mcp_service/chart/resources/chart_configs.py   |   45 +
 superset/mcp_service/chart/schemas.py              |  632 ++-
 superset/mcp_service/chart/tool/generate_chart.py  |   10 +-
 superset/mcp_service/chart/tool/get_chart_data.py  |   75 +-
 .../mcp_service/chart/tool/get_chart_preview.py    |   13 +
 .../chart/tool/get_chart_type_schema.py            |   25 +-
 superset/mcp_service/chart/tool/update_chart.py    |    4 +-
 .../mcp_service/chart/tool/update_chart_preview.py |    1 +
 .../chart/validation/dataset_validator.py          |   36 +
 superset/mcp_service/chart/validation/pipeline.py  |   61 +-
 tests/unit_tests/dataframe_test.py                 |   86 +
 .../mcp_service/chart/test_bullet_chart.py         | 5308 ++++++++++++++++++++
 .../mcp_service/chart/test_bullet_tool_paths.py    | 2337 +++++++++
 .../chart/test_chart_plugin_contract.py            |   85 +-
 tests/unit_tests/mcp_service/chart/test_compile.py |   43 +
 .../mcp_service/chart/test_gauge_chart.py          |   98 +-
 .../chart/test_native_chart_regressions.py         |  351 ++
 .../test_review_query_and_preview_regressions.py   |   92 +
 .../mcp_service/chart/test_sunburst_chart.py       |    5 +
 .../chart/tool/test_update_chart_preview.py        |   18 +-
 .../semantic_layer/tool/test_get_table.py          |   48 +
 41 files changed, 12637 insertions(+), 106 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index febb5024aea..74666fbb845 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -60,6 +60,8 @@ assists people when migrating to a new version.
   the response size guard does not raise these fixed limits. Oversized results,
   including saved Table exports, return `InvalidQueryResult`; lower row limits,
   filter, select fewer/narrower columns, or aggregate before exporting.
+  Bullet preview numeric format precision is limited to 20 digits; raw data
+  reads do not validate presentation formats.
 
 - MCP `update_chart` requires a complete `config` when changing `dataset_id`
   to a different dataset, for both preview and immediate-save requests. Re-sending
diff --git a/docs/admin_docs/configuration/mcp-server.mdx b/docs/admin_docs/configuration/mcp-server.mdx
index 4204187d187..ee9ef0db14f 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1472,9 +1472,74 @@ MCP_DISABLED_TOOLS = {"extensions.myorg.myextension.some_tool"}
 Specifying a tool name that does not exist logs a warning at startup and is otherwise ignored — it will not prevent the server from starting.
 :::
 
+## Bullet chart compatibility
+
+The MCP Bullet plugin uses `chart_type: "bullet"` and the native ECharts
+`viz_type: "bullet"`. Its optional `dimensions` hierarchy maps to `groupby`.
+Omit `dimensions` (or use `null`) to create a single-metric Bullet without a
+breakdown. On updates, omission or `null` preserves the saved hierarchy; use
+`dimensions: []` to clear it explicitly. An `order_by` update can reference the
+saved dimensions without resending them; unknown targets are rejected after
+resolving the saved hierarchy. Replacing the dataset follows the shared rebind
+contract: saved query roles are not inherited, so resend the hierarchy.
+
+Inherited native SQL dimensions remain query expressions when updating metrics
+or `order_by`; reference their output labels to sort by them. Caller-supplied
+`dimensions` must still be physical columns, not SQL expressions.
+
+Dimension and metric output names are case-sensitive: quoted physical columns
+such as `Region` and `region` remain distinct. Reference lookup prefers exact
+names and uses case-insensitive matching only when there is a single candidate;
+ambiguous references require the exact spelling. Bullet metric strings use
+JavaScript numeric spellings: underscore separators and non-ASCII digits are
+rejected rather than interpreted as numbers. Bounded array-valued dimensions
+retain their raw values in data reads and exports; preview category labels use
+JavaScript string conversion (for example, `[1, 2]` displays as `1,2`). Native
+SQL metrics without a label use their SQL expression as the output label.
+Native metric objects in `orderby` or `order_by_cols` use the same output-label
+fallback, so an unlabeled SQL metric can also be used as its own sort target.
+
+Presentation-only updates preserve saved predicates, including legacy top-level
+`where`, `having`, and `filters`, and normalize the native `order_by_cols` alias.
+When both `orderby` and `order_by_cols` are saved, their sort entries are
+concatenated in form-data key order, including when `orderby` is empty.
+Explicit `filters: []` and `order_by: []` clear those inherited controls.
+
+Range, marker, and marker-line label lists may be shorter than their value lists.
+Labels that become empty after sanitization retain their slots so later labels
+remain aligned with their corresponding values.
+As in Explore, missing or empty range labels are not displayed, and missing or
+empty marker labels use the formatted numeric value. Extra labels have no value
+to annotate and are ignored. These rules also apply to saved-chart previews and
+updates. Omitted label controls preserve the saved state only when their
+corresponding value controls (`ranges`, `markers`, or `marker_lines`) are also
+omitted. Replacing a value control without its labels clears the saved labels;
+resend the labels to retain annotations with the replacement values.
+
+Saved native `ranges`, `markers`, and `marker_lines` controls ignore empty,
+non-numeric, and NaN tokens, matching Explore. If no numeric range remains, the
+preview uses the default band up to 110% of the largest measure. Unrelated
+updates preserve these saved controls. Newly authored typed lists must contain
+finite numbers; infinite native values and oversized controls remain errors.
+
+On same-dataset chart updates, `filters: []` clears both user filters and the
+generated dashboard-time binding. `temporal_column: null` clears only that
+generated binding, preserving user filters. Omitting those controls preserves
+the saved binding. Bullet Vega previews keep separate indexed rows even when
+dimension display labels are identical, and support both `SMART_NUMBER` and
+`SMART_NUMBER_SIGNED` number formats. Bullet preview numeric format
+precision is limited to 20 digits before formatting; raw data reads do not
+validate presentation formats.
+
+Native Bullet temporal filters retain the active filter's subject and range as a
+pair; `No filter` placeholders do not supply the subject of another active range.
+Typed configs support one such pair. Multiple active native temporal filters, or
+conflicts with explicit `temporal_column`/`time_range`, are rejected rather than
+silently dropping or moving a predicate.
+
 ## Disabling chart type plugins
 
-The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `treemap_v2`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart type plugin [...]
+The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `treemap_v2`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`, `bullet`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart t [...]
 
 Saved Bubble and Histogram Vega-Lite previews with zero rows return an empty
 specification instead of `NoDataError`, matching their unsaved previews.
diff --git a/docs/docs/using-superset/using-ai-with-superset.mdx b/docs/docs/using-superset/using-ai-with-superset.mdx
index 74af9c4a050..e651cc0c69e 100644
--- a/docs/docs/using-superset/using-ai-with-superset.mdx
+++ b/docs/docs/using-superset/using-ai-with-superset.mdx
@@ -90,6 +90,11 @@ built-in `pivot_table` chart type and is not offered when the host visualization
 is unavailable.
 :::
 
+For saved Bullet charts, updates that omit dimensions preserve Explore's
+hierarchy, including SQL expressions, along with native query metadata such as
+URL parameters. Replacing a metric also updates inherited expression-based
+ordering for that metric output. Raw chart data and CSV/Excel exports do not
+require the selected number formatter to be supported by MCP previews.
 MCP Vega-Lite previews of grouped time-series charts retain each series from
 post-processed chart data, including charts with multiple grouping dimensions.
 Saved line, bar, area, and scatter previews honor the requested width and height
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/buildQuery.test.ts
index fa2c0c0a540..6861a9e2780 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/buildQuery.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/buildQuery.test.ts
@@ -46,3 +46,18 @@ test('passes the groupby through as query columns', () => {
   expect(query.columns).toEqual(['state']);
   expect(query.metrics).toEqual(['sum__num']);
 });
+
+test('forwards explicit ordering and row limit to the query', () => {
+  const [query] = buildQuery({
+    datasource: '5__table',
+    viz_type: 'bullet',
+    metric: 'Revenue',
+    groupby: ['Region'],
+    orderby: [['Revenue', false]],
+    row_limit: 25,
+  }).queries;
+  expect(query.columns).toEqual(['Region']);
+  expect(query.metrics).toEqual(['Revenue']);
+  expect(query.orderby).toEqual([['Revenue', false]]);
+  expect(query.row_limit).toBe(25);
+});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/transformProps.test.ts
index 306db08e527..c8d68b2065f 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/transformProps.test.ts
@@ -33,13 +33,16 @@ const formData: SqlaFormData = {
   markerLineLabels: 'stretch',
 };
 
-const chartProps = (overrides: Partial<SqlaFormData> = {}) =>
+const chartProps = (
+  overrides: Partial<SqlaFormData> = {},
+  data: Array<Record<string, number>> = [{ sum__num: 120 }],
+) =>
   new ChartProps({
     width: 800,
     height: 200,
     formData: { ...formData, ...overrides },
     theme: supersetTheme,
-    queriesData: [{ data: [{ sum__num: 120 }] }],
+    queriesData: [{ data }],
     hooks: {},
   }) as unknown as EchartsBulletChartProps;
 
@@ -149,6 +152,27 @@ test('marks a measure beyond every range as past the last label', () => {
   );
 });
 
+test('uses sorted inclusive range boundaries and omits unavailable labels', () => {
+  const tooltip = (measure: number, ranges: string, rangeLabels: string) => {
+    const { echartOptions } = transformProps(
+      chartProps({ ranges, rangeLabels }, [{ sum__num: measure }]),
+    );
+    const series = echartOptions.series as Array<{
+      tooltip: { formatter: () => string };
+    }>;
+    return series[0].tooltip.formatter();
+  };
+
+  expect(tooltip(100, '300,100,200', 'high,low,mid')).toContain('Range: low');
+  expect(tooltip(120, '300,100,200', 'high,low,mid')).toContain('Range: mid');
+  expect(tooltip(-5, '10,-10,0', 'positive,negative,zero')).toContain(
+    'Range: zero',
+  );
+  expect(tooltip(50, '100,200', ',high')).not.toContain('Range:');
+  expect(tooltip(250, '100,200', 'low,')).not.toContain('Range:');
+  expect(tooltip(120, '100,200', '')).not.toContain('Range:');
+});
+
 test('renders range labels inside the top-right corner of their bands', () => {
   const { echartOptions } = transformProps(chartProps({ showLabels: true }));
   const { series } = echartOptions as any;
@@ -222,6 +246,101 @@ test('handles an empty query result without crashing', () => {
   expect(max).toBeGreaterThan(min);
 });
 
+test('coerces grouped categories with JavaScript String semantics', () => {
+  const props = new ChartProps({
+    width: 800,
+    height: 400,
+    formData: { ...formData, groupby: ['state'] },
+    theme: supersetTheme,
+    queriesData: [
+      {
+        data: [
+          { state: null, sum__num: 1 },
+          { state: '', sum__num: 2 },
+          { state: true, sum__num: 3 },
+          { state: false, sum__num: 4 },
+          { state: 12.0, sum__num: 5 },
+          { state: 12.5, sum__num: 6 },
+          { state: -0, sum__num: 7 },
+          // Chart Data serializes date/datetime values as epoch-ms JSON
+          // numbers before ECharts applies String(value).
+          { state: JSON.parse('1788307200000'), sum__num: 8 },
+          { state: JSON.parse('1788318245000'), sum__num: 9 },
+          { state: JSON.parse('1699162200000'), sum__num: 10 },
+          { state: JSON.parse('1699165800000'), sum__num: 11 },
+          { state: '12345678-1234-5678-1234-567812345678', sum__num: 12 },
+        ],
+      },
+    ],
+    hooks: {},
+  }) as unknown as EchartsBulletChartProps;
+  const { echartOptions } = transformProps(props);
+  const { data } = (echartOptions as { yAxis: { data: string[] } }).yAxis;
+  expect(data).toEqual([
+    'null',
+    '',
+    'true',
+    'false',
+    '12',
+    '12.5',
+    '0',
+    '1788307200000',
+    '1788318245000',
+    '1699162200000',
+    '1699165800000',
+    '12345678-1234-5678-1234-567812345678',
+  ]);
+});
+
+test('coerces JSON number boundaries through IEEE-754 before String', () => {
+  const wireNumbers = [
+    '9007199254740993',
+    '9007199254740995',
+    '1.0000000000000001',
+    '1.0000000000000002',
+    '1e20',
+    '1e21',
+    '1e-6',
+    '1e-7',
+    '1.7976931348623157e308',
+    '1.7976931348623159e308',
+    '-1e309',
+    '-0',
+  ].map(value => JSON.parse(value) as number);
+  const props = new ChartProps({
+    width: 800,
+    height: 400,
+    formData: { ...formData, groupby: ['state'] },
+    theme: supersetTheme,
+    queriesData: [
+      {
+        data: wireNumbers.map((state, index) => ({
+          state,
+          sum__num: index + 1,
+        })),
+      },
+    ],
+    hooks: {},
+  }) as unknown as EchartsBulletChartProps;
+
+  const { echartOptions } = transformProps(props);
+  const { data } = (echartOptions as { yAxis: { data: string[] } }).yAxis;
+  expect(data).toEqual([
+    '9007199254740992',
+    '9007199254740996',
+    '1',
+    '1.0000000000000002',
+    '100000000000000000000',
+    '1e+21',
+    '0.000001',
+    '1e-7',
+    '1.7976931348623157e+308',
+    'Infinity',
+    '-Infinity',
+    '0',
+  ]);
+});
+
 test('splits into one bullet row per group value with shared markers', () => {
   const props = new ChartProps({
     width: 800,
@@ -256,3 +375,52 @@ test('splits into one bullet row per group value with shared markers', () => {
   // grouped bar tooltips name the row
   expect(series[0].tooltip.formatter({ dataIndex: 1 })).toContain('NY');
 });
+
+test('keeps exact-case dimensions and uses per-index fallbacks for short labels', () => {
+  const { echartOptions } = transformProps(
+    chartProps(
+      {
+        groupby: ['Region', 'region'],
+        ranges: '100,200,300',
+        rangeLabels: 'low',
+        markers: '150,160',
+        markerLabels: 'goal',
+        markerLines: '250,260',
+        markerLineLabels: 'stretch',
+        showLabels: true,
+        yAxisFormat: '.1f',
+      },
+      [{ Region: 1, region: 2, sum__num: 120 }],
+    ),
+  );
+  type Label = { show?: boolean; formatter: () => string };
+  const options = echartOptions as {
+    yAxis: { data: string[] };
+    series: Array<{
+      type: string;
+      symbol?: string;
+      label?: Label;
+      markArea?: { data: Array<[{ label: Label }, { xAxis: number }]> };
+      markLine?: { data: Array<{ label: Label }> };
+      data: Array<{ label?: Label }>;
+    }>;
+  };
+  expect(options.yAxis.data).toEqual(['1, 2']);
+  const bands = options.series[0].markArea?.data;
+  expect(bands?.map(band => band[0].label.show)).toEqual([false, false, true]);
+  expect(bands?.map(band => band[0].label.formatter())).toEqual([
+    '',
+    '',
+    'low',
+  ]);
+  expect(
+    options.series
+      .filter(series => series.symbol === 'triangle')
+      .map(series => series.label?.formatter()),
+  ).toEqual(['goal', '160.0']);
+  expect(
+    options.series
+      .filter(series => series.type === 'line')
+      .map(series => series.markLine?.data[0].label.formatter()),
+  ).toEqual(['stretch', '260.0']);
+});
diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/Geojson.test.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/Geojson.test.tsx
index b930634e77e..48cb9a9a151 100644
--- a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/Geojson.test.tsx
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/Geojson.test.tsx
@@ -32,6 +32,7 @@ import DeckGLGeoJson, {
   getPoints,
   getLayer,
 } from './Geojson';
+import buildQuery, { type DeckGeoJsonFormData } from './buildQuery';
 import controlPanel from './controlPanel';
 
 const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
@@ -59,6 +60,27 @@ jest.mock('react-map-gl/maplibre', () => ({
   useControl: () => null,
 }));
 
+test('buildQuery includes geometry, cross-filter, and tooltip fields', () => {
+  const formData: DeckGeoJsonFormData = {
+    datasource: '1__table',
+    viz_type: 'deck_geojson',
+    geojson: 'geom',
+    cross_filter_column: 'region',
+    tooltip_contents: ['name', 'region'],
+  };
+
+  const [query] = buildQuery(formData).queries;
+
+  expect(query.columns).toEqual(['geom', 'region', 'name']);
+  expect(query.metrics).toEqual([]);
+  expect(query.groupby).toEqual([]);
+  expect(query.filters).toContainEqual({
+    col: 'geom',
+    op: 'IS NOT NULL',
+  });
+  expect(query.is_timeseries).toBe(false);
+});
+
 test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
   const formData: SqlaFormData = {
     label_property_name: 'name',
diff --git a/superset/dataframe.py b/superset/dataframe.py
index 16dfe35abdd..82bd6e779a5 100644
--- a/superset/dataframe.py
+++ b/superset/dataframe.py
@@ -139,6 +139,10 @@ def df_to_records(
             # converting it to float can overflow or lose precision.
             if type(value) in _NUMPY_NATIVE_TYPES:
                 value = value.item()
+            elif type(value) is np.timedelta64:
+                value = pd.Timedelta(value)
+            elif type(value) is np.datetime64:
+                value = pd.Timestamp(value)
             if _is_trusted_missing_or_nonfinite(value):
                 value = None
             else:
diff --git a/superset/mcp_service/CLAUDE.md b/superset/mcp_service/CLAUDE.md
index fba77ae9ce5..78d24061388 100644
--- a/superset/mcp_service/CLAUDE.md
+++ b/superset/mcp_service/CLAUDE.md
@@ -410,8 +410,9 @@ Used by: `get_chart_info`, `get_chart_preview`, `get_chart_data`, `generate_char
 
 When creating, saving, or previewing charts, run schema validation (Tier 1)
 and optionally a compile check (Tier 2) before persisting or caching.
-``validate_and_compile`` glues both together; tools with tight SLAs
-(``generate_explore_link``, ``update_chart_preview``) opt out of Tier 2.
+``validate_and_compile`` glues both together. Explicitly latency-sensitive
+link-only tools such as ``generate_explore_link`` may opt out of Tier 2;
+mutation and cached-preview update tools run Tier 2 before persistence.
 
 ```python
 from superset.mcp_service.chart.compile import validate_and_compile
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 5021be2cdbc..b1cbf6151b3 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -444,6 +444,8 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
 - chart_type="bubble_v2": Bubble scatter plotting three metrics at once
   (entity dimension + x, y and size metrics required; optional series
    dimension colors the bubbles by group)
+- chart_type="bullet": Bullet Chart for comparing one numeric measure per row
+  against qualitative ranges, point markers, and marker lines
 
 Time grain for temporal x-axis (time_grain parameter):
 - PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P1Y (yearly)
@@ -453,7 +455,7 @@ Each chart returned by list_charts / get_chart_info includes a
 chart_type_display_name field with a human-readable name when available.
 This field is populated for chart types known to the MCP registry
 (xy, pie, sunburst, table, pivot_table, big_number, mixed_timeseries, handlebars,
-histogram, box_plot, waterfall, gantt, bubble_v2, and interactive_pivot).
+histogram, box_plot, waterfall, gantt, bullet, bubble_v2, and interactive_pivot).
 Availability gates creation and schema discovery, not display names for
 existing charts.
 For all other viz_types (Funnel, Gauge, Heatmap, etc.) it will be null —
diff --git a/superset/mcp_service/caching.py b/superset/mcp_service/caching.py
index 164cf8d3357..94587a76bb8 100644
--- a/superset/mcp_service/caching.py
+++ b/superset/mcp_service/caching.py
@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
 # namespace whenever a cached response from an older release is not valid under
 # the active contract. This keeps rolling upgrades from serving incompatible
 # entries through newly upgraded processes without trying to rewrite cached data.
-MCP_RESPONSE_CACHE_NAMESPACE = "response-contract-v2:"
+MCP_RESPONSE_CACHE_NAMESPACE = "response-contract-v3:"
 
 # Tools whose responses must never be served from the response cache,
 # regardless of MCP_CACHE_CONFIG["excluded_tools"]. The catalog is a live,
diff --git a/superset/mcp_service/chart/chart_helpers.py b/superset/mcp_service/chart/chart_helpers.py
index c3707175839..1e5771dedd8 100644
--- a/superset/mcp_service/chart/chart_helpers.py
+++ b/superset/mcp_service/chart/chart_helpers.py
@@ -1429,3 +1429,66 @@ def build_applied_dashboard_filters(
         )
 
     return applied
+
+
+def _parse_orderby(values: Any) -> list[list[Any]]:
+    """Parse bounded native ``order_by_cols`` without coercing malformed input."""
+    if values is not None and not isinstance(values, list):
+        raise ValueError("order_by_cols must be a list")
+    if isinstance(values, list) and len(values) > 100:
+        raise ValueError("order_by_cols must contain at most 100 entries")
+    from superset.utils import json as utils_json
+
+    result: list[list[Any]] = []
+    for index, value in enumerate(values or []):
+        if isinstance(value, str):
+            if len(value) > 1000:
+                raise ValueError(f"order_by_cols[{index}] is too long")
+            try:
+                value = utils_json.loads(value)
+            except (TypeError, ValueError) as ex:
+                raise ValueError(f"order_by_cols[{index}] is not valid JSON") from ex
+        if (
+            isinstance(value, (list, tuple))
+            and len(value) == 2
+            and isinstance(value[0], str)
+            and bool(value[0])
+            and isinstance(value[1], bool)
+        ):
+            result.append(list(value))
+        else:
+            raise ValueError(
+                f"order_by_cols[{index}] must be [column, ascending_boolean]"
+            )
+    return result
+
+
+def _column_label(column: Any) -> str | None:
+    """Return the frontend ``getColumnLabel`` value for a query column."""
+    if isinstance(column, str):
+        return column
+    if not isinstance(column, dict):
+        return None
+    return (
+        column.get("label") or column.get("sqlExpression") or column.get("column_name")
+    )
+
+
+def _metric_label(metric: Any) -> str | None:
+    """Return the frontend ``getMetricLabel`` value for a query metric."""
+    if isinstance(metric, str):
+        return metric
+    if not isinstance(metric, dict):
+        return None
+    if label := metric.get("label"):
+        return label
+    if metric.get("expressionType") == "SIMPLE":
+        column = metric.get("column") or {}
+        name = (
+            column.get("columnName") or column.get("column_name")
+            if isinstance(column, dict)
+            else None
+        )
+        if name and metric.get("aggregate"):
+            return f"{metric['aggregate']}({name})"
+    return metric.get("sqlExpression")
diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py
index cf546dc27d3..f8fe13ce789 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -24,6 +24,7 @@ generation that can be used by both generate_chart and generate_explore_link too
 
 import hashlib
 import logging
+import math
 from collections.abc import Mapping, Sequence
 from dataclasses import dataclass
 from typing import Any, cast, Dict, TYPE_CHECKING
@@ -39,9 +40,11 @@ from superset.constants import NO_TIME_RANGE
 from superset.daos.exceptions import DatasourceNotFound, DatasourceValueIsIncorrect
 from superset.exceptions import SupersetSecurityException
 from superset.mcp_service.chart.schemas import (
+    _get_known_fields,
     BigNumberChartConfig,
     BoxPlotChartConfig,
     BubbleChartConfig,
+    BulletChartConfig,
     ChartCapabilities,
     ChartConfig,
     ChartSemantics,
@@ -56,6 +59,7 @@ from superset.mcp_service.chart.schemas import (
     MixedTimeseriesChartConfig,
     PieChartConfig,
     PivotTableChartConfig,
+    resolve_bullet_order_target,
     SortByConfig,
     SunburstChartConfig,
     TableChartConfig,
@@ -468,9 +472,37 @@ def map_config_to_form_data(
         raise ValueError(" ".join(parts))
 
     _bind_dashboard_time_range_filter(form_data, config, dataset_id)
+    if plugin.binds_time_range_to_temporal_filter:
+        _apply_time_range_to_bound_filter(form_data, config)
     return form_data
 
 
+def _apply_time_range_to_bound_filter(
+    form_data: Dict[str, Any], config: ChartConfig
+) -> None:
+    """Set an explicit ``time_range`` on the generated dashboard time binding.
+
+    Charts whose frontend reads time only through adhoc filters (for example
+    Bullet) would otherwise ignore an explicit range once the binding falls
+    back to the dataset's main temporal column.
+    """
+    if "time_range" not in getattr(config, "model_fields_set", set()):
+        return
+    subject = form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    if not isinstance(subject, str) or not subject:
+        return
+    comparator = getattr(config, "time_range", None) or NO_TIME_RANGE
+    for filter_ in form_data.get("adhoc_filters") or []:
+        if (
+            isinstance(filter_, dict)
+            and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+            and filter_.get("subject") == subject
+            and filter_.get("comparator") == NO_TIME_RANGE
+        ):
+            filter_["comparator"] = comparator
+            break
+
+
 def _add_adhoc_filters(
     form_data: Dict[str, Any], filters: Sequence[FilterConfig] | None
 ) -> None:
@@ -3538,7 +3570,676 @@ def _as_column_list(value: Any) -> list[Any]:
     return [value]
 
 
-def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabilities:
+_NATIVE_TEMPORAL_ROLE_FIELDS: dict[str, frozenset[str]] = {
+    # Typed ``x`` is persisted as native x_axis/granularity_sqla for XY and
+    # Mixed Timeseries. Waterfall exposes the typed field as ``x_axis``.
+    "x_axis": frozenset({"x", "x_axis"}),
+    "granularity_sqla": frozenset({"x", "x_axis", "temporal_column"}),
+    # Chart plugins may designate a chart-specific query role as the implicit
+    # dashboard-time subject.
+    "start_time": frozenset({"start_time"}),
+}
+
+
+def _bullet_token_list(values: Sequence[str | int | float]) -> str:
+    """Serialize typed Bullet controls to the frontend's comma-separated form."""
+    tokens: list[str] = []
+    for value in values:
+        if isinstance(value, float):
+            token = repr(value)
+            # ``100`` parses back to the same binary float as ``100.0`` and
+            # preserves the frontend's established compact integer spelling.
+            if token.endswith(".0") and not (
+                value == 0.0 and math.copysign(1.0, value) < 0
+            ):
+                token = token[:-2]
+            tokens.append(token)
+        else:
+            tokens.append(str(value))
+    return ",".join(tokens)
+
+
+def map_bullet_config(config: BulletChartConfig) -> Dict[str, Any]:  # noqa: C901
+    """Map typed Bullet config to ``Bullet/buildQuery`` and transformProps.
+
+    The frontend buildQuery replaces the generic query fields with exactly one
+    metric and the groupby hierarchy. Presentation controls stay in native
+    snake_case form_data; the chart plugin camelizes them for transformProps.
+    """
+    if (
+        config.dimensions is None
+        and config._inherited_groupby is None
+        and config.order_by
+    ):
+        # An update resolves its saved hierarchy before mapping. Without one,
+        # creation must validate sort targets against an empty hierarchy.
+        BulletChartConfig.model_validate(
+            {**config.model_dump(exclude_unset=True), "dimensions": []}
+        )
+    metric = create_metric_object(config.metric)
+    form_data: Dict[str, Any] = {
+        "viz_type": "bullet",
+        "metric": metric,
+    }
+
+    # Optional semantic/query fields are emitted only when explicitly supplied.
+    # This lets update_chart and update_chart_preview preserve native saved state,
+    # while an explicit empty value still clears it through the generic merge path.
+    # The row limit always carries the schema default so a new chart is bounded;
+    # update merging restores the saved limit when the caller omits it.
+    if "dimensions" in config.model_fields_set:
+        form_data["groupby"] = [dimension.name for dimension in config.dimensions or []]
+    form_data["row_limit"] = config.row_limit
+    if "time_range" in config.model_fields_set:
+        form_data["time_range"] = config.time_range
+
+    if config.order_by:
+        dimensions = config.order_dimensions
+        orderby: list[list[Any]] = []
+        for order in config.order_by:
+            role, index = resolve_bullet_order_target(
+                order.column, dimensions, config.metric
+            )
+            if role == "metric":
+                order_target: Any = metric
+            else:
+                if index is None:  # Defensive: resolver pairs dimensions with indexes.
+                    raise ValueError("Bullet dimension order target has no index")
+                dimension = dimensions[index]
+                order_target = (
+                    dimension.name if isinstance(dimension, ColumnRef) else dimension
+                )
+            orderby.append([order_target, order.ascending])
+        form_data["orderby"] = orderby
+    elif "order_by" in config.model_fields_set:
+        form_data["orderby"] = []
+
+    presentation_fields: dict[str, tuple[str, Any]] = {
+        "ranges": ("ranges", _bullet_token_list(config.ranges)),
+        "range_labels": (
+            "range_labels",
+            _bullet_token_list(config.range_labels),
+        ),
+        "markers": ("markers", _bullet_token_list(config.markers)),
+        "marker_labels": (
+            "marker_labels",
+            _bullet_token_list(config.marker_labels),
+        ),
+        "marker_lines": (
+            "marker_lines",
+            _bullet_token_list(config.marker_lines),
+        ),
+        "marker_line_labels": (
+            "marker_line_labels",
+            _bullet_token_list(config.marker_line_labels),
+        ),
+        "y_axis_format": ("y_axis_format", config.y_axis_format),
+        "show_labels": ("show_labels", config.show_labels),
+        "show_legend": ("show_legend", config.show_legend),
+    }
+    for field_name, (form_key, value) in presentation_fields.items():
+        if field_name in config.model_fields_set:
+            form_data[form_key] = value
+
+    _add_adhoc_filters(form_data, config.filters)
+    if config.filters == [] and "filters" in config.model_fields_set:
+        form_data["adhoc_filters"] = []
+    if config.time_range and config.temporal_column:
+        _ensure_temporal_adhoc_filter(form_data, config.temporal_column)
+        for filter_ in form_data.get("adhoc_filters", []):
+            if (
+                isinstance(filter_, dict)
+                and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+                and filter_.get("subject") == config.temporal_column
+                and filter_.get("comparator") == NO_TIME_RANGE
+            ):
+                filter_["comparator"] = config.time_range
+    return form_data
+
+
+def _normalize_native_filter_aliases(form_data: Mapping[str, Any]) -> Dict[str, Any]:
+    """Fold legacy WHERE, HAVING, and filter predicates into adhoc controls."""
+    from superset.utils.core import form_data_to_adhoc, simple_filter_to_adhoc
+
+    normalized = dict(form_data)
+    legacy_filters = [
+        form_data_to_adhoc(normalized, clause)
+        for clause in ("having", "where")
+        if normalized.get(clause)
+    ]
+    legacy_filters.extend(
+        simple_filter_to_adhoc(filter_, "where")
+        for filter_ in normalized.get("filters") or []
+        if filter_ is not None
+    )
+    if legacy_filters:
+        normalized["adhoc_filters"] = [
+            *legacy_filters,
+            *(normalized.get("adhoc_filters") or []),
+        ]
+    for key in ("where", "having", "filters"):
+        normalized.pop(key, None)
+    return normalized
+
+
+def _normalize_bullet_query_aliases(form_data: Mapping[str, Any]) -> Dict[str, Any]:
+    """Fold inherited native predicates and ordering into canonical controls."""
+    from superset.mcp_service.chart.chart_helpers import _parse_orderby
+
+    normalized = _normalize_native_filter_aliases(form_data)
+    if "groupby" in normalized and not isinstance(normalized["groupby"], list):
+        # Bullet/buildQuery and transformProps read ensureIsArray(groupby).
+        normalized["groupby"] = bullet_groupby_list(normalized["groupby"])
+    if "order_by_cols" in normalized:
+        # Native extractQueryFields concatenates both aliases in key order.
+        ordering: list[Any] = []
+        for key, value in normalized.items():
+            if key == "order_by_cols":
+                ordering.extend(_parse_orderby(value))
+            elif key == "orderby":
+                ordering.extend(value or [])
+        normalized["orderby"] = ordering
+    normalized.pop("order_by_cols", None)
+    return normalized
+
+
+def merge_bullet_form_data(
+    existing_form_data: Mapping[str, Any], new_form_data: Dict[str, Any]
+) -> None:
+    """Preserve omitted native Bullet controls across update tool paths.
+
+    Query roles and every UI control have an explicit typed representation.
+    Mappers emit optional fields only when the caller supplied them, so copying
+    the bounded native keys below preserves omitted state while explicit empty,
+    false, null, and zero-like values remain authoritative.
+    """
+    if (
+        existing_form_data.get("viz_type") != "bullet"
+        or new_form_data.get("viz_type") != "bullet"
+    ):
+        return
+    existing_form_data = _normalize_bullet_query_aliases(existing_form_data)
+    preserved_keys = {
+        "groupby",
+        "adhoc_filters",
+        "time_range",
+        "row_limit",
+        "orderby",
+        "ranges",
+        "range_labels",
+        "markers",
+        "marker_labels",
+        "marker_lines",
+        "marker_line_labels",
+        "y_axis_format",
+        "show_labels",
+        "show_legend",
+        "url_params",
+        # Native query context (dashboard/native filter predicates and time
+        # overrides) that buildQueryObject applies on top of the controls.
+        "extra_form_data",
+        "extra_filters",
+        MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+    }
+
+    # Threshold and label arrays are one frontend control pair. If callers
+    # replace the values without replacing their labels, clear the stale labels
+    # instead of accidentally reassigning them by position.
+    dependent_controls = {
+        "ranges": "range_labels",
+        "markers": "marker_labels",
+        "marker_lines": "marker_line_labels",
+    }
+    for values_key, labels_key in dependent_controls.items():
+        if values_key in new_form_data and labels_key not in new_form_data:
+            new_form_data[labels_key] = ""
+
+    preserve_orderby = (
+        "orderby" not in new_form_data and "orderby" in existing_form_data
+    )
+    for key in preserved_keys:
+        if (
+            key == MCP_DASHBOARD_TIME_FILTER_SUBJECT
+            and "adhoc_filters" in new_form_data
+        ):
+            # The marker describes a mapper-generated temporal filter. Do not
+            # retain stale provenance when an explicit filter update removed it.
+            continue
+        if key in existing_form_data and key not in new_form_data:
+            new_form_data[key] = existing_form_data[key]
+    if preserve_orderby:
+        new_form_data["orderby"] = _orderby_for_final_output_roles(
+            existing_form_data, new_form_data
+        )
+
+
+def _bullet_output_labels(
+    form_data: Mapping[str, Any],
+) -> tuple[set[str], dict[str, Any]]:
+    """Return a Bullet state's dimension output labels and metric outputs."""
+    from superset.mcp_service.chart.chart_helpers import _column_label, _metric_label
+
+    dimensions = {
+        label
+        for column in bullet_groupby_list(form_data.get("groupby"))
+        if (label := _column_label(column)) is not None
+    }
+    metrics = form_data.get("metrics") or []
+    if not isinstance(metrics, (list, tuple)):
+        metrics = [metrics]
+    metric_outputs = {
+        label: metric
+        for metric in [form_data.get("metric"), *metrics]
+        if (label := _metric_label(metric)) is not None
+    }
+    return dimensions, metric_outputs
+
+
+def bullet_groupby_list(groupby: Any) -> list[Any]:
+    """Normalize a saved Bullet hierarchy like the frontend ``ensureIsArray``."""
+    if groupby is None:
+        return []
+    return list(groupby) if isinstance(groupby, (list, tuple)) else [groupby]
+
+
+def _orderby_for_final_output_roles(
+    existing_form_data: Mapping[str, Any], new_form_data: Mapping[str, Any]
+) -> Any:
+    """Drop sorts on removed output roles and rebind inherited metric expressions.
+
+    Native ordering may also rank by a saved metric or column that is not a
+    displayed output (``get_sqla_query`` resolves it independently). Those
+    sorters never named a Bullet role, so a role change does not remove them.
+    """
+    from superset.mcp_service.chart.chart_helpers import _column_label, _metric_label
+
+    saved = existing_form_data.get("orderby")
+    if not isinstance(saved, list):
+        return saved
+    outputs, metric_outputs = _bullet_output_labels(new_form_data)
+    outputs.update(metric_outputs)
+    previous_dimensions, previous_metrics = _bullet_output_labels(existing_form_data)
+    previous_outputs = previous_dimensions | set(previous_metrics)
+    retained = []
+    for entry in saved:
+        if isinstance(entry, (list, tuple)) and entry:
+            target = entry[0]
+            label = (
+                _metric_label(target)
+                or _column_label(target)
+                or target.get("metric_name")
+                if isinstance(target, Mapping)
+                else target
+            )
+            if (
+                isinstance(label, str)
+                and label not in outputs
+                and label in previous_outputs
+            ):
+                continue
+            if (
+                isinstance(target, Mapping)
+                and isinstance(label, str)
+                and label in metric_outputs
+            ):
+                # Label equality identifies an output role, not expression
+                # equality: execute the final metric, never the saved expression.
+                entry = [metric_outputs[label], *entry[1:]]
+        retained.append(entry)
+    return retained
+
+
+def _filter_identity(filter_: Any) -> tuple[Any, ...] | None:
+    """Return the native identity used when one filter replaces another."""
+    if not isinstance(filter_, Mapping):
+        return None
+    return (
+        filter_.get("clause"),
+        filter_.get("expressionType"),
+        filter_.get("subject"),
+        filter_.get("operator"),
+    )
+
+
+def _temporal_binding_filter(filters: list[Any], subject: Any) -> dict[str, Any] | None:
+    """Find the unique filter owned by a recorded MCP temporal marker."""
+    if subject is None:
+        return None
+    if not isinstance(subject, str) or not subject:
+        raise ValueError(
+            "MCP temporal binding provenance subject must be a non-empty string"
+        )
+    matches = [
+        filter_
+        for filter_ in filters
+        if isinstance(filter_, dict)
+        and filter_.get("subject") == subject
+        and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+    ]
+    if len(matches) != 1:
+        raise ValueError(
+            "MCP temporal binding provenance must match exactly one "
+            f"TEMPORAL_RANGE filter for subject {subject!r}; found {len(matches)}"
+        )
+    return matches[0]
+
+
+def _append_or_replace_filter(filters: list[Any], filter_: Any) -> None:
+    """Append a filter, replacing the same native role when identifiable."""
+    identity = _filter_identity(filter_)
+    if identity is None:
+        if filter_ not in filters:
+            filters.append(filter_)
+        return
+    filters[:] = [item for item in filters if _filter_identity(item) != identity]
+    filters.append(filter_)
+
+
+def _native_temporal_subject_changed(
+    existing_form_data: Mapping[str, Any],
+    new_form_data: Mapping[str, Any],
+    explicit_fields: set[str],
+) -> bool:
+    """Return whether an authoritative native temporal role was replaced.
+
+    Mapping a partial update can propose a dataset fallback binding even when
+    the caller only changed filters. That proposal is not authoritative. A
+    changed x/granularity/chart-specific role is authoritative only when its
+    corresponding typed field was actually supplied.
+    """
+    for native_key, typed_fields in _NATIVE_TEMPORAL_ROLE_FIELDS.items():
+        if explicit_fields.isdisjoint(typed_fields):
+            continue
+        existing_value = existing_form_data.get(native_key)
+        incoming_value = new_form_data.get(native_key)
+        if existing_value != incoming_value:
+            return True
+    return False
+
+
+def _native_temporal_binding(
+    form_data: Mapping[str, Any], filters: list[Any]
+) -> tuple[str | None, dict[str, Any] | None]:
+    """Resolve one binding for a trusted native temporal role, if present."""
+    for native_key in _NATIVE_TEMPORAL_ROLE_FIELDS:
+        subject = form_data.get(native_key)
+        if not isinstance(subject, str) or not subject:
+            continue
+        matches = [
+            filter_
+            for filter_ in filters
+            if isinstance(filter_, dict)
+            and filter_.get("subject") == subject
+            and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+        ]
+        if len(matches) > 1:
+            raise ValueError(
+                "An authoritative native temporal subject must match at most one "
+                f"TEMPORAL_RANGE filter for subject {subject!r}; found "
+                f"{len(matches)}"
+            )
+        if matches:
+            return subject, matches[0]
+    return None, None
+
+
+def merge_update_form_data(  # noqa: C901
+    existing_form_data: Mapping[str, Any],
+    new_form_data: Dict[str, Any],
+    config: ChartConfig,
+) -> None:
+    """Apply the shared omission/provenance contract for chart updates.
+
+    Mapper-generated neutral temporal bindings are infrastructure, not evidence
+    that the caller supplied ``filters`` or changed a saved time-range binding.
+    This helper is used by immediate saves, preview-first saved updates, and
+    cached-preview updates so omission, clear, replacement, and temporal
+    overrides have identical behavior.
+
+    State never crosses a visualization boundary: a viz-type change starts from
+    the mapper's output, so the previous chart's predicates are not restored.
+    """
+    # A missing saved viz_type is a boundary too, matching merge_chart_form_data.
+    if existing_form_data.get("viz_type") != new_form_data.get("viz_type"):
+        return
+    existing_form_data = _normalize_native_filter_aliases(existing_form_data)
+    # The initial overlay may carry legacy keys from saved form data. Filter
+    # omission/replacement below owns the complete canonical predicate sequence.
+    for key in ("where", "having", "filters"):
+        new_form_data.pop(key, None)
+    existing_filters = list(existing_form_data.get("adhoc_filters") or [])
+    incoming_filters = list(new_form_data.get("adhoc_filters") or [])
+    existing_subject = existing_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    incoming_subject = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    existing_binding = _temporal_binding_filter(existing_filters, existing_subject)
+    incoming_binding = _temporal_binding_filter(incoming_filters, incoming_subject)
+
+    explicit_fields = set(getattr(config, "model_fields_set", set()))
+    filters_explicit = "filters" in explicit_fields
+    range_explicit = "time_range" in explicit_fields
+    subject_explicit = "temporal_column" in explicit_fields
+    native_subject_changed = _native_temporal_subject_changed(
+        existing_form_data, new_form_data, explicit_fields
+    )
+    subject_authoritative = subject_explicit or native_subject_changed
+    if incoming_binding is None:
+        native_subject, native_binding = _native_temporal_binding(
+            new_form_data, incoming_filters
+        )
+        if native_binding is not None:
+            incoming_subject = native_subject
+            incoming_binding = native_binding
+    incoming_user_filters = [
+        filter_ for filter_ in incoming_filters if filter_ is not incoming_binding
+    ]
+    temporal_explicit = range_explicit or subject_authoritative
+    if (
+        existing_binding is None
+        and incoming_binding is not None
+        and isinstance(incoming_subject, str)
+        and "filters" not in explicit_fields
+        and temporal_explicit
+    ):
+        # A saved temporal filter that Explore wrote has no MCP provenance
+        # marker. When it is the only native filter for the incoming subject,
+        # the update replaces it in place instead of appending a duplicate.
+        native_matches = [
+            filter_
+            for filter_ in existing_filters
+            if isinstance(filter_, dict)
+            and filter_.get("subject") == incoming_subject
+            and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+        ]
+        if len(native_matches) == 1:
+            existing_binding = native_matches[0]
+            existing_subject = incoming_subject
+    if (
+        existing_binding is None
+        and existing_subject is None
+        and range_explicit
+        and not subject_authoritative
+        and "filters" not in explicit_fields
+    ):
+        # A range-only update on an unmarked chart adopts its saved temporal
+        # filter even when Explore bound it to a column other than the mapper's
+        # proposed subject. Zero or several native ranges stay ambiguous, so the
+        # proposed binding is added instead of guessing which one to rewrite.
+        unmarked_matches = [
+            filter_
+            for filter_ in existing_filters
+            if isinstance(filter_, dict)
+            and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+            and filter_.get("expressionType") == "SIMPLE"
+            and filter_.get("clause") == "WHERE"
+            and isinstance(filter_.get("subject"), str)
+        ]
+        if len(unmarked_matches) == 1:
+            existing_binding = unmarked_matches[0]
+            existing_subject = existing_binding["subject"]
+
+    chosen_binding: dict[str, Any] | None = None
+    chosen_subject: Any = None
+    if not filters_explicit:
+        # Omission is byte-faithful: keep the native sequence in its exact order,
+        # including SQL/HAVING objects and a provenance-owned binding at any index.
+        merged_filters = list(existing_filters)
+        chosen_binding = existing_binding
+        chosen_subject = existing_subject
+        if temporal_explicit:
+            if subject_authoritative:
+                chosen_binding = incoming_binding
+                chosen_subject = incoming_subject
+            elif existing_binding is not None:
+                # A range-only update belongs to the saved subject, even when
+                # mapping the partial config proposed the dataset main_dttm.
+                chosen_binding = dict(existing_binding)
+                chosen_subject = existing_subject
+            else:
+                chosen_binding = incoming_binding
+                chosen_subject = incoming_subject
+
+            if chosen_binding is not None:
+                chosen_binding = dict(chosen_binding)
+                if range_explicit:
+                    chosen_binding["comparator"] = (
+                        getattr(config, "time_range", None) or NO_TIME_RANGE
+                    )
+                elif existing_binding is not None:
+                    # Subject-only replacement preserves the saved active or
+                    # neutral range instead of resetting it to No filter.
+                    chosen_binding["comparator"] = existing_binding.get(
+                        "comparator", NO_TIME_RANGE
+                    )
+            if existing_binding is not None:
+                binding_index = next(
+                    index
+                    for index, filter_ in enumerate(merged_filters)
+                    if filter_ is existing_binding
+                )
+                if chosen_binding is None:
+                    merged_filters.pop(binding_index)
+                else:
+                    # A temporal override changes infrastructure in place instead
+                    # of moving it past surrounding native filters.
+                    merged_filters[binding_index] = chosen_binding
+            elif chosen_binding is not None:
+                merged_filters.append(chosen_binding)
+    else:
+        # An explicit filter array replaces the saved native sequence. The mapper
+        # deliberately emits [] for an explicit clear; otherwise retain its
+        # generated temporal binding after the replacement filters.
+        merged_filters = list(incoming_user_filters)
+        if incoming_user_filters or temporal_explicit:
+            if subject_authoritative:
+                chosen_binding = incoming_binding
+                chosen_subject = incoming_subject
+            elif range_explicit and existing_binding is not None:
+                chosen_binding = dict(existing_binding)
+                chosen_subject = existing_subject
+            elif range_explicit:
+                # With no saved binding to rewrite, an explicit range uses the
+                # mapper's generated binding rather than being dropped.
+                chosen_binding = incoming_binding
+                chosen_subject = incoming_subject
+            else:
+                # A filter-only replacement keeps the saved provenance binding.
+                # The mapper's incoming binding may merely be a dataset fallback
+                # and must not reset the saved subject or active range.
+                chosen_binding = existing_binding
+                chosen_subject = existing_subject
+            if chosen_binding is not None:
+                chosen_binding = dict(chosen_binding)
+                if range_explicit:
+                    chosen_binding["comparator"] = (
+                        getattr(config, "time_range", None) or NO_TIME_RANGE
+                    )
+                elif subject_authoritative and existing_binding is not None:
+                    chosen_binding["comparator"] = existing_binding.get(
+                        "comparator", NO_TIME_RANGE
+                    )
+            if chosen_binding is not None:
+                _append_or_replace_filter(merged_filters, chosen_binding)
+
+    # Materialize exactly when saved state had the key or the caller made the
+    # controls authoritative. An omitted update must not turn a missing native
+    # filter key into [] merely because its mapper proposed a neutral binding.
+    if filters_explicit or "adhoc_filters" in existing_form_data or temporal_explicit:
+        new_form_data["adhoc_filters"] = merged_filters
+    else:
+        new_form_data.pop("adhoc_filters", None)
+    if chosen_binding is not None and isinstance(chosen_subject, str):
+        new_form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = chosen_subject
+    else:
+        new_form_data.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+
+
+# The shared preservation registry may carry envelope, presentation, and time
+# keys over from another visualization. Bullet does not model them, so they are
+# opaque persisted state rather than misspelled typed fields.
+_BULLET_UNMODELED_PRESERVED_KEYS = (
+    FORM_DATA_UPDATE_PRESERVE_KEYS["envelope"]
+    | FORM_DATA_UPDATE_PRESERVE_KEYS["presentation"]
+    | FORM_DATA_UPDATE_PRESERVE_KEYS["time"]
+) - _get_known_fields(BulletChartConfig)
+
+
+def _strip_inherited_bullet_roles(
+    validation_data: dict[str, Any], update_config: ChartConfig | None
+) -> list[Any] | None:
+    """Drop omitted native query roles; return the inherited hierarchy, if any."""
+    if update_config is not None and not isinstance(update_config, BulletChartConfig):
+        return None
+    inherited_groupby: list[Any] | None = None
+    if update_config is None or update_config.dimensions is None:
+        inherited_groupby = bullet_groupby_list(validation_data.pop("groupby", None))
+    if update_config is None or "filters" not in update_config.model_fields_set:
+        validation_data.pop("adhoc_filters", None)
+        validation_data.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+    if update_config is None or "order_by" not in update_config.model_fields_set:
+        # An inherited sort may rank by an independent saved metric; the
+        # native query contract validates it, not the authoring schema.
+        for order_key in ("orderby", "order_by_cols"):
+            validation_data.pop(order_key, None)
+    return inherited_groupby
+
+
+def validate_merged_bullet_form_data(
+    form_data: Mapping[str, Any],
+    update_config: ChartConfig | None = None,
+) -> BulletChartConfig | None:
+    """Validate final Bullet controls without reinterpreting inherited query roles.
+
+    Saved Explore state may contain SQL dimensions and SQL WHERE/SIMPLE HAVING
+    filters beyond the typed authoring surface. Omitted roles are validated by
+    the native query contract and compilation, not as newly authored physical
+    columns or SIMPLE WHERE filters. Only this validation copy excludes them
+    and native query metadata; compiled and persisted form data stays intact.
+    Explicit replacements, including ``[]``, retain strict typed validation.
+    """
+    if form_data.get("viz_type") != "bullet":
+        return None
+    validation_data = dict(form_data)
+    for native_query_key in ("url_params", "extra_form_data", "extra_filters"):
+        validation_data.pop(native_query_key, None)
+    for preserved_key in _BULLET_UNMODELED_PRESERVED_KEYS:
+        validation_data.pop(preserved_key, None)
+    if not validation_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT):
+        # A null marker records an explicit subject clear with no binding to
+        # validate; it is not an authored physical column.
+        validation_data.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+    inherited_groupby = _strip_inherited_bullet_roles(validation_data, update_config)
+    validated = BulletChartConfig.model_validate(validation_data)
+    if inherited_groupby is not None:
+        # The final query keeps the saved hierarchy, so the metric output label
+        # must not collide with any inherited dimension output.
+        validated._inherited_groupby = inherited_groupby
+        validated.validate_roles_and_outputs()
+    return validated
+
+
+def analyze_chart_capabilities(  # noqa: C901
+    viz_type: str | None, config: Any
+) -> ChartCapabilities:
     """Analyze chart capabilities based on type and configuration."""
     if not viz_type:
         viz_type = _resolve_viz_type(config)
@@ -3556,7 +4257,7 @@ def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabi
         "sunburst_v2",
     ]
 
-    supports_interaction = viz_type in interactive_types
+    supports_interaction = viz_type == "bullet" or viz_type in interactive_types
     supports_drill_down = viz_type in [
         "table",
         "pivot_table_v2",
@@ -3570,26 +4271,31 @@ def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabi
 
     # Determine optimal formats
     optimal_formats = ["url"]  # Always include static image
-    if supports_interaction:
-        from superset.mcp_service.chart.preview_utils import (
-            plugin_unsupported_preview,
-        )
-        from superset.mcp_service.chart.registry import plugin_for_viz_type
-
-        optimal_formats.append("interactive")
-        # Only advertise Vega-Lite where a spec can actually be produced. A
-        # plugin whose output cannot be expressed as a Vega-Lite spec (e.g. map
-        # geometry) opts out, so the capability list never promises a failing
-        # preview.
-        owner = plugin_for_viz_type(viz_type)
-        if (
-            owner is None or owner.supports_vega_lite_preview
-        ) and plugin_unsupported_preview(viz_type, "vega_lite") is None:
-            optimal_formats.append("vega_lite")
-    optimal_formats.extend(["ascii", "table"])
+    if viz_type == "bullet":
+        # These are the formats implemented by both saved and transient Bullet
+        # preview paths. Table explicitly rejects Bullet's layered semantics.
+        optimal_formats.extend(["vega_lite", "ascii"])
+    else:
+        if supports_interaction:
+            from superset.mcp_service.chart.preview_utils import (
+                plugin_unsupported_preview,
+            )
+            from superset.mcp_service.chart.registry import plugin_for_viz_type
+
+            optimal_formats.append("interactive")
+            owner = plugin_for_viz_type(viz_type)
+            if (
+                owner is None or owner.supports_vega_lite_preview
+            ) and plugin_unsupported_preview(viz_type, "vega_lite") is None:
+                optimal_formats.append("vega_lite")
+        optimal_formats.extend(["ascii", "table"])
 
     # Classify data types
     data_types = []
+    if viz_type == "bullet":
+        data_types.append("metric")
+        if getattr(config, "dimensions", None):
+            data_types.append("categorical")
     if hasattr(config, "x") and config.x:
         data_types.append("categorical" if not config.x.is_metric else "metric")
     if hasattr(config, "y") and config.y:
@@ -3605,7 +4311,11 @@ def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabi
         supports_drill_down=supports_drill_down,
         supports_export=True,  # All charts can be exported
         optimal_formats=optimal_formats,
-        data_types=list(set(data_types)),
+        data_types=(
+            list(dict.fromkeys(data_types))
+            if viz_type == "bullet"
+            else list(set(data_types))
+        ),
     )
 
 
diff --git a/superset/mcp_service/chart/compile.py b/superset/mcp_service/chart/compile.py
index 6fb87a6dc5d..feb96f94e90 100644
--- a/superset/mcp_service/chart/compile.py
+++ b/superset/mcp_service/chart/compile.py
@@ -46,12 +46,14 @@ from superset.mcp_service.chart.query_result import (
     first_query_data,
     normalize_chart_query_result,
     query_result_failure,
+    temporal_json_numbers,
 )
 from superset.mcp_service.chart.schemas import ChartError
 from superset.mcp_service.chart.validation.dataset_validator import (
     AmbiguousDatasetReferenceError,
     build_dataset_context_from_orm,
     DatasetValidator,
+    resolve_dataset_column,
     resolve_dataset_reference,
 )
 from superset.mcp_service.common.error_schemas import (
@@ -149,7 +151,10 @@ def _compile_chart(  # noqa: C901
         command.validate()
         result = command.run()
 
-        if query_failure := query_result_failure(result):
+        if query_failure := query_result_failure(
+            result,
+            temporal_json_numbers=temporal_json_numbers(form_data.get("viz_type")),
+        ):
             error_str = query_failure.error
             return CompileResult(
                 success=False,
@@ -457,7 +462,286 @@ def _build_compile_error(message: str) -> ChartGenerationError:
     )
 
 
-def validate_and_compile(
+def _native_validation_error(role: str, reference: str) -> ChartGenerationError:
+    """Build a fail-closed error for an incompatible native chart reference."""
+    return ChartGenerationError(
+        error_type="invalid_native_chart_reference",
+        message=f"Native chart {role} {reference!r} is incompatible with the dataset",
+        details=(
+            "The rebound form data must retain its exact query roles on the target "
+            "dataset; no column or saved-metric reference may be guessed or dropped."
+        ),
+        suggestions=[
+            "Choose a target dataset with a compatible schema",
+            "Provide a complete typed chart config using target-dataset fields",
+        ],
+        error_code="CHART_VALIDATION_FAILED",
+    )
+
+
+def _native_column_name(value: Any) -> str | None:
+    """Extract a physical QueryFormColumn reference, or None for SQL columns."""
+    if isinstance(value, str):
+        return value
+    if not isinstance(value, dict):
+        return None
+    if value.get("expressionType") == "SQL":
+        reference = value.get("sqlExpression")
+        if value.get("isColumnReference") is True and isinstance(reference, str):
+            return reference or None
+        return None
+    name = value.get("column_name") or value.get("columnName")
+    return name if isinstance(name, str) and name else None
+
+
+def _native_column_label(value: Any) -> str | None:
+    """Return the frontend label for a native column without custom hooks."""
+    if isinstance(value, str):
+        return value
+    if not isinstance(value, dict):
+        return None
+    for key in ("label", "sqlExpression", "column_name", "columnName"):
+        candidate = value.get(key)
+        if isinstance(candidate, str) and candidate:
+            return candidate
+    return None
+
+
+def _native_metric_ref(value: Any) -> tuple[str, str] | None:
+    """Return ``(saved_metric|column, name)`` for a native query metric."""
+    if isinstance(value, str):
+        return "saved_metric", value
+    if not isinstance(value, dict):
+        return None
+    if value.get("expressionType") == "SQL":
+        return None
+    if value.get("expressionType") != "SIMPLE":
+        # Match QueryObject's guarded legacy saved-metric normalization.
+        if not ({"sqlExpression", "aggregate", "column"} & value.keys()):
+            label = value.get("label")
+            if isinstance(label, str) and label:
+                return "saved_metric", label
+        return None
+    column = value.get("column")
+    name = (
+        column.get("column_name") or column.get("columnName")
+        if isinstance(column, dict)
+        else None
+    )
+    return ("column", name) if isinstance(name, str) and name else None
+
+
+def _native_reference_error(  # noqa: C901
+    form_data: Dict[str, Any],
+    dataset_context: DatasetContext,
+    dataset_id: int,
+    *,
+    strict_all_form_refs: bool,
+) -> ChartGenerationError | None:
+    """Validate the canonical native QueryObjects against a rebound dataset."""
+    from superset.mcp_service.chart.chart_helpers import (
+        build_query_dicts_from_form_data,
+    )
+    from superset.mcp_service.chart.response_preflight import (
+        bounded_exception_message,
+    )
+
+    try:
+        queries = build_query_dicts_from_form_data(
+            deepcopy(form_data), dataset_id, "table"
+        )
+    except (KeyError, TypeError, ValueError) as ex:
+        return _native_validation_error("query contract", bounded_exception_message(ex))
+
+    saved_metrics = [item["name"] for item in dataset_context.available_metrics]
+
+    def column_error(value: Any, role: str) -> ChartGenerationError | None:
+        name = _native_column_name(value)
+        if name is None:
+            if isinstance(value, dict) and value.get("expressionType") == "SQL":
+                return None
+            return _native_validation_error(role, repr(value)[:200])
+        try:
+            if resolve_dataset_column(name, dataset_context) is not None:
+                return None
+        except ValueError:
+            pass
+        return _native_validation_error(role, name)
+
+    def metric_error(value: Any, role: str) -> ChartGenerationError | None:
+        """Validate one raw or generated metric reference against the target."""
+        ref = _native_metric_ref(value)
+        if ref is None:
+            if isinstance(value, dict) and value.get("expressionType") == "SQL":
+                return None
+            return _native_validation_error(role, repr(value)[:200])
+        kind, name = ref
+        if kind == "saved_metric":
+            # Native lookup selects an exact name unambiguously; only a
+            # case-folded reference has to be unique.
+            matches = (
+                [name]
+                if name in saved_metrics
+                else [
+                    item for item in saved_metrics if item.casefold() == name.casefold()
+                ]
+            )
+            if len(set(matches)) != 1:
+                saved_role = f"{role.removesuffix(' metric')} saved metric"
+                return _native_validation_error(saved_role, name)
+            return None
+        return column_error(name, f"{role} column")
+
+    for filter_ in form_data.get("adhoc_filters") or []:
+        if not isinstance(filter_, dict) or filter_.get("expressionType") != "SIMPLE":
+            continue
+        if not strict_all_form_refs and _is_inert_adhoc_filter(filter_):
+            continue
+        subject = filter_.get("subject")
+        clause = str(filter_.get("clause") or "WHERE").upper()
+        if clause == "HAVING" and isinstance(subject, str):
+            metric_matches = (
+                [subject]
+                if subject in saved_metrics
+                else [
+                    name
+                    for name in saved_metrics
+                    if name.casefold() == subject.casefold()
+                ]
+            )
+            if len(metric_matches) == 1:
+                continue
+        if subject is not None and (
+            error := column_error(subject, "form-data filter column")
+        ):
+            return error
+        if filter_.get("operator") == "TEMPORAL_RANGE" and isinstance(subject, str):
+            try:
+                temporal = resolve_dataset_column(subject, dataset_context)
+            except ValueError:
+                temporal = None
+            if temporal is not None and not temporal.get("is_temporal", False):
+                return _native_validation_error("temporal filter column", subject)
+
+    # temporal_columns_lookup describes the entire datasource, not selected
+    # roles. The physical form/query column checks validate selected references.
+
+    for query_index, query in enumerate(queries, 1):
+        metric_labels: set[str] = set()
+        for column in query.get("columns") or []:
+            if error := column_error(column, f"query {query_index} column"):
+                return error
+        for column in query.get("series_columns") or []:
+            if error := column_error(column, f"query {query_index} series column"):
+                return error
+        for column in query.get("groupby") or []:
+            if error := column_error(column, f"query {query_index} groupby column"):
+                return error
+        selected_column_labels = {
+            label
+            for column in query.get("columns") or []
+            if (label := _native_column_label(column)) is not None
+        }
+        adhoc_column_labels = {
+            label
+            for column in query.get("columns") or []
+            if isinstance(column, dict)
+            and isinstance(label := column.get("label"), str)
+            and label
+        }
+        for level in query.get("grouping_sets") or []:
+            for column in level:
+                # Grouping sets reference selected logical outputs, including
+                # Custom SQL labels. Their source columns were checked above.
+                if isinstance(column, str) and column in selected_column_labels:
+                    continue
+                if error := column_error(
+                    column, f"query {query_index} grouping-set column"
+                ):
+                    return error
+
+        metrics = query.get("metrics") or []
+        for metric in metrics:
+            if label := _metric_label_for_validation(metric):
+                metric_labels.add(label)
+            if error := metric_error(metric, f"query {query_index} metric"):
+                return error
+
+        granularity = query.get("granularity")
+        if granularity:
+            if error := column_error(granularity, "temporal column"):
+                return error
+            try:
+                temporal = resolve_dataset_column(granularity, dataset_context)
+            except ValueError:
+                temporal = None
+            if (
+                (query.get("extras") or {}).get("time_grain_sqla")
+                and temporal is not None
+                and not temporal.get("is_temporal", False)
+            ):
+                return _native_validation_error("temporal column", granularity)
+
+        for filter_ in query.get("filters") or []:
+            if not isinstance(filter_, dict):
+                return _native_validation_error("filter", repr(filter_)[:200])
+            column = filter_.get("col")
+            if isinstance(column, str) and column in metric_labels:
+                continue
+            if isinstance(column, str) and any(
+                name.casefold() == column.casefold() for name in saved_metrics
+            ):
+                continue
+            if column is not None and (
+                error := column_error(column, f"query {query_index} filter column")
+            ):
+                return error
+
+        for order in query.get("orderby") or []:
+            if not isinstance(order, (list, tuple)) or len(order) != 2:
+                return _native_validation_error("ordering", repr(order)[:200])
+            target = order[0]
+            target_label = _metric_label_for_validation(target)
+            if target in metrics or (target_label and target_label in metric_labels):
+                continue
+            if isinstance(target, str) and target in metric_labels:
+                continue
+            # get_sqla_query resolves a string sort against labelled adhoc
+            # (Custom SQL) query columns before physical columns.
+            if isinstance(target, str) and target in adhoc_column_labels:
+                continue
+            # Native ordering can use a metric that is not displayed. Resolve
+            # saved names with the same exact/case-folded rules as other metrics,
+            # and validate adhoc metric columns rather than their output labels.
+            if (
+                isinstance(target, str)
+                and any(name.casefold() == target.casefold() for name in saved_metrics)
+            ) or (isinstance(target, dict) and _native_metric_ref(target) is not None):
+                if error := metric_error(
+                    target, f"query {query_index} ordering metric"
+                ):
+                    return error
+            elif error := column_error(target, f"query {query_index} ordering column"):
+                return error
+    return None
+
+
+def _metric_label_for_validation(metric: Any) -> str | None:
+    """Resolve a native metric output label without executing custom code."""
+    if isinstance(metric, str):
+        return metric
+    if not isinstance(metric, dict):
+        return None
+    if isinstance(metric.get("label"), str) and metric["label"]:
+        return metric["label"]
+    ref = _native_metric_ref(metric)
+    if ref and ref[0] == "column" and isinstance(metric.get("aggregate"), str):
+        return f"{metric['aggregate']}({ref[1]})"
+    expression = metric.get("sqlExpression")
+    return expression if isinstance(expression, str) and expression else None
+
+
+def validate_and_compile(  # noqa: C901
     config: Any,
     form_data: Dict[str, Any],
     dataset: Any,
@@ -534,6 +818,21 @@ def validate_and_compile(
                 tier="validation",
                 error_obj=temporal_state_error,
             )
+        if state_plugin is not None and state_plugin.validates_native_references:
+            native_error = _native_reference_error(
+                form_data,
+                dataset_context,
+                dataset.id,
+                strict_all_form_refs=config is None,
+            )
+            if native_error is not None:
+                return CompileResult(
+                    success=False,
+                    error=native_error.details or native_error.message,
+                    error_code="CHART_VALIDATION_FAILED",
+                    tier="validation",
+                    error_obj=native_error,
+                )
 
     if not run_compile_check:
         return CompileResult(success=True)
diff --git a/superset/mcp_service/chart/plugin.py b/superset/mcp_service/chart/plugin.py
index da3bb75ae52..c0c4902df28 100755
--- a/superset/mcp_service/chart/plugin.py
+++ b/superset/mcp_service/chart/plugin.py
@@ -300,6 +300,9 @@ class ChartTypePlugin(Protocol):
     #: Whether an empty result is a valid, renderable preview.
     allows_empty_result: ClassVar[bool]
 
+    #: Whether get_chart_data returns a successful response for zero rows.
+    allows_empty_data_result: ClassVar[bool]
+
     #: Whether saved-chart Vega-Lite previews take the requested width,
     #: height and chart description instead of the spec's own frame.
     resizes_saved_preview: ClassVar[bool]
@@ -326,6 +329,20 @@ class ChartTypePlugin(Protocol):
     invalid_result_message: ClassVar[str]
     invalid_result_suggestions: ClassVar[tuple[str, ...]]
 
+    #: Whether chart-data consumers convert temporal and duration result
+    #: values to the JSON numbers and text the frontend ``transformProps``
+    #: receives, instead of the generic scalar serialization.
+    temporal_json_numbers: ClassVar[bool]
+
+    #: Whether an explicit ``time_range`` sets the comparator of the generated
+    #: dashboard temporal filter instead of being ignored by the binding.
+    binds_time_range_to_temporal_filter: ClassVar[bool]
+
+    #: Whether the compile check's validation tier also resolves every column
+    #: and saved-metric reference of the canonical native QueryObjects (saved
+    #: native roles the typed config does not model) against the dataset.
+    validates_native_references: ClassVar[bool]
+
     def resolve_query_fields(
         self, form_data: Mapping[str, Any], viz_type: str
     ) -> tuple[list[Any], list[Any]] | None:
@@ -468,15 +485,24 @@ class ChartTypePlugin(Protocol):
         form_data: Mapping[str, Any],
         dataset_id: int | str | None,
         dataset_context: Callable[[], Any] | None = None,
+        update_config: Any = None,
     ) -> Any | None:
         """Validate the final merged update state.
 
-        Return the config the merged state implies (replacing the request
+        ``update_config`` is the request config (None for a dataset-only
+        update), so a plugin can tell explicitly supplied controls from saved
+        ones. Return the config the merged state implies (replacing the request
         config for compile and persistence), or None to keep the request
         config. Raise ``ValueError`` when the merged state is invalid.
         """
         ...
 
+    def sanitize_data_rows(
+        self, data: list[Any], form_data: Mapping[str, Any]
+    ) -> tuple[list[Any], ChartError | None]:
+        """Return the rows get_chart_data (JSON, CSV, XLSX) exposes, or an error."""
+        ...
+
 
 class BaseChartPlugin:
     """
@@ -504,6 +530,7 @@ class BaseChartPlugin:
     unbound_form_data_is_rebind: ClassVar[bool] = False
     normalize_data_results: ClassVar[bool] = False
     allows_empty_result: ClassVar[bool] = False
+    allows_empty_data_result: ClassVar[bool] = False
     resizes_saved_preview: ClassVar[bool] = False
     supports_column_append: ClassVar[bool] = False
     supports_vega_lite_preview: ClassVar[bool] = True
@@ -511,6 +538,9 @@ class BaseChartPlugin:
     null_data_is_empty: ClassVar[bool] = True
     invalid_result_error_code: ClassVar[str] = "INVALID_CHART_RESULT"
     invalid_result_message: ClassVar[str] = "Chart query returned invalid values"
+    temporal_json_numbers: ClassVar[bool] = False
+    binds_time_range_to_temporal_filter: ClassVar[bool] = False
+    validates_native_references: ClassVar[bool] = False
     invalid_result_suggestions: ClassVar[tuple[str, ...]] = (
         "Use a numeric-producing metric",
         "Check the metric alias and SQL expression",
@@ -678,9 +708,15 @@ class BaseChartPlugin:
         form_data: Mapping[str, Any],
         dataset_id: int | str | None,
         dataset_context: Callable[[], Any] | None = None,
+        update_config: Any = None,
     ) -> Any | None:
         return None
 
+    def sanitize_data_rows(
+        self, data: list[Any], form_data: Mapping[str, Any]
+    ) -> tuple[list[Any], ChartError | None]:
+        return data, None
+
     @staticmethod
     def _with_context(what: str, context: str | None) -> str:
         """Combine a 'what' label and optional context with an en-dash."""
diff --git a/superset/mcp_service/chart/plugins/__init__.py b/superset/mcp_service/chart/plugins/__init__.py
index efc5423eeac..2dd8c0cdfd4 100644
--- a/superset/mcp_service/chart/plugins/__init__.py
+++ b/superset/mcp_service/chart/plugins/__init__.py
@@ -30,6 +30,7 @@ To add a new chart type:
 from superset.mcp_service.chart.plugins.big_number import BigNumberChartPlugin
 from superset.mcp_service.chart.plugins.box_plot import BoxPlotChartPlugin
 from superset.mcp_service.chart.plugins.bubble import BubbleChartPlugin
+from superset.mcp_service.chart.plugins.bullet import BulletChartPlugin
 from superset.mcp_service.chart.plugins.gantt import GanttChartPlugin
 from superset.mcp_service.chart.plugins.gauge import GaugeChartPlugin
 from superset.mcp_service.chart.plugins.geographic import (
@@ -56,6 +57,7 @@ from superset.mcp_service.chart.registry import register
 
 # Register all built-in chart type plugins
 register(XYChartPlugin())
+register(BulletChartPlugin())
 register(TableChartPlugin())
 register(PieChartPlugin())
 register(SunburstChartPlugin())
@@ -76,6 +78,7 @@ __all__ = [
     "BigNumberChartPlugin",
     "BoxPlotChartPlugin",
     "BubbleChartPlugin",
+    "BulletChartPlugin",
     "GanttChartPlugin",
     "GaugeChartPlugin",
     "CountryMapChartPlugin",
diff --git a/superset/mcp_service/chart/plugins/bullet.py b/superset/mcp_service/chart/plugins/bullet.py
new file mode 100644
index 00000000000..c7d92eff28b
--- /dev/null
+++ b/superset/mcp_service/chart/plugins/bullet.py
@@ -0,0 +1,596 @@
+# 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.
+
+"""ECharts Bullet chart type plugin."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+from copy import deepcopy
+from typing import Any, ClassVar
+
+from superset.mcp_service.chart.chart_utils import (
+    _summarize_filters,
+    bullet_groupby_list,
+    map_bullet_config,
+)
+from superset.mcp_service.chart.plugin import BaseChartPlugin
+from superset.mcp_service.chart.schemas import (
+    BulletChartConfig,
+    ChartError,
+    ColumnRef,
+    MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+    resolve_bullet_order_target,
+    TablePreview,
+    VegaLitePreview,
+)
+from superset.mcp_service.chart.validation.dataset_validator import (
+    AmbiguousDatasetReferenceError,
+    DatasetValidator,
+    is_numeric_column,
+    resolve_dataset_column,
+)
+from superset.mcp_service.common.error_schemas import (
+    ChartGenerationError,
+    DatasetContext,
+)
+
+
+def _canonical_reference(
+    name: str,
+    candidates: list[str],
+    role: str,
+) -> str:
+    """Resolve exact/casefold matches without silently choosing ambiguity."""
+    if name in candidates:
+        return name
+    matches = [
+        candidate for candidate in candidates if candidate.casefold() == name.casefold()
+    ]
+    if len(matches) > 1:
+        # AmbiguousDatasetReferenceError subclasses ValueError, so existing
+        # ValueError handlers keep working, while the validation pipeline
+        # re-raises it instead of downgrading it to a warning and proceeding
+        # with the unresolved reference.
+        raise AmbiguousDatasetReferenceError(name, matches, f"Bullet {role}")
+    return matches[0] if matches else name
+
+
+def _render_model_error(rows: Any, form_data: Mapping[str, Any]) -> ChartError | None:
+    """Validate Bullet result rows without requiring a backend preview formatter."""
+    from superset.mcp_service.chart.preview_utils import (
+        BulletOutputError,
+        resolve_bullet_render_model,
+    )
+    from superset.mcp_service.chart.response_preflight import (
+        bounded_exception_message,
+    )
+
+    try:
+        # Every consumer passes through here; preview renderers re-resolve with
+        # the label bound, so raw-data fetches keep the shared result limits.
+        resolve_bullet_render_model(
+            rows, dict(form_data), validate_format=False, bound_labels=False
+        )
+    except BulletOutputError as ex:
+        return ChartError(error=bounded_exception_message(ex), error_type=ex.error_type)
+    return None
+
+
+def _unsupported_table_preview() -> ChartError:
+    return ChartError(
+        error=(
+            "Table previews cannot represent Bullet ranges, markers, "
+            "labels, and legend semantics"
+        ),
+        error_type="UnsupportedFormat",
+    )
+
+
+class BulletChartPlugin(BaseChartPlugin):
+    """Plugin matching ``plugin-chart-echarts/src/Bullet``."""
+
+    chart_type = "bullet"
+    display_name = "Bullet Chart"
+    native_viz_types: ClassVar[Mapping[str, str]] = {
+        "bullet": "Bullet Chart",
+    }
+    # Bullet/transformProps.ts converts temporal values with Number().
+    temporal_json_numbers = True
+    # The frontend renders an empty result (a zero measure when ungrouped).
+    allows_empty_result = True
+    allows_empty_data_result = True
+    # A null ``data`` array is a malformed producer result, not an empty chart.
+    null_data_is_empty = False
+    # Updates merge filter provenance plus Bullet's bounded native controls;
+    # no other saved control is carried into the typed Bullet state.
+    owns_update_merge = True
+    binds_time_range_to_temporal_filter = True
+    validates_native_references = True
+    invalid_result_error_code = "MALFORMED_BULLET_OUTPUT"
+    invalid_result_message = (
+        "Bullet query output does not contain a usable sizing measure."
+    )
+
+    def pre_validate(self, config: dict[str, Any]) -> ChartGenerationError | None:
+        if "metric" in config:
+            return None
+        return ChartGenerationError(
+            error_type="missing_bullet_fields",
+            message="Bullet chart missing required field: metric",
+            details=(
+                "A Bullet chart measures one numeric aggregate or saved/SQL metric; "
+                "optional dimensions split it into one row per group."
+            ),
+            suggestions=[
+                "Add metric: {'name': 'revenue', 'aggregate': 'SUM'}",
+                "For a saved metric use {'name': 'revenue', 'saved_metric': true}",
+                "Add dimensions: [{'name': 'region'}] for grouped bullet rows",
+            ],
+            error_code="MISSING_BULLET_FIELDS",
+        )
+
+    def extract_column_refs(self, config: Any) -> list[ColumnRef]:
+        if not isinstance(config, BulletChartConfig):
+            return []
+        refs = [config.metric, *(config.dimensions or [])]
+        refs.extend(ColumnRef(name=filter_.column) for filter_ in config.filters or [])
+        # order_by is constrained to role outputs by the schema, so those names
+        # are already represented by metric/dimension refs and must not be
+        # reinterpreted as physical columns.
+        return refs
+
+    def to_form_data(
+        self, config: Any, dataset_id: int | str | None = None
+    ) -> dict[str, Any]:
+        if not isinstance(config, BulletChartConfig):
+            raise TypeError("BulletChartPlugin requires BulletChartConfig")
+        return map_bullet_config(config)
+
+    def post_map_validate(  # noqa: C901
+        self,
+        config: Any,
+        form_data: dict[str, Any],
+        dataset_id: int | str | None = None,
+    ) -> ChartGenerationError | None:
+        """Require an unambiguous numeric metric output for Number(...)."""
+        if not isinstance(config, BulletChartConfig) or dataset_id is None:
+            return None
+        dataset_context = DatasetValidator._get_dataset_context(dataset_id)
+        if dataset_context is None:
+            return None
+
+        columns = [column["name"] for column in dataset_context.available_columns]
+        metrics = [metric["name"] for metric in dataset_context.available_metrics]
+        requested: list[tuple[str, list[str], str]] = []
+        if config.metric.name and not config.metric.sql_expression:
+            requested.append(
+                (
+                    config.metric.name,
+                    metrics if config.metric.saved_metric else columns,
+                    "saved metric" if config.metric.saved_metric else "metric column",
+                )
+            )
+        requested.extend(
+            (dimension.name or "", columns, "dimension")
+            for dimension in config.dimensions or []
+            if dimension.name
+        )
+        requested.extend(
+            (filter_.column, columns, "filter column")
+            for filter_ in config.filters or []
+        )
+        if config.temporal_column:
+            requested.append((config.temporal_column, columns, "temporal column"))
+
+        for name, candidates, role in requested:
+            if (
+                name not in candidates
+                and sum(
+                    candidate.casefold() == name.casefold() for candidate in candidates
+                )
+                > 1
+            ):
+                return ChartGenerationError(
+                    error_type="ambiguous_bullet_reference",
+                    message=(
+                        f"Bullet {role} {name!r} is ambiguous in dataset metadata"
+                    ),
+                    details=(
+                        "Multiple dataset fields differ only by case. The query and "
+                        "frontend require an exact canonical field name."
+                    ),
+                    suggestions=[
+                        "Use get_dataset_info and copy the exact-case field name"
+                    ],
+                    error_code="AMBIGUOUS_BULLET_REFERENCE",
+                )
+
+        metric = config.metric
+        if metric.saved_metric or metric.sql_expression:
+            # Saved/SQL metric result types are determined by their expressions;
+            # Tier-2 compile validation remains authoritative.
+            return None
+        if (metric.aggregate or "SUM") in {"COUNT", "COUNT_DISTINCT"}:
+            return None
+        try:
+            column = resolve_dataset_column(metric.name or "", dataset_context)
+        except ValueError as ex:
+            return ChartGenerationError(
+                error_type="ambiguous_bullet_reference",
+                message=(
+                    f"Bullet metric column {metric.name!r} is ambiguous in "
+                    "dataset metadata"
+                ),
+                details=str(ex),
+                suggestions=["Use get_dataset_info and copy the exact-case field name"],
+                error_code="AMBIGUOUS_BULLET_REFERENCE",
+            )
+        if column is None or is_numeric_column(column):
+            return None
+        return ChartGenerationError(
+            error_type="non_numeric_bullet_metric",
+            message=(
+                f"Bullet metric {metric.name!r} must produce a number; dataset "
+                f"type is {column.get('type', 'UNKNOWN')}."
+            ),
+            details=(
+                "Bullet/transformProps.ts converts the metric result with Number(). "
+                "A non-numeric MIN/MAX or default SUM would render an invalid bar."
+            ),
+            suggestions=[
+                "Use COUNT or COUNT_DISTINCT for a text column",
+                "Choose a numeric dataset column",
+                "Use a saved or SQL metric that returns a numeric value",
+            ],
+            error_code="NON_NUMERIC_BULLET_METRIC",
+        )
+
+    def normalize_column_refs(
+        self, config: Any, dataset_context: DatasetContext
+    ) -> Any:
+        if not isinstance(config, BulletChartConfig):
+            return config
+        explicit_fields = set(config.model_fields_set)
+        config_dict = config.model_dump(exclude_unset=True)
+        columns = [column["name"] for column in dataset_context.available_columns]
+        metrics = [metric["name"] for metric in dataset_context.available_metrics]
+
+        metric = config_dict["metric"]
+        if not metric.get("sql_expression"):
+            metric["name"] = _canonical_reference(
+                metric["name"],
+                metrics if metric.get("saved_metric") else columns,
+                "saved metric" if metric.get("saved_metric") else "metric column",
+            )
+        for dimension in config_dict.get("dimensions") or []:
+            dimension["name"] = _canonical_reference(
+                dimension["name"], columns, "dimension"
+            )
+        if temporal := config_dict.get("temporal_column"):
+            config_dict["temporal_column"] = _canonical_reference(
+                temporal, columns, "temporal column"
+            )
+        for filter_ in config_dict.get("filters") or []:
+            filter_["column"] = _canonical_reference(
+                filter_["column"], columns, "filter column"
+            )
+
+        # Sort targets may use ergonomic role names or labels. Canonicalize
+        # physical-name targets and leave explicit display labels untouched.
+        # A merged validation copy omits native dimensions; its native sorts
+        # belong to query-contract validation, not physical-column resolution.
+        orders = (
+            config_dict.get("order_by") or []
+            if config.dimensions is not None or config._inherited_groupby is not None
+            else []
+        )
+        for order in orders:
+            role, index = resolve_bullet_order_target(
+                order["column"], config.order_dimensions, config.metric
+            )
+            if role == "dimension" and index is not None:
+                if config.dimensions is not None:
+                    order["column"] = config_dict["dimensions"][index]["name"]
+            elif not metric.get("sql_expression") and not metric.get("label"):
+                order["column"] = metric["name"]
+
+        normalized = BulletChartConfig.model_validate(config_dict)
+        normalized._inherited_groupby = deepcopy(config._inherited_groupby)
+        normalized.validate_roles_and_outputs()
+        normalized.model_fields_set.clear()
+        normalized.model_fields_set.update(explicit_fields)
+        return normalized
+
+    def generate_name(self, config: Any, dataset_name: str | None = None) -> str:
+        metric = config.metric.label or config.metric.name or "Metric"
+        what = f"{metric} bullet"
+        if config.dimensions:
+            what += " by " + ", ".join(
+                dimension.label or dimension.name or "dimension"
+                for dimension in config.dimensions
+            )
+        return self._with_context(what, _summarize_filters(config.filters))
+
+    def resolve_viz_type(self, config: Any) -> str:
+        return "bullet"
+
+    def schema_error_hint(self) -> ChartGenerationError | None:
+        return ChartGenerationError(
+            error_type="bullet_validation_error",
+            message="Bullet chart configuration validation failed",
+            details=(
+                "Bullet requires one numeric metric and optional unique physical "
+                "dimensions. Threshold/marker labels are optional; missing marker "
+                "labels fall back to formatted values."
+            ),
+            suggestions=[
+                "Use metric with aggregate, saved_metric, or sql_expression + label",
+                "Use dimensions (alias: groupby) for row hierarchy",
+                "Use ranges, markers, and marker_lines for comparison targets",
+            ],
+            error_code="BULLET_VALIDATION_ERROR",
+        )
+
+    def build_query_dicts(
+        self,
+        form_data: dict[str, Any],
+        *,
+        viz_type: str,
+        engine: str,
+        row_limit: int | None,
+        order_desc: bool | None,
+    ) -> list[dict[str, Any]] | None:
+        """Mirror ``Bullet/buildQuery``: one metric over the groupby hierarchy.
+
+        The frontend keeps only explicitly saved ordering; it never applies
+        the legacy top-N metric ordering of unadapted visualizations.
+        """
+        from superset.common.form_data_query_context import (
+            build_query_objects_from_form_data,
+        )
+
+        queries = build_query_objects_from_form_data(
+            form_data,
+            viz_type=viz_type,
+            row_limit=row_limit,
+            order_desc=order_desc,
+            filters_prepared=True,
+        )
+        query = queries[0]
+        metric = form_data.get("metric")
+        query["columns"] = bullet_groupby_list(form_data.get("groupby"))
+        query["metrics"] = [metric] if metric else []
+        if not form_data.get("orderby") and not form_data.get("order_by_cols"):
+            query["orderby"] = []
+        return queries
+
+    def normalize_query_result(self, result: Any, form_data: Mapping[str, Any]) -> Any:
+        """Reject results that cannot size a Bullet chart without guessing."""
+        from superset.mcp_service.chart.query_result import first_query_data
+
+        rows, failure = first_query_data(
+            result, none_as_empty=False, temporal_json_numbers=True
+        )
+        if failure is not None:
+            return failure
+        assert rows is not None
+        if (error := _render_model_error(rows, form_data)) is not None:
+            return error
+        return result
+
+    def sanitize_data_rows(
+        self, data: list[Any], form_data: Mapping[str, Any]
+    ) -> tuple[list[Any], ChartError | None]:
+        """Expose validated rows independently of backend preview formatter support."""
+        from superset.mcp_service.chart.preview_utils import (
+            _safe_enum_backing,
+            BulletOutputError,
+            resolve_bullet_render_model,
+        )
+        from superset.mcp_service.chart.response_preflight import (
+            bounded_exception_message,
+        )
+
+        try:
+            model = resolve_bullet_render_model(
+                data, dict(form_data), validate_format=False, bound_labels=False
+            )
+        except BulletOutputError as ex:
+            return [], ChartError(
+                error=bounded_exception_message(ex), error_type=ex.error_type
+            )
+        if not data:
+            # The strict model's zero-valued ungrouped row is a render-only
+            # frontend fallback, not source query data, so an empty query
+            # exposes no rows to get-data and exports.
+            return [], None
+        # The strict model retains exact result keys while replacing unselected
+        # values with None and normalizing the dimensions. Its float measure is
+        # render-only: exported rows keep the validated source metric so exact
+        # BIGINT/Decimal values are not rounded to binary64.
+        # An ungrouped model keeps only the rendered first row; every later
+        # source row was validated by the same resolver and stays exported.
+        model_rows = list(model.rows)
+        if not model.dimensions:
+            try:
+                for source in data[len(model_rows) :]:
+                    model_rows.extend(
+                        resolve_bullet_render_model(
+                            [source],
+                            dict(form_data),
+                            validate_format=False,
+                            bound_labels=False,
+                        ).rows
+                    )
+            except BulletOutputError as ex:
+                return [], ChartError(
+                    error=bounded_exception_message(ex), error_type=ex.error_type
+                )
+        rows: list[Any] = []
+        for source, row in zip(data, model_rows, strict=True):
+            exposed = dict(row)
+            if model.metric_field in source:
+                exposed[model.metric_field] = _safe_enum_backing(
+                    source[model.metric_field]
+                )
+            rows.append(exposed)
+        return rows, None
+
+    def ascii_preview(
+        self,
+        data: list[Any],
+        form_data: dict[str, Any],
+        width: int,
+        height: int = 20,
+    ) -> str | ChartError | None:
+        from superset.mcp_service.chart.preview_utils import (
+            _generate_ascii_bullet_chart,
+            BulletOutputError,
+        )
+        from superset.mcp_service.chart.response_preflight import (
+            bounded_exception_message,
+        )
+
+        try:
+            return _generate_ascii_bullet_chart(data, form_data)
+        except BulletOutputError as ex:
+            return ChartError(
+                error=bounded_exception_message(ex), error_type=ex.error_type
+            )
+
+    def table_preview(
+        self, data: list[Any], form_data: dict[str, Any]
+    ) -> TablePreview | ChartError | None:
+        return _unsupported_table_preview()
+
+    def unsupported_preview(self, preview_format: str) -> ChartError | None:
+        if preview_format == "table":
+            return _unsupported_table_preview()
+        return None
+
+    def vega_lite_preview(
+        self, data: list[Any], form_data: dict[str, Any]
+    ) -> VegaLitePreview | ChartError | None:
+        from superset.mcp_service.chart.preview_utils import (
+            _generate_bullet_vega_lite_preview,
+            BulletOutputError,
+        )
+        from superset.mcp_service.chart.response_preflight import (
+            bounded_exception_message,
+        )
+
+        try:
+            return _generate_bullet_vega_lite_preview(data, form_data)
+        except BulletOutputError as ex:
+            return ChartError(
+                error=bounded_exception_message(ex), error_type=ex.error_type
+            )
+
+    def resolve_update_config(
+        self,
+        config: Any,
+        existing_form_data: dict[str, Any],
+        *,
+        dataset_rebind: bool,
+    ) -> Any:
+        """Resolve sort targets against the saved hierarchy before mapping."""
+        if not isinstance(config, BulletChartConfig) or config.dimensions is not None:
+            return config
+        if not config.order_by:
+            return config
+        dimensions = (
+            bullet_groupby_list(existing_form_data.get("groupby"))
+            if not dataset_rebind and existing_form_data.get("viz_type") == "bullet"
+            else []
+        )
+        resolved = config.model_copy(deep=True)
+        resolved._inherited_groupby = deepcopy(dimensions)
+        return resolved.validate_roles_and_outputs()
+
+    def merge_update_form_data(
+        self,
+        existing_form_data: dict[str, Any],
+        new_form_data: dict[str, Any],
+        config: Any,
+        *,
+        dataset_rebind: bool,
+    ) -> dict[str, Any] | None:
+        """Merge filter provenance and preserve omitted native Bullet controls."""
+        if not isinstance(config, BulletChartConfig) or dataset_rebind:
+            return None
+        from superset.mcp_service.chart.chart_utils import (
+            _normalize_bullet_query_aliases,
+            merge_bullet_form_data,
+            merge_update_form_data,
+        )
+
+        saved_form_data = existing_form_data
+        if "orderby" in new_form_data:
+            # An explicit ordering replaces every saved ordering alias, so a
+            # stale or malformed saved alias is never parsed or inherited.
+            existing_form_data = {
+                key: value
+                for key, value in existing_form_data.items()
+                if key not in {"orderby", "order_by_cols"}
+            }
+        if existing_form_data.get("viz_type") == "bullet":
+            existing_form_data = _normalize_bullet_query_aliases(existing_form_data)
+        merged = dict(new_form_data)
+        if "row_limit" not in config.model_fields_set:
+            # The mapper always emits the schema default; an omitted limit
+            # keeps the saved value instead.
+            merged.pop("row_limit", None)
+        merge_update_form_data(existing_form_data, merged, config)
+        merge_bullet_form_data(existing_form_data, merged)
+        if "row_limit" not in merged and "row_limit" in new_form_data:
+            merged["row_limit"] = new_form_data["row_limit"]
+        # Like the shared overlay, unmodeled native presentation controls
+        # survive a same-viz update; query roles of other visualizations and
+        # legacy predicate aliases (already folded into adhoc filters) do not.
+        # The time-binding provenance marker follows the merged filters, so it
+        # is never restored once the merge decided the filter sequence.
+        excluded = {"where", "having", "filters"}
+        if "adhoc_filters" in merged:
+            excluded.add(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+        for key, value in saved_form_data.items():
+            if (
+                key not in merged
+                and key not in self.query_role_keys
+                and key not in excluded
+            ):
+                merged[key] = value
+        return merged
+
+    def validate_merged_form_data(
+        self,
+        form_data: Mapping[str, Any],
+        dataset_id: int | str | None,
+        dataset_context: Callable[[], Any] | None = None,
+        update_config: Any = None,
+    ) -> Any | None:
+        """Validate the merged state; canonicalize it when dataset metadata exists."""
+        from superset.mcp_service.chart.chart_utils import (
+            validate_merged_bullet_form_data,
+        )
+
+        merged = validate_merged_bullet_form_data(form_data, update_config)
+        if merged is None or dataset_context is None or dataset_id is None:
+            return merged
+        return DatasetValidator.normalize_column_names(
+            merged, dataset_id, dataset_context=dataset_context()
+        )
diff --git a/superset/mcp_service/chart/plugins/gantt.py b/superset/mcp_service/chart/plugins/gantt.py
index 544994705ca..34ba0d1fc36 100644
--- a/superset/mcp_service/chart/plugins/gantt.py
+++ b/superset/mcp_service/chart/plugins/gantt.py
@@ -309,6 +309,7 @@ class GanttChartPlugin(BaseChartPlugin):
         form_data: Mapping[str, Any],
         dataset_id: int | str | None,
         dataset_context: Callable[[], Any] | None = None,
+        update_config: Any = None,
     ) -> Any | None:
         from superset.mcp_service.chart.chart_utils import validate_gantt_form_data
 
diff --git a/superset/mcp_service/chart/plugins/geographic.py b/superset/mcp_service/chart/plugins/geographic.py
index 217b40a26f9..b4f7485f8d1 100644
--- a/superset/mcp_service/chart/plugins/geographic.py
+++ b/superset/mcp_service/chart/plugins/geographic.py
@@ -590,6 +590,7 @@ class WorldMapChartPlugin(GeographicChartPlugin):
         form_data: Mapping[str, Any],
         dataset_id: int | str | None,
         dataset_context: Any = None,
+        update_config: Any = None,
     ) -> Any | None:
         """Reject merged color and bubble metrics that share a label.
 
@@ -689,6 +690,7 @@ class DeckScatterChartPlugin(GeographicChartPlugin):
         form_data: Mapping[str, Any],
         dataset_id: int | str | None,
         dataset_context: Any = None,
+        update_config: Any = None,
     ) -> Any | None:
         """Reject effective roles that conflict with native spatial features.
 
diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py
index 5d3db543bc8..0f73e990464 100644
--- a/superset/mcp_service/chart/preview_utils.py
+++ b/superset/mcp_service/chart/preview_utils.py
@@ -24,14 +24,19 @@ from form data without requiring a saved chart object.
 
 import logging
 import math
+import re
 import unicodedata
 from copy import deepcopy
+from dataclasses import dataclass
 from datetime import date, datetime, time, timezone
-from decimal import Decimal
+from decimal import Decimal, localcontext
+from enum import Enum
 from numbers import Real
 from typing import Any, Dict, List
 from uuid import UUID
 
+import numpy as np
+
 from superset.mcp_service.chart.chart_helpers import canonicalize_operation_form_data
 from superset.mcp_service.chart.query_result import (
     first_query_data,
@@ -50,6 +55,7 @@ from superset.mcp_service.chart.sunburst import (
     normalize_and_validate_sunburst_result_data,
     resolve_sunburst_result_roles,
 )
+from superset.utils import json
 from superset.utils.core import get_column_name
 
 logger = logging.getLogger(__name__)
@@ -286,11 +292,18 @@ def _generate_ascii_preview_from_data(
         renderer = _GENERIC_ASCII_RENDERERS.get(viz_type, _generate_safe_ascii_table)
         content = renderer(data)
 
-    content = "\n".join(
-        _truncate_display_line(line, width) for line in content.splitlines()[:height]
-    )
     return ASCIIPreview(
-        ascii_content=content, width=width, height=height, supports_color=False
+        ascii_content=fit_ascii_frame(content, width, height),
+        width=width,
+        height=height,
+        supports_color=False,
+    )
+
+
+def fit_ascii_frame(content: str, width: int, height: int) -> str:
+    """Clip rendered ASCII text to the reported ``width`` x ``height`` frame."""
+    return "\n".join(
+        _truncate_display_line(line, width) for line in content.splitlines()[:height]
     )
 
 
@@ -1828,7 +1841,1080 @@ def generate_xy_vega_lite_preview(
             spec["transform"] = [
                 {"fold": series_columns, "as": [series_field, value_field]}
             ]
-            encoding["y"] = {"field": value_field, "type": "quantitative"}
+            # Vega-Lite stacks bar and area marks colored by a nominal field
+            # by default; follow the chart's saved ``stack`` control instead.
+            encoding["y"] = {
+                "field": value_field,
+                "type": "quantitative",
+                "stack": _xy_vega_lite_stack(form_data.get("stack")),
+            }
             encoding["color"] = {"field": series_field, "type": "nominal"}
+            if (
+                spec.get("mark") == "bar"
+                and encoding["y"]["stack"] is None
+                and "x" in encoding
+            ):
+                # Unstacked bars sit side by side like Explore's grouped bars.
+                # Offset channels need a discrete band x scale, so temporal
+                # values stay date-formatted through a millisecond time unit so
+                # sub-second timestamps keep distinct bands as in Explore.
+                x_encoding = encoding["x"]
+                if x_encoding.get("type") == "temporal":
+                    x_encoding["timeUnit"] = (
+                        "yearmonthdatehoursminutessecondsmilliseconds"
+                    )
+                x_encoding["type"] = "ordinal"
+                encoding["xOffset"] = {"field": series_field, "type": "nominal"}
 
     return preview
+
+
+def _xy_vega_lite_stack(stack: Any) -> str | None:
+    """Map the ECharts timeseries ``stack`` control to a Vega-Lite stack."""
+    if not stack:
+        return None
+    return "normalize" if stack == "Expand" else "zero"
+
+
+def _truncate_utf8(value: str, max_bytes: int) -> str:
+    """Return bounded, replacement-decoded UTF-8 text.
+
+    Encoding even the non-truncated path is intentional: Python strings may
+    contain unpaired surrogates, while MCP/JSON responses must always be valid
+    UTF-8.  Slicing by characters before encoding also prevents an
+    attacker-sized string from being encoded in full.
+    """
+    if max_bytes <= 0:
+        return ""
+    candidate = value[:max_bytes]
+    encoded = candidate.encode("utf-8", errors="replace")
+    if len(encoded) <= max_bytes and len(candidate) == len(value):
+        return encoded.decode("utf-8", errors="replace")
+    suffix = "... [truncated]"
+    suffix_bytes = suffix.encode()
+    if max_bytes <= len(suffix_bytes):
+        return suffix_bytes[:max_bytes].decode("ascii")
+    content_limit = max(0, max_bytes - len(suffix_bytes))
+    content = encoded[:content_limit].decode("utf-8", errors="ignore")
+    return content + suffix
+
+
+_MAX_BULLET_FIELDS = 256
+
+
+_MAX_BULLET_FIELD_BYTES = 1000
+
+
+_MAX_BULLET_TEXT_BYTES = 2000
+
+
+# ECMAScript WhiteSpace and LineTerminator characters used by trim/Number.
+_JAVASCRIPT_WHITESPACE = (
+    "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004"
+    "\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
+)
+
+
+_MAX_BULLET_TOKENS = 256
+
+
+# A comma-joined presentation control holds up to _MAX_BULLET_TOKENS tokens of
+# _MAX_BULLET_TEXT_BYTES each, so the whole-control bound must cover that many
+# tokens plus separators rather than reuse the per-token bound.
+_MAX_BULLET_CONTROL_CHARS = _MAX_BULLET_TOKENS * (_MAX_BULLET_TEXT_BYTES + 1)
+
+
+_ENUM_SCALAR_TYPES = (str, int, float, bool, Decimal)
+
+
+class BulletOutputError(ValueError):
+    """A Bullet query result cannot be rendered without guessing its roles."""
+
+    def __init__(self, message: str, error_type: str = "MalformedBulletOutput") -> None:
+        super().__init__(message)
+        self.error_type = error_type
+
+
+@dataclass(frozen=True)
+class BulletRenderModel:
+    """Strict, frontend-aligned data and presentation roles for one preview."""
+
+    rows: list[dict[str, Any]]
+    metric_field: str
+    dimensions: list[str]
+    measures: list[float]
+    ranges: list[float]
+    range_labels: list[str]
+    markers: list[float]
+    marker_labels: list[str]
+    marker_lines: list[float]
+    marker_line_labels: list[str]
+    y_axis_format: str
+    show_labels: bool
+    show_legend: bool
+
+
+def _form_metric_label(metric: Any) -> str | None:
+    """Return the result-column label for a native QueryFormMetric."""
+    if type(metric) is str:
+        return metric
+    if type(metric) is not dict:
+        return None
+    if label := dict.get(metric, "label"):
+        return label if type(label) is str else None
+    if dict.get(metric, "expressionType") == "SQL":
+        expression = dict.get(metric, "sqlExpression")
+        return expression if type(expression) is str and expression else None
+    column = dict.get(metric, "column")
+    column_name = dict.get(column, "column_name") if type(column) is dict else column
+    aggregate = dict.get(metric, "aggregate")
+    if type(column_name) is str and type(aggregate) is str:
+        return f"{aggregate}({column_name})"
+    return None
+
+
+def _form_column_label(column: Any) -> str | None:
+    """Return the result-column label for a native QueryFormColumn."""
+    if type(column) is str:
+        return column
+    if type(column) is not dict:
+        return None
+    # getColumnLabel: an adhoc column without a label is keyed by its SQL.
+    for key in ("label", "sqlExpression", "column_name"):
+        if type(value := dict.get(column, key)) is str and value:
+            return value
+    return None
+
+
+def _require_result_field(label: str | None, row: dict[str, Any], role: str) -> str:
+    """Resolve a role without falling back to an unrelated result field."""
+    if not label:
+        raise BulletOutputError(f"Bullet {role} has no declared result alias")
+    if label in dict.keys(row):
+        return label
+    matches = sorted(
+        field
+        for field in dict.keys(row)
+        if type(field) is str and field.casefold() == label.casefold()
+    )
+    if len(matches) == 1:
+        return matches[0]
+    if matches:
+        raise BulletOutputError(
+            f"Bullet {role} alias {label!r} is ambiguous; candidates: "
+            f"{', '.join(matches)}"
+        )
+    raise BulletOutputError(
+        f"Bullet {role} alias {label!r} is missing from query output"
+    )
+
+
+def _safe_enum_backing(value: Any) -> Any:
+    """Extract Enum's stored value without public descriptors/conversions."""
+    value_type = type(value)
+    try:
+        mro = type.__getattribute__(value_type, "__mro__")
+    except (AttributeError, TypeError):  # pragma: no cover - normal types have MRO
+        return value
+    if type(mro) is not tuple or not any(base is Enum for base in mro):
+        return value
+    try:
+        backing = object.__getattribute__(value, "_value_")
+    except Exception as ex:
+        raise BulletOutputError("Bullet output contains an unreadable enum") from ex
+    if not any(type(backing) is allowed for allowed in _ENUM_SCALAR_TYPES):
+        raise BulletOutputError("Bullet output contains an unsupported enum value")
+    return backing
+
+
+def _decimal_javascript_string(value: Decimal) -> str:
+    """Render an exact binary64 spelling with JavaScript Number thresholds."""
+    sign, digits_tuple, exponent = Decimal.as_tuple(value)
+    if type(exponent) is not int:  # finite Decimals always have an integer exponent
+        raise BulletOutputError("Bullet dimension contains a non-finite Decimal")
+    if not any(digits_tuple):
+        return "0"
+
+    digits = "".join(str(digit) for digit in digits_tuple)
+    adjusted = len(digits) + exponent - 1
+    prefix = "-" if sign else ""
+    if -6 <= adjusted < 21:
+        point = len(digits) + exponent
+        if point <= 0:
+            text = f"0.{('0' * -point)}{digits}"
+        elif point >= len(digits):
+            text = digits + ("0" * (point - len(digits)))
+        else:
+            text = f"{digits[:point]}.{digits[point:]}"
+        if "." in text:
+            text = text.rstrip("0").rstrip(".")
+        return prefix + text
+
+    fraction = digits[1:].rstrip("0")
+    coefficient = digits[0] + (f".{fraction}" if fraction else "")
+    exponent_text = f"+{adjusted}" if adjusted >= 0 else str(adjusted)
+    return f"{prefix}{coefficient}e{exponent_text}"
+
+
+def _javascript_number_string(value: int | float | Decimal) -> str:
+    """Apply JSON-number -> IEEE-754 Number -> JavaScript String semantics.
+
+    Exact result scalars can retain precision that the frontend cannot: JSON
+    parsing first rounds a numeric token to binary64, and ``String`` then emits
+    the shortest round-tripping decimal with fixed notation for exponents in
+    [-6, 20].  Converting exact builtin scalars to an exact builtin float keeps
+    the path hook-free.  Python and JavaScript use the same shortest
+    round-tripping binary64 digits; ``_decimal_javascript_string`` only adjusts
+    the notation thresholds and exponent spelling.
+
+    A finite integer or Decimal outside binary64's range becomes an infinity
+    after JSON parsing, matching JavaScript.  Non-finite source values are
+    rejected by the trusted scalar normalizer before this helper is called.
+    """
+    value_type = type(value)
+    if value_type not in {int, float, Decimal}:
+        raise BulletOutputError("Bullet dimension contains an unsupported number")
+    if value_type is float and not math.isfinite(value):
+        raise BulletOutputError("Bullet dimension contains a non-finite number")
+    if isinstance(value, Decimal) and not Decimal.is_finite(value):
+        raise BulletOutputError("Bullet dimension contains a non-finite Decimal")
+    try:
+        number = float(value)
+    except OverflowError:
+        number = -math.inf if value < 0 else math.inf
+
+    if math.isinf(number):
+        return "-Infinity" if number < 0 else "Infinity"
+    if number == 0:
+        # String(-0) is "0" even though JSON.parse preserves negative zero.
+        return "0"
+    return _decimal_javascript_string(Decimal(float.__repr__(number)))
+
+
+def _bullet_category_value(  # noqa: C901
+    value: Any, dimension: str, row_index: int, *, bound_labels: bool = True
+) -> tuple[Any, str]:
+    """Return a JSON-safe value and bounded frontend ``String(value)`` text.
+
+    The trusted scalar normalizer is type-exact and does not dispatch through
+    application hooks.  Vega data retains the normalized Chart Data wire value
+    (including epoch-ms temporal numbers); only the derived category key and
+    ASCII label use the JavaScript-compatible text.
+
+    With ``bound_labels=False`` (raw row export) no label text is derived, so
+    string values keep the shared result-cell limit instead of the label bound.
+    """
+    from superset.mcp_service.chart.query_result import (
+        _bounded_utf8_length,
+        _chart_data_duration_text,
+        _chart_data_temporal_number,
+        _is_chart_data_duration_scalar,
+        _is_chart_data_temporal_scalar,
+        _normalize_scalar,
+        MAX_RESULT_STRING_LENGTH,
+    )
+
+    text_limit = _MAX_BULLET_TEXT_BYTES if bound_labels else MAX_RESULT_STRING_LENGTH
+
+    normalized: Any
+    reason: str | None
+    if type(value) in {list, tuple, dict}:
+        data, failure = first_query_data(
+            {"queries": [{"data": [{"value": value}]}]}, temporal_json_numbers=True
+        )
+        if failure is not None or data is None:
+            raise BulletOutputError(
+                f"Bullet dimension {dimension!r} row {row_index} "
+                "has an invalid or unbounded container value"
+            )
+        normalized = data[0]["value"]
+        reason = None
+    elif _is_chart_data_temporal_scalar(value := _safe_enum_backing(value)):
+        normalized, reason = _chart_data_temporal_number(value)
+    elif _is_chart_data_duration_scalar(value):
+        normalized, reason = _chart_data_duration_text(value)
+    else:
+        normalized, reason = _normalize_scalar(value)
+        if (
+            reason is None
+            and normalized is None
+            and value is not None
+            and type(value) in {float, np.float16, np.float32, np.float64}
+            and math.isinf(float(value))
+        ):
+            # NaN keeps the shared null normalization, but an infinite source
+            # value has no faithful category text and is rejected.
+            reason = "contains a non-finite number"
+        if (
+            reason is None
+            and type(normalized) is str
+            and _bounded_utf8_length(normalized, text_limit) is None
+        ):
+            reason = "exceeds the size limit"
+    if reason is not None:
+        if reason == "an unsupported or subclassed value":
+            reason = "has an unsupported value type"
+        elif not reason.startswith(("contains ", "exceeds ", "has ")):
+            reason = f"contains {reason}"
+        raise BulletOutputError(
+            f"Bullet dimension {dimension!r} row {row_index} {reason}"
+        )
+    if not bound_labels:
+        return normalized, ""
+
+    value_type = type(normalized)
+    if value_type in {list, dict}:
+        text = _bullet_container_category_text(normalized, dimension, row_index)
+    elif normalized is None:
+        text = "null"
+    elif value_type is str:
+        text = normalized
+    elif value_type is bool:
+        text = "true" if normalized else "false"
+    elif value_type is int or value_type is float or value_type is Decimal:
+        text = _javascript_number_string(normalized)
+    else:
+        raise BulletOutputError(
+            f"Bullet dimension {dimension!r} row {row_index} has an "
+            "unsupported value type"
+        )
+
+    if _bounded_utf8_length(text, _MAX_BULLET_TEXT_BYTES) is None:
+        raise BulletOutputError(
+            f"Bullet dimension {dimension!r} row {row_index} exceeds the size limit"
+        )
+    return normalized, text
+
+
+def _bullet_container_category_text(value: Any, dimension: str, row_index: int) -> str:
+    """Stringify validated containers like JavaScript with a bounded text budget."""
+    from superset.mcp_service.chart.query_result import _bounded_utf8_length
+
+    if type(value) is dict:
+        return "[object Object]"
+    parts: list[str] = []
+    size = 0
+    for index, item in enumerate(value):
+        if type(item) in {list, dict}:
+            text = _bullet_container_category_text(item, dimension, row_index)
+        else:
+            text = (
+                ""
+                if item is None
+                else _bullet_category_value(item, dimension, row_index)[1]
+            )
+        text_size = _bounded_utf8_length(text, _MAX_BULLET_TEXT_BYTES)
+        size += (text_size if text_size is not None else _MAX_BULLET_TEXT_BYTES + 1) + (
+            index > 0
+        )
+        if size > _MAX_BULLET_TEXT_BYTES:
+            raise BulletOutputError(
+                f"Bullet dimension {dimension!r} row {row_index} exceeds the size limit"
+            )
+        parts.append(text)
+    return ",".join(parts)
+
+
+def _javascript_numeric_string(value: str) -> float:
+    """Parse a nonempty trimmed string using JavaScript Number's grammar."""
+    if re.fullmatch(r"0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+", value):
+        return float(int(value, 0))
+    if re.fullmatch(
+        r"[+-]?(?:Infinity|(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)",
+        value,
+    ):
+        return float(value)
+    raise ValueError("Invalid JavaScript number spelling")
+
+
+def _bullet_number(value: Any, row_index: int, metric_field: str) -> float:
+    """Apply the frontend's useful ``Number(value ?? 0)`` numeric subset."""
+    value = _safe_enum_backing(value)
+    if value is None:
+        number = 0.0
+    elif type(value) is bool:
+        raise BulletOutputError(
+            f"Bullet metric {metric_field!r} row {row_index} returned a boolean"
+        )
+    elif type(value) is int or type(value) is float or type(value) is Decimal:
+        try:
+            number = float(value)
+        except (TypeError, ValueError, OverflowError) as ex:
+            raise BulletOutputError(
+                f"Bullet metric {metric_field!r} row {row_index} is not numeric"
+            ) from ex
+    elif type(value) is str:
+        if len(value) > _MAX_BULLET_TEXT_BYTES:
+            raise BulletOutputError(
+                f"Bullet metric {metric_field!r} row {row_index} is not numeric"
+            )
+        stripped = value.strip(_JAVASCRIPT_WHITESPACE)
+        if not stripped:
+            raise BulletOutputError(
+                f"Bullet metric {metric_field!r} row {row_index} is not numeric"
+            )
+        try:
+            number = _javascript_numeric_string(stripped)
+        except (ValueError, OverflowError) as ex:
+            raise BulletOutputError(
+                f"Bullet metric {metric_field!r} row {row_index} returned "
+                f"non-numeric text"
+            ) from ex
+    else:
+        raise BulletOutputError(
+            f"Bullet metric {metric_field!r} row {row_index} is not numeric"
+        )
+    if not math.isfinite(number):
+        raise BulletOutputError(
+            f"Bullet metric {metric_field!r} row {row_index} is NaN or infinite"
+        )
+    return number
+
+
+def _bullet_string_tokens(value: Any) -> list[str]:
+    """Parse labels exactly like the frontend's comma tokenizer."""
+    value = _safe_enum_backing(value)
+    if value is None:
+        return []
+    if type(value) is not str or len(value) > _MAX_BULLET_CONTROL_CHARS:
+        raise BulletOutputError("Bullet labels must be a bounded comma-separated list")
+    if not value.strip():
+        return []
+    tokens = value.split(",")
+    if len(tokens) > _MAX_BULLET_TOKENS:
+        raise BulletOutputError("Bullet labels exceed the item limit")
+    return [_truncate_utf8(token.strip(), _MAX_BULLET_TEXT_BYTES) for token in tokens]
+
+
+def _unique_bullet_derived_field(
+    rows: list[dict[str, Any]], base: str, reserved: tuple[str, ...] = ()
+) -> str:
+    """Return one internal key absent from result rows and prior derived keys."""
+    occupied = {key for row in rows for key in dict.keys(row)}
+    occupied.update(reserved)
+    candidate = base
+    suffix = 0
+    while candidate in occupied:
+        suffix += 1
+        candidate = f"{base}_{suffix}"
+    return candidate
+
+
+def _unique_bullet_category_field(rows: list[dict[str, Any]]) -> str:
+    """Return an internal category key absent from every query-result row."""
+    return _unique_bullet_derived_field(rows, "__mcp_bullet_category")
+
+
+def _validate_bullet_format(format_: Any, values: list[float]) -> str:
+    """Reject a presentation format the backend cannot reproduce."""
+    format_ = _safe_enum_backing(format_)
+    if format_ is None or format_ == "":
+        format_ = "SMART_NUMBER"
+    if type(format_) is not str or len(format_) > 50:
+        raise BulletOutputError(
+            "Bullet number format is unsupported by previews",
+            error_type="UnsupportedFormat",
+        )
+    from superset.utils.number_format import D3_FORMAT_RE
+
+    # Specifier length does not bound its requested output precision. Check the
+    # parsed precision before the formatter can allocate or round any value.
+    match = D3_FORMAT_RE.match(format_)
+    if match and match.group(8) is not None and int(match.group(8)) > 20:
+        raise BulletOutputError(
+            "Bullet number format precision must not exceed 20",
+            error_type="UnsupportedFormat",
+        )
+    try:
+        for value in values:
+            _format_bullet_number(format_, value)
+    except (TypeError, ValueError, OverflowError) as ex:
+        raise BulletOutputError(
+            f"Bullet number format {format_!r} is unsupported by previews",
+            error_type="UnsupportedFormat",
+        ) from ex
+    return format_
+
+
+def _format_bullet_number(format_: str, value: float) -> str:
+    """Format finite Bullet values, including the full binary-float range."""
+    from superset.utils.number_format import format_numeric
+
+    try:
+        # Binary64 has at most 309 integer digits. Leave room for the bounded
+        # fractional precision, percent scaling, and a rounding carry without
+        # changing the caller's Decimal context.
+        with localcontext() as context:
+            context.prec = max(context.prec, 334)
+            return format_numeric(format_, value)
+    except OverflowError:
+        # SMART_NUMBER's significant-digit rounding can overflow a finite float
+        # near DBL_MAX. Scientific repr remains deterministic and informative.
+        if format_ in {"SMART_NUMBER", "SMART_NUMBER_SIGNED"} and math.isfinite(value):
+            prefix = "+" if format_ == "SMART_NUMBER_SIGNED" and value > 0 else ""
+            return prefix + repr(value)
+        raise
+
+
+def _containing_bullet_range_label(
+    measure: float, ranges: list[float], labels: list[str]
+) -> str | None:
+    """Match the frontend's labelled containing-range tooltip selection."""
+    ascending = sorted(
+        (
+            (value, labels[index] if index < len(labels) else "")
+            for index, value in enumerate(ranges)
+        ),
+        key=lambda entry: entry[0],
+    )
+    for threshold, label in ascending:
+        if measure <= threshold:
+            return label or None
+    if ascending and ascending[-1][1]:
+        return f"> {ascending[-1][1]}"
+    return None
+
+
+def resolve_bullet_render_model(  # noqa: C901
+    data: List[Dict[str, Any]],
+    form_data: Dict[str, Any],
+    *,
+    validate_format: bool = True,
+    bound_labels: bool = True,
+) -> BulletRenderModel:
+    """Resolve Bullet rows; check preview formatter support only when rendering.
+
+    ``bound_labels=False`` serves raw-data consumers (get_chart_data and its
+    exports): dimension values keep the shared result limits, and the preview
+    label bound applies only where category text is rendered.
+    """
+    if type(data) is not list:
+        raise BulletOutputError("Bullet query output must be an array of objects")
+    for row_index in range(list.__len__(data)):
+        row = list.__getitem__(data, row_index)
+        if type(row) is not dict:
+            raise BulletOutputError("Bullet query output must be an array of objects")
+        if dict.__len__(row) > _MAX_BULLET_FIELDS:
+            raise BulletOutputError("Bullet query row exceeds the field limit")
+        for key in dict.keys(row):
+            if type(key) is not str:
+                raise BulletOutputError("Bullet query row keys must be strings")
+            if len(key) > _MAX_BULLET_FIELD_BYTES:
+                raise BulletOutputError("Bullet query row key exceeds the size limit")
+
+    if type(form_data) is not dict:
+        raise BulletOutputError("Bullet form data must be an object")
+
+    metric_label = _form_metric_label(dict.get(form_data, "metric"))
+    if not metric_label:
+        raise BulletOutputError("Bullet metric has no declared result alias")
+    raw_groupby = dict.get(form_data, "groupby")
+    # Bullet/transformProps.ts reads ensureIsArray(groupby): a saved scalar
+    # column (or adhoc column object) is a one-level hierarchy.
+    if raw_groupby is None:
+        raw_groupby = []
+    elif type(raw_groupby) is str or type(raw_groupby) is dict:
+        raw_groupby = [raw_groupby]
+    if type(raw_groupby) is not list:
+        raise BulletOutputError("Bullet dimensions must be an array")
+    dimension_labels = [
+        _form_column_label(list.__getitem__(raw_groupby, index))
+        for index in range(list.__len__(raw_groupby))
+    ]
+    if any(not label for label in dimension_labels):
+        raise BulletOutputError("Bullet dimension has no declared result alias")
+
+    if data:
+        first_row = list.__getitem__(data, 0)
+        metric_field = _require_result_field(metric_label, first_row, "metric")
+        dimensions = [
+            _require_result_field(label, first_row, "dimension")
+            for label in dimension_labels
+        ]
+    else:
+        # The frontend accepts empty results. Ungrouped charts retain one
+        # zero-valued measure; grouped charts retain the declared roles but no
+        # categories or rows are fabricated.
+        metric_field = metric_label
+        dimensions = [label for label in dimension_labels if label is not None]
+
+    measures: list[float] = []
+    copied_rows: list[dict[str, Any]] = []
+    for index in range(list.__len__(data)):
+        row = list.__getitem__(data, index)
+        row_metric_field = _require_result_field(
+            metric_label, row, f"metric row {index}"
+        )
+        measure = _bullet_number(
+            dict.__getitem__(row, row_metric_field), index, metric_field
+        )
+        # Reserve every exact output key so the internal Vega category alias
+        # cannot collide with an unselected result field. Unselected values are
+        # deliberately replaced with None rather than converted or serialized.
+        copied: dict[str, Any] = dict.fromkeys(dict.keys(row))
+        copied[metric_field] = measure
+        for label, dimension in zip(dimension_labels, dimensions, strict=True):
+            row_dimension = _require_result_field(label, row, f"dimension row {index}")
+            dimension_value, _ = _bullet_category_value(
+                dict.__getitem__(row, row_dimension),
+                dimension,
+                index,
+                bound_labels=bound_labels,
+            )
+            copied[dimension] = dimension_value
+        copied_rows.append(copied)
+        measures.append(measure)
+
+    # The frontend validates/coerces the whole result array but renders only
+    # the first row for an ungrouped aggregate.
+    if not dimensions:
+        copied_rows = copied_rows[:1]
+        measures = measures[:1]
+        if not copied_rows:
+            copied_rows = [{metric_field: 0.0}]
+            measures = [0.0]
+
+    ranges = _bullet_numeric_control_tokens(dict.get(form_data, "ranges"), "ranges")
+    if not ranges:
+        # Match Bullet/transformProps.ts: the largest measure drives one
+        # qualitative band whose upper threshold is 110% of that measure.
+        ranges = [0.0, max(measures, default=0.0) * 1.1]
+    markers = _bullet_numeric_control_tokens(dict.get(form_data, "markers"), "markers")
+    marker_lines = _bullet_numeric_control_tokens(
+        dict.get(form_data, "marker_lines"), "marker lines"
+    )
+    all_numbers = [*measures, *ranges, *markers, *marker_lines]
+    if any(not math.isfinite(value) for value in all_numbers):
+        raise BulletOutputError("Bullet presentation values must be finite")
+
+    show_labels = dict.get(form_data, "show_labels", False)
+    show_legend = dict.get(form_data, "show_legend", False)
+    if type(show_labels) is not bool or type(show_legend) is not bool:
+        raise BulletOutputError("Bullet label and legend controls must be booleans")
+
+    range_labels = _bullet_string_tokens(dict.get(form_data, "range_labels"))
+    marker_labels = _bullet_string_tokens(dict.get(form_data, "marker_labels"))
+    marker_line_labels = _bullet_string_tokens(
+        dict.get(form_data, "marker_line_labels")
+    )
+    return BulletRenderModel(
+        rows=copied_rows,
+        metric_field=metric_field,
+        dimensions=dimensions,
+        measures=measures,
+        ranges=ranges,
+        range_labels=range_labels,
+        markers=markers,
+        marker_labels=marker_labels,
+        marker_lines=marker_lines,
+        marker_line_labels=marker_line_labels,
+        y_axis_format=(
+            _validate_bullet_format(
+                dict.get(form_data, "y_axis_format", "SMART_NUMBER"), all_numbers
+            )
+            if validate_format
+            else "SMART_NUMBER"
+        ),
+        show_labels=show_labels,
+        show_legend=show_legend,
+    )
+
+
+def _generate_ascii_bullet_chart(
+    data: List[Dict[str, Any]], form_data: Dict[str, Any]
+) -> str:
+    """Generate a horizontal Bullet preview from the shared strict model."""
+    model = resolve_bullet_render_model(data, form_data)
+    if model.dimensions and not model.rows:
+        return "No data available for grouped Bullet chart"
+    extent = (
+        max(
+            [abs(value) for value in [*model.measures, *model.ranges, *model.markers]],
+            default=1,
+        )
+        or 1
+    )
+    lines = [f"ASCII Bullet Chart — {model.metric_field}", "=" * 60]
+    for row_index, (row, value) in enumerate(
+        zip(model.rows[:10], model.measures[:10], strict=True)
+    ):
+        category = ", ".join(
+            _bullet_category_value(dict.get(row, field), field, row_index)[1]
+            for field in model.dimensions
+        )
+        category = category or "Measure"
+        width = round(abs(value) / extent * 32)
+        bar = "█" * width
+        formatted = _format_bullet_number(model.y_axis_format, value)
+        lines.append(f"{category[:20]:>20} |{bar:<32} {formatted}")
+
+    def labeled(values: list[float], labels: list[str], prefix: str) -> list[str]:
+        return [
+            f"{labels[index] if index < len(labels) and labels[index] else prefix}: "
+            f"{_format_bullet_number(model.y_axis_format, value)}"
+            for index, value in enumerate(values)
+        ]
+
+    # The frontend always draws range bands, markers, and marker lines;
+    # show_labels/show_legend only toggle their text. A text preview has no
+    # geometry, so the comparison targets are always listed.
+    lines.append("Key:")
+    lines.extend(
+        f"  range {item}" for item in labeled(model.ranges, model.range_labels, "Range")
+    )
+    lines.extend(
+        f"  marker {item}"
+        for item in labeled(model.markers, model.marker_labels, "Marker")
+    )
+    lines.extend(
+        f"  line {item}"
+        for item in labeled(model.marker_lines, model.marker_line_labels, "Marker line")
+    )
+    return "\n".join(lines)
+
+
+def _bullet_numeric_control_tokens(value: Any, role: str) -> list[float]:  # noqa: C901
+    """Drop non-numeric native tokens like Explore, retaining safety bounds."""
+    value = _safe_enum_backing(value)
+    if value is None or (type(value) is str and value == ""):
+        return []
+    if type(value) is str:
+        if len(value) > _MAX_BULLET_CONTROL_CHARS:
+            raise BulletOutputError(f"Bullet {role} exceeds the size limit")
+        tokens: list[Any] = value.split(",")
+    elif type(value) is list:
+        tokens = [
+            list.__getitem__(value, index) for index in range(list.__len__(value))
+        ]
+    else:
+        raise BulletOutputError(f"Bullet {role} must be a comma-separated list")
+    if len(tokens) > _MAX_BULLET_TOKENS:
+        raise BulletOutputError(f"Bullet {role} exceeds the item limit")
+
+    numbers: list[float] = []
+    for index, token in enumerate(tokens):
+        token = _safe_enum_backing(token)
+        if type(token) is str:
+            token = token.strip(_JAVASCRIPT_WHITESPACE)
+        if type(token) is str and token == "":
+            continue
+        if type(token) is bool or not (
+            type(token) is str
+            or type(token) is int
+            or type(token) is float
+            or type(token) is Decimal
+        ):
+            raise BulletOutputError(f"Bullet {role}[{index}] is not numeric")
+        if type(token) is str and len(token) > _MAX_BULLET_TEXT_BYTES:
+            raise BulletOutputError(f"Bullet {role}[{index}] is not numeric")
+        try:
+            number = (
+                _javascript_numeric_string(token)
+                if type(token) is str
+                else float(token)
+            )
+        except ValueError:
+            # Native controls tolerate stray text and incomplete input.
+            continue
+        except (TypeError, OverflowError) as ex:
+            raise BulletOutputError(f"Bullet {role}[{index}] is not numeric") from ex
+        if math.isnan(number):
+            continue
+        if not math.isfinite(number):
+            raise BulletOutputError(f"Bullet {role}[{index}] is NaN or infinite")
+        numbers.append(number)
+    return numbers
+
+
+def _generate_bullet_vega_lite_preview(  # noqa: C901
+    data: List[Dict[str, Any]], form_data: Dict[str, Any]
+) -> VegaLitePreview:
+    """Build a horizontal layered preview from the shared strict model."""
+    model = resolve_bullet_render_model(data, form_data)
+
+    metric_reference = "".join(
+        "\\" + char if char in ".[]\\" else char for char in model.metric_field
+    )
+    category_field = _unique_bullet_category_field(model.rows)
+    row_field = _unique_bullet_derived_field(
+        model.rows, "__mcp_bullet_row", (category_field,)
+    )
+    containing_range_labels = (
+        [
+            _containing_bullet_range_label(measure, model.ranges, model.range_labels)
+            for measure in model.measures
+        ]
+        if model.range_labels
+        else [None] * len(model.rows)
+    )
+    range_tooltip_field = (
+        _unique_bullet_derived_field(
+            model.rows, "__mcp_bullet_range", (category_field, row_field)
+        )
+        if any(label is not None for label in containing_range_labels)
+        else None
+    )
+    values = []
+    for row_index, row in enumerate(model.rows):
+        copied = dict.copy(row)
+        copied[row_field] = row_index
+        copied[category_field] = (
+            ", ".join(
+                _bullet_category_value(dict.get(row, field), field, row_index)[1]
+                for field in model.dimensions
+            )
+            if model.dimensions
+            else ""
+        )
+        if (
+            range_tooltip_field is not None
+            and containing_range_labels[row_index] is not None
+        ):
+            copied[range_tooltip_field] = containing_range_labels[row_index]
+        values.append(copied)
+
+    # Explore uses indexed rows even when their display labels are identical.
+    category_labels = [row[category_field] for row in values]
+    vega_format = {
+        "SMART_NUMBER": "~s",
+        "SMART_NUMBER_SIGNED": "+~s",
+    }.get(model.y_axis_format, model.y_axis_format)
+    y_encoding = {
+        "field": row_field,
+        "type": "nominal",
+        "title": ", ".join(model.dimensions) if model.dimensions else None,
+        "sort": None,
+        "axis": {"labelExpr": f"{json.dumps(category_labels)}[datum.value]"},
+    }
+    tooltip = [
+        {
+            "field": category_field,
+            "type": "nominal",
+            "title": ", ".join(model.dimensions) if model.dimensions else None,
+        },
+        {
+            "field": metric_reference,
+            "type": "quantitative",
+            "title": model.metric_field,
+            "format": vega_format,
+        },
+    ]
+    if range_tooltip_field is not None:
+        tooltip.append(
+            {"field": range_tooltip_field, "type": "nominal", "title": "Range"}
+        )
+    axis_min = min(
+        0.0,
+        *model.measures,
+        *model.ranges,
+        *model.markers,
+        *model.marker_lines,
+    )
+    axis_max = max(
+        [*model.measures, *model.ranges, *model.markers, *model.marker_lines]
+    )
+    if axis_min == axis_max:
+        axis_max = axis_min + (abs(axis_min) or 1)
+
+    def label_at(labels: list[str], index: int, value: float, prefix: str) -> str:
+        if index < len(labels) and labels[index]:
+            return labels[index]
+        return (
+            ""
+            if prefix == "Range"
+            else _format_bullet_number(model.y_axis_format, value)
+        )
+
+    def legend_color(name: str) -> dict[str, Any]:
+        return {
+            "datum": name,
+            "type": "nominal",
+            "legend": {"title": None} if model.show_legend else None,
+        }
+
+    label_field = _unique_bullet_derived_field(values, "__mcp_bullet_threshold_label")
+    value_field = _unique_bullet_derived_field(
+        values, "__mcp_bullet_threshold_value", (label_field,)
+    )
+
+    def threshold_details(
+        label: str, value: float, label_title: str, value_title: str
+    ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+        """Materialize constants as fields for valid Vega tooltip arrays."""
+        return (
+            [
+                {"calculate": json.dumps(label), "as": label_field},
+                {"calculate": json.dumps(value), "as": value_field},
+            ],
+            [
+                {"field": label_field, "type": "nominal", "title": label_title},
+                {
+                    "field": value_field,
+                    "type": "quantitative",
+                    "title": value_title,
+                    "format": vega_format,
+                },
+            ],
+        )
+
+    layers: list[dict[str, Any]] = []
+    range_entries = sorted(
+        [
+            (
+                threshold,
+                label_at(model.range_labels, index, threshold, "Range"),
+            )
+            for index, threshold in enumerate(model.ranges)
+        ],
+        key=lambda entry: entry[0],
+        reverse=True,
+    )
+    for index, (threshold, label) in enumerate(range_entries):
+        transforms, threshold_tooltip = threshold_details(
+            label, threshold, "Range", "Threshold"
+        )
+        layers.append(
+            {
+                "transform": transforms,
+                "mark": {
+                    "type": "rect",
+                    "opacity": max(0.08, 0.28 - index * 0.04),
+                },
+                "encoding": {
+                    "x": {"datum": axis_min, "type": "quantitative"},
+                    "x2": {"datum": threshold},
+                    "y": y_encoding,
+                    "color": legend_color(
+                        (f"{label}: " if label else "")
+                        + f"≤ {_format_bullet_number(model.y_axis_format, threshold)}"
+                    ),
+                    "tooltip": threshold_tooltip,
+                },
+            }
+        )
+        if model.show_labels and label:
+            layers.append(
+                {
+                    "mark": {"type": "text", "align": "right", "dx": -3},
+                    "encoding": {
+                        "x": {"datum": threshold, "type": "quantitative"},
+                        "y": y_encoding,
+                        "text": {"value": label},
+                    },
+                }
+            )
+    layers.append(
+        {
+            "mark": {"type": "bar", "tooltip": True, "size": 16},
+            "encoding": {
+                "x": {
+                    "field": metric_reference,
+                    "type": "quantitative",
+                    "title": model.metric_field,
+                    "scale": {"domain": [axis_min, axis_max]},
+                    "axis": {"format": vega_format},
+                },
+                "y": y_encoding,
+                "tooltip": tooltip,
+                "color": legend_color(model.metric_field),
+            },
+        }
+    )
+    for index, marker in enumerate(model.markers):
+        label = label_at(model.marker_labels, index, marker, "Marker")
+        transforms, threshold_tooltip = threshold_details(
+            label, marker, "Marker", "Value"
+        )
+        layers.append(
+            {
+                "transform": transforms,
+                "mark": {
+                    "type": "point",
+                    "shape": "triangle-up",
+                    "filled": True,
+                    "size": 80,
+                },
+                "encoding": {
+                    "x": {"datum": marker, "type": "quantitative"},
+                    "y": y_encoding,
+                    "color": legend_color(
+                        f"{label}: {_format_bullet_number(model.y_axis_format, marker)}"
+                    ),
+                    "tooltip": threshold_tooltip,
+                },
+            }
+        )
+        if model.show_labels:
+            layers.append(
+                {
+                    "mark": {"type": "text", "dy": 14},
+                    "encoding": {
+                        "x": {"datum": marker, "type": "quantitative"},
+                        "y": y_encoding,
+                        "text": {"value": label},
+                    },
+                }
+            )
+    for index, marker_line in enumerate(model.marker_lines):
+        label = label_at(model.marker_line_labels, index, marker_line, "Marker line")
+        transforms, threshold_tooltip = threshold_details(
+            label, marker_line, "Marker line", "Value"
+        )
+        layers.append(
+            {
+                "transform": transforms,
+                "mark": {"type": "rule", "strokeWidth": 2},
+                "encoding": {
+                    "x": {"datum": marker_line, "type": "quantitative"},
+                    "color": legend_color(
+                        f"{label}: "
+                        f"{_format_bullet_number(model.y_axis_format, marker_line)}"
+                    ),
+                    "tooltip": threshold_tooltip,
+                },
+            }
+        )
+        # ECharts markLine renders its label regardless of show_labels. Keep
+        # Vega faithful by always materializing one label per reference line.
+        layers.append(
+            {
+                "transform": [{"aggregate": []}],
+                "mark": {"type": "text", "align": "right", "dx": -3, "dy": -8},
+                "encoding": {
+                    "x": {"datum": marker_line, "type": "quantitative"},
+                    "y": {"value": 8},
+                    "text": {"value": label},
+                },
+            }
+        )
+
+    return VegaLitePreview(
+        type="vega_lite",
+        specification={
+            "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+            **(
+                {"description": "No data available for grouped Bullet chart"}
+                if model.dimensions and not model.rows
+                else {}
+            ),
+            "data": {"values": values},
+            "layer": layers,
+            "resolve": {"scale": {"color": "independent"}},
+            "usermeta": {
+                "bullet": {
+                    "metric": model.metric_field,
+                    "dimensions": model.dimensions,
+                    "ranges": model.ranges,
+                    "range_labels": model.range_labels,
+                    "markers": model.markers,
+                    "marker_labels": model.marker_labels,
+                    "marker_lines": model.marker_lines,
+                    "marker_line_labels": model.marker_line_labels,
+                    "y_axis_format": model.y_axis_format,
+                    "show_labels": model.show_labels,
+                    "show_legend": model.show_legend,
+                }
+            },
+        },
+        supports_streaming=False,
+    )
diff --git a/superset/mcp_service/chart/query_result.py b/superset/mcp_service/chart/query_result.py
index 1bddc37ea65..975b976350e 100644
--- a/superset/mcp_service/chart/query_result.py
+++ b/superset/mcp_service/chart/query_result.py
@@ -49,6 +49,7 @@ from superset.utils.core import (
     ExtraFiltersTimeColumnType,
     GenericDataType,
 )
+from superset.utils.dates import datetime_to_epoch, EPOCH
 
 FAILED_QUERY_STATUSES = frozenset(
     {"error", "failed", "stopped", "timed_out", "cancelled", "canceled"}
@@ -174,6 +175,10 @@ class _ResultBudget:
     json_bytes: int = 0
     metadata_items: int = 0
     metadata_bytes: int = 0
+    # Charts whose frontend ``transformProps`` reads temporal values with
+    # ``Number()`` (for example Bullet) receive the Chart Data API's epoch-ms
+    # numbers and duration text instead of ISO-8601 strings.
+    temporal_json_numbers: bool = False
 
 
 @dataclass(frozen=True)
@@ -782,6 +787,19 @@ def _timestamp_offset_without_hooks(value: pd.Timestamp) -> timezone | None:
 
 def _canonical_datetime(value: datetime) -> tuple[str | None, str | None]:
     """Serialize an exact datetime through trusted timezone state only."""
+    canonical_value, reason = _canonical_datetime_value(value)
+    if canonical_value is None:
+        return None, reason
+    try:
+        return datetime.isoformat(canonical_value), None
+    except (OverflowError, TypeError, ValueError):
+        return None, "an invalid datetime"
+
+
+def _canonical_datetime_value(
+    value: datetime,
+) -> tuple[datetime | None, str | None]:
+    """Rebuild an exact datetime on a trusted timezone without its hooks."""
     tzinfo = value.tzinfo
     canonical_value = value
     if tzinfo is not None and not any(
@@ -806,10 +824,7 @@ def _canonical_datetime(value: datetime) -> tuple[str | None, str | None]:
             tzinfo=canonical_tz,
             fold=value.fold,
         )
-    try:
-        return datetime.isoformat(canonical_value), None
-    except (OverflowError, TypeError, ValueError):
-        return None, "an invalid datetime"
+    return canonical_value, None
 
 
 def _canonical_time(value: time) -> tuple[str | None, str | None]:
@@ -856,6 +871,19 @@ def _canonical_time(value: time) -> tuple[str | None, str | None]:
 
 def _canonical_timestamp(value: pd.Timestamp) -> tuple[str | None, str | None]:
     """Preserve a trusted timestamp's instant, offset, nanoseconds, and fold."""
+    canonical_value, reason = _canonical_timestamp_value(value)
+    if canonical_value is None:
+        return None, reason
+    try:
+        return pd.Timestamp.isoformat(canonical_value), None
+    except (KeyError, OverflowError, TypeError, ValueError):
+        return None, "an invalid timestamp"
+
+
+def _canonical_timestamp_value(
+    value: pd.Timestamp,
+) -> tuple[pd.Timestamp | None, str | None]:
+    """Rebuild an exact timestamp on a trusted timezone without its hooks."""
     try:
         tzinfo = value.tzinfo
         if tzinfo is not None and not any(
@@ -876,11 +904,152 @@ def _canonical_timestamp(value: pd.Timestamp) -> tuple[str | None, str | None]:
             value = pd.Timestamp(raw_value, unit=value.unit, tz="UTC").tz_convert(
                 canonical_tz
             )
-        return pd.Timestamp.isoformat(value), None
+        return value, None
     except (KeyError, OverflowError, TypeError, ValueError):
         return None, "an invalid timestamp"
 
 
+def _is_chart_data_temporal_scalar(value: Any) -> bool:
+    """Return whether an exact scalar has Chart Data temporal wire semantics."""
+    return type(value) in {
+        date,
+        datetime,
+        pd.Timestamp,
+        np.datetime64,
+        _PANDAS_NAT_TYPE,
+    }
+
+
+def _is_chart_data_duration_scalar(value: Any) -> bool:
+    """Return whether an exact scalar has Chart Data duration semantics."""
+    return type(value) in {timedelta, pd.Timedelta, np.timedelta64}
+
+
+def _chart_data_builtin_timedelta_text(value: timedelta) -> str:
+    """Reproduce ``format_timedelta`` without comparison or string hooks."""
+    total_microseconds = (
+        value.days * 86_400 + value.seconds
+    ) * 1_000_000 + value.microseconds
+    sign = "-" if total_microseconds < 0 else ""
+    remaining = abs(total_microseconds)
+    days, remaining = divmod(remaining, 86_400 * 1_000_000)
+    hours, remaining = divmod(remaining, 3_600 * 1_000_000)
+    minutes, remaining = divmod(remaining, 60 * 1_000_000)
+    seconds, microseconds = divmod(remaining, 1_000_000)
+    day_text = f"{days} {'day' if days == 1 else 'days'}, " if days else ""
+    fraction = f".{microseconds:06d}" if microseconds else ""
+    return f"{sign}{day_text}{hours}:{minutes:02d}:{seconds:02d}{fraction}"
+
+
+def _chart_data_pandas_timedelta_text(value: pd.Timedelta) -> str:
+    """Reproduce Chart Data ``format_timedelta`` output from exact fields."""
+    total_nanoseconds = (
+        (
+            object.__getattribute__(value, "days") * 86_400
+            + object.__getattribute__(value, "seconds")
+        )
+        * 1_000_000
+        + object.__getattribute__(value, "microseconds")
+    ) * 1_000 + object.__getattribute__(value, "nanoseconds")
+    sign = "-" if total_nanoseconds < 0 else ""
+    remaining = abs(total_nanoseconds)
+    days, remaining = divmod(remaining, 86_400 * 1_000_000_000)
+    hours, remaining = divmod(remaining, 3_600 * 1_000_000_000)
+    minutes, remaining = divmod(remaining, 60 * 1_000_000_000)
+    seconds, nanoseconds = divmod(remaining, 1_000_000_000)
+    if nanoseconds % 1_000:
+        fraction = f".{nanoseconds:09d}"
+    elif nanoseconds:
+        fraction = f".{nanoseconds // 1_000:06d}"
+    else:
+        fraction = ""
+    return f"{sign}{days} days {hours:02d}:{minutes:02d}:{seconds:02d}{fraction}"
+
+
+def _chart_data_duration_text(value: Any) -> tuple[str | None, str | None]:
+    """Project an exact duration through Chart Data's public JSON spelling.
+
+    Builtin and pandas durations are ``timedelta`` instances consumed by
+    ``format_timedelta``. Exact NumPy durations model the real DataFrame
+    producer boundary: unambiguous units are promoted to a pandas Timedelta,
+    NaT becomes JSON null, and ambiguous or overflowing units fail closed.
+    """
+    value_type = type(value)
+    if value_type is timedelta:
+        text = _chart_data_builtin_timedelta_text(value)
+    elif value_type is pd.Timedelta:
+        text = _chart_data_pandas_timedelta_text(value)
+    elif value_type is np.timedelta64:
+        if np.isnat(value):
+            return None, None
+        try:
+            pandas_value = pd.Timedelta(value)
+        except (OverflowError, TypeError, ValueError):
+            return None, "contains an invalid NumPy duration"
+        text = _chart_data_pandas_timedelta_text(pandas_value)
+    else:
+        return None, "contains an unsupported duration value"
+    if _bounded_utf8_length(text, MAX_RESULT_STRING_LENGTH) is None:
+        return None, "contains an oversized duration"
+    return text, None
+
+
+def _chart_data_temporal_number(  # noqa: C901
+    value: Any,
+) -> tuple[float | None, str | None]:
+    """Project an exact date/datetime through Chart Data's epoch-ms wire form.
+
+    The public Chart Data API uses ``json_int_dttm_ser`` before the browser
+    parses the payload. Trusted canonicalizers first validate or replace
+    timezone implementations without arbitrary hooks, then the production
+    ``datetime_to_epoch`` helper preserves its exact float behavior.
+    """
+    value_type = type(value)
+    if value_type is _PANDAS_NAT_TYPE:
+        # json_int_dttm_ser produces NaN and the Chart Data response's
+        # ``ignore_nan=True`` projects it to JSON null.
+        return None, None
+    if value_type is np.datetime64:
+        if np.isnat(value):
+            return None, None
+        try:
+            value = pd.Timestamp(value)
+        except (OverflowError, TypeError, ValueError):
+            return None, "contains an invalid NumPy datetime"
+        value_type = type(value)
+    if value_type is pd.Timestamp:
+        canonical_timestamp, reason = _canonical_timestamp_value(value)
+        if reason is not None or canonical_timestamp is None:
+            return None, reason or "contains an invalid pandas timestamp"
+        try:
+            return datetime_to_epoch(canonical_timestamp), None
+        except (OverflowError, TypeError, ValueError):
+            return None, "contains an invalid pandas timestamp"
+    if value_type is datetime:
+        canonical_datetime, reason = _canonical_datetime_value(value)
+        if reason is not None or canonical_datetime is None:
+            return None, reason or "contains an invalid datetime"
+        try:
+            return datetime_to_epoch(canonical_datetime), None
+        except (OverflowError, TypeError, ValueError):
+            return None, "contains an invalid datetime"
+    if value_type is date:
+        return (value - EPOCH.date()).total_seconds() * 1000, None
+    return None, "contains an unsupported temporal value"
+
+
+def _normalize_chart_data_scalar(
+    value: Any, *, temporal_json_numbers: bool
+) -> tuple[Any, str | None]:
+    """Normalize a row scalar, optionally with Chart Data temporal numbers."""
+    if temporal_json_numbers:
+        if _is_chart_data_temporal_scalar(value):
+            return _chart_data_temporal_number(value)
+        if _is_chart_data_duration_scalar(value):
+            return _chart_data_duration_text(value)
+    return _normalize_scalar(value)
+
+
 def _canonical_binary(
     value: bytes | bytearray | memoryview,
 ) -> tuple[str | None, str | None]:
@@ -1176,7 +1345,10 @@ def _normalize_value(  # noqa: C901
             number = float(item)
             if not math.isfinite(number) and not math.isnan(number):
                 return None, "a non-finite metadata number"
-        normalized, reason = _normalize_scalar(item)
+        normalized, reason = _normalize_chart_data_scalar(
+            item,
+            temporal_json_numbers=budget.temporal_json_numbers and not metadata,
+        )
         if reason is not None:
             return None, reason
         max_string_bytes = (
@@ -1430,7 +1602,10 @@ def _validate_query_metadata(query: dict[str, Any], index: int) -> ChartError |
 
 
 def validate_query_result_envelope(  # noqa: C901
-    result: Any, *, none_as_empty: bool = False
+    result: Any,
+    *,
+    none_as_empty: bool = False,
+    temporal_json_numbers: bool = False,
 ) -> ChartError | None:
     """Canonicalize and strictly validate one real command result in place.
 
@@ -1443,7 +1618,7 @@ def validate_query_result_envelope(  # noqa: C901
     if type(result) is not dict:
         return _invalid_result("a malformed result envelope")
 
-    budget = _ResultBudget()
+    budget = _ResultBudget(temporal_json_numbers=temporal_json_numbers)
     if reason := _charge_value(budget, metadata=True):
         return _invalid_result(reason)
     top_level_keys = list(dict.keys(result))
@@ -1640,7 +1815,9 @@ def _normalize_index_names(
     return None
 
 
-def query_result_failure(result: Any) -> ChartError | None:
+def query_result_failure(
+    result: Any, *, temporal_json_numbers: bool = False
+) -> ChartError | None:
     """Return a structured failure embedded in a ChartDataCommand payload.
 
     ChartDataCommand can return an HTTP-successful envelope whose top level or
@@ -1653,11 +1830,16 @@ def query_result_failure(result: Any) -> ChartError | None:
     reported as failure-free, because every caller reads data from at least one
     query.
     """
-    return validate_query_result_envelope(result)
+    return validate_query_result_envelope(
+        result, temporal_json_numbers=temporal_json_numbers
+    )
 
 
 def first_query_data(
-    result: Any, *, none_as_empty: bool = False
+    result: Any,
+    *,
+    none_as_empty: bool = False,
+    temporal_json_numbers: bool = False,
 ) -> tuple[list[Any] | None, ChartError | None]:
     """Validate the full result and return its first canonical data array.
 
@@ -1665,7 +1847,11 @@ def first_query_data(
     this with the strict default so hierarchy input can never silently become an
     empty visualization.
     """
-    if failure := validate_query_result_envelope(result, none_as_empty=none_as_empty):
+    if failure := validate_query_result_envelope(
+        result,
+        none_as_empty=none_as_empty,
+        temporal_json_numbers=temporal_json_numbers,
+    ):
         return None, failure
     queries = dict.__getitem__(result, "queries")
     first_query = list.__getitem__(queries, 0)
@@ -1948,6 +2134,14 @@ def normalize_chart_query_result(result: Any, form_data: Mapping[str, Any]) -> A
     return plugin.normalize_query_result(result, form_data)
 
 
+def temporal_json_numbers(viz_type: str | None) -> bool:
+    """Return whether the owning plugin reads temporals as Chart Data numbers."""
+    from superset.mcp_service.chart.registry import plugin_for_viz_type
+
+    plugin = plugin_for_viz_type(viz_type)
+    return plugin.temporal_json_numbers if plugin is not None else False
+
+
 def null_data_is_empty(viz_type: str | None) -> bool:
     """Return whether the owning plugin reads a null ``data`` array as empty."""
     from superset.mcp_service.chart.registry import plugin_for_viz_type
diff --git a/superset/mcp_service/chart/resources/chart_configs.py b/superset/mcp_service/chart/resources/chart_configs.py
index 603fe47d922..215775bcc5d 100644
--- a/superset/mcp_service/chart/resources/chart_configs.py
+++ b/superset/mcp_service/chart/resources/chart_configs.py
@@ -167,6 +167,44 @@ def get_chart_configs_resource() -> str:
         },
     }
 
+    # Bullet chart examples
+    bullet_chart_configs = {
+        "attainment_against_targets": {
+            "description": (
+                "Revenue attainment against qualitative thresholds and a plan marker"
+            ),
+            "config": {
+                "chart_type": "bullet",
+                "metric": {
+                    "name": "revenue",
+                    "aggregate": "SUM",
+                    "label": "Revenue",
+                },
+                "ranges": [100000, 250000, 500000],
+                "range_labels": ["Minimum", "Target", "Stretch"],
+                "markers": [300000],
+                "marker_labels": ["Plan"],
+                "show_labels": True,
+                "y_axis_format": "$,.0f",
+            },
+            "use_cases": ["Goal attainment", "KPI target comparison"],
+        },
+        "regional_attainment": {
+            "description": "One bullet row per region using a saved metric",
+            "config": {
+                "chart_type": "bullet",
+                "metric": {"name": "attainment", "saved_metric": True},
+                "dimensions": [{"name": "region"}],
+                "marker_lines": [1],
+                "marker_line_labels": ["Goal"],
+                "show_legend": True,
+                "y_axis_format": ".0%",
+                "row_limit": 100,
+            },
+            "use_cases": ["Regional scorecards", "Progress by team"],
+        },
+    }
+
     # Table chart examples
     table_chart_configs = {
         "basic_table": {
@@ -357,6 +395,12 @@ def get_chart_configs_resource() -> str:
             "Apply sort_by to highlight important data",
             "Use ag-grid-table viz_type for large interactive datasets",
         ],
+        "bullet_charts": [
+            "Use one numeric metric; COUNT/COUNT_DISTINCT work for text columns",
+            "Use dimensions only when separate bullet rows are meaningful",
+            "Keep range/marker labels aligned one-for-one with their values",
+            "Use marker_lines for hard targets and markers for point comparisons",
+        ],
         "interactive_pivots": [
             "Use rows for AG Grid row groups and columns for pivot labels",
             "Metric aggregates also seed AG Grid's group aggregation model",
@@ -394,6 +438,7 @@ def get_chart_configs_resource() -> str:
 
     resource_data = {
         "xy_chart_configs": xy_chart_configs,
+        "bullet_chart_configs": bullet_chart_configs,
         "table_chart_configs": table_chart_configs,
         "sunburst_chart_configs": sunburst_chart_configs,
         "interactive_pivot_configs": interactive_pivot_configs,
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index de8509235eb..cac3dc46866 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -25,7 +25,7 @@ import difflib
 import logging
 import math
 import re
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
 from copy import deepcopy
 from datetime import datetime, time
 from typing import Annotated, Any, Dict, get_args, List, Literal, Protocol, Tuple
@@ -40,6 +40,7 @@ from pydantic import (
     JsonValue,
     model_serializer,
     model_validator,
+    PrivateAttr,
     StrictBool,
     StrictInt,
     StrictStr,
@@ -89,6 +90,7 @@ from superset.mcp_service.utils.serialization import (
     OptionalRowCount,
     RowCount,
 )
+from superset.utils import json
 
 logger = logging.getLogger(__name__)
 
@@ -3700,6 +3702,123 @@ def _metric_display_label(col: ColumnRef) -> str:
     return col.label or col.name or ""
 
 
+def _require_unique_bullet_order_match(
+    matches: list[tuple[str, int | None]], target: str, role: str
+) -> tuple[str, int | None] | None:
+    """Return a unique sort-role match or reject ambiguity."""
+    if len(matches) == 1:
+        return matches[0]
+    if len(matches) > 1:
+        raise ValueError(f"ambiguous {role}: {target}")
+    return None
+
+
+def normalize_metric_aggregate(aggregate: str | None) -> str:
+    """Return the native aggregate, including defaulting and shorthand aliases."""
+    aliases = {"STDDEV": "STDDEV_SAMP", "VAR": "VAR_SAMP"}
+    native = aliases.get((aggregate or "SUM").upper(), (aggregate or "SUM").upper())
+    valid = {
+        "SUM",
+        "COUNT",
+        "AVG",
+        "MIN",
+        "MAX",
+        "COUNT_DISTINCT",
+        "STDDEV_SAMP",
+        "VAR_SAMP",
+        "MEDIAN",
+        "PERCENTILE",
+    }
+    return native if native in valid else "SUM"
+
+
+def _bullet_metric_output_label(metric: ColumnRef) -> str:
+    """Return the field name emitted by Bullet's native metric shape."""
+    if metric.saved_metric:
+        # Saved metrics are serialized as a bare metric-name string; a
+        # ColumnRef display label does not change the query output alias.
+        return metric.name or ""
+    if metric.sql_expression:
+        return metric.label or ""
+    return (
+        metric.label or f"{normalize_metric_aggregate(metric.aggregate)}({metric.name})"
+    )
+
+
+def resolve_bullet_order_target(
+    target: str,
+    dimensions: Sequence[ColumnRef | str | dict[str, Any]],
+    metric: ColumnRef,
+) -> tuple[str, int | None]:
+    """Resolve a Bullet sort target to one actual query output role.
+
+    Physical dimension names and the emitted metric label are authoritative.
+    Friendly dimension labels and a metric's source name are accepted only as
+    secondary input aliases. This makes an exact physical name win when, for
+    example, another dimension uses that name as its display label.
+    Inherited native dimensions use their query output labels instead.
+    """
+
+    from superset.mcp_service.chart.chart_helpers import _column_label
+
+    physical = [
+        (
+            dimension.name
+            if isinstance(dimension, ColumnRef)
+            else _column_label(dimension)
+        )
+        or ""
+        for dimension in dimensions
+    ]
+    metric_output = _bullet_metric_output_label(metric)
+
+    # Exact actual output fields are always preferred, with physical columns
+    # taking precedence over all display/source aliases.
+    for index, name in enumerate(physical):
+        if target == name:
+            return "dimension", index
+    if target == metric_output:
+        return "metric", None
+
+    actual_matches: list[tuple[str, int | None]] = [
+        ("dimension", index)
+        for index, name in enumerate(physical)
+        if name.casefold() == target.casefold()
+    ]
+    if metric_output.casefold() == target.casefold():
+        actual_matches.append(("metric", None))
+    if match := _require_unique_bullet_order_match(
+        actual_matches, target, "actual output"
+    ):
+        return match
+
+    aliases: list[tuple[str, int | None, str]] = [
+        ("dimension", index, dimension.label)
+        for index, dimension in enumerate(dimensions)
+        if isinstance(dimension, ColumnRef) and dimension.label
+    ]
+    for alias in (metric.name, metric.label):
+        if alias and alias != metric_output:
+            aliases.append(("metric", None, alias))
+
+    exact_aliases = [(kind, index) for kind, index, alias in aliases if alias == target]
+    if match := _require_unique_bullet_order_match(
+        exact_aliases, target, "display alias"
+    ):
+        return match
+
+    folded_aliases = [
+        (kind, index)
+        for kind, index, alias in aliases
+        if alias.casefold() == target.casefold()
+    ]
+    if match := _require_unique_bullet_order_match(
+        folded_aliases, target, "display alias"
+    ):
+        return match
+    raise ValueError(f"unknown: {target}")
+
+
 def _coerce_sort_item(item: Any) -> Any:
     if isinstance(item, str):
         return SortByConfig(column=item, ascending=False)
@@ -3985,6 +4104,504 @@ class XYChartConfig(BaseChartConfig):
         return self
 
 
+class BulletChartConfig(BaseChartConfig):
+    """Config for bullet charts (viz_type ``bullet``)."""
+
+    # Semantic field names are exposed to MCP clients; validation aliases and the
+    # native adapter accept saved Explore ``form_data`` without weakening the
+    # unknown-field checks that catch misspelled controls.
+    model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+    # Saved query roles are internal sort context, never typed authoring input.
+    # Keeping them out of dimensions preserves omission and native SQL shapes.
+    _inherited_groupby: list[str | dict[str, Any]] | None = PrivateAttr(default=None)
+
+    @property
+    def order_dimensions(self) -> Sequence[ColumnRef | str | dict[str, Any]]:
+        """Return authored dimensions or the inherited native sort hierarchy."""
+        if self.dimensions is None and self._inherited_groupby is not None:
+            return self._inherited_groupby
+        return self.dimensions or []
+
+    chart_type: Literal["bullet"] = "bullet"
+    metric: ColumnRef = Field(
+        ...,
+        description="Numeric measure shown by each bullet bar",
+    )
+    dimensions: List[ColumnRef] | None = Field(
+        None,
+        validation_alias=AliasChoices("dimensions", "groupby"),
+        description=(
+            "Category hierarchy; one bullet row per unique combination. Omit to "
+            "keep the saved hierarchy on update; [] clears it."
+        ),
+        max_length=20,
+    )
+    filters: List[FilterConfig] | None = Field(None, max_length=100)
+    time_range: str | None = Field(
+        None,
+        min_length=1,
+        max_length=1000,
+        description=(
+            "Superset time range, e.g. 'Last 30 days' or '2025-01-01 : 2025-12-31'"
+        ),
+    )
+    row_limit: int = Field(
+        10000,
+        ge=1,
+        le=50000,
+        description="Maximum bullet rows",
+    )
+    order_by: List[SortByConfig] = Field(
+        default_factory=list,
+        validation_alias=AliasChoices("order_by", "orderby", "order_by_cols"),
+        max_length=20,
+        description="Row order by a dimension name or the metric output label/name",
+    )
+
+    # Presentation fields map one-for-one onto Bullet/transformProps.ts controls.
+    ranges: List[float] = Field(
+        default_factory=list,
+        max_length=100,
+        description="Qualitative range thresholds shaded behind the measure",
+    )
+    range_labels: List[str] = Field(
+        default_factory=list,
+        validation_alias=AliasChoices("range_labels", "rangeLabels"),
+        max_length=100,
+    )
+    markers: List[float] = Field(
+        default_factory=list,
+        max_length=100,
+        description="Target values drawn as point markers",
+    )
+    marker_labels: List[str] = Field(
+        default_factory=list,
+        validation_alias=AliasChoices("marker_labels", "markerLabels"),
+        max_length=100,
+    )
+    marker_lines: List[float] = Field(
+        default_factory=list,
+        validation_alias=AliasChoices("marker_lines", "markerLines"),
+        max_length=100,
+        description="Reference values drawn as vertical lines",
+    )
+    marker_line_labels: List[str] = Field(
+        default_factory=list,
+        validation_alias=AliasChoices("marker_line_labels", "markerLineLabels"),
+        max_length=100,
+    )
+    y_axis_format: str = Field(
+        "SMART_NUMBER",
+        validation_alias=AliasChoices("y_axis_format", "yAxisFormat"),
+        max_length=100,
+    )
+    show_labels: bool = Field(
+        False,
+        validation_alias=AliasChoices("show_labels", "showLabels"),
+    )
+    show_legend: bool = Field(
+        False,
+        validation_alias=AliasChoices("show_legend", "showLegend"),
+    )
+
+    @staticmethod
+    def _native_metric_label(value: dict[str, Any]) -> Any:
+        """Derive the output label used by native metrics and metric sorts."""
+        return value.get("label") or (
+            value.get("sqlExpression") if value.get("expressionType") == "SQL" else None
+        )
+
+    @classmethod
+    def _adapt_native_metric(cls, value: Any) -> Any:
+        """Translate QueryFormMetric shapes into the shared ColumnRef contract."""
+        if isinstance(value, str):
+            return {"name": value, "saved_metric": True}
+        if not isinstance(value, dict):
+            return value
+        if "expressionType" not in value:
+            # QueryObject's documented legacy saved-metric representation is a
+            # label-only object. Keep this adapter deliberately narrow: objects
+            # carrying ad-hoc fields must declare expressionType explicitly, and
+            # semantic ColumnRef objects continue through normal validation.
+            if set(value) == {"label"}:
+                label = value["label"]
+                if not isinstance(label, str) or not label or len(label) > 255:
+                    raise ValueError(
+                        "legacy saved metric label must be a non-empty string of "
+                        "at most 255 characters"
+                    )
+                return {"name": label, "saved_metric": True}
+            return value
+        expression_type = value.get("expressionType")
+        if expression_type == "SQL":
+            return {
+                "sql_expression": value.get("sqlExpression"),
+                "label": cls._native_metric_label(value),
+            }
+        if expression_type != "SIMPLE":
+            raise ValueError("metric.expressionType must be 'SIMPLE' or 'SQL'")
+        column = value.get("column")
+        if isinstance(column, dict):
+            name = column.get("column_name")
+        else:
+            name = column
+        return {
+            "name": name,
+            "aggregate": value.get("aggregate"),
+            "label": value.get("label"),
+        }
+
+    @staticmethod
+    def _canonical_dimension_alias(value: Any, field_name: str) -> list[str]:
+        """Canonicalize semantic/native dimension aliases for conflict checks."""
+        if not isinstance(value, list):
+            raise ValueError(f"{field_name} must be an array")
+        canonical: list[str] = []
+        for index, item in enumerate(value):
+            name: str | None
+            if isinstance(item, str):
+                name = item
+            elif isinstance(item, ColumnRef):
+                name = item.name
+            elif isinstance(item, dict):
+                name = next(
+                    (
+                        item[key]
+                        for key in ("name", "column_name", "column")
+                        if isinstance(item.get(key), str)
+                    ),
+                    None,
+                )
+            else:
+                name = None
+            if not name:
+                raise ValueError(
+                    f"{field_name}[{index}] must identify a physical column"
+                )
+            canonical.append(name)
+        return canonical
+
+    @classmethod
+    def _adapt_native_order_by(cls, value: Any) -> Any:  # noqa: C901
+        if value is None:
+            return []
+        if not isinstance(value, list):
+            raise ValueError("order_by must be an array")
+        result: list[Any] = []
+        for index, entry in enumerate(value):
+            if isinstance(entry, str):
+                if len(entry) > 2000:
+                    raise ValueError(f"order_by[{index}] is too long")
+                try:
+                    entry = json.loads(entry)
+                except json.JSONDecodeError:
+                    # A bare output/column name is the ergonomic typed form.
+                    result.append({"column": entry, "ascending": False})
+                    continue
+            if isinstance(entry, dict):
+                result.append(entry)
+                continue
+            if not isinstance(entry, (list, tuple)) or len(entry) != 2:
+                raise ValueError(
+                    f"order_by[{index}] must be [column, ascending_boolean]"
+                )
+            target, ascending = entry
+            if isinstance(target, dict):
+                target = cls._native_metric_label(target) or target.get("metric_name")
+            if not isinstance(target, str) or not target:
+                raise ValueError(f"order_by[{index}] needs a column or metric label")
+            if not isinstance(ascending, bool):
+                raise ValueError(f"order_by[{index}] ascending value must be boolean")
+            result.append({"column": target, "ascending": ascending})
+        return result
+
+    @staticmethod
+    def _adapt_native_filters(data: dict[str, Any]) -> None:  # noqa: C901
+        if "adhoc_filters" not in data:
+            return
+        if "filters" in data:
+            raise ValueError("Use either filters or native adhoc_filters, not both")
+        raw_filters = data.pop("adhoc_filters")
+        if not isinstance(raw_filters, list):
+            raise ValueError("adhoc_filters must be an array")
+        filters: list[dict[str, Any]] = []
+        temporal_pairs: list[tuple[str, str]] = []
+        inert_subjects: set[str] = set()
+        for index, raw_filter in enumerate(raw_filters):
+            if not isinstance(raw_filter, dict):
+                raise ValueError(f"adhoc_filters[{index}] must be an object")
+            if raw_filter.get("expressionType") != "SIMPLE":
+                raise ValueError(
+                    f"adhoc_filters[{index}] must use expressionType='SIMPLE'"
+                )
+            if raw_filter.get("clause") not in (None, "WHERE"):
+                raise ValueError(f"adhoc_filters[{index}] must use clause='WHERE'")
+            subject = raw_filter.get("subject")
+            operator = raw_filter.get("operator")
+            comparator = raw_filter.get("comparator")
+            if operator == "TEMPORAL_RANGE":
+                if not isinstance(subject, str) or not subject:
+                    raise ValueError(
+                        f"adhoc_filters[{index}] temporal filter needs subject"
+                    )
+                if not isinstance(comparator, str) or not comparator:
+                    raise ValueError(
+                        f"adhoc_filters[{index}] temporal filter needs a range"
+                    )
+                if comparator.casefold() == "no filter":
+                    inert_subjects.add(subject)
+                else:
+                    temporal_pairs.append((subject, comparator))
+                continue
+            if not isinstance(operator, str):
+                raise ValueError(f"adhoc_filters[{index}] needs an operator")
+            operator_map = {
+                "==": "=",
+                "EQUALS": "=",
+                "NOT_EQUALS": "!=",
+                "LESS_THAN": "<",
+                "LESS_THAN_OR_EQUAL": "<=",
+                "GREATER_THAN": ">",
+                "GREATER_THAN_OR_EQUAL": ">=",
+                "NOT_IN": "NOT IN",
+                "IS_NULL": "IS NULL",
+                "IS_NOT_NULL": "IS NOT NULL",
+            }
+            operator = operator_map.get(operator, operator)
+            filters.append({"column": subject, "op": operator, "value": comparator})
+        if len(temporal_pairs) > 1:
+            raise ValueError(
+                "Multiple active native temporal filters cannot be represented by "
+                "a single temporal_column/time_range pair"
+            )
+        if temporal_pairs:
+            subject, comparator = temporal_pairs[0]
+            if data.get("temporal_column") not in (None, subject) or data.get(
+                "time_range"
+            ) not in (None, "No filter", comparator):
+                raise ValueError(
+                    "Native temporal filter conflicts with temporal_column/time_range"
+                )
+            data["temporal_column"] = subject
+            data["time_range"] = comparator
+        elif len(inert_subjects) == 1:
+            data.setdefault("temporal_column", next(iter(inert_subjects)))
+        data["filters"] = filters
+
+    @model_validator(mode="before")
+    @classmethod
+    def adapt_native_form_data(cls, raw: Any) -> Any:  # noqa: C901
+        """Accept recognized saved Bullet form_data and reject ambiguous state."""
+        if not isinstance(raw, dict):
+            return raw
+        data = dict(raw)
+        # Null means no hierarchy was supplied, not a conflicting alias or a
+        # request to clear saved dimensions. Only an explicit [] clears them.
+        for key in ("dimensions", "groupby"):
+            if data.get(key) is None:
+                data.pop(key, None)
+        if "dimensions" in data and "groupby" in data:
+            dimensions = cls._canonical_dimension_alias(
+                data["dimensions"], "dimensions"
+            )
+            groupby = cls._canonical_dimension_alias(data["groupby"], "groupby")
+            if dimensions != groupby:
+                raise ValueError(
+                    "Conflicting Bullet dimension aliases: 'dimensions' and "
+                    "native 'groupby' must identify the same physical columns in "
+                    "the same order; provide only one or make them equivalent"
+                )
+            # Avoid relying on AliasChoices precedence or JSON key order.
+            data.pop("groupby")
+        # The request normalizer maps viz_type to chart_type before this model
+        # runs and records native provenance with the shared marker instead.
+        native_marker = data.pop(_NATIVE_FORM_DATA_MARKER, False)
+        if data.get("viz_type") == "bullet" or native_marker is True:
+            from superset.mcp_service.chart.preview_utils import (
+                _bullet_numeric_control_tokens,
+            )
+
+            data.setdefault("chart_type", "bullet")
+            data.pop("viz_type", None)
+            # Saved Explore controls are lenient; newly authored typed lists
+            # still undergo the strict List[float] validation below.
+            for control in ("ranges", "markers", "marker_lines"):
+                if control in data:
+                    data[control] = _bullet_numeric_control_tokens(
+                        data[control], control
+                    )
+        for key in (
+            "annotation_layers",
+            "dashboards",
+            "datasource",
+            "datasource_id",
+            "datasource_type",
+            "extra_form_data",
+            "slice_id",
+            "slice_name",
+        ):
+            data.pop(key, None)
+
+        if (marker_key := "_mcp_dashboard_time_filter_subject") in data:
+            marker = data.pop(marker_key)
+            if not isinstance(marker, str) or not marker:
+                raise ValueError(f"{marker_key} must be a physical column name")
+            raw_filters = data.get("adhoc_filters")
+            if not isinstance(raw_filters, list):
+                raise ValueError(
+                    f"{marker_key} requires an adhoc_filters array containing its "
+                    "generated binding"
+                )
+            provenance_matches = [
+                filter_
+                for filter_ in raw_filters
+                if isinstance(filter_, dict)
+                and filter_.get("subject") == marker
+                and filter_.get("operator") == "TEMPORAL_RANGE"
+            ]
+            if len(provenance_matches) != 1:
+                raise ValueError(
+                    f"{marker_key} must match exactly one TEMPORAL_RANGE filter "
+                    f"for subject {marker!r}; found {len(provenance_matches)}"
+                )
+            data.setdefault("temporal_column", marker)
+
+        if "metric" in data:
+            data["metric"] = cls._adapt_native_metric(data["metric"])
+        for key in ("groupby", "dimensions"):
+            if key in data:
+                if not isinstance(data[key], list):
+                    raise ValueError(f"{key} must be an array")
+                data[key] = [
+                    {"name": item} if isinstance(item, str) else item
+                    for item in data[key]
+                ]
+        # Native extractQueryFields concatenates orderby and order_by_cols in
+        # key order; AliasChoices would keep only the first alias present.
+        order_keys = [
+            key for key in data if key in ("order_by", "orderby", "order_by_cols")
+        ]
+        if order_keys:
+            ordering: list[Any] = []
+            for key in order_keys:
+                ordering.extend(cls._adapt_native_order_by(data.pop(key)))
+            data["order_by"] = ordering
+        cls._adapt_native_filters(data)
+        return data
+
+    @field_validator("ranges", "markers", "marker_lines", mode="before")
+    @classmethod
+    def tokenize_native_numeric_lists(cls, value: Any) -> Any:
+        """Parse numeric controls without creating values for empty tokens."""
+        if value is None:
+            return []
+        if isinstance(value, str):
+            return [token.strip() for token in value.split(",") if token.strip()]
+        return value
+
+    @field_validator(
+        "range_labels", "marker_labels", "marker_line_labels", mode="before"
+    )
+    @classmethod
+    def tokenize_native_label_lists(cls, value: Any) -> Any:
+        """Parse label controls while preserving positional empty tokens."""
+        if value is None or value == "":
+            return []
+        if isinstance(value, str):
+            return [token.strip() for token in value.split(",")]
+        return value
+
+    @field_validator("ranges", "markers", "marker_lines")
+    @classmethod
+    def reject_non_finite_values(cls, values: List[float]) -> List[float]:
+        if any(not math.isfinite(value) for value in values):
+            raise ValueError("Bullet thresholds and markers must be finite numbers")
+        return values
+
+    @field_validator("range_labels", "marker_labels", "marker_line_labels")
+    @classmethod
+    def validate_presentation_labels(cls, labels: List[str]) -> List[str]:
+        result: list[str] = []
+        for label in labels:
+            if "," in label:
+                raise ValueError(
+                    "Bullet labels cannot contain commas because the frontend "
+                    "comma-separated controls have no escaping"
+                )
+            if label == "":
+                result.append("")
+                continue
+            sanitized = sanitize_user_input(
+                label, "Bullet label", max_length=200, allow_empty=True
+            )
+            result.append(sanitized or "")
+        return result
+
+    @field_validator("time_range")
+    @classmethod
+    def sanitize_time_range(cls, value: str | None) -> str | None:
+        return sanitize_user_input(
+            value, "Time range", max_length=1000, allow_empty=True
+        )
+
+    @model_validator(mode="after")
+    def validate_roles_and_outputs(self) -> "BulletChartConfig":  # noqa: C901
+        dimensions = self.dimensions or []
+        seen_names: set[str] = set()
+        for index, dimension in enumerate(dimensions):
+            _reject_sql_expression_on_dimension(dimension, f"dimensions[{index}]")
+            if dimension.saved_metric or dimension.aggregate:
+                raise ValueError(
+                    f"dimensions[{index}] must be a physical dimension, not a metric"
+                )
+            name = dimension.name or ""
+            if name in seen_names:
+                raise ValueError(f"Duplicate Bullet dimension: {name!r}")
+            seen_names.add(name)
+
+        # ``map_bullet_config`` deliberately emits physical groupby names. The
+        # frontend therefore reads dimension results under those names even when
+        # a friendly ``ColumnRef.label`` was supplied. Only the metric is emitted
+        # with an output alias. Keep validation aligned with those actual result
+        # fields instead of treating dimension display labels as SQL aliases.
+        if self.dimensions is None and self._inherited_groupby is not None:
+            # An omitted hierarchy keeps the saved native dimensions, whose
+            # query output labels share the result row with the metric.
+            from superset.mcp_service.chart.chart_helpers import _column_label
+
+            seen_names = {
+                label
+                for dimension in self._inherited_groupby
+                if (label := _column_label(dimension))
+            }
+        if (metric_output := _bullet_metric_output_label(self.metric)) in seen_names:
+            raise ValueError(
+                f"Bullet metric output label {metric_output!r} conflicts with a "
+                "dimension (its physical output name); provide a unique metric label"
+            )
+
+        if self.dimensions is None and self._inherited_groupby is None:
+            # Partial updates resolve the saved hierarchy before mapping.
+            # Creation validates with an empty hierarchy in map_bullet_config.
+            return self
+        resolved_order: list[tuple[str, int | None]] = []
+        for item in self.order_by:
+            try:
+                resolved_order.append(
+                    resolve_bullet_order_target(
+                        item.column, self.order_dimensions, self.metric
+                    )
+                )
+            except ValueError as ex:
+                raise ValueError(
+                    f"order_by must reference a Bullet dimension or metric output; {ex}"
+                ) from ex
+        if len(resolved_order) != len(set(resolved_order)):
+            raise ValueError("order_by cannot contain duplicate outputs")
+        return self
+
+
 DEFAULT_HISTOGRAM_BINS: int = 5
 
 
@@ -5198,6 +5815,7 @@ CHART_TYPE_DISCRIMINATOR: str = "chart_type"
 
 ChartConfig = Annotated[
     XYChartConfig
+    | BulletChartConfig
     | TableChartConfig
     | PieChartConfig
     | SunburstChartConfig
@@ -5219,7 +5837,7 @@ ChartConfig = Annotated[
     Field(
         discriminator=CHART_TYPE_DISCRIMINATOR,
         description=(
-            "Chart configuration - specify chart_type as 'xy', 'table', "
+            "Chart configuration - specify chart_type as 'xy', 'bullet', 'table', "
             "'pie', 'sunburst', 'gauge', 'treemap_v2', 'bubble_v2', 'pivot_table', "
             "'interactive_pivot', 'mixed_timeseries', 'handlebars', "
             "'big_number', 'histogram', 'box_plot', 'waterfall', or 'gantt'"
@@ -5286,6 +5904,7 @@ _VIZ_TYPE_TO_CHART_TYPE: dict[str, tuple[str, str | None]] = {
     "echarts_area": ("xy", "area"),
     "scatter": ("xy", "scatter"),
     "echarts_timeseries_scatter": ("xy", "scatter"),
+    "bullet": ("bullet", None),
     "ag-grid-table": ("table", None),
     "big_number_total": ("big_number", None),
     "gauge_chart": ("gauge", None),
@@ -5319,7 +5938,7 @@ def _normalize_chart_request_input(data: Any) -> Any:  # noqa: C901
         # Explore form data always carries ``viz_type``; typed requests use
         # ``chart_type``. Only native payloads get native metric semantics.
         is_native_payload = isinstance(viz_type, str)
-        if viz_type == "sunburst_v2":
+        if viz_type in ("sunburst_v2", "bullet"):
             # The discriminator normalization below removes viz_type before the
             # nested model runs. Carry native provenance through that boundary
             # so saved Explore payloads retain their bounded round-trip mode.
@@ -5950,7 +6569,12 @@ class ChartData(BaseModel):
     # LLM-friendly summaries
     summary: str = Field(description="Human-readable data summary")
     insights: List[str] = Field(description="Key patterns discovered in the data")
-    data_quality: Dict[str, Any] = Field(description="Data quality assessment")
+    data_quality: Dict[str, Any] = Field(
+        description=(
+            "Data quality assessment. Sampled completeness includes "
+            "completeness_is_approximate=true and sampled_rows."
+        )
+    )
     recommended_visualizations: List[str] = Field(
         description="Suggested chart types for this data"
     )
diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py
index f5c5c874a90..bd2f0f53d3b 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -116,7 +116,7 @@ async def generate_chart(  # noqa: C901
     - Set save_chart=True to permanently save the chart
     - LLM clients MUST display returned chart URL to users
     - Use numeric dataset ID or UUID (NOT schema.table_name format)
-    - MUST include chart_type in config (one of: 'xy', 'table', 'pie',
+    - MUST include chart_type in config (one of: 'xy', 'table', 'pie', 'bullet',
       'sunburst', 'gauge', 'treemap_v2', 'bubble_v2', 'pivot_table', 'mixed_timeseries',
       'handlebars', 'big_number', 'histogram', 'box_plot', 'waterfall',
       'gantt', 'country_map', 'world_map', 'deck_scatter', plus host-gated
@@ -134,6 +134,7 @@ async def generate_chart(  # noqa: C901
     - "bar chart" / "line chart" / "area chart" / "scatter plot"
       -> chart_type='xy', kind='bar'/'line'/'area'/'scatter'
     - "pie chart" / "donut chart" -> chart_type='pie'
+    - "bullet chart" / "progress against target" -> chart_type='bullet'
     - "sunburst" / "hierarchical rings" -> chart_type='sunburst'
     - "table" / "data grid" -> chart_type='table'
     - "pivot table" / "cross-tab" -> chart_type='pivot_table'
@@ -208,8 +209,13 @@ async def generate_chart(  # noqa: C901
         with event_logger.log_context(action="mcp.generate_chart.validation"):
             from superset.mcp_service.chart.validation import ValidationPipeline
 
+            # Preserve omissions, including nested optional controls. A full dump
+            # turns defaults into explicit values before schema revalidation.
+            request_data = request.model_dump(exclude_unset=True)
+            # Typed callers may rely on the model's default discriminator.
+            request_data["config"]["chart_type"] = request.config.chart_type
             validation_result = ValidationPipeline.validate_request_with_warnings(
-                request.model_dump()
+                request_data
             )
 
             if validation_result.is_valid and validation_result.request is not None:
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py
index a2067b1dec0..0ab50426f6c 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -66,6 +66,7 @@ from superset.mcp_service.chart.query_result import (
     normalize_chart_query_result,
     null_data_is_empty,
     query_result_failure,
+    temporal_json_numbers,
     validate_query_result_envelope,
 )
 from superset.mcp_service.chart.response_preflight import (
@@ -127,6 +128,13 @@ _GENERIC_TYPE_MAP: dict[int, str] = {
 }
 
 
+def _data_plugin(viz_type: str | None) -> Any:
+    """Return the plugin that owns chart-data rows for ``viz_type``."""
+    from superset.mcp_service.chart.registry import plugin_for_viz_type
+
+    return plugin_for_viz_type(viz_type)
+
+
 def _normalizes_data_results(form_data: dict[str, Any]) -> bool:
     """Return whether the owning plugin validates get_chart_data rows/exports."""
     from superset.mcp_service.chart.registry import plugin_for_viz_type
@@ -158,6 +166,7 @@ _VIZ_CATEGORY: dict[str, str] = {
     "scatter": "scatter",
     "bubble": "bubble",
     "bubble_v2": "bubble",
+    "bullet": "bullet",
     "treemap_v2": "treemap",
     "sunburst_v2": "sunburst",
     "heatmap_v2": "heatmap",
@@ -311,7 +320,7 @@ def _candidates_categorical_numeric(
 
 
 def _candidates_single_numeric(col: DataColumn, row_count: int) -> list[str]:
-    candidates = ["big number / KPI", "gauge chart"]
+    candidates = ["big number / KPI", "bullet chart", "gauge chart"]
     if row_count > 20 and col.unique_count > 10:
         candidates.insert(0, "histogram")
     return candidates
@@ -338,6 +347,7 @@ _CANDIDATE_CATEGORY: dict[str, str] = {
     "bar chart": "bar",
     "scatter plot": "scatter",
     "bubble chart": "bubble",
+    "bullet chart": "bullet",
     "pie chart": "pie",
     "treemap": "treemap",
     "sunburst chart": "sunburst",
@@ -936,6 +946,9 @@ async def _get_chart_data(  # noqa: C901
             if result_error := validate_query_result_envelope(
                 result,
                 none_as_empty=null_data_is_empty(effective_form_data.get("viz_type")),
+                temporal_json_numbers=temporal_json_numbers(
+                    effective_form_data.get("viz_type")
+                ),
             ):
                 return result_error
             if _normalizes_data_results(effective_form_data):
@@ -980,6 +993,13 @@ async def _get_chart_data(  # noqa: C901
             query_result = result["queries"][0]
             data = query_result.get("data", [])
             raw_columns = query_result.get("colnames", [])
+            data_plugin = _data_plugin(effective_form_data.get("viz_type"))
+            if data_plugin is not None:
+                data, rows_error = data_plugin.sanitize_data_rows(
+                    data, effective_form_data
+                )
+                if rows_error is not None:
+                    return rows_error
 
             await ctx.debug(
                 "Query results received: row_count=%s, column_count=%s, "
@@ -992,7 +1012,9 @@ async def _get_chart_data(  # noqa: C901
             )
 
             # Check if we have data to work with
-            if not any(query.get("data") for query in result["queries"]):
+            if not (data_plugin and data_plugin.allows_empty_data_result) and not any(
+                query.get("data") for query in result["queries"]
+            ):
                 await ctx.warning("No data in query results: chart_id=%s" % (chart_id,))
                 logger.warning(
                     "get_chart_data: no data in query results for chart_id=%s",
@@ -1291,7 +1313,9 @@ async def _query_from_form_data(  # noqa: C901
             result = command.run()
 
         if result_error := validate_query_result_envelope(
-            result, none_as_empty=null_data_is_empty(viz_type)
+            result,
+            none_as_empty=null_data_is_empty(viz_type),
+            temporal_json_numbers=temporal_json_numbers(viz_type),
         ):
             return result_error
         if _normalizes_data_results(form_data):
@@ -1328,8 +1352,15 @@ async def _query_from_form_data(  # noqa: C901
         query_result = result["queries"][0]
         data = query_result.get("data", [])
         raw_columns = query_result.get("colnames", [])
-
-        if not any(query.get("data") for query in result["queries"]):
+        data_plugin = _data_plugin(viz_type)
+        if data_plugin is not None:
+            data, rows_error = data_plugin.sanitize_data_rows(data, form_data)
+            if rows_error is not None:
+                return rows_error
+
+        if not (data_plugin and data_plugin.allows_empty_data_result) and not any(
+            query.get("data") for query in result["queries"]
+        ):
             logger.warning(
                 "get_chart_data: no data for unsaved chart (form_data_key=%s)",
                 request.form_data_key,
@@ -1427,7 +1458,9 @@ def _export_data_as_csv(
     # Create CSV content
     output = io.StringIO()
 
-    if data and columns:
+    # An empty result still exports its header row (for example a Bullet
+    # chart, whose empty result is valid).
+    if columns:
         writer = csv.DictWriter(output, fieldnames=columns)
         writer.writeheader()
 
@@ -1506,7 +1539,9 @@ def _create_excel_with_openpyxl(
     ws = wb.active
     ws.title = chart.slice_name[:31] if chart.slice_name else "Chart Data"
 
-    if data and columns:
+    # An empty result still exports its header row (for example a Bullet
+    # chart, whose empty result is valid).
+    if columns:
         _write_excel_headers(ws, columns)
         _write_excel_data(ws, data, columns, temporal_columns)
 
@@ -1539,6 +1574,7 @@ _EXCEL_MIN_DATETIME = dt.datetime.combine(_EXCEL_MIN_DATE, dt.time.min)
 # supported day or time cannot roll over past Excel's range.
 _EXCEL_MAX_TIME = dt.time(23, 59, 59, 999000)
 _EXCEL_MAX_DATETIME = dt.datetime.combine(dt.date.max, _EXCEL_MAX_TIME)
+_EXCEL_EPOCH = dt.datetime(1970, 1, 1)
 
 
 def _temporal_result_columns(query_result: Any) -> frozenset[str]:
@@ -1559,10 +1595,14 @@ def _excel_temporal_value(value: Any) -> Any:
     projection. Excel stores dates as typed serial values, so temporal columns
     are restored here. Excel cannot store offsets; like the Superset Excel
     export, aware values keep their wall-clock time and drop the offset.
-    Unparseable text, and values outside Excel's 1900 date system (before
-    1900-01-01, or rounding past its final supported day or time), are
+    Plugins that read temporals as Chart Data numbers (for example Bullet)
+    carry epoch milliseconds instead; those are restored as naive UTC
+    datetimes. Unparseable text, and values outside Excel's 1900 date system
+    (before 1900-01-01, or rounding past its final supported day or time), are
     written unchanged.
     """
+    if type(value) is int or type(value) is float:
+        return _excel_epoch_ms_value(value)
     if type(value) is not str:
         return value
     try:
@@ -1585,6 +1625,19 @@ def _excel_temporal_value(value: Any) -> Any:
     return moment
 
 
+def _excel_epoch_ms_value(value: int | float) -> Any:
+    """Restore an epoch-millisecond temporal cell as an Excel datetime."""
+    if not math.isfinite(value):
+        return value
+    try:
+        moment = _EXCEL_EPOCH + dt.timedelta(milliseconds=value)
+    except OverflowError:
+        return value
+    if not _EXCEL_MIN_DATETIME <= moment <= _EXCEL_MAX_DATETIME:
+        return value
+    return moment
+
+
 def _excel_scalar(value: Any, temporal: bool) -> Any:
     """Project one validated cell for an Excel writer."""
     projected = _export_scalar(value)
@@ -1654,7 +1707,9 @@ def _create_excel_with_xlsxwriter(
     sheet_name = chart.slice_name[:31] if chart.slice_name else "Chart Data"
     worksheet = workbook.add_worksheet(sheet_name)
 
-    if data and columns:
+    # An empty result still exports its header row (for example a Bullet
+    # chart, whose empty result is valid).
+    if columns:
         # xlsxwriter writes unformatted serial numbers for temporal values, so
         # give each Python temporal type the date format openpyxl applies.
         temporal_formats = {
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py
index 992ff565424..e0435ca76c3 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -42,6 +42,7 @@ from superset.mcp_service.chart.chart_helpers import (
 from superset.mcp_service.chart.chart_utils import validate_chart_dataset
 from superset.mcp_service.chart.preview_utils import (
     fallback_vega_lite_preview,
+    fit_ascii_frame,
     plugin_ascii_preview,
     plugin_table_preview,
     plugin_unsupported_preview,
@@ -335,6 +336,14 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
                 self.request.ascii_width or 80,
                 self.request.ascii_height or 20,
             )
+            if isinstance(ascii_chart, str):
+                # Plugin renderers may ignore the frame; clip like the unsaved
+                # path so the reported width/height describe the content.
+                ascii_chart = fit_ascii_frame(
+                    ascii_chart,
+                    self.request.ascii_width or 80,
+                    self.request.ascii_height or 20,
+                )
             if ascii_chart is None:
                 ascii_chart = generate_ascii_chart(
                     data,
@@ -370,6 +379,10 @@ class TablePreviewStrategy(PreviewFormatStrategy):
     """Generate table preview of chart data."""
 
     def generate(self) -> TablePreview | ChartError:
+        if (
+            unsupported := plugin_unsupported_preview(self.chart.viz_type, "table")
+        ) is not None:
+            return unsupported
         try:
             from superset.commands.chart.data.get_data_command import ChartDataCommand
             from superset.utils import json as utils_json
diff --git a/superset/mcp_service/chart/tool/get_chart_type_schema.py b/superset/mcp_service/chart/tool/get_chart_type_schema.py
index 17fd98ef1c0..9ec016ed788 100644
--- a/superset/mcp_service/chart/tool/get_chart_type_schema.py
+++ b/superset/mcp_service/chart/tool/get_chart_type_schema.py
@@ -35,6 +35,7 @@ from superset.mcp_service.chart.schemas import (
     BigNumberChartConfig,
     BoxPlotChartConfig,
     BubbleChartConfig,
+    BulletChartConfig,
     CountryMapChartConfig,
     DeckScatterChartConfig,
     GanttChartConfig,
@@ -72,6 +73,7 @@ _CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
     "world_map": TypeAdapter(WorldMapChartConfig),
     "deck_scatter": TypeAdapter(DeckScatterChartConfig),
     "xy": TypeAdapter(XYChartConfig),
+    "bullet": TypeAdapter(BulletChartConfig),
     "table": TypeAdapter(TableChartConfig),
     "pie": TypeAdapter(PieChartConfig),
     "sunburst": TypeAdapter(SunburstChartConfig),
@@ -132,6 +134,26 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
             "y": [{"name": "sales", "aggregate": "SUM"}],
         },
     ],
+    "bullet": [
+        {
+            "chart_type": "bullet",
+            "metric": {"name": "revenue", "aggregate": "SUM"},
+            "ranges": [100000, 250000, 500000],
+            "range_labels": ["Minimum", "Target", "Stretch"],
+            "markers": [300000],
+            "marker_labels": ["Plan"],
+            "show_labels": True,
+        },
+        {
+            "chart_type": "bullet",
+            "metric": {"name": "attainment", "saved_metric": True},
+            "dimensions": [{"name": "region"}],
+            "marker_lines": [1],
+            "marker_line_labels": ["Goal"],
+            "y_axis_format": ".0%",
+            "row_limit": 100,
+        },
+    ],
     "table": [
         {
             "chart_type": "table",
@@ -416,7 +438,8 @@ def get_chart_type_schema(
     for a chart configuration before calling generate_chart or update_chart.
 
     Valid chart_type values depend on the host deployment. Core types are xy,
-    country_map, world_map, deck_scatter, table, pie, sunburst, gauge, treemap_v2,
+    country_map, world_map, deck_scatter, table, pie, sunburst, bullet, gauge,
+    treemap_v2,
     bubble_v2, pivot_table, mixed_timeseries, handlebars, big_number, histogram,
     box_plot, waterfall, and gantt. Deployments that enable an AG Grid pivot
     extension also expose interactive_pivot.
diff --git a/superset/mcp_service/chart/tool/update_chart.py b/superset/mcp_service/chart/tool/update_chart.py
index 0ed6012ee58..6d7079ff419 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -569,7 +569,9 @@ def _validate_update_against_dataset(  # noqa: C901
     try:
         merged_plugin = plugin_for_viz_type(form_data.get("viz_type"))
         merged_config = (
-            merged_plugin.validate_merged_form_data(form_data, dataset.id)
+            merged_plugin.validate_merged_form_data(
+                form_data, dataset.id, update_config=parsed_config
+            )
             if merged_plugin is not None
             else None
         )
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py
index 850e2c4282d..0d5b75e45c9 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -360,6 +360,7 @@ def update_chart_preview(  # noqa: C901
                     new_form_data,
                     request.dataset_id,
                     dataset_context=lambda: dataset_context,
+                    update_config=config,
                 )
                 if merged_plugin is not None
                 else None
diff --git a/superset/mcp_service/chart/validation/dataset_validator.py b/superset/mcp_service/chart/validation/dataset_validator.py
index 53d1c0ef3ff..19482b20c83 100644
--- a/superset/mcp_service/chart/validation/dataset_validator.py
+++ b/superset/mcp_service/chart/validation/dataset_validator.py
@@ -227,6 +227,42 @@ def resolve_dataset_reference(
     return matches[0] if matches else None
 
 
+def resolve_dataset_column(
+    column_name: str, dataset_context: DatasetContext
+) -> Mapping[str, Any] | None:
+    """Resolve an exact, otherwise unique case-insensitive dataset column.
+
+    Metadata ordering must never decide which SQL type is used for validation.
+    Exact case is authoritative; a case-insensitive fallback is safe only when
+    it identifies one column.
+    """
+
+    exact = [
+        column
+        for column in dataset_context.available_columns
+        if column["name"] == column_name
+    ]
+    if len(exact) == 1:
+        return exact[0]
+    if len(exact) > 1:
+        raise ValueError(f"Duplicate exact dataset column {column_name!r}")
+
+    folded = [
+        column
+        for column in dataset_context.available_columns
+        if column["name"].casefold() == column_name.casefold()
+    ]
+    if len(folded) == 1:
+        return folded[0]
+    if len(folded) > 1:
+        raise AmbiguousDatasetReferenceError(
+            column_name,
+            [column["name"] for column in folded],
+            "column",
+        )
+    return None
+
+
 class DatasetValidator:
     """Validates chart configuration against dataset schema."""
 
diff --git a/superset/mcp_service/chart/validation/pipeline.py b/superset/mcp_service/chart/validation/pipeline.py
index c42737fdf67..1657ce33983 100644
--- a/superset/mcp_service/chart/validation/pipeline.py
+++ b/superset/mcp_service/chart/validation/pipeline.py
@@ -119,20 +119,9 @@ class ValidationPipeline:
                 request.dataset_id
             )
 
-            # Layer 2: Dataset validation (reuses context)
-            is_valid, error = ValidationPipeline._validate_dataset(
-                typed_config, request.dataset_id, dataset_context
-            )
-            if not is_valid:
-                return ValidationResult(is_valid=False, request=request, error=error)
-
-            # Layer 3: Runtime validation - returns warnings as metadata, not errors
-            _is_valid, warnings_metadata = ValidationPipeline._validate_runtime(
-                typed_config, request.dataset_id
-            )
-            # Runtime validation always returns True now, warnings are informational
-
-            # Layer 4: Column name normalization (reuses context)
+            # Canonicalize against the same authorized schema used by
+            # validation. Ambiguous case-insensitive matches must fail rather
+            # than silently selecting one dataset field.
             from .dataset_validator import GanttSemanticNormalizationError
 
             try:
@@ -140,6 +129,16 @@ class ValidationPipeline:
                     request, dataset_context, typed_config=typed_config
                 )
             except GanttSemanticNormalizationError as ex:
+                # A semantic conflict can be the downstream symptom of dataset
+                # metadata that collides only by case. Report that more specific
+                # diagnosis when dataset validation can name it.
+                _is_valid, dataset_error = ValidationPipeline._validate_dataset(
+                    typed_config, request.dataset_id, dataset_context
+                )
+                if dataset_error is not None:
+                    return ValidationResult(
+                        is_valid=False, request=request, error=dataset_error
+                    )
                 return ValidationResult(
                     is_valid=False,
                     request=request,
@@ -157,6 +156,34 @@ class ValidationPipeline:
                         error_code="GANTT_SEMANTIC_VALIDATION_ERROR",
                     ),
                 )
+            except ValueError as ex:
+                return ValidationResult(
+                    is_valid=False,
+                    request=request,
+                    error=ChartGenerationError(
+                        error_type="ambiguous_column_reference",
+                        message="Chart references could not be canonicalized",
+                        details=str(ex),
+                        suggestions=[
+                            "Use get_dataset_info and copy exact-case field names"
+                        ],
+                        error_code="AMBIGUOUS_COLUMN_REFERENCE",
+                    ),
+                )
+            typed_config = normalized_request.config
+
+            # Layer 2: Dataset validation (reuses context)
+            is_valid, error = ValidationPipeline._validate_dataset(
+                typed_config, request.dataset_id, dataset_context
+            )
+            if not is_valid:
+                return ValidationResult(is_valid=False, request=request, error=error)
+
+            # Layer 3: Runtime validation - returns warnings as metadata, not errors
+            _is_valid, warnings_metadata = ValidationPipeline._validate_runtime(
+                typed_config, request.dataset_id
+            )
+            # Runtime validation always returns True now, warnings are informational
 
             return ValidationResult(
                 is_valid=True,
@@ -272,6 +299,7 @@ class ValidationPipeline:
         """
         try:
             from .dataset_validator import (
+                AmbiguousDatasetReferenceError,
                 DatasetValidator,
                 GanttSemanticNormalizationError,
             )
@@ -289,7 +317,10 @@ class ValidationPipeline:
 
             return GenerateChartRequest.model_validate(request_dict)
 
-        except GanttSemanticNormalizationError:
+        except (GanttSemanticNormalizationError, AmbiguousDatasetReferenceError):
+            # These name a real, actionable schema conflict rather than a
+            # transient normalization failure; callers map them to structured
+            # errors instead of silently querying an unresolved reference.
             raise
         except (ImportError, AttributeError, KeyError, ValueError, TypeError) as e:
             # If normalization fails, return the original request
diff --git a/tests/unit_tests/dataframe_test.py b/tests/unit_tests/dataframe_test.py
index 02d974a061d..7f70cd31485 100644
--- a/tests/unit_tests/dataframe_test.py
+++ b/tests/unit_tests/dataframe_test.py
@@ -204,6 +204,25 @@ def test_df_to_records_normalizes_only_exact_nonfinite_decimals() -> None:
     ]
 
 
+@pytest.mark.parametrize(
+    "value, expected",
+    [
+        (np.timedelta64("NaT"), None),
+        (np.timedelta64(123456789, "ns"), pd.Timedelta(123456789, unit="ns")),
+        (np.datetime64("NaT"), None),
+        (np.datetime64("2026-01-01"), pd.Timestamp("2026-01-01")),
+    ],
+)
+def test_df_to_records_boxes_numpy_temporals(value: Any, expected: Any) -> None:
+    """Object-column temporal scalars retain pandas' JSON-compatible boxing."""
+    frame = pd.DataFrame({"value": pd.Series([value], dtype=object)})
+
+    result = df_to_records(frame)[0]["value"]
+
+    assert type(result) is type(expected)
+    assert result == expected
+
+
 def test_df_to_records() -> None:
     data = [("a1", "b1", "c1"), ("a2", "b2", "c2")]
     cursor_descr: DbapiDescription = [
@@ -441,6 +460,73 @@ def test_df_to_records_with_inf_and_nan() -> None:
     assert records[4]["result"] == 42.5
 
 
+def test_df_to_records_avoids_object_equality_during_nonfinite_cleanup() -> None:
+    class AcceptedEnum(Enum):
+        VALUE = "accepted"
+
+    class HostileEquality:
+        def __eq__(self, _other: object) -> bool:
+            raise AssertionError("object equality must not run during projection")
+
+    hostile = HostileEquality()
+    enum_value = AcceptedEnum.VALUE
+    frame = pd.DataFrame(
+        {
+            "hostile": pd.Series([hostile], dtype=object),
+            "enum": pd.Series([enum_value], dtype=object),
+            "infinite": [np.inf],
+            "missing": pd.Series([pd.NA], dtype=object),
+        }
+    )
+
+    records = df_to_records(frame, convert_big_integers=False)
+
+    assert records[0]["hostile"] is hostile
+    assert records[0]["enum"] is enum_value
+    assert records[0]["infinite"] is None
+    assert records[0]["missing"] is None
+
+
+def test_df_to_records_normalizes_decimals_without_integer_conversion() -> None:
+    """Exact non-finite Decimal cells become null without subclass hooks."""
+
+    class HostileDecimal(Decimal):
+        def is_finite(self) -> bool:
+            raise AssertionError("hostile Decimal is_finite hook executed")
+
+        def __eq__(self, _other: object) -> bool:
+            raise AssertionError("hostile Decimal equality hook executed")
+
+        def __float__(self) -> float:
+            raise AssertionError("hostile Decimal float hook executed")
+
+    finite = Decimal("0.10000000000000000001")
+    hostile = HostileDecimal("NaN")
+    values = [
+        Decimal("NaN"),
+        Decimal("sNaN"),
+        Decimal("Infinity"),
+        Decimal("-Infinity"),
+        finite,
+        hostile,
+    ]
+    frame = pd.DataFrame({"value": pd.Series(values, dtype=object)})
+
+    records = df_to_records(frame, convert_big_integers=False)
+
+    assert [records[index]["value"] for index in range(4)] == [None] * 4
+    assert records[4]["value"] is finite
+    assert records[5]["value"] is hostile
+    strict_json = superset_json.dumps(records[:5], ignore_nan=False)
+    assert superset_json.loads(strict_json) == [
+        {"value": None},
+        {"value": None},
+        {"value": None},
+        {"value": None},
+        {"value": 0.1},
+    ]
+
+
 def test_df_to_records_nan_json_serialization() -> None:
     """
     Test that NaN values are properly converted to None for JSON serialization.
diff --git a/tests/unit_tests/mcp_service/chart/test_bullet_chart.py b/tests/unit_tests/mcp_service/chart/test_bullet_chart.py
new file mode 100644
index 00000000000..e5ae9e8c7fd
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_bullet_chart.py
@@ -0,0 +1,5308 @@
+# 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.
+
+"""Product-path coverage for typed ECharts Bullet MCP support."""
+
+import asyncio
+import math
+from datetime import date, datetime, time, timedelta, timezone, tzinfo
+from decimal import Decimal
+from enum import Enum, IntEnum, StrEnum
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+from uuid import UUID
+from zoneinfo import ZoneInfo
+
+import numpy as np
+import pandas as pd
+import pytest
+import pytz
+from dateutil import tz as dateutil_tz
+from dateutil.zoneinfo import get_zonefile_instance
+from pydantic import TypeAdapter, ValidationError
+
+from superset.mcp_service.chart.chart_helpers import (
+    build_query_dicts_from_form_data,
+)
+from superset.mcp_service.chart.chart_utils import (
+    analyze_chart_capabilities,
+    map_bullet_config,
+    map_config_to_form_data,
+    MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+    merge_update_form_data,
+    validate_merged_bullet_form_data,
+)
+from superset.mcp_service.chart.compile import _compile_chart, CompileResult
+from superset.mcp_service.chart.plugins.bullet import BulletChartPlugin
+from superset.mcp_service.chart.preview_utils import (
+    _generate_ascii_preview_from_data,
+    _generate_vega_lite_preview_from_data,
+    _javascript_number_string,
+    BulletOutputError,
+    generate_preview_from_form_data,
+    resolve_bullet_render_model,
+)
+from superset.mcp_service.chart.query_result import (
+    _chart_data_duration_text,
+    _chart_data_temporal_number,
+)
+from superset.mcp_service.chart.schemas import (
+    ASCIIPreview,
+    BulletChartConfig,
+    ChartConfig,
+    ChartError,
+    ChartInfo,
+    DataColumn,
+    GenerateChartRequest,
+    GetChartPreviewRequest,
+    UpdateChartPreviewRequest,
+    UpdateChartRequest,
+    VegaLitePreview,
+    XYChartConfig,
+)
+from superset.mcp_service.chart.tool.generate_chart import generate_chart
+from superset.mcp_service.chart.tool.get_chart_data import (
+    _candidates_single_numeric,
+    _VIZ_CATEGORY,
+)
+from superset.mcp_service.chart.tool.get_chart_preview import (
+    ASCIIPreviewStrategy,
+    TablePreviewStrategy,
+    VegaLitePreviewStrategy,
+)
+from superset.mcp_service.chart.tool.get_chart_type_schema import (
+    _get_chart_type_schema_impl,
+    VALID_CHART_TYPES,
+)
+from superset.mcp_service.chart.tool.update_chart import (
+    _build_preview_form_data,
+    _build_update_payload,
+    update_chart,
+)
+from superset.mcp_service.chart.tool.update_chart_preview import update_chart_preview
+from superset.mcp_service.chart.validation.dataset_validator import (
+    AmbiguousDatasetReferenceError,
+    DatasetValidator,
+)
+from superset.mcp_service.chart.validation.pipeline import ValidationPipeline
+from superset.mcp_service.common.error_schemas import DatasetContext
+from superset.utils import json
+from superset.utils.core import GenericDataType
+from superset.utils.json import json_int_dttm_ser
+
+
+def _reject_scalar_conversion(*_args: object, **_kwargs: object) -> Any:
+    raise AssertionError("hostile query scalar method must not run")
+
+
+class _PathHostileStr(str):
+    __getitem__ = _reject_scalar_conversion
+    __str__ = _reject_scalar_conversion
+
+
+class _PathHostileEnum(str, Enum):
+    FAILED = "warehouse unavailable"
+
+    @property
+    def value(self) -> str:
+        """Reject the public descriptor while preserving Enum's stored value."""
+        return _reject_scalar_conversion()
+
+    __getitem__ = _reject_scalar_conversion
+    __str__ = _reject_scalar_conversion
+
+
+class _OutputHostileInt(int):
+    __float__ = _reject_scalar_conversion
+    __str__ = _reject_scalar_conversion
+
+
+class _OutputHostileFloat(float):
+    __float__ = _reject_scalar_conversion
+    __str__ = _reject_scalar_conversion
+
+
+class _OutputHostileStr(str):
+    __str__ = _reject_scalar_conversion
+    strip = _reject_scalar_conversion
+
+
+class _OutputHostileDecimal(Decimal):
+    __float__ = _reject_scalar_conversion
+    __str__ = _reject_scalar_conversion
+
+
+class _OutputSafeIntEnum(IntEnum):
+    VALUE = 12
+
+
+class _OutputSafeStrEnum(StrEnum):
+    VALUE = "12.5"
+
+
+_OutputSafeIntEnum.__float__ = _reject_scalar_conversion  # type: ignore[method-assign]
+_OutputSafeIntEnum.__str__ = _reject_scalar_conversion  # type: ignore[method-assign]
+_OutputSafeStrEnum.__float__ = _reject_scalar_conversion  # type: ignore[attr-defined]
+_OutputSafeStrEnum.__str__ = _reject_scalar_conversion  # type: ignore[method-assign]
+
+
+def _simple_metric(name: str = "revenue") -> dict[str, str]:
+    return {"name": name, "aggregate": "SUM"}
+
+
+def _tool_user() -> SimpleNamespace:
+    return SimpleNamespace(id=1, username="admin", roles=[], groups=[])
+
+
+def _orm_dataset() -> SimpleNamespace:
+    def column(
+        name: str, type_: str, *, temporal: bool = False, numeric: bool = False
+    ) -> SimpleNamespace:
+        return SimpleNamespace(
+            column_name=name,
+            type=type_,
+            is_temporal=temporal,
+            is_numeric=numeric,
+            is_dttm=temporal,
+            python_date_format=None,
+        )
+
+    return SimpleNamespace(
+        id=7,
+        table_name="sales",
+        schema=None,
+        main_dttm_col="OrderDate",
+        database=SimpleNamespace(database_name="main", db_engine_spec=None),
+        columns=[
+            column("Revenue", "NUMERIC", numeric=True),
+            column("Region", "VARCHAR"),
+            column("Team", "VARCHAR"),
+            column("Country", "VARCHAR"),
+            column("region", "VARCHAR"),
+            column("Status", "VARCHAR"),
+            column("OrderDate", "TIMESTAMP", temporal=True),
+            column("EventDate", "TIMESTAMP", temporal=True),
+        ],
+        metrics=[
+            SimpleNamespace(
+                metric_name="SavedRevenue",
+                expression="SUM(Revenue)",
+                description=None,
+            )
+        ],
+    )
+
+
+def test_bullet_discriminated_union_uses_exact_tag() -> None:
+    config = TypeAdapter(ChartConfig).validate_python(
+        {"chart_type": "bullet", "metric": _simple_metric()}
+    )
+    assert isinstance(config, BulletChartConfig)
+    with pytest.raises(ValidationError):
+        TypeAdapter(ChartConfig).validate_python(
+            {"chart_type": "bullet_chart", "metric": _simple_metric()}
+        )
+
+
+def test_bullet_equal_dimension_aliases_are_order_independent_and_round_trip() -> None:
+    for payload in (
+        {
+            "dimensions": [{"name": "Region", "label": "Market"}, "Team"],
+            "groupby": ["Region", {"column_name": "Team"}],
+        },
+        {
+            "groupby": ["Region", {"column": "Team"}],
+            "dimensions": [{"name": "Region", "label": "Market"}, "Team"],
+        },
+    ):
+        config = BulletChartConfig.model_validate(
+            {"metric": _simple_metric(), **payload}
+        )
+        assert [dimension.name for dimension in config.dimensions or []] == [
+            "Region",
+            "Team",
+        ]
+        mapped = map_bullet_config(config)
+        assert mapped["groupby"] == ["Region", "Team"]
+        round_trip = BulletChartConfig.model_validate(mapped)
+        assert [dimension.name for dimension in round_trip.dimensions or []] == [
+            "Region",
+            "Team",
+        ]
+
+
+def test_bullet_dimension_aliases_require_exact_physical_identity() -> None:
+    """Schema-only alias resolution must not merge quoted case variants."""
+    with pytest.raises(ValidationError, match="Conflicting Bullet dimension aliases"):
+        BulletChartConfig.model_validate(
+            {
+                "metric": _simple_metric(),
+                "dimensions": ["Region"],
+                "groupby": ["region"],
+            }
+        )
+
+
+@pytest.mark.parametrize(
+    "request_payload",
+    [
+        {"dataset_id": 7},
+        {"identifier": 9},
+        {"dataset_id": 7, "form_data_key": "preview"},
+    ],
+)
+@pytest.mark.parametrize("reverse", [False, True])
+def test_bullet_request_models_reject_conflicting_dimension_aliases(
+    request_payload: dict[str, object], reverse: bool
+) -> None:
+    aliases = [
+        ("dimensions", [{"name": "Region"}, {"name": "Team"}]),
+        ("groupby", ["Team", "Region"]),
+    ]
+    if reverse:
+        aliases.reverse()
+    config = {"chart_type": "bullet", "metric": _simple_metric(), **dict(aliases)}
+    payload = {**request_payload, "config": config}
+    request_type = (
+        UpdateChartRequest
+        if "identifier" in request_payload
+        else (
+            UpdateChartPreviewRequest
+            if "form_data_key" in request_payload
+            else GenerateChartRequest
+        )
+    )
+    with pytest.raises(ValidationError, match="Conflicting Bullet dimension aliases"):
+        request_type.model_validate(payload)
+
+
+@pytest.mark.parametrize(
+    "metric",
+    [
+        {"name": "revenue", "aggregate": "SUM", "label": "Revenue"},
+        {"name": "saved_revenue", "saved_metric": True},
+        {"sql_expression": "SUM(revenue) / COUNT(*)", "label": "Average"},
+    ],
+)
+def test_bullet_accepts_simple_saved_and_sql_metrics(metric: dict[str, object]) -> None:
+    config = BulletChartConfig(metric=metric)
+    form_data = map_bullet_config(config)
+    assert form_data["viz_type"] == "bullet"
+    assert form_data["metric"]
+
+
+def test_bullet_native_form_data_round_trip_is_semantically_stable() -> None:
+    native = {
+        "viz_type": "bullet",
+        "datasource": "7__table",
+        "metric": {
+            "aggregate": "SUM",
+            "column": {"column_name": "Revenue"},
+            "expressionType": "SIMPLE",
+            "label": "Total Revenue",
+        },
+        "groupby": ["Region", "Team"],
+        "ranges": "100,250,500",
+        "range_labels": "Minimum,Target,Stretch",
+        "markers": "300",
+        "marker_labels": "Plan",
+        "marker_lines": "400",
+        "marker_line_labels": "Forecast",
+        "y_axis_format": "$,.0f",
+        "show_labels": False,
+        "show_legend": True,
+        "row_limit": 250,
+        "orderby": [["Region", True], ["Total Revenue", False]],
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "Status",
+                "operator": "==",
+                "comparator": "Active",
+            }
+        ],
+    }
+    config = BulletChartConfig.model_validate(native)
+    mapped = map_bullet_config(config)
+
+    assert mapped["metric"]["label"] == "Total Revenue"
+    assert mapped["groupby"] == ["Region", "Team"]
+    assert mapped["ranges"] == "100,250,500"
+    assert mapped["range_labels"] == "Minimum,Target,Stretch"
+    assert mapped["markers"] == "300"
+    assert mapped["marker_lines"] == "400"
+    assert mapped["show_labels"] is False
+    assert mapped["show_legend"] is True
+    assert mapped["orderby"][0] == ["Region", True]
+    assert mapped["orderby"][1][0]["label"] == "Total Revenue"
+    assert mapped["adhoc_filters"][0]["subject"] == "Status"
+
+
+def test_bullet_presentation_numbers_use_shortest_round_trip_safe_tokens() -> None:
+    ranges = [1.2345678901234567, 1.7976931348623157e308]
+    markers = [5e-324, -0.0]
+    marker_lines = [9.876543210987654e-200]
+    config = BulletChartConfig(
+        metric=_simple_metric(),
+        ranges=ranges,
+        markers=markers,
+        marker_lines=marker_lines,
+        show_legend=True,
+    )
+    mapped = map_bullet_config(config)
+
+    for key, expected in (
+        ("ranges", ranges),
+        ("markers", markers),
+        ("marker_lines", marker_lines),
+    ):
+        tokens = mapped[key].split(",")
+        assert [float(token) for token in tokens] == expected
+        assert all(
+            float(token).hex() == value.hex()
+            for token, value in zip(tokens, expected, strict=True)
+        )
+
+    round_trip = BulletChartConfig.model_validate(mapped)
+    assert round_trip.ranges == ranges
+    assert round_trip.markers == markers
+    assert round_trip.marker_lines == marker_lines
+
+    model = resolve_bullet_render_model(
+        [{"SUM(revenue)": 1.0}],
+        mapped,
+    )
+    assert model.ranges == ranges
+    assert model.markers == markers
+    assert model.marker_lines == marker_lines
+    assert (
+        "1.7976931348623157e+308"
+        in _generate_ascii_preview_from_data(
+            [{"SUM(revenue)": 1.0}], mapped
+        ).ascii_content
+    )
+    vega = _generate_vega_lite_preview_from_data([{"SUM(revenue)": 1.0}], mapped)
+    assert vega.specification["layer"]
+
+
+def test_bullet_native_saved_metric_and_legacy_metric_aliases() -> None:
+    saved = BulletChartConfig.model_validate(
+        {"viz_type": "bullet", "metric": "saved_revenue"}
+    )
+    legacy = BulletChartConfig.model_validate(
+        {"viz_type": "bullet", "metric": "sum__revenue"}
+    )
+    assert saved.metric.saved_metric is True
+    assert saved.metric.name == "saved_revenue"
+    assert legacy.metric.name == "sum__revenue"
+    assert legacy.metric.saved_metric is True
+
+
+@pytest.mark.parametrize("metric_name", ["sum__num", "sum__SP_POP_TOTL"])
+@pytest.mark.parametrize(
+    ("request_type", "request_fields"),
+    [
+        (GenerateChartRequest, {"dataset_id": 7}),
+        (UpdateChartRequest, {"identifier": 9}),
+        (UpdateChartPreviewRequest, {"dataset_id": 7}),
+    ],
+)
+def test_bullet_repository_metric_names_round_trip_as_saved_metrics_on_all_requests(
+    metric_name: str,
+    request_type: type[
+        GenerateChartRequest | UpdateChartRequest | UpdateChartPreviewRequest
+    ],
+    request_fields: dict[str, object],
+) -> None:
+    request = request_type.model_validate(
+        {**request_fields, "config": {"chart_type": "bullet", "metric": metric_name}}
+    )
+    config = request.config
+    assert isinstance(config, BulletChartConfig)
+    assert config.metric.saved_metric is True
+    assert map_bullet_config(config)["metric"] == metric_name
+
+
+@pytest.mark.parametrize(
+    ("native_operator", "typed_operator", "round_trip_operator"),
+    [
+        ("EQUALS", "=", "=="),
+        ("NOT_EQUALS", "!=", "!="),
+        ("LESS_THAN", "<", "<"),
+        ("LESS_THAN_OR_EQUAL", "<=", "<="),
+        ("GREATER_THAN", ">", ">"),
+        ("GREATER_THAN_OR_EQUAL", ">=", ">="),
+        ("IN", "IN", "IN"),
+        ("NOT_IN", "NOT IN", "NOT IN"),
+        ("LIKE", "LIKE", "LIKE"),
+        ("ILIKE", "ILIKE", "ILIKE"),
+        ("IS_NULL", "IS NULL", "IS NULL"),
+        ("IS_NOT_NULL", "IS NOT NULL", "IS NOT NULL"),
+    ],
+)
+def test_bullet_native_filter_operator_names_round_trip(
+    native_operator: str, typed_operator: str, round_trip_operator: str
+) -> None:
+    comparator: object
+    if native_operator.startswith("IS_"):
+        comparator = None
+    elif native_operator in {"IN", "NOT_IN"}:
+        comparator = ["North"]
+    else:
+        comparator = "North"
+    config = BulletChartConfig.model_validate(
+        {
+            "viz_type": "bullet",
+            "metric": "SavedRevenue",
+            "adhoc_filters": [
+                {
+                    "clause": "WHERE",
+                    "expressionType": "SIMPLE",
+                    "subject": "Region",
+                    "operator": native_operator,
+                    "comparator": comparator,
+                }
+            ],
+        }
+    )
+
+    assert config.filters is not None
+    assert config.filters[0].op == typed_operator
+    mapped = map_bullet_config(config)
+    assert mapped["adhoc_filters"][0]["operator"] == round_trip_operator
+
+
+def test_bullet_legacy_label_only_saved_metric_adapter_is_strict_and_bounded() -> None:
+    config = BulletChartConfig.model_validate(
+        {"viz_type": "bullet", "metric": {"label": "sum__num"}}
+    )
+    assert config.metric.saved_metric is True
+    assert map_bullet_config(config)["metric"] == "sum__num"
+
+    with pytest.raises(ValidationError):
+        BulletChartConfig.model_validate(
+            {
+                "viz_type": "bullet",
+                "metric": {"label": "sum__num", "aggregate": "SUM"},
+            }
+        )
+    with pytest.raises(ValidationError, match="at most 255"):
+        BulletChartConfig.model_validate(
+            {"viz_type": "bullet", "metric": {"label": "m" * 256}}
+        )
+
+
+@pytest.mark.parametrize(
+    "metric",
+    [
+        "SavedRevenue",
+        {
+            "aggregate": "SUM",
+            "column": {"column_name": "Revenue"},
+            "expressionType": "SIMPLE",
+            "label": "Simple Revenue",
+        },
+        {
+            "aggregate": None,
+            "column": None,
+            "expressionType": "SQL",
+            "sqlExpression": "SUM(Revenue)",
+            "label": "SQL Revenue",
+        },
+    ],
+)
+def test_bullet_all_metric_shapes_round_trip_full_native_presentation(
+    metric: object,
+) -> None:
+    native = {
+        "viz_type": "bullet",
+        "metric": metric,
+        "groupby": ["Region"],
+        "ranges": "50,100",
+        "range_labels": "Low,High",
+        "markers": "75",
+        "marker_labels": "Plan",
+        "marker_lines": "90",
+        "marker_line_labels": "Forecast",
+        "y_axis_format": "$,.0f",
+        "show_labels": True,
+        "show_legend": True,
+    }
+    mapped = map_bullet_config(BulletChartConfig.model_validate(native))
+    assert validate_merged_bullet_form_data(mapped) is not None
+    assert mapped["groupby"] == ["Region"]
+    assert mapped["ranges"] == "50,100"
+    assert mapped["marker_line_labels"] == "Forecast"
+
+
+def test_bullet_rejects_invalid_roles_and_output_collisions() -> None:
+    with pytest.raises(ValidationError, match="physical dimension"):
+        BulletChartConfig(
+            metric=_simple_metric(),
+            dimensions=[{"name": "region", "aggregate": "COUNT"}],
+        )
+    with pytest.raises(ValidationError, match="Duplicate Bullet dimension"):
+        BulletChartConfig(
+            metric=_simple_metric(),
+            dimensions=[{"name": "Region"}, {"name": "Region"}],
+        )
+    with pytest.raises(ValidationError, match="conflicts with a dimension"):
+        BulletChartConfig(
+            metric={"name": "revenue", "aggregate": "SUM", "label": "Region"},
+            dimensions=[{"name": "Region", "label": "Friendly"}],
+        )
+
+
+def test_bullet_accepts_short_labels_and_rejects_bad_order_target() -> None:
+    config = BulletChartConfig(
+        metric=_simple_metric(), ranges=[1, 2], range_labels=["Only one"]
+    )
+    assert config.range_labels == ["Only one"]
+    with pytest.raises(ValidationError, match="unknown: not_a_role"):
+        BulletChartConfig(
+            metric=_simple_metric(), dimensions=[], order_by=[{"column": "not_a_role"}]
+        )
+
+
+@pytest.mark.parametrize("empty_label", ["", " ", "\u200b", "<b></b>"])
+@pytest.mark.parametrize(
+    ("value_control", "label_control"),
+    [
+        ("ranges", "range_labels"),
+        ("markers", "marker_labels"),
+        ("marker_lines", "marker_line_labels"),
+    ],
+)
+def test_bullet_sanitized_empty_labels_preserve_slots(
+    empty_label: str, value_control: str, label_control: str
+) -> None:
+    """Empty annotations must not shift later labels to earlier values."""
+    config = BulletChartConfig.model_validate(
+        {
+            "metric": _simple_metric(),
+            value_control: [10, 20],
+            label_control: [empty_label, "goal"],
+        }
+    )
+    assert getattr(config, label_control) == ["", "goal"]
+    mapped = map_bullet_config(config)
+    assert mapped[value_control] == "10,20"
+    assert mapped[label_control] == ",goal"
+
+
+def test_bullet_dimension_labels_are_input_aliases_not_result_aliases() -> None:
+    config = BulletChartConfig(
+        metric={"name": "Revenue", "aggregate": "SUM", "label": "Total"},
+        dimensions=[
+            {"name": "Team", "label": "Region"},
+            {"name": "Region", "label": "Market"},
+        ],
+        # The exact physical Region must win over Team's display label.
+        order_by=[
+            {"column": "Region", "ascending": True},
+            {"column": "Revenue", "ascending": False},
+        ],
+    )
+    form_data = map_bullet_config(config)
+    assert form_data["groupby"] == ["Team", "Region"]
+    assert form_data["orderby"] == [
+        ["Region", True],
+        [form_data["metric"], False],
+    ]
+
+    label_order = map_bullet_config(
+        BulletChartConfig(
+            metric=config.metric,
+            dimensions=config.dimensions,
+            order_by=[{"column": "Market"}],
+        )
+    )
+    assert label_order["orderby"] == [["Region", False]]
+
+    model = resolve_bullet_render_model(
+        [{"Team": "Blue", "Region": "North", "Total": 10}], form_data
+    )
+    assert model.dimensions == ["Team", "Region"]
+    assert [model.rows[0][name] for name in model.dimensions] == ["Blue", "North"]
+
+
+def test_bullet_rejects_ambiguous_display_alias_for_ordering() -> None:
+    with pytest.raises(ValidationError, match="ambiguous display alias"):
+        BulletChartConfig(
+            metric=_simple_metric(),
+            dimensions=[
+                {"name": "Region", "label": "Area"},
+                {"name": "Team", "label": "area"},
+            ],
+            order_by=[{"column": "AREA"}],
+        )
+
+
+@pytest.mark.parametrize(
+    ("metric", "order_target", "output"),
+    [
+        (
+            {"name": "SavedRevenue", "saved_metric": True, "label": "Friendly"},
+            "Friendly",
+            "SavedRevenue",
+        ),
+        (
+            {"name": "Revenue", "aggregate": "SUM", "label": "Simple Total"},
+            "Revenue",
+            "Simple Total",
+        ),
+        (
+            {"sql_expression": "SUM(Revenue)", "label": "SQL Total"},
+            "SQL Total",
+            "SQL Total",
+        ),
+    ],
+)
+def test_bullet_metric_shapes_share_physical_dimension_output_contract(
+    metric: dict[str, object], order_target: str, output: str
+) -> None:
+    config = BulletChartConfig(
+        metric=metric,
+        dimensions=[{"name": "Region", "label": "Market"}],
+        order_by=[{"column": order_target}],
+    )
+    form_data = map_bullet_config(config)
+    assert form_data["groupby"] == ["Region"]
+    assert form_data["orderby"] == [[form_data["metric"], False]]
+    model = resolve_bullet_render_model([{"Region": "North", output: 12}], form_data)
+    assert model.metric_field == output
+    assert model.dimensions == ["Region"]
+
+
+def test_bullet_mapper_preserves_omission_and_honors_explicit_values() -> None:
+    omitted = map_bullet_config(BulletChartConfig(metric=_simple_metric()))
+    explicit = map_bullet_config(
+        BulletChartConfig(
+            metric=_simple_metric(),
+            dimensions=[],
+            filters=[],
+            ranges=[],
+            show_labels=False,
+            show_legend=False,
+            row_limit=42,
+            time_range=None,
+        )
+    )
+    for key in (
+        "groupby",
+        "adhoc_filters",
+        "ranges",
+        "show_labels",
+        "show_legend",
+        "time_range",
+    ):
+        assert key not in omitted
+    # A new chart is bounded by the advertised schema default.
+    assert omitted["row_limit"] == 10000
+    assert explicit["groupby"] == []
+    assert explicit["adhoc_filters"] == []
+    assert explicit["ranges"] == ""
+    assert explicit["show_labels"] is False
+    assert explicit["show_legend"] is False
+    assert explicit["row_limit"] == 42
+    assert explicit["time_range"] is None
+
+
+def test_bullet_registry_schema_and_recommendation_metadata() -> None:
+    from superset.mcp_service.app import get_default_instructions
+    from superset.mcp_service.chart.registry import display_name_for_viz_type, get
+
+    plugin = get("bullet")
+    assert plugin is not None
+    assert plugin.resolve_viz_type(None) == "bullet"
+    assert display_name_for_viz_type("bullet") == "Bullet Chart"
+    assert "bullet" in VALID_CHART_TYPES
+    discovered = _get_chart_type_schema_impl("bullet")
+    assert discovered["chart_type"] == "bullet"
+    assert discovered["examples"][0]["ranges"] == [100000, 250000, 500000]
+    assert _VIZ_CATEGORY["bullet"] == "bullet"
+    candidates = _candidates_single_numeric(
+        DataColumn(
+            name="Revenue",
+            display_name="Revenue",
+            data_type="numeric",
+            sample_values=[1],
+            null_count=0,
+            unique_count=1,
+        ),
+        row_count=1,
+    )
+    assert "bullet chart" in candidates
+    guidance = get_default_instructions()
+    assert 'chart_type="bullet": Bullet Chart' in guidance
+    assert "waterfall, gantt, bullet, bubble_v2, and interactive_pivot" in guidance
+
+
+def test_bullet_dataset_normalization_canonicalizes_every_reference() -> None:
+    from superset.mcp_service.chart.registry import get
+
+    context = DatasetContext(
+        id=7,
+        table_name="sales",
+        schema=None,
+        database_name="main",
+        available_columns=[
+            {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+            {"name": "Region", "type": "VARCHAR"},
+            {"name": "OrderDate", "type": "TIMESTAMP", "is_temporal": True},
+            {"name": "Status", "type": "VARCHAR"},
+        ],
+        available_metrics=[],
+    )
+    config = BulletChartConfig(
+        metric={"name": "revenue", "aggregate": "SUM"},
+        dimensions=[{"name": "region"}],
+        temporal_column="orderdate",
+        filters=[{"column": "status", "op": "=", "value": "active"}],
+        order_by=[{"column": "region", "ascending": True}],
+    )
+    plugin = get("bullet")
+    assert plugin is not None
+    normalized = plugin.normalize_column_refs(config, context)
+    assert normalized.metric.name == "Revenue"
+    assert normalized.dimensions[0].name == "Region"
+    assert normalized.temporal_column == "OrderDate"
+    assert normalized.filters[0].column == "Status"
+    assert normalized.order_by[0].column == "Region"
+    assert normalized.model_fields_set == config.model_fields_set
+
+
+def test_bullet_dataset_normalization_rejects_ambiguous_casefold_candidates() -> None:
+    from superset.mcp_service.chart.registry import get
+
+    context = DatasetContext(
+        id=7,
+        table_name="sales",
+        schema=None,
+        database_name="main",
+        available_columns=[
+            {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+            {"name": "revenue", "type": "NUMERIC", "is_numeric": True},
+        ],
+        available_metrics=[],
+    )
+    plugin = get("bullet")
+    assert plugin is not None
+    config = BulletChartConfig(metric={"name": "REVENUE", "aggregate": "SUM"})
+    with pytest.raises(ValueError, match="'Revenue', 'revenue'"):
+        plugin.normalize_column_refs(config, context)
+
+
+def test_bullet_numeric_output_constraint_rejects_text_min() -> None:
+    from superset.mcp_service.chart.registry import get
+
+    context = DatasetContext(
+        id=7,
+        table_name="sales",
+        schema=None,
+        database_name="main",
+        available_columns=[{"name": "status", "type": "VARCHAR"}],
+        available_metrics=[],
+    )
+    plugin = get("bullet")
+    assert plugin is not None
+    config = BulletChartConfig(metric={"name": "status", "aggregate": "MIN"})
+    with patch.object(DatasetValidator, "_get_dataset_context", return_value=context):
+        error = plugin.post_map_validate(config, {}, dataset_id=7)
+    assert error is not None
+    assert error.error_type == "non_numeric_bullet_metric"
+
+
+@pytest.mark.parametrize("reverse_metadata", [False, True])
+def test_bullet_exact_case_type_and_role_resolution_is_order_independent(
+    reverse_metadata: bool,
+) -> None:
+    from superset.mcp_service.chart.registry import get
+
+    columns = [
+        {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+        {"name": "revenue", "type": "VARCHAR", "is_numeric": False},
+        {"name": "Region", "type": "VARCHAR"},
+    ]
+    if reverse_metadata:
+        columns.reverse()
+    context = DatasetContext(
+        id=7,
+        table_name="sales",
+        schema=None,
+        database_name="main",
+        available_columns=columns,
+        available_metrics=[],
+    )
+    plugin = get("bullet")
+    assert plugin is not None
+
+    numeric = BulletChartConfig(metric={"name": "Revenue", "aggregate": "SUM"})
+    text = BulletChartConfig(metric={"name": "revenue", "aggregate": "MIN"})
+    with patch.object(DatasetValidator, "_get_dataset_context", return_value=context):
+        assert plugin.post_map_validate(numeric, {}, dataset_id=7) is None
+        error = plugin.post_map_validate(text, {}, dataset_id=7)
+    assert error is not None
+    assert error.error_type == "non_numeric_bullet_metric"
+
+    roles = BulletChartConfig(
+        metric={"name": "Revenue", "aggregate": "SUM"},
+        dimensions=[{"name": "revenue"}, {"name": "Region"}],
+        filters=[{"column": "revenue", "op": "=", "value": "retail"}],
+        order_by=[{"column": "revenue", "ascending": True}],
+    )
+    normalized = plugin.normalize_column_refs(roles, context)
+    assert normalized.metric.name == "Revenue"
+    assert [dimension.name for dimension in normalized.dimensions or []] == [
+        "revenue",
+        "Region",
+    ]
+    assert normalized.filters
+    assert normalized.filters[0].column == "revenue"
+    assert normalized.order_by[0].column == "revenue"
+
+    ambiguous = BulletChartConfig(metric={"name": "REVENUE", "aggregate": "SUM"})
+    with pytest.raises(
+        AmbiguousDatasetReferenceError, match="Bullet metric column reference"
+    ):
+        plugin.normalize_column_refs(ambiguous, context)
+
+
+@pytest.mark.parametrize("reverse_metadata", [False, True])
+def test_generic_aggregation_validation_uses_exact_case_before_type(
+    reverse_metadata: bool,
+) -> None:
+    from superset.mcp_service.chart.schemas import PieChartConfig
+
+    columns = [
+        {"name": "Amount", "type": "BIGINT", "is_numeric": True},
+        {"name": "amount", "type": "VARCHAR", "is_numeric": False},
+    ]
+    if reverse_metadata:
+        columns.reverse()
+    context = DatasetContext(
+        id=7,
+        table_name="sales",
+        schema=None,
+        database_name="main",
+        available_columns=columns,
+        available_metrics=[],
+    )
+
+    assert (
+        DatasetValidator._validate_aggregations(
+            [BulletChartConfig(metric={"name": "Amount", "aggregate": "SUM"}).metric],
+            context,
+        )
+        == []
+    )
+    errors = DatasetValidator._validate_aggregations(
+        [BulletChartConfig(metric={"name": "amount", "aggregate": "SUM"}).metric],
+        context,
+    )
+    assert errors
+    assert errors[0].error_type == "invalid_aggregation"
+
+    ambiguous = DatasetValidator._validate_aggregations(
+        [BulletChartConfig(metric={"name": "AMOUNT", "aggregate": "SUM"}).metric],
+        context,
+    )
+    assert ambiguous
+    assert ambiguous[0].error_type == "ambiguous_dataset_reference"
+
+    valid, error = DatasetValidator.validate_against_dataset(
+        PieChartConfig(
+            dimension={"name": "amount"},
+            metric={"name": "Amount", "aggregate": "SUM"},
+        ),
+        7,
+        dataset_context=context,
+    )
+    assert valid is True
+    assert error is None
+
+
+@pytest.mark.parametrize(
+    ("metric", "field"),
+    [
+        ({"name": "Revenue", "aggregate": "SUM", "label": "Simple"}, "Simple"),
+        ({"name": "SavedRevenue", "saved_metric": True}, "SavedRevenue"),
+        ({"sql_expression": "SUM(Revenue)", "label": "SQL Total"}, "SQL Total"),
+    ],
+)
+def test_bullet_result_roles_are_exact_for_every_metric_shape(
+    metric: dict[str, object], field: str
+) -> None:
+    form_data = map_bullet_config(BulletChartConfig(metric=metric))
+    model = resolve_bullet_render_model([{field.swapcase(): "12.5"}], form_data)
+    assert model.metric_field == field.swapcase()
+    assert model.measures == [12.5]
+
+
+@pytest.mark.parametrize(
+    "rows, message",
+    [
+        ([{"other": 123}], "missing"),
+        ([{"Revenue": "not a number"}], "non-numeric text"),
+        ([{"Revenue": math.nan}], "NaN or infinite"),
+        ([{"Revenue": math.inf}], "NaN or infinite"),
+        ([{"Revenue": 1}, {}], "row 1.*missing"),
+        ([{"REVENUE": 1, "revenue": 2}], "ambiguous"),
+    ],
+)
+def test_bullet_result_validation_rejects_malformed_rows(
+    rows: list[dict[str, object]], message: str
+) -> None:
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "amount", "aggregate": "SUM", "label": "Revenue"}
+        )
+    )
+    with pytest.raises(BulletOutputError, match=message):
+        resolve_bullet_render_model(rows, form_data)
+
+
+def test_bullet_result_validation_accepts_null_and_numeric_strings() -> None:
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "amount", "aggregate": "SUM", "label": "Revenue"},
+            dimensions=[{"name": "Region"}],
+        )
+    )
+    model = resolve_bullet_render_model(
+        [
+            {"Region": "North", "Revenue": None},
+            {"Region": "South", "Revenue": " 4.25 "},
+        ],
+        form_data,
+    )
+    assert model.measures == [0.0, 4.25]
+
+
+@pytest.mark.parametrize(
+    ("presentation", "message"),
+    [
+        ({"markers": "Infinity"}, r"markers\[0\].*NaN or infinite"),
+    ],
+)
+def test_bullet_result_validation_rejects_malformed_presentation(
+    presentation: dict[str, object], message: str
+) -> None:
+    form_data = {
+        **map_bullet_config(
+            BulletChartConfig(metric={"name": "amount", "aggregate": "SUM"})
+        ),
+        **presentation,
+    }
+    with pytest.raises(BulletOutputError, match=message):
+        resolve_bullet_render_model([{"SUM(amount)": 1}], form_data)
+
+
+def test_bullet_compile_accepts_empty_ungrouped_result() -> None:
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"}
+        )
+    )
+    factory = MagicMock()
+    factory.create.return_value = MagicMock()
+    command = MagicMock()
+    command.run.return_value = {"queries": [{"data": []}]}
+    with (
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        result = _compile_chart(form_data, 7)
+    assert result.success is True
+    assert result.row_count == 0
+
+
+def test_bullet_compile_inspects_top_level_and_query_error_envelopes() -> None:
+    form_data = map_bullet_config(
+        BulletChartConfig(metric={"name": "Revenue", "aggregate": "SUM"})
+    )
+    factory = MagicMock()
+    factory.create.return_value = MagicMock()
+    command = MagicMock()
+    command.run.return_value = {
+        "status": "success",
+        "queries": [{"status": "failed", "message": "warehouse timeout"}],
+    }
+    with (
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        result = _compile_chart(form_data, 7)
+    assert result.success is False
+    assert "warehouse timeout" in (result.error or "")
+
+
+_MALFORMED_QUERY_ENVELOPES: list[object] = [
+    None,
+    [],
+    {},
+    {"queries": None},
+    {"queries": []},
+    {"queries": [None]},
+    {"queries": [{}]},
+    {"queries": [{"data": None}]},
+    {"queries": [{"data": []}, {"data": "not-an-array"}]},
+    {
+        "queries": [
+            {
+                "data": [{"Revenue": 12}],
+                "colnames": ["Revenue"],
+                "coltypes": [],
+            }
+        ]
+    },
+]
+
+
+def _compile_bullet_with_result(result: object) -> Any:
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"}
+        )
+    )
+    factory = MagicMock()
+    factory.create.return_value = MagicMock()
+    command = MagicMock()
+    command.run.return_value = result
+    with (
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        return _compile_chart(form_data, 7)
+
+
+@pytest.mark.parametrize("envelope", _MALFORMED_QUERY_ENVELOPES)
+def test_bullet_compile_returns_stable_error_for_malformed_envelopes(
+    envelope: object,
+) -> None:
+    result = _compile_bullet_with_result(envelope)
+    assert result.success is False
+    assert result.error_code == "CHART_COMPILE_FAILED"
+    assert result.error_obj is not None
+    assert result.error_obj.error_type == "compile_error"
+
+
+@pytest.mark.parametrize(
+    ("data", "expected_code", "expected_type"),
+    [
+        ([1], "CHART_COMPILE_FAILED", "compile_error"),
+        (
+            [{"Revenue": 10**10000}],
+            "CHART_COMPILE_FAILED",
+            "compile_error",
+        ),
+    ],
+)
+def test_bullet_compile_returns_malformed_output_for_bad_rows(
+    data: list[object],
+    expected_code: str,
+    expected_type: str,
+) -> None:
+    result = _compile_bullet_with_result({"queries": [{"data": data}]})
+    assert result.success is False
+    assert result.error_code == expected_code
+    assert result.error_obj is not None
+    assert result.error_obj.error_type == expected_type
+
+
+def test_bullet_query_builder_forwards_configured_roles_sort_and_limit() -> None:
+    metric = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+            dimensions=[{"name": "Region"}],
+            row_limit=25,
+            order_by=[{"column": "Revenue", "ascending": False}],
+        )
+    )
+    with patch(
+        "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+        return_value="base",
+    ):
+        queries = build_query_dicts_from_form_data(metric, 7, "table")
+    assert len(queries) == 1
+    assert queries[0]["columns"] == ["Region"]
+    assert queries[0]["metrics"] == [metric["metric"]]
+    assert queries[0]["orderby"] == metric["orderby"]
+    assert queries[0]["row_limit"] == 25
+
+
+def test_bullet_compile_path_uses_groupby_metric_orderby_and_usable_result() -> None:
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+            dimensions=[{"name": "Region"}],
+            order_by=[{"column": "Revenue", "ascending": False}],
+        )
+    )
+    context = MagicMock()
+    factory = MagicMock()
+    factory.create.return_value = context
+    command = MagicMock()
+    command.run.return_value = {
+        "queries": [{"data": [{"Region": "North", "Revenue": "12.5"}]}]
+    }
+    with (
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        result = _compile_chart(form_data, 7)
+    query = factory.create.call_args.kwargs["queries"][0]
+    assert query["columns"] == ["Region"]
+    assert query["metrics"] == [form_data["metric"]]
+    assert query["orderby"] == form_data["orderby"]
+    assert result.success is True
+    assert result.row_count == 1
+
+
+def test_bullet_compile_projects_dataframe_timestamp_before_validation() -> None:
+    from superset.dataframe import df_to_records
+
+    dublin = dateutil_tz.gettz("Europe/Dublin")
+    new_york = dateutil_tz.gettz("America/New_York")
+    assert dublin is not None
+    assert new_york is not None
+    source_values = [
+        pd.Timestamp("2024-01-02 03:04:05.123456789"),
+        datetime(2024, 10, 27, 1, 30, tzinfo=dublin, fold=1),
+        datetime(2024, 3, 10, 2, 30, tzinfo=new_york),
+        datetime(2040, 7, 1, 12, tzinfo=new_york),
+    ]
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+            dimensions=[{"name": "Category"}],
+        )
+    )
+    rows = df_to_records(
+        pd.DataFrame(
+            {
+                "Category": pd.Series(source_values, dtype=object),
+                "Revenue": range(1, len(source_values) + 1),
+            }
+        ),
+        convert_big_integers=False,
+    )
+    factory = MagicMock()
+    factory.create.return_value = MagicMock()
+    command = MagicMock()
+    command.run.return_value = {"queries": [{"data": rows}]}
+    captured: list[list[dict[str, Any]]] = []
+
+    def validate(
+        data: list[dict[str, Any]],
+        config: dict[str, Any],
+        *,
+        validate_format: bool = True,
+        bound_labels: bool = True,
+    ) -> Any:
+        """Capture the projected data while preserving render-model options."""
+        captured.append(data)
+        return resolve_bullet_render_model(
+            data,
+            config,
+            validate_format=validate_format,
+            bound_labels=bound_labels,
+        )
+
+    with (
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+        patch(
+            "superset.mcp_service.chart.preview_utils.resolve_bullet_render_model",
+            side_effect=validate,
+        ),
+    ):
+        result = _compile_chart(form_data, 7)
+
+    assert result.success is True
+    assert [row["Category"] for row in captured[0]] == [
+        1704164645123.456,
+        1729989000000.0,
+        1710052200000.0,
+        2224774800000.0,
+    ]
+
+
+def test_bullet_compile_projects_real_dataframe_durations_to_chart_data_wire() -> None:
+    from superset.commands.chart.data.get_data_command import ChartDataCommand
+    from superset.common.chart_data import ChartDataResultType
+    from superset.dataframe import df_to_records
+    from superset.utils import json
+
+    source_values = [
+        timedelta(0),
+        timedelta(days=1, seconds=2, microseconds=3),
+        pd.Timedelta(-1, unit="ns"),
+        pd.Timedelta("1 days 00:00:02.000003004"),
+        np.timedelta64(123456789, "ns"),
+        np.timedelta64("NaT"),
+    ]
+    rows = df_to_records(
+        pd.DataFrame(
+            {
+                "Duration": pd.Series(source_values, dtype=object),
+                "Revenue": range(1, len(source_values) + 1),
+            }
+        ),
+        convert_big_integers=False,
+    )
+    assert type(rows[4]["Duration"]) is pd.Timedelta
+    assert rows[5]["Duration"] is None
+    expected = [
+        None
+        if row["Duration"] is None
+        else json.loads(json.dumps(row["Duration"], default=json.json_int_dttm_ser))
+        for row in rows
+    ]
+
+    class _ProducerContext:
+        result_type = ChartDataResultType.FULL
+
+        def get_payload(self, **_kwargs: Any) -> dict[str, Any]:
+            return {"queries": [{"data": rows, "rowcount": len(rows)}]}
+
+    producer_result = ChartDataCommand(_ProducerContext()).run()  # type: ignore[arg-type]
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Duration"],
+    }
+    factory = MagicMock()
+    factory.create.return_value = MagicMock()
+    command = MagicMock()
+    command.run.return_value = producer_result
+    captured: list[list[dict[str, Any]]] = []
+
+    def validate(
+        data: list[dict[str, Any]],
+        config: dict[str, Any],
+        *,
+        validate_format: bool = True,
+        bound_labels: bool = True,
+    ) -> Any:
+        """Capture the projected data while preserving render-model options."""
+        captured.append(data)
+        return resolve_bullet_render_model(
+            data,
+            config,
+            validate_format=validate_format,
+            bound_labels=bound_labels,
+        )
+
+    with (
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+        patch(
+            "superset.mcp_service.chart.preview_utils.resolve_bullet_render_model",
+            side_effect=validate,
+        ),
+    ):
+        result = _compile_chart(form_data, 7)
+
+    assert result.success is True
+    assert [row["Duration"] for row in captured[0]] == expected
+
+
+def test_bullet_compile_accepts_transitionless_dateutil_dataframe_producer() -> None:
+    from superset.commands.chart.data.get_data_command import ChartDataCommand
+    from superset.common.chart_data import ChartDataResultType
+    from superset.dataframe import df_to_records
+
+    names = [
+        "UTC",
+        "GMT",
+        "Universal",
+        "Zulu",
+        "EST",
+        "HST",
+        "MST",
+        "Etc/GMT+1",
+        "Etc/GMT-2",
+    ]
+    values = []
+    for getter in (dateutil_tz.gettz, get_zonefile_instance().get):
+        for name in names:
+            timezone_value = getter(name)
+            assert timezone_value is not None
+            values.append(
+                datetime(2040, 7, 1, 12, 34, 56, 123456, tzinfo=timezone_value)
+            )
+    rows = df_to_records(
+        pd.DataFrame(
+            {
+                "Category": pd.Series(values, dtype=object),
+                "Revenue": range(1, len(values) + 1),
+            }
+        ),
+        convert_big_integers=False,
+    )
+
+    class _ProducerContext:
+        result_type = ChartDataResultType.FULL
+
+        def get_payload(self, **_kwargs: Any) -> dict[str, Any]:
+            return {
+                "queries": [
+                    {
+                        "data": rows,
+                        "colnames": ["Category", "Revenue"],
+                        "coltypes": [
+                            GenericDataType.TEMPORAL,
+                            GenericDataType.NUMERIC,
+                        ],
+                        "rowcount": len(rows),
+                    }
+                ]
+            }
+
+    producer_result = ChartDataCommand(_ProducerContext()).run()  # type: ignore[arg-type]
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+            dimensions=[{"name": "Category"}],
+        )
+    )
+    factory = MagicMock()
+    factory.create.return_value = MagicMock()
+    command = MagicMock()
+    command.run.return_value = producer_result
+    captured: list[list[dict[str, Any]]] = []
+
+    def validate(
+        data: list[dict[str, Any]],
+        config: dict[str, Any],
+        *,
+        validate_format: bool = True,
+        bound_labels: bool = True,
+    ) -> Any:
+        """Capture the projected data while preserving render-model options."""
+        captured.append(data)
+        return resolve_bullet_render_model(
+            data,
+            config,
+            validate_format=validate_format,
+            bound_labels=bound_labels,
+        )
+
+    with (
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+        patch(
+            "superset.mcp_service.chart.preview_utils.resolve_bullet_render_model",
+            side_effect=validate,
+        ),
+    ):
+        result = _compile_chart(form_data, 7)
+
+    assert result.success is True
+    assert result.row_count == len(values)
+    assert [row["Category"] for row in captured[0]] == [
+        json_int_dttm_ser(value) for value in values
+    ]
+
+
+def test_bullet_unsaved_preview_path_returns_faithful_vega_spec() -> None:
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={
+                "name": "revenue_amount",
+                "aggregate": "SUM",
+                "label": "Total Revenue",
+            },
+            dimensions=[{"name": "Region"}, {"name": "Team"}],
+            ranges=[100, 200],
+            markers=[150],
+            marker_lines=[175],
+        )
+    )
+    preview = _generate_vega_lite_preview_from_data(
+        [{"Region": "North", "Team": "Blue", "Total Revenue": 123}], form_data
+    )
+    specification = preview.specification
+    assert specification["data"]["values"][0]["__mcp_bullet_category"] == (
+        "North, Blue"
+    )
+    bar_layer = next(
+        layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+    )
+    assert bar_layer["encoding"]["x"]["field"] == "Total Revenue"
+    assert bar_layer["encoding"]["y"]["field"] == "__mcp_bullet_row"
+    assert [item["field"] for item in bar_layer["encoding"]["tooltip"]] == [
+        "__mcp_bullet_category",
+        "Total Revenue",
+    ]
+    assert bar_layer["encoding"]["tooltip"][0]["title"] == "Region, Team"
+    assert {layer["mark"]["type"] for layer in specification["layer"]} == {
+        "rect",
+        "bar",
+        "point",
+        "rule",
+        "text",
+    }
+
+
+def test_bullet_vega_internal_category_key_avoids_adversarial_row_aliases() -> None:
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "value", "aggregate": "SUM", "label": "Metric"},
+            dimensions=[{"name": "__mcp_bullet_category"}],
+        )
+    )
+    preview = _generate_vega_lite_preview_from_data(
+        [
+            {
+                "__mcp_bullet_category": "North",
+                "__mcp_bullet_category_1": "occupied",
+                "__mcp_bullet_row": "occupied",
+                "Metric": 10,
+            }
+        ],
+        form_data,
+    )
+    specification = preview.specification
+    bar = next(
+        layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+    )
+    category_field = bar["encoding"]["tooltip"][0]["field"]
+    assert category_field == "__mcp_bullet_category_2"
+    assert specification["data"]["values"][0][category_field] == "North"
+    assert bar["encoding"]["y"]["field"] == "__mcp_bullet_row_1"
+    assert specification["data"]["values"][0]["__mcp_bullet_row_1"] == 0
+    assert "transform" not in specification
+
+
+def test_bullet_duration_categories_match_chart_data_wire_in_ascii_and_vega() -> None:
+    values = [
+        timedelta(0),
+        timedelta(microseconds=-1),
+        timedelta(days=1, seconds=2, microseconds=3),
+        pd.Timedelta(0),
+        pd.Timedelta(-1, unit="ns"),
+        pd.Timedelta("1 days 00:00:02.000003004"),
+        np.timedelta64(123456789, "ns"),
+        np.timedelta64("NaT"),
+    ]
+    expected = []
+    for value in values:
+        projected, reason = _chart_data_duration_text(value)
+        assert reason is None
+        expected.append("null" if projected is None else projected)
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Duration"],
+    }
+    data = [
+        {"Duration": value, "Revenue": index + 1} for index, value in enumerate(values)
+    ]
+
+    vega = _generate_vega_lite_preview_from_data(data, form_data).specification
+    bar = next(layer for layer in vega["layer"] if layer["mark"]["type"] == "bar")
+    category_field = bar["encoding"]["tooltip"][0]["field"]
+    assert [row[category_field] for row in vega["data"]["values"]] == expected
+    assert [row["Duration"] for row in vega["data"]["values"]] == [
+        None if value == "null" else value for value in expected
+    ]
+    assert bar["encoding"]["tooltip"][0]["field"] == category_field
+
+    ascii_preview = _generate_ascii_preview_from_data(data, form_data).ascii_content
+    for value in expected:
+        assert value[:20] in ascii_preview
+
+
+def test_bullet_duration_category_rejects_subclass_without_hooks() -> None:
+    class HostileTimedelta(timedelta):
+        calls = 0
+
+        def _call(self, *_args: object) -> Any:
+            type(self).calls += 1
+            raise AssertionError("duration hook executed")
+
+        __abs__ = _call
+        __eq__ = _call
+        __lt__ = _call
+        __str__ = _call
+
+    with pytest.raises(BulletOutputError, match="unsupported value type"):
+        resolve_bullet_render_model(
+            [{"Duration": HostileTimedelta(seconds=1), "Revenue": 1}],
+            {"metric": "Revenue", "groupby": ["Duration"]},
+        )
+    assert HostileTimedelta.calls == 0
+
+
+@pytest.mark.parametrize(
+    ("value", "expected"),
+    [
+        (9007199254740993, "9007199254740992"),
+        (9007199254740995, "9007199254740996"),
+        (Decimal("1.0000000000000001"), "1"),
+        (Decimal("1.0000000000000002"), "1.0000000000000002"),
+        (Decimal("1e20"), "100000000000000000000"),
+        (Decimal("1e21"), "1e+21"),
+        (Decimal("1e-6"), "0.000001"),
+        (Decimal("1e-7"), "1e-7"),
+        (Decimal("1.7976931348623157e308"), "1.7976931348623157e+308"),
+        (Decimal("1.7976931348623159e308"), "Infinity"),
+        (Decimal("-1e309"), "-Infinity"),
+        (10**400, "Infinity"),
+        (Decimal("-0"), "0"),
+    ],
+)
+def test_bullet_number_categories_match_node_ieee754_string_semantics(
+    value: int | Decimal, expected: str
+) -> None:
+    assert _javascript_number_string(value) == expected
+
+
+@pytest.mark.parametrize(
+    "value",
+    [float("inf"), Decimal("NaN"), Decimal("Infinity")],
+)
+def test_bullet_number_categories_reject_nonfinite_source_values(value: object) -> None:
+    with pytest.raises(BulletOutputError, match="non-finite"):
+        resolve_bullet_render_model(
+            [{"Category": value, "Revenue": 1}],
+            {"metric": "Revenue", "groupby": ["Category"]},
+        )
+
+
+def test_bullet_nan_category_retains_shared_null_normalization() -> None:
+    model = resolve_bullet_render_model(
+        [{"Category": float("nan"), "Revenue": 1}],
+        {"metric": "Revenue", "groupby": ["Category"]},
+    )
+    assert model.rows[0]["Category"] is None
+
+
+def test_bullet_number_category_boundaries_flow_through_ascii_and_vega() -> None:
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Category"],
+    }
+    values = [
+        9007199254740993,
+        Decimal("1.0000000000000001"),
+        Decimal("1e20"),
+        Decimal("1e21"),
+        Decimal("1e-7"),
+        Decimal("1.7976931348623159e308"),
+        Decimal("-0"),
+    ]
+    expected = [
+        "9007199254740992",
+        "1",
+        "100000000000000000000",
+        "1e+21",
+        "1e-7",
+        "Infinity",
+        "0",
+    ]
+    data = [
+        {"Category": value, "Revenue": index + 1} for index, value in enumerate(values)
+    ]
+
+    vega = _generate_vega_lite_preview_from_data(data, form_data).specification
+    category_field = next(
+        layer for layer in vega["layer"] if layer["mark"]["type"] == "bar"
+    )["encoding"]["tooltip"][0]["field"]
+    assert [row[category_field] for row in vega["data"]["values"]] == expected
+    assert "transform" not in vega
+    tooltip = next(layer for layer in vega["layer"] if layer["mark"]["type"] == "bar")[
+        "encoding"
+    ]["tooltip"]
+    assert tooltip[0] == {
+        "field": category_field,
+        "type": "nominal",
+        "title": "Category",
+    }
+    assert all(item.get("field") != "Category" for item in tooltip)
+    # Raw normalized dimensions stay available for inspection, while every
+    # category labels use the frontend-coerced derived value.
+    assert vega["data"]["values"][0]["Category"] == 9007199254740993
+    assert vega["data"]["values"][1]["Category"] == Decimal("1.0000000000000001")
+
+    ascii_preview = _generate_ascii_preview_from_data(data, form_data)
+    for category in ("9007199254740992", "1e+21", "1e-7", "Infinity"):
+        assert category in ascii_preview.ascii_content
+
+
+def test_bullet_vega_tooltip_uses_joined_derived_numeric_category() -> None:
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Large integer", "Precise decimal"],
+    }
+    specification = _generate_vega_lite_preview_from_data(
+        [
+            {
+                "Large integer": 9007199254740993,
+                "Precise decimal": Decimal("1.0000000000000001"),
+                "Revenue": 1,
+            }
+        ],
+        form_data,
+    ).specification
+    bar = next(
+        layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+    )
+    category_field = bar["encoding"]["tooltip"][0]["field"]
+
+    assert specification["data"]["values"][0][category_field] == ("9007199254740992, 1")
+    assert bar["encoding"]["tooltip"][0] == {
+        "field": category_field,
+        "type": "nominal",
+        "title": "Large integer, Precise decimal",
+    }
+    assert "transform" not in specification
+
+
+def test_bullet_temporal_category_rejects_hostile_timezone_without_hooks() -> None:
+    class _HostileTimezone(tzinfo):
+        calls = 0
+
+        def utcoffset(self, _value: datetime | None) -> None:
+            type(self).calls += 1
+            raise AssertionError("timezone hook invoked")
+
+        def dst(self, _value: datetime | None) -> None:
+            type(self).calls += 1
+            raise AssertionError("timezone hook invoked")
+
+        def tzname(self, _value: datetime | None) -> str | None:
+            type(self).calls += 1
+            raise AssertionError("timezone hook invoked")
+
+    with pytest.raises(BulletOutputError, match="unsupported timezone"):
+        resolve_bullet_render_model(
+            [
+                {
+                    "Category": datetime(2026, 9, 2, tzinfo=_HostileTimezone()),
+                    "Revenue": 1,
+                }
+            ],
+            {"metric": "Revenue", "groupby": ["Category"]},
+        )
+    assert _HostileTimezone.calls == 0
+
+
+def test_bullet_timestamp_categories_match_chart_data_wire_and_all_previews() -> None:
+    zoneinfo_tz = ZoneInfo("America/New_York")
+    pytz_tz = pytz.timezone("America/New_York")
+    values = [
+        pd.Timestamp("2024-01-02 03:04:05.123456789"),
+        pd.Timestamp("2024-01-02 03:04:05.123456789", tz="UTC"),
+        pd.Timestamp("2024-01-02 08:34:05.123456789+05:30"),
+        pd.Timestamp(datetime(2024, 11, 3, 1, 30, tzinfo=zoneinfo_tz, fold=0)),
+        pd.Timestamp(datetime(2024, 11, 3, 1, 30, tzinfo=zoneinfo_tz, fold=1)),
+        pd.Timestamp(pytz_tz.localize(datetime(2024, 11, 3, 1, 30), is_dst=True)),
+        pd.Timestamp(pytz_tz.localize(datetime(2024, 11, 3, 1, 30), is_dst=False)),
+        pd.Timestamp("1969-12-31 23:59:59.999999999"),
+        pd.Timestamp("1970-01-01 00:00:00.000000001"),
+        np.datetime64("2024-01-02", "D"),
+        np.datetime64("2024-01-02T03:04:05.123456789"),
+        date(2024, 1, 2),
+        pd.NaT,
+        np.datetime64("NaT"),
+    ]
+    expected = [
+        "1704164645123.456",
+        "1704164645123.456",
+        "1704164645123.456",
+        "1730611800000",
+        "1730615400000",
+        "1730611800000",
+        "1730615400000",
+        "-0.0010000000000287557",
+        "0",
+        "1704153600000",
+        "1704164645123.456",
+        "1704153600000",
+        "null",
+        "null",
+    ]
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Category"],
+    }
+    data = [
+        {"Category": value, "Revenue": index + 1} for index, value in enumerate(values)
+    ]
+
+    # Exact pandas Timestamp values match Superset's production serializer,
+    # including its pre-epoch/sub-millisecond float behavior.
+    for value in values[:9]:
+        number, reason = _chart_data_temporal_number(value)
+        assert reason is None
+        assert number == json_int_dttm_ser(value)
+
+    vega = _generate_vega_lite_preview_from_data(data, form_data).specification
+    bar = next(layer for layer in vega["layer"] if layer["mark"]["type"] == "bar")
+    category_field = bar["encoding"]["tooltip"][0]["field"]
+    assert [row[category_field] for row in vega["data"]["values"]] == expected
+    assert bar["encoding"]["tooltip"][0]["field"] == category_field
+    assert bar["encoding"]["tooltip"][0]["title"] == "Category"
+    assert "transform" not in vega
+
+    for row, category in zip(data, expected, strict=True):
+        ascii_content = _generate_ascii_preview_from_data(
+            [row], form_data
+        ).ascii_content
+        assert category[:20] in ascii_content
+
+
+def test_bullet_dateutil_categories_preserve_chart_data_selected_instants() -> None:
+    dublin = dateutil_tz.gettz("Europe/Dublin")
+    new_york = dateutil_tz.gettz("America/New_York")
+    assert dublin is not None
+    assert new_york is not None
+    dateutil_values = [
+        datetime(2024, 10, 27, 1, 30, 0, 123456, tzinfo=dublin, fold=1),
+        datetime(2024, 3, 10, 2, 30, 0, 123456, tzinfo=new_york),
+        datetime(2040, 7, 1, 12, 0, 0, 123456, tzinfo=new_york),
+        datetime(
+            2024,
+            3,
+            10,
+            2,
+            30,
+            0,
+            123456,
+            tzinfo=dateutil_tz.tzoffset("EDT", -4 * 3600),
+        ),
+        datetime(1969, 12, 31, 23, 59, 59, 999999, tzinfo=dateutil_tz.UTC),
+    ]
+    equivalent_values = [
+        datetime(2024, 3, 10, 6, 30, 0, 123456, tzinfo=timezone.utc),
+        pytz.timezone("America/New_York").localize(
+            datetime(2024, 3, 10, 1, 30, 0, 123456), is_dst=False
+        ),
+        datetime(
+            2024,
+            3,
+            10,
+            1,
+            30,
+            0,
+            123456,
+            tzinfo=ZoneInfo("America/New_York"),
+        ),
+    ]
+    pandas_values = [pd.Timestamp(value) for value in dateutil_values[:3]]
+    values = [*dateutil_values, *equivalent_values, *pandas_values]
+    expected_numbers = [json_int_dttm_ser(value) for value in values]
+    expected_categories = [
+        "1729989000123.456",
+        "1710052200123.456",
+        "2224774800123.456",
+        "1710052200123.456",
+        "-0.001",
+        "1710052200123.456",
+        "1710052200123.456",
+        "1710052200123.456",
+        "1729989000123.456",
+        "1710052200123.456",
+        "2224774800123.456",
+    ]
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Category"],
+    }
+    data = [
+        {"Category": value, "Revenue": index + 1} for index, value in enumerate(values)
+    ]
+
+    for value, expected_number in zip(values, expected_numbers, strict=True):
+        projected, reason = _chart_data_temporal_number(value)
+        assert reason is None
+        assert projected == expected_number
+
+    vega = _generate_vega_lite_preview_from_data(data, form_data).specification
+    bar = next(layer for layer in vega["layer"] if layer["mark"]["type"] == "bar")
+    category_field = bar["encoding"]["tooltip"][0]["field"]
+    assert [row[category_field] for row in vega["data"]["values"]] == (
+        expected_categories
+    )
+    assert bar["encoding"]["tooltip"][0]["field"] == category_field
+
+    for row, category in zip(data, expected_categories, strict=True):
+        ascii_content = _generate_ascii_preview_from_data(
+            [row], form_data
+        ).ascii_content
+        assert category[:20] in ascii_content
+
+
+def test_bullet_pandas_timestamp_rejects_hostile_timezone_without_hooks() -> None:
+    class _ArmedTimezone(tzinfo):
+        armed = False
+        calls = 0
+
+        def _offset(self) -> timedelta:
+            type(self).calls += 1
+            if self.armed:
+                raise AssertionError("timezone hook invoked")
+            return timedelta(hours=1)
+
+        def utcoffset(self, _value: datetime | None) -> timedelta:
+            return self._offset()
+
+        def dst(self, _value: datetime | None) -> timedelta:
+            return timedelta(0)
+
+        def tzname(self, _value: datetime | None) -> str:
+            return "armed"
+
+    hostile_tz = _ArmedTimezone()
+    value = pd.Timestamp(datetime(2024, 1, 2, tzinfo=hostile_tz))
+    _ArmedTimezone.calls = 0
+    _ArmedTimezone.armed = True
+
+    with pytest.raises(BulletOutputError, match="unsupported timezone"):
+        resolve_bullet_render_model(
+            [{"Category": value, "Revenue": 1}],
+            {"metric": "Revenue", "groupby": ["Category"]},
+        )
+    assert _ArmedTimezone.calls == 0
+
+
+def test_bullet_categories_match_frontend_string_coercion_and_preserve_values() -> None:
+    identifier = UUID("12345678-1234-5678-1234-567812345678")
+    values = [
+        None,
+        "",
+        True,
+        False,
+        12.0,
+        12.5,
+        -0.0,
+        Decimal("12.00"),
+        Decimal("12.50"),
+        date(2026, 9, 2),
+        datetime(2026, 9, 2, 3, 4, 5, tzinfo=timezone.utc),
+        datetime(
+            2023,
+            11,
+            5,
+            1,
+            30,
+            tzinfo=ZoneInfo("America/New_York"),
+            fold=0,
+        ),
+        datetime(
+            2023,
+            11,
+            5,
+            1,
+            30,
+            tzinfo=ZoneInfo("America/New_York"),
+            fold=1,
+        ),
+        time(3, 4, 5),
+        identifier,
+        "null",
+        "true",
+        12,
+    ]
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Category"],
+    }
+    preview = _generate_vega_lite_preview_from_data(
+        [
+            {"Category": value, "Revenue": index + 1}
+            for index, value in enumerate(values)
+        ],
+        form_data,
+    )
+    rows = preview.specification["data"]["values"]
+    categories = [row["__mcp_bullet_category"] for row in rows]
+    assert categories == [
+        "null",
+        "",
+        "true",
+        "false",
+        "12",
+        "12.5",
+        "0",
+        "12",
+        "12.5",
+        "1788307200000",
+        "1788318245000",
+        "1699162200000",
+        "1699165800000",
+        "03:04:05",
+        str(identifier),
+        "null",
+        "true",
+        "12",
+    ]
+    # Null, string, boolean, integral-float, Decimal, and integer collisions
+    # are intentional display labels, but their row identities remain distinct.
+    assert len({row["__mcp_bullet_row"] for row in rows}) == len(rows)
+    assert categories.count("null") == 2
+    assert categories.count("true") == 2
+    assert categories.count("12") == 3
+    assert rows[0]["Category"] is None
+    assert rows[2]["Category"] is True
+    assert rows[4]["Category"] == 12.0
+    assert rows[7]["Category"] == Decimal("12.00")
+    assert rows[9]["Category"] == 1788307200000.0
+    assert rows[11]["Category"] == 1699162200000.0
+    assert rows[12]["Category"] == 1699165800000.0
+
+    ascii_preview = _generate_ascii_preview_from_data(
+        [{"Category": None, "Revenue": 1}, {"Category": True, "Revenue": 2}],
+        form_data,
+    )
+    assert "null" in ascii_preview.ascii_content
+    assert "true" in ascii_preview.ascii_content
+
+
+def test_bullet_category_text_is_bounded_without_truncation_collisions() -> None:
+    with pytest.raises(BulletOutputError, match="size limit"):
+        resolve_bullet_render_model(
+            [{"Category": "x" * 2001, "Revenue": 1}],
+            {"metric": "Revenue", "groupby": ["Category"]},
+        )
+
+
+@pytest.mark.parametrize("ranges", [None, "100"])
+def test_grouped_empty_bullet_has_clear_no_data_without_fabricated_category(
+    ranges: str | None,
+) -> None:
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Region"],
+    }
+    if ranges is not None:
+        form_data["ranges"] = ranges
+    model = resolve_bullet_render_model([], form_data)
+    assert model.rows == []
+    assert model.measures == []
+    assert model.dimensions == ["Region"]
+
+    vega = _generate_vega_lite_preview_from_data([], form_data)
+    assert vega.specification["data"]["values"] == []
+    assert vega.specification["description"] == (
+        "No data available for grouped Bullet chart"
+    )
+    ascii_preview = _generate_ascii_preview_from_data([], form_data)
+    assert ascii_preview.ascii_content == ("No data available for grouped Bullet chart")
+
+
+def test_bullet_preview_applies_default_band_and_every_presentation_control() -> None:
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+            dimensions=[{"name": "Region"}],
+            range_labels=["Baseline", "Capacity"],
+            markers=[90],
+            marker_labels=["Plan"],
+            marker_lines=[105],
+            marker_line_labels=["Forecast"],
+            y_axis_format="$,.1f",
+            show_labels=True,
+            show_legend=True,
+        )
+    )
+    preview = _generate_vega_lite_preview_from_data(
+        [
+            {"Region": "North", "Revenue": 100},
+            {"Region": "South", "Revenue": 50},
+        ],
+        form_data,
+    )
+    bullet = preview.specification["usermeta"]["bullet"]
+    assert bullet["metric"] == "Revenue"
+    assert bullet["dimensions"] == ["Region"]
+    assert bullet["ranges"] == pytest.approx([0, 110])
+    assert bullet["range_labels"] == ["Baseline", "Capacity"]
+    assert bullet["markers"] == [90.0]
+    assert bullet["marker_labels"] == ["Plan"]
+    assert bullet["marker_lines"] == [105.0]
+    assert bullet["marker_line_labels"] == ["Forecast"]
+    assert bullet["y_axis_format"] == "$,.1f"
+    assert bullet["show_labels"] is True
+    assert bullet["show_legend"] is True
+    bar = next(
+        layer
+        for layer in preview.specification["layer"]
+        if layer["mark"]["type"] == "bar"
+    )
+    range_tooltip = next(
+        item for item in bar["encoding"]["tooltip"] if item.get("title") == "Range"
+    )
+    assert preview.specification["data"]["values"][0][range_tooltip["field"]] == (
+        "Capacity"
+    )
+    assert any(
+        layer["mark"]["type"] == "text" for layer in preview.specification["layer"]
+    )
+    assert any(
+        encoding.get("color", {}).get("legend") == {"title": None}
+        for layer in preview.specification["layer"]
+        if isinstance((encoding := layer.get("encoding")), dict)
+    )
+
+
+@pytest.mark.parametrize(
+    ("measure", "ranges", "labels", "expected"),
+    [
+        (100, [300, 100, 200], ["High", "Low", "Mid"], "Low"),
+        (120.0, [300, 100, 200], ["High", "Low", "Mid"], "Mid"),
+        (Decimal("450"), [300, 100, 200], ["High", "Low", "Mid"], "> High"),
+        (-5, [10, -10, 0], ["Positive", "Negative", "Zero"], "Zero"),
+        (0, [10, 0], ["Positive", "Zero"], "Zero"),
+        (50, [100, 200], ["", "High"], None),
+        (250, [100, 200], ["Low", ""], None),
+    ],
+)
+def test_bullet_vega_measure_tooltip_matches_frontend_containing_range(
+    measure: int | float | Decimal,
+    ranges: list[int],
+    labels: list[str],
+    expected: str | None,
+) -> None:
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "ranges": ",".join(str(value) for value in ranges),
+        "range_labels": ",".join(labels),
+    }
+    specification = _generate_vega_lite_preview_from_data(
+        [{"Revenue": measure}], form_data
+    ).specification
+    bar = next(
+        layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+    )
+    range_tooltip = [
+        item for item in bar["encoding"]["tooltip"] if item.get("title") == "Range"
+    ]
+
+    if expected is None:
+        assert range_tooltip == []
+        assert all(
+            not key.startswith("__mcp_bullet_range")
+            for key in specification["data"]["values"][0]
+        )
+    else:
+        assert len(range_tooltip) == 1
+        range_field = range_tooltip[0]["field"]
+        assert specification["data"]["values"][0][range_field] == expected
+
+
+def test_bullet_vega_range_tooltip_is_grouped_per_row_and_collision_safe() -> None:
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Region"],
+        "ranges": "100,200,300",
+        "range_labels": "Low,Mid,High",
+    }
+    rows = [
+        {
+            "Region": "North",
+            "Revenue": 100,
+            "__mcp_bullet_range": "occupied",
+            "__mcp_bullet_range_1": "occupied",
+        },
+        {
+            "Region": "South",
+            "Revenue": 120,
+            "__mcp_bullet_range": "occupied",
+            "__mcp_bullet_range_1": "occupied",
+        },
+        {
+            "Region": "West",
+            "Revenue": 350,
+            "__mcp_bullet_range": "occupied",
+            "__mcp_bullet_range_1": "occupied",
+        },
+    ]
+    specification = _generate_vega_lite_preview_from_data(rows, form_data).specification
+    bar = next(
+        layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+    )
+    range_tooltip = next(
+        item for item in bar["encoding"]["tooltip"] if item.get("title") == "Range"
+    )
+    range_field = range_tooltip["field"]
+
+    assert range_field == "__mcp_bullet_range_2"
+    assert [row[range_field] for row in specification["data"]["values"]] == [
+        "Low",
+        "Mid",
+        "> High",
+    ]
+    assert "transform" not in specification
+
+
+def test_bullet_vega_omits_range_tooltip_for_frontend_default_labels() -> None:
+    specification = _generate_vega_lite_preview_from_data(
+        [{"Revenue": 5}],
+        {"viz_type": "bullet", "metric": "Revenue", "ranges": "10,20"},
+    ).specification
+    bar = next(
+        layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+    )
+    assert all(item.get("title") != "Range" for item in bar["encoding"]["tooltip"])
+
+
+def test_bullet_vega_empty_range_tooltip_matches_grouping_semantics() -> None:
+    presentation = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "ranges": "-1,1",
+        "range_labels": "Negative,Positive",
+    }
+    ungrouped = _generate_vega_lite_preview_from_data([], presentation).specification
+    grouped = _generate_vega_lite_preview_from_data(
+        [], {**presentation, "groupby": ["Region"]}
+    ).specification
+    ungrouped_bar = next(
+        layer for layer in ungrouped["layer"] if layer["mark"]["type"] == "bar"
+    )
+    grouped_bar = next(
+        layer for layer in grouped["layer"] if layer["mark"]["type"] == "bar"
+    )
+
+    range_tooltip = next(
+        item
+        for item in ungrouped_bar["encoding"]["tooltip"]
+        if item.get("title") == "Range"
+    )
+    assert ungrouped["data"]["values"][0][range_tooltip["field"]] == "Positive"
+    assert all(
+        item.get("title") != "Range" for item in grouped_bar["encoding"]["tooltip"]
+    )
+    assert grouped["data"]["values"] == []
+
+
+def test_bullet_ascii_uses_shared_roles_labels_and_format() -> None:
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+            dimensions=[{"name": "Region"}],
+            ranges=[100],
+            range_labels=["Healthy"],
+            markers=[80],
+            marker_labels=["Plan"],
+            marker_lines=[90],
+            marker_line_labels=["Forecast"],
+            y_axis_format="$,.0f",
+            show_legend=True,
+        )
+    )
+    preview = _generate_ascii_preview_from_data(
+        [{"Region": "North", "Revenue": "75"}], form_data
+    )
+    assert "North" in preview.ascii_content
+    assert "$75" in preview.ascii_content
+    assert "Healthy" in preview.ascii_content
+    assert "Plan" in preview.ascii_content
+    assert "Forecast" in preview.ascii_content
+
+
+@pytest.mark.parametrize("show_labels", [False, True])
+def test_bullet_marker_line_labels_ignore_show_labels_like_frontend(
+    show_labels: bool,
+) -> None:
+    config = BulletChartConfig(
+        metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+        marker_lines=[90, 110],
+        marker_line_labels=["Plan", "Stretch"],
+        show_labels=show_labels,
+    )
+    form_data = map_bullet_config(config)
+    round_trip = map_bullet_config(BulletChartConfig.model_validate(form_data))
+    assert round_trip["marker_lines"] == "90,110"
+    assert round_trip["marker_line_labels"] == "Plan,Stretch"
+
+    vega = _generate_vega_lite_preview_from_data(
+        [{"Revenue": 100}], form_data
+    ).specification
+    visible_text = [
+        layer["encoding"]["text"]["value"]
+        for layer in vega["layer"]
+        if layer["mark"]["type"] == "text"
+        and layer.get("encoding", {}).get("text", {}).get("value")
+        in {"Plan", "Stretch"}
+    ]
+    assert visible_text == ["Plan", "Stretch"]
+
+    ascii_preview = _generate_ascii_preview_from_data([{"Revenue": 100}], form_data)
+    assert ascii_preview.ascii_content.count("line Plan") == 1
+    assert ascii_preview.ascii_content.count("line Stretch") == 1
+
+
+def test_bullet_unsaved_preview_handles_empty_and_error_results() -> None:
+    dataset = SimpleNamespace(id=7)
+    factory = MagicMock()
+    factory.create.return_value = MagicMock()
+    command = MagicMock()
+    command.run.return_value = {"queries": []}
+    with (
+        patch("superset.extensions.db.session.get", return_value=dataset),
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        result = generate_preview_from_form_data(
+            {"viz_type": "bullet", "metric": "count"}, 7, "table"
+        )
+    assert isinstance(result, ChartError)
+    assert result.error_type == "UnsupportedFormat"
+
+
+def test_bullet_unsaved_preview_rejects_embedded_query_error() -> None:
+    dataset = SimpleNamespace(id=7)
+    factory = MagicMock()
+    factory.create.return_value = MagicMock()
+    command = MagicMock()
+    command.run.return_value = {
+        "queries": [{"status": "error", "error": "metric failed"}]
+    }
+    with (
+        patch("superset.extensions.db.session.get", return_value=dataset),
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        result = generate_preview_from_form_data(
+            {"viz_type": "bullet", "metric": "count"}, 7, "vega_lite"
+        )
+    assert isinstance(result, ChartError)
+    assert result.error_type == "QueryError"
+    assert "metric failed" in result.error
+
+
+def _unsaved_bullet_result_with_result(
+    result: object, form_data: dict[str, Any] | None = None, format_: str = "vega_lite"
+) -> ChartError | ASCIIPreview | VegaLitePreview:
+    dataset = SimpleNamespace(id=7)
+    factory = MagicMock()
+    factory.create.return_value = MagicMock()
+    command = MagicMock()
+    command.run.return_value = result
+    with (
+        patch("superset.extensions.db.session.get", return_value=dataset),
+        patch(
+            "superset.common.query_context_factory.QueryContextFactory",
+            return_value=factory,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        preview = generate_preview_from_form_data(
+            form_data or {"viz_type": "bullet", "metric": "Revenue"}, 7, format_
+        )
+    assert isinstance(preview, (ChartError, ASCIIPreview, VegaLitePreview))
+    return preview
+
+
+def _unsaved_bullet_preview_with_result(result: object) -> ChartError:
+    preview = _unsaved_bullet_result_with_result(result)
+    assert isinstance(preview, ChartError)
+    return preview
+
+
+@pytest.mark.parametrize("envelope", _MALFORMED_QUERY_ENVELOPES)
+def test_bullet_unsaved_preview_structures_malformed_envelopes(
+    envelope: object,
+) -> None:
+    preview = _unsaved_bullet_preview_with_result(envelope)
+    assert preview.error_type == "InvalidQueryResult"
+
+
+def test_bullet_unsaved_preview_structures_oversized_numeric_output() -> None:
+    preview = _unsaved_bullet_preview_with_result(
+        {"queries": [{"data": [{"Revenue": 10**10000}]}]}
+    )
+    assert preview.error_type == "InvalidQueryResult"
+
+
+def test_bullet_empty_saved_and_unsaved_vega_use_same_no_data_contract() -> None:
+    envelope: dict[str, Any] = {"queries": [{"data": []}]}
+    unsaved = _unsaved_bullet_result_with_result(envelope)
+    saved = _saved_bullet_result_with_result(envelope, "vega_lite")
+    assert isinstance(unsaved, VegaLitePreview)
+    assert isinstance(saved, VegaLitePreview)
+    for preview in (unsaved, saved):
+        assert preview.specification["data"]["values"] == [
+            {"Revenue": 0.0, "__mcp_bullet_category": "", "__mcp_bullet_row": 0}
+        ]
+        assert preview.specification["usermeta"]["bullet"]["ranges"] == [0.0, 0.0]
+
+    chart = SimpleNamespace(
+        id=9,
+        params=__import__("json").dumps(
+            {"viz_type": "bullet", "metric": "SavedRevenue"}
+        ),
+        viz_type="bullet",
+        slice_name="Saved Bullet",
+    )
+    strategy = VegaLitePreviewStrategy(
+        chart, GetChartPreviewRequest(identifier=9, format="vega_lite")
+    )
+    specification = strategy._create_vega_lite_spec([])
+    assert specification["data"]["values"] == [
+        {"SavedRevenue": 0.0, "__mcp_bullet_category": "", "__mcp_bullet_row": 0}
+    ]
+
+
+def _saved_bullet_result_with_result(
+    result: object,
+    format_: str,
+    form_data: dict[str, Any] | None = None,
+) -> ChartError | ASCIIPreview | VegaLitePreview:
+    form_data = form_data or {"viz_type": "bullet", "metric": "Revenue"}
+    chart = SimpleNamespace(
+        id=9,
+        params=__import__("json").dumps(form_data),
+        viz_type="bullet",
+        slice_name="Saved Bullet",
+        datasource_id=7,
+        datasource_type="table",
+    )
+    request = GetChartPreviewRequest(identifier=9, format=format_)
+    strategy = (
+        ASCIIPreviewStrategy(chart, request)
+        if format_ == "ascii"
+        else VegaLitePreviewStrategy(chart, request)
+    )
+    command = MagicMock()
+    command.run.return_value = result
+    with (
+        patch(
+            "superset.mcp_service.chart.tool.get_chart_preview."
+            "build_query_context_from_form_data",
+            return_value=MagicMock(),
+        ),
+        patch.object(strategy, "_authorize_guest_query"),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        preview = strategy.generate()
+    assert isinstance(preview, (ChartError, ASCIIPreview, VegaLitePreview))
+    return preview
+
+
+def _saved_bullet_preview_with_result(result: object, format_: str) -> ChartError:
+    preview = _saved_bullet_result_with_result(result, format_)
+    assert isinstance(preview, ChartError)
+    return preview
+
+
+@pytest.mark.parametrize("message", ["short\ud800error", "é\ud800中" * 2000])
+def test_bullet_query_paths_replacement_sanitize_surrogate_errors(
+    message: str,
+) -> None:
+    envelope = {"error": message}
+    compiled = _compile_bullet_with_result(envelope)
+    unsaved = _unsaved_bullet_preview_with_result(envelope)
+    saved = _saved_bullet_preview_with_result(envelope, "ascii")
+
+    assert "\ud800" not in (compiled.error or "")
+    assert "\ud800" not in unsaved.error
+    assert "\ud800" not in saved.error
+    assert len((compiled.error or "").encode("utf-8")) <= 2100
+    assert len(unsaved.error.encode("utf-8")) <= 2100
+    assert len(saved.error.encode("utf-8")) <= 2100
+
+
+@pytest.mark.parametrize("format_", ["ascii", "vega_lite"])
+@pytest.mark.parametrize("envelope", _MALFORMED_QUERY_ENVELOPES)
+def test_bullet_saved_preview_structures_malformed_envelopes(
+    envelope: object, format_: str
+) -> None:
+    preview = _saved_bullet_preview_with_result(envelope, format_)
+    assert preview.error_type == "InvalidQueryResult"
+
+
+@pytest.mark.parametrize("format_", ["ascii", "vega_lite"])
+def test_bullet_saved_preview_structures_oversized_numeric_output(format_: str) -> None:
+    preview = _saved_bullet_preview_with_result(
+        {"queries": [{"data": [{"Revenue": 10**10000}]}]}, format_
+    )
+    assert preview.error_type == "InvalidQueryResult"
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        _OutputHostileInt(12),
+        _OutputHostileFloat(12.5),
+        _OutputHostileStr("12.5"),
+        _OutputHostileDecimal("12.5"),
+    ],
+    ids=["int-subclass", "float-subclass", "str-subclass", "decimal-subclass"],
+)
+def test_bullet_output_rejects_scalar_subclasses_on_every_query_path(
+    value: object,
+) -> None:
+    envelope = {"queries": [{"data": [{"Revenue": value}]}]}
+    compiled = _compile_bullet_with_result(envelope)
+    unsaved = _unsaved_bullet_preview_with_result(envelope)
+    saved = _saved_bullet_preview_with_result(envelope, "vega_lite")
+
+    assert compiled.success is False
+    assert compiled.error_code == "CHART_COMPILE_FAILED"
+    assert unsaved.error_type == saved.error_type == "InvalidQueryResult"
+    assert len((compiled.error or "").encode("utf-8")) <= 2000
+    assert len(unsaved.error.encode("utf-8")) <= 2000
+    assert len(saved.error.encode("utf-8")) <= 2000
+
+
+@pytest.mark.parametrize(
+    "value",
+    [_OutputSafeIntEnum.VALUE, _OutputSafeStrEnum.VALUE],
+    ids=["int-enum", "str-enum"],
+)
+def test_bullet_output_uses_enum_backing_without_conversion(value: object) -> None:
+    model = resolve_bullet_render_model([{"Revenue": value}], {"metric": "Revenue"})
+    assert model.measures == [12.0 if isinstance(value, IntEnum) else 12.5]
+
+
+def test_bullet_dimension_rejects_custom_values_without_string_conversion() -> None:
+    class HostileDimension:
+        __repr__ = _reject_scalar_conversion
+        __str__ = _reject_scalar_conversion
+
+    with pytest.raises(BulletOutputError, match="unsupported value type"):
+        resolve_bullet_render_model(
+            [{"Region": HostileDimension(), "Revenue": 12}],
+            {"metric": "Revenue", "groupby": ["Region"]},
+        )
+
+
+def test_bullet_rejects_dict_subclass_rows_without_invoking_overrides() -> None:
+    class HostileRow(dict[str, object]):
+        __contains__ = _reject_scalar_conversion
+        __getitem__ = _reject_scalar_conversion
+        __iter__ = _reject_scalar_conversion
+        __len__ = _reject_scalar_conversion
+
+    envelope = {"queries": [{"data": [HostileRow(Revenue=12)]}]}
+    assert _compile_bullet_with_result(envelope).error_code == "CHART_COMPILE_FAILED"
+    assert _unsaved_bullet_preview_with_result(envelope).error_type == (
+        "InvalidQueryResult"
+    )
+
+
+def test_bullet_saved_table_preview_is_explicitly_unsupported() -> None:
+    chart = SimpleNamespace(
+        id=9,
+        params=__import__("json").dumps(
+            {"viz_type": "bullet", "metric": "SavedRevenue"}
+        ),
+        viz_type="bullet",
+        slice_name="Saved Bullet",
+    )
+    result = TablePreviewStrategy(
+        chart, GetChartPreviewRequest(identifier=9, format="table")
+    ).generate()
+    assert isinstance(result, ChartError)
+    assert result.error_type == "UnsupportedFormat"
+
+
+def test_bullet_capabilities_match_implemented_preview_and_data_roles() -> None:
+    ungrouped = BulletChartConfig(metric=_simple_metric())
+    grouped = BulletChartConfig(
+        metric=_simple_metric(), dimensions=[{"name": "Region"}]
+    )
+    assert analyze_chart_capabilities("bullet", ungrouped).model_dump() == {
+        "supports_interaction": True,
+        "supports_real_time": False,
+        "supports_drill_down": False,
+        "supports_export": True,
+        "optimal_formats": ["url", "vega_lite", "ascii"],
+        "data_types": ["metric"],
+    }
+    assert analyze_chart_capabilities("bullet", grouped).data_types == [
+        "metric",
+        "categorical",
+    ]
+
+
+def test_bullet_saved_preview_uses_native_roles_aliases_and_overlays() -> None:
+    form_data = {
+        "viz_type": "bullet",
+        "metric": {
+            "aggregate": "SUM",
+            "column": {"column_name": "revenue_amount"},
+            "expressionType": "SIMPLE",
+            "label": "Total Revenue",
+        },
+        "groupby": ["Region", "Team"],
+        "ranges": "100,250",
+        "markers": "200",
+        "marker_lines": "225",
+    }
+    chart = SimpleNamespace(
+        id=9,
+        params=__import__("json").dumps(form_data),
+        viz_type="bullet",
+        slice_name="Saved Bullet",
+    )
+    strategy = VegaLitePreviewStrategy(
+        chart,
+        GetChartPreviewRequest(identifier=9, format="vega_lite"),
+    )
+    specification = strategy._create_vega_lite_spec(
+        [{"Region": "North", "Team": "Blue", "Total Revenue": 123}]
+    )
+
+    assert specification["data"]["values"][0]["__mcp_bullet_category"] == (
+        "North, Blue"
+    )
+    assert "transform" not in specification
+    bar_layer = next(
+        layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+    )
+    assert bar_layer["encoding"]["x"]["field"] == "Total Revenue"
+    assert bar_layer["encoding"]["y"]["field"] == "__mcp_bullet_row"
+    range_layers = [
+        layer for layer in specification["layer"] if layer["mark"]["type"] == "rect"
+    ]
+    assert range_layers
+    assert all(
+        layer["encoding"]["y"] == bar_layer["encoding"]["y"] for layer in range_layers
+    )
+    assert {layer["mark"]["type"] for layer in specification["layer"]} == {
+        "rect",
+        "bar",
+        "point",
+        "rule",
+        "text",
+    }
+
+
+def test_bullet_saved_sql_metric_without_label_uses_expression_result_field() -> None:
+    expression = "SUM(revenue) / COUNT(*)"
+    preview = _generate_vega_lite_preview_from_data(
+        [{"Region": "North", expression: 12.5}],
+        {
+            "viz_type": "bullet",
+            "metric": {
+                "expressionType": "SQL",
+                "sqlExpression": expression,
+            },
+            "groupby": ["Region"],
+            "ranges": "10,20",
+        },
+    )
+
+    bar = next(
+        layer
+        for layer in preview.specification["layer"]
+        if layer["mark"]["type"] == "bar"
+    )
+    assert bar["encoding"]["x"]["field"] == expression
+
+
+def test_bullet_update_paths_preserve_omitted_native_state() -> None:
+    existing = {
+        "viz_type": "bullet",
+        "metric": "old_metric",
+        "groupby": ["Region"],
+        "ranges": "10,20",
+        "markers": "15",
+        "show_labels": True,
+        "show_legend": True,
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "Status",
+                "operator": "==",
+                "comparator": "Active",
+            }
+        ],
+    }
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+    )
+    request = UpdateChartRequest(
+        identifier=9,
+        config=BulletChartConfig(metric=_simple_metric("NewRevenue")),
+        generate_preview=False,
+    )
+    payload = _build_update_payload(request, chart, request.config)
+    assert isinstance(payload, dict)
+    persisted = __import__("json").loads(payload["params"])
+    assert persisted["metric"]["column"]["column_name"] == "NewRevenue"
+    assert persisted["groupby"] == ["Region"]
+    assert persisted["ranges"] == "10,20"
+    assert persisted["markers"] == "15"
+    assert persisted["show_labels"] is True
+    assert persisted["adhoc_filters"] == existing["adhoc_filters"]
+
+    preview = _build_preview_form_data(request, chart, request.config)
+    assert isinstance(preview, dict)
+    assert preview["ranges"] == "10,20"
+    assert preview["show_legend"] is True
+
+
+def test_bullet_update_paths_honor_explicit_clear_and_false() -> None:
+    existing = {
+        "viz_type": "bullet",
+        "metric": "old_metric",
+        "groupby": ["Region"],
+        "ranges": "10,20",
+        "show_labels": True,
+        "adhoc_filters": [{"subject": "Status"}],
+    }
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+    )
+    config = BulletChartConfig(
+        metric=_simple_metric(),
+        dimensions=[],
+        filters=[],
+        ranges=[],
+        show_labels=False,
+    )
+    request = UpdateChartRequest(identifier=9, config=config, generate_preview=False)
+    payload = _build_update_payload(request, chart, config)
+    assert isinstance(payload, dict)
+    persisted = __import__("json").loads(payload["params"])
+    assert persisted["groupby"] == []
+    assert persisted["ranges"] == ""
+    assert persisted["show_labels"] is False
+    assert persisted["adhoc_filters"] == []
+
+
+def _saved_bullet_with_filters() -> dict[str, object]:
+    return {
+        "viz_type": "bullet",
+        "metric": "SavedRevenue",
+        "groupby": ["Region"],
+        "time_range": "Last year",
+        MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "Status",
+                "operator": "==",
+                "comparator": "Active",
+            },
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "OrderDate",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "Last year",
+            },
+        ],
+    }
+
+
+def _saved_bullet_with_opaque_filters() -> dict[str, object]:
+    return {
+        "viz_type": "bullet",
+        "metric": "SavedRevenue",
+        "groupby": ["Region"],
+        MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SQL",
+                "sqlExpression": "Status = 'Active'  ",
+            },
+            {
+                "clause": "HAVING",
+                "expressionType": "SIMPLE",
+                "subject": "SavedRevenue",
+                "operator": ">",
+                "comparator": 100,
+            },
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "OrderDate",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "No filter",
+            },
+        ],
+    }
+
+
+@pytest.mark.parametrize("binding_index", [0, 1, 2, None])
+@pytest.mark.parametrize("preview_first", [False, True])
+def test_bullet_omitted_filters_preserve_exact_native_sequence_at_every_position(
+    binding_index: int | None, preview_first: bool
+) -> None:
+    opaque = _saved_bullet_with_opaque_filters()
+    raw_filters = opaque["adhoc_filters"]
+    assert isinstance(raw_filters, list)
+    filters = list(raw_filters)
+    binding = filters.pop()
+    if binding_index is not None:
+        filters.insert(binding_index, binding)
+    existing = {**opaque, "adhoc_filters": filters}
+    if binding_index is None:
+        existing.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+    )
+    config = BulletChartConfig(metric=_simple_metric("Revenue"))
+    request = UpdateChartRequest(identifier=9, config=config)
+    with patch(
+        "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+        return_value=_orm_dataset(),
+    ):
+        if preview_first:
+            merged = _build_preview_form_data(request, chart, config)
+            assert isinstance(merged, dict)
+        else:
+            payload = _build_update_payload(request, chart, config)
+            assert isinstance(payload, dict)
+            merged = __import__("json").loads(payload["params"])
+
+    assert merged["adhoc_filters"] == filters
+
+    cached = map_config_to_form_data(config, dataset_id=7)
+    merge_update_form_data(existing, cached, config)
+    assert cached["adhoc_filters"] == filters
+
+
+def test_bullet_temporal_binding_override_replaces_in_place() -> None:
+    existing = _saved_bullet_with_opaque_filters()
+    raw_filters = existing["adhoc_filters"]
+    assert isinstance(raw_filters, list)
+    filters = list(raw_filters)
+    binding = filters.pop()
+    filters.insert(1, binding)
+    existing["adhoc_filters"] = filters
+    config = BulletChartConfig(
+        metric=_simple_metric(),
+        temporal_column="EventDate",
+        time_range="Last 30 days",
+    )
+    with patch(
+        "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+        return_value=True,
+    ):
+        mapped = map_config_to_form_data(config, dataset_id=7)
+    merge_update_form_data(existing, mapped, config)
+
+    assert mapped["adhoc_filters"][0] == filters[0]
+    assert mapped["adhoc_filters"][2] == filters[2]
+    assert mapped["adhoc_filters"][1]["subject"] == "EventDate"
+    assert mapped["adhoc_filters"][1]["comparator"] == "Last 30 days"
+
+
+@pytest.mark.parametrize(
+    ("time_range", "expected_comparator"),
+    [("Last 30 days", "Last 30 days"), (None, "No filter")],
+)
+@pytest.mark.parametrize("path", ["immediate", "preview_first", "cached"])
+def test_bullet_time_range_only_updates_saved_subject_on_every_update_path(
+    time_range: str | None, expected_comparator: str, path: str
+) -> None:
+    existing = _saved_bullet_with_filters()
+    existing[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = "EventDate"
+    existing_filters = existing["adhoc_filters"]
+    assert isinstance(existing_filters, list)
+    existing_filters[1] = {**existing_filters[1], "subject": "EventDate"}
+    config = BulletChartConfig(metric=_simple_metric(), time_range=time_range)
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+    )
+    request = UpdateChartRequest(identifier=9, config=config)
+
+    with (
+        patch(
+            "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+            return_value=_orm_dataset(),
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+            return_value=True,
+        ),
+    ):
+        if path == "immediate":
+            payload = _build_update_payload(request, chart, config)
+            assert isinstance(payload, dict)
+            merged = __import__("json").loads(payload["params"])
+        elif path == "preview_first":
+            result = _build_preview_form_data(request, chart, config)
+            assert isinstance(result, dict)
+            merged = result
+        else:
+            merged = map_config_to_form_data(config, dataset_id=7)
+            merge_update_form_data(existing, merged, config)
+
+    assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+    assert [item["subject"] for item in merged["adhoc_filters"]] == [
+        "Status",
+        "EventDate",
+    ]
+    assert merged["adhoc_filters"][1]["comparator"] == expected_comparator
+
+
+@pytest.mark.parametrize(
+    ("time_range", "expected_comparator"),
+    [("Last month", "Last month"), (None, "No filter")],
+)
+@pytest.mark.parametrize("path", ["immediate", "preview_first", "cached"])
+def test_bullet_range_only_update_adopts_single_unmarked_native_binding(
+    time_range: str | None, expected_comparator: str, path: str
+) -> None:
+    # Explore saved the time filter on a non-main column with no MCP marker.
+    existing = _saved_bullet_with_filters()
+    existing.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    existing_filters = existing["adhoc_filters"]
+    assert isinstance(existing_filters, list)
+    existing_filters[1] = {**existing_filters[1], "subject": "EventDate"}
+    config = BulletChartConfig(metric=_simple_metric(), time_range=time_range)
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+    )
+    request = UpdateChartRequest(identifier=9, config=config)
+
+    with (
+        patch(
+            "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+            return_value=_orm_dataset(),
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+            return_value=True,
+        ),
+    ):
+        if path == "immediate":
+            payload = _build_update_payload(request, chart, config)
+            assert isinstance(payload, dict)
+            merged = __import__("json").loads(payload["params"])
+        elif path == "preview_first":
+            result = _build_preview_form_data(request, chart, config)
+            assert isinstance(result, dict)
+            merged = result
+        else:
+            merged = map_config_to_form_data(config, dataset_id=7)
+            merge_update_form_data(existing, merged, config)
+
+    assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+    assert [item["subject"] for item in merged["adhoc_filters"]] == [
+        "Status",
+        "EventDate",
+    ]
+    assert merged["adhoc_filters"][1]["comparator"] == expected_comparator
+
+
+def test_bullet_range_only_update_keeps_several_unmarked_native_bindings() -> None:
+    # Several unmarked native ranges are ambiguous, so none is adopted: the
+    # saved predicates stay untouched and the dataset-default binding is added.
+    existing = _saved_bullet_with_filters()
+    existing.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    filters = existing["adhoc_filters"]
+    assert isinstance(filters, list)
+    filters[1] = {**filters[1], "subject": "EventDate"}
+    filters.append({**filters[1], "comparator": "Last week"})
+    saved = [dict(filter_) for filter_ in filters]
+    config = BulletChartConfig(metric=_simple_metric(), time_range="Last month")
+    with (
+        patch(
+            "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+            return_value=_orm_dataset(),
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+            return_value=True,
+        ),
+    ):
+        mapped = map_config_to_form_data(config, dataset_id=7)
+    merge_update_form_data(existing, mapped, config)
+
+    assert mapped["adhoc_filters"][:3] == saved
+    assert mapped["adhoc_filters"][3]["subject"] == "OrderDate"
+    assert mapped["adhoc_filters"][3]["comparator"] == "Last month"
+    assert mapped[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "OrderDate"
+
+
+def test_bullet_subject_only_update_preserves_saved_active_range() -> None:
+    existing = _saved_bullet_with_filters()
+    config = BulletChartConfig(metric=_simple_metric(), temporal_column="EventDate")
+    with patch(
+        "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+        return_value=True,
+    ):
+        mapped = map_config_to_form_data(config, dataset_id=7)
+    merge_update_form_data(existing, mapped, config)
+    assert mapped[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+    assert mapped["adhoc_filters"][1]["subject"] == "EventDate"
+    assert mapped["adhoc_filters"][1]["comparator"] == "Last year"
+
+
+def test_bullet_rejects_ambiguous_provenance_subject_operator_matches() -> None:
+    existing = _saved_bullet_with_filters()
+    filters = existing["adhoc_filters"]
+    assert isinstance(filters, list)
+    filters.append({**filters[1], "comparator": "Last month"})
+    config = BulletChartConfig(metric=_simple_metric())
+    with pytest.raises(ValueError, match="must match exactly one"):
+        merge_update_form_data(existing, map_bullet_config(config), config)
+
+    with pytest.raises(ValidationError, match="must match exactly one"):
+        BulletChartConfig.model_validate(existing)
+
+
+def test_bullet_omitted_filters_preserve_missing_native_filter_key() -> None:
+    existing = {"viz_type": "bullet", "metric": "SavedRevenue"}
+    config = BulletChartConfig(metric=_simple_metric())
+    mapped = {
+        "viz_type": "bullet",
+        "metric": "new",
+        MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "OrderDate",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "No filter",
+            }
+        ],
+    }
+
+    merge_update_form_data(existing, mapped, config)
+
+    assert "adhoc_filters" not in mapped
+    assert MCP_DASHBOARD_TIME_FILTER_SUBJECT not in mapped
+
+
+@pytest.mark.parametrize("preview_first", [False, True])
+def test_bullet_update_paths_preserve_opaque_filters_byte_for_byte(
+    preview_first: bool,
+) -> None:
+    existing = _saved_bullet_with_opaque_filters()
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+    )
+    config = BulletChartConfig(metric=_simple_metric("Revenue"))
+    request = UpdateChartRequest(identifier=9, config=config)
+    with (
+        patch(
+            "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+            return_value=_orm_dataset(),
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+            return_value=True,
+        ),
+    ):
+        if preview_first:
+            merged = _build_preview_form_data(request, chart, config)
+            assert isinstance(merged, dict)
+        else:
+            payload = _build_update_payload(request, chart, config)
+            assert isinstance(payload, dict)
+            merged = __import__("json").loads(payload["params"])
+
+    assert merged["adhoc_filters"] == existing["adhoc_filters"]
+    assert validate_merged_bullet_form_data(merged, config) is not None
+
+
+@pytest.mark.parametrize("preview_first", [False, True])
+def test_bullet_update_from_other_viz_ignores_preserved_unmodeled_keys(
+    preview_first: bool,
+) -> None:
+    """Registry-preserved presentation/time keys are not typed Bullet input."""
+    existing = {
+        "viz_type": "pie",
+        "datasource": "7__table",
+        "metric": "count",
+        "groupby": ["region"],
+        "color_scheme": "bnbColors",
+        "number_format": ",d",
+        "legendOrientation": "top",
+        "date_format": "smart_date",
+        "currency_format": {"symbol": "USD", "symbolPosition": "prefix"},
+        "linear_color_scheme": "blue_white_yellow",
+        "time_grain_sqla": "P1D",
+        "url_params": {},
+        "slice_id": 9,
+    }
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Pie",
+        params=__import__("json").dumps(existing),
+    )
+    config = BulletChartConfig(metric=_simple_metric("Revenue"))
+    request = UpdateChartRequest(identifier=9, config=config)
+    with patch(
+        "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+        return_value=_orm_dataset(),
+    ):
+        if preview_first:
+            merged = _build_preview_form_data(request, chart, config)
+            assert isinstance(merged, dict)
+        else:
+            payload = _build_update_payload(request, chart, config)
+            assert isinstance(payload, dict)
+            merged = __import__("json").loads(payload["params"])
+
+    assert merged["color_scheme"] == "bnbColors"
+    assert merged["legendOrientation"] == "top"
+    validated = validate_merged_bullet_form_data(merged, config)
+    assert validated is not None
+    assert validated.metric.name == "Revenue"
+    # Unrelated typos remain rejected rather than silently dropped.
+    with pytest.raises(ValidationError, match="Unknown field 'colour_scheme'"):
+        validate_merged_bullet_form_data({**merged, "colour_scheme": "x"}, config)
+
+
+def test_bullet_filter_provenance_keeps_strict_replacement_validation() -> None:
+    existing = _saved_bullet_with_opaque_filters()
+    config = BulletChartConfig(metric=_simple_metric(), filters=[])
+    mapped = map_bullet_config(config)
+    merge_update_form_data(existing, mapped, config)
+    assert mapped["adhoc_filters"] == []
+
+    # A native SQL filter cannot masquerade as an explicitly supplied typed
+    # replacement: only omitted, provenance-preserved filters get that path.
+    existing_filters = existing["adhoc_filters"]
+    assert isinstance(existing_filters, list)
+    mapped["adhoc_filters"] = [existing_filters[0]]
+    with pytest.raises(ValidationError, match="expressionType='SIMPLE'"):
+        validate_merged_bullet_form_data(mapped, config)
+
+
+@pytest.mark.parametrize("preview_first", [False, True])
+def test_bullet_saved_update_paths_preserve_omitted_user_filters_with_binding(
+    preview_first: bool,
+) -> None:
+    existing = _saved_bullet_with_filters()
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+    )
+    config = BulletChartConfig(metric=_simple_metric("Revenue"))
+    request = UpdateChartRequest(identifier=9, config=config)
+    with (
+        patch(
+            "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+            return_value=_orm_dataset(),
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+            return_value=True,
+        ),
+    ):
+        if preview_first:
+            merged = _build_preview_form_data(request, chart, config)
+            assert isinstance(merged, dict)
+        else:
+            payload = _build_update_payload(request, chart, config)
+            assert isinstance(payload, dict)
+            merged = __import__("json").loads(payload["params"])
+
+    assert merged["adhoc_filters"] == existing["adhoc_filters"]
+    assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "OrderDate"
+
+
+@pytest.mark.parametrize(
+    ("filters", "expected_subjects"),
+    [
+        ([], []),
+        (
+            [{"column": "Status", "op": "=", "value": "Inactive"}],
+            ["Status", "OrderDate"],
+        ),
+    ],
+)
+def test_bullet_filter_clear_and_replacement_are_authoritative(
+    filters: list[dict[str, object]], expected_subjects: list[str]
+) -> None:
+    existing = _saved_bullet_with_filters()
+    config = BulletChartConfig(metric=_simple_metric("Revenue"), filters=filters)
+    mapped: dict[str, Any] = {"viz_type": "bullet", "metric": "new"}
+    if filters:
+        mapped["adhoc_filters"] = [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "Status",
+                "operator": "==",
+                "comparator": "Inactive",
+            }
+        ]
+    merge_update_form_data(existing, mapped, config)
+    assert [item["subject"] for item in mapped["adhoc_filters"]] == expected_subjects
+
+
+def test_bullet_temporal_override_replaces_only_provenanced_binding() -> None:
+    existing = _saved_bullet_with_filters()
+    config = BulletChartConfig(
+        metric=_simple_metric("Revenue"),
+        temporal_column="EventDate",
+        time_range="Last 30 days",
+    )
+    with (
+        patch(
+            "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+            return_value=_orm_dataset(),
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+            return_value=True,
+        ),
+    ):
+        mapped = map_config_to_form_data(config, dataset_id=7)
+    merge_update_form_data(existing, mapped, config)
+    assert [item["subject"] for item in mapped["adhoc_filters"]] == [
+        "Status",
+        "EventDate",
+    ]
+    assert mapped["adhoc_filters"][1]["comparator"] == "Last 30 days"
+    assert mapped[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+
+
+@pytest.mark.parametrize("path", ["immediate", "preview_first", "cached"])
+def test_bullet_filter_only_update_ignores_mapper_temporal_fallback(path: str) -> None:
+    existing = _saved_bullet_with_filters()
+    config = BulletChartConfig(
+        metric=_simple_metric(),
+        filters=[{"column": "Status", "op": "=", "value": "Inactive"}],
+    )
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+    )
+    request = UpdateChartRequest(identifier=9, config=config)
+    dataset = _orm_dataset()
+    dataset.main_dttm_col = "EventDate"
+    with (
+        patch(
+            "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+            return_value=dataset,
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+            return_value=True,
+        ),
+    ):
+        if path == "immediate":
+            payload = _build_update_payload(request, chart, config)
+            assert isinstance(payload, dict)
+            merged = __import__("json").loads(payload["params"])
+        elif path == "preview_first":
+            result = _build_preview_form_data(request, chart, config)
+            assert isinstance(result, dict)
+            merged = result
+        else:
+            merged = map_config_to_form_data(config, dataset_id=7)
+            merge_update_form_data(existing, merged, config)
+
+    assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "OrderDate"
+    assert [filter_["subject"] for filter_ in merged["adhoc_filters"]] == [
+        "Status",
+        "OrderDate",
+    ]
+    assert merged["adhoc_filters"][1]["comparator"] == "Last year"
+
+
+def test_xy_native_axis_change_replaces_obsolete_provenanced_binding() -> None:
+    existing = {
+        "viz_type": "echarts_timeseries_line",
+        "x_axis": "OrderDate",
+        "granularity_sqla": "OrderDate",
+        MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "OrderDate",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "Last year",
+            }
+        ],
+    }
+    config = XYChartConfig(
+        x={"name": "EventDate"},
+        y=[_simple_metric()],
+        filters=[{"column": "Status", "op": "=", "value": "Active"}],
+    )
+    with patch(
+        "superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
+        return_value=True,
+    ):
+        mapped = map_config_to_form_data(config, dataset_id=7)
+    merge_update_form_data(existing, mapped, config)
+
+    assert mapped[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+    assert [filter_["subject"] for filter_ in mapped["adhoc_filters"]] == [
+        "Status",
+        "EventDate",
+    ]
+    assert mapped["adhoc_filters"][1]["comparator"] == "Last year"
+
+
+def test_bullet_presentation_updates_are_atomic_and_comma_safe() -> None:
+    with pytest.raises(ValidationError, match="cannot contain commas"):
+        BulletChartConfig(
+            metric=_simple_metric(), ranges=[1], range_labels=["Low, medium"]
+        )
+
+    partial = BulletChartConfig.model_validate(
+        {
+            "viz_type": "bullet",
+            "metric": "SavedRevenue",
+            "ranges": "10,20,30",
+            "range_labels": "Low,,High",
+        }
+    )
+    assert partial.range_labels == ["Low", "", "High"]
+    assert map_bullet_config(partial)["range_labels"] == "Low,,High"
+
+    existing = {
+        "viz_type": "bullet",
+        "metric": "SavedRevenue",
+        "ranges": "10,20",
+        "range_labels": "Low,High",
+        "markers": "15,18",
+        "marker_labels": "Plan,Stretch",
+        "marker_lines": "12,16",
+        "marker_line_labels": "Goal,Forecast",
+    }
+    preserved = BulletChartPlugin().merge_update_form_data(
+        existing,
+        map_bullet_config(BulletChartConfig(metric=_simple_metric())),
+        BulletChartConfig(metric=_simple_metric()),
+        dataset_rebind=False,
+    )
+    assert preserved is not None
+    for control in ("range_labels", "marker_labels", "marker_line_labels"):
+        assert preserved[control] == existing[control]
+    config = BulletChartConfig(
+        metric=_simple_metric(), ranges=[30], markers=[25], marker_lines=[28]
+    )
+    mapped = BulletChartPlugin().merge_update_form_data(
+        existing, map_bullet_config(config), config, dataset_rebind=False
+    )
+    assert mapped is not None
+    assert mapped["ranges"] == "30"
+    assert mapped["range_labels"] == ""
+    assert mapped["markers"] == "25"
+    assert mapped["marker_labels"] == ""
+    assert mapped["marker_lines"] == "28"
+    assert mapped["marker_line_labels"] == ""
+    assert validate_merged_bullet_form_data(mapped) is not None
+
+    stale = dict(existing)
+    stale["ranges"] = "10"
+    assert validate_merged_bullet_form_data(stale) is not None
+
+
+def test_mapping_other_registered_chart_type_is_unchanged() -> None:
+    from superset.mcp_service.chart.schemas import PieChartConfig
+
+    form_data = map_config_to_form_data(
+        PieChartConfig(dimension={"name": "region"}, metric=_simple_metric("revenue"))
+    )
+    assert form_data["viz_type"] == "pie"
+    assert form_data["groupby"] == ["region"]
+
+
+@pytest.mark.asyncio
+async def test_generate_chart_tool_emits_native_bullet_form_data() -> None:
+    request = GenerateChartRequest(
+        dataset_id=7,
+        config=BulletChartConfig(
+            metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+            dimensions=[{"name": "Region"}],
+            ranges=[100, 250],
+            markers=[200],
+        ),
+        preview_formats=["url"],
+    )
+    validation_result = SimpleNamespace(
+        is_valid=True,
+        request=request,
+        warnings={},
+        error=None,
+    )
+    ctx = MagicMock()
+    ctx.info = AsyncMock()
+    ctx.debug = AsyncMock()
+    ctx.warning = AsyncMock()
+    ctx.error = AsyncMock()
+    ctx.report_progress = AsyncMock()
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=_tool_user(),
+        ),
+        patch(
+            "superset.mcp_service.chart.validation.ValidationPipeline."
+            "validate_request_with_warnings",
+            return_value=validation_result,
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils.generate_explore_link",
+            return_value=(
+                "http://localhost:8088/explore/?form_data_key=bullet_preview_key"
+            ),
+        ),
+        patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=None),
+    ):
+        result = await generate_chart(request, ctx=ctx)
+
+    assert result.success is True
+    assert result.form_data["viz_type"] == "bullet"
+    assert result.form_data["groupby"] == ["Region"]
+    assert result.form_data["ranges"] == "100,250"
+    assert result.form_data["markers"] == "200"
+
+
+@pytest.mark.asyncio
+async def test_generate_chart_creates_bullet_with_dimensions_omitted() -> None:
+    """Exercise schema revalidation, normalization and creation without dimensions."""
+    from superset.mcp_service.chart.compile import CompileResult
+
+    raw_config = {
+        "chart_type": "bullet",
+        "metric": {"name": "sales", "aggregate": "SUM"},
+        "ranges": [100000, 300000, 600000],
+        "range_labels": ["Low", "Medium", "High"],
+        "markers": [400000],
+        "marker_labels": ["Target"],
+        "show_labels": True,
+    }
+    assert "dimensions" not in raw_config
+    assert BulletChartConfig.model_validate(raw_config).dimensions is None
+    request = GenerateChartRequest(
+        dataset_id=7, config=raw_config, save_chart=True, generate_preview=False
+    )
+    dataset_context = DatasetContext(
+        id=7,
+        table_name="sales",
+        schema=None,
+        database_name="main",
+        available_columns=[{"name": "sales", "type": "NUMERIC", "is_numeric": True}],
+        available_metrics=[],
+    )
+    dataset = MagicMock(id=7, datasource_name="sales", table_name="sales", sql=None)
+    chart = SimpleNamespace(
+        id=9, datasource_id=7, slice_name="Sales", viz_type="bullet", uuid=None
+    )
+    ctx = MagicMock()
+    for method in ("info", "debug", "warning", "error", "report_progress"):
+        setattr(ctx, method, AsyncMock())
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request", return_value=_tool_user()
+        ),
+        patch.object(
+            DatasetValidator, "_get_dataset_context", return_value=dataset_context
+        ),
+        patch.object(
+            ValidationPipeline, "_validate_runtime", return_value=(True, None)
+        ),
+        patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset),
+        patch(
+            "superset.mcp_service.chart.tool.generate_chart.has_dataset_access",
+            return_value=True,
+        ),
+        patch("superset.mcp_service.auth.has_dataset_access", return_value=True),
+        patch(
+            "superset.mcp_service.chart.tool.generate_chart._compile_chart",
+            return_value=CompileResult(success=True, warnings=[]),
+        ),
+        patch("superset.commands.chart.create.CreateChartCommand") as command,
+        patch("superset.db.session"),
+        patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart),
+        patch(
+            "superset.mcp_service.chart.schemas.serialize_chart_object",
+            return_value=ChartInfo(id=9, slice_name="Sales", viz_type="bullet"),
+        ),
+        patch(
+            "superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand",
+            return_value=MagicMock(run=MagicMock(return_value="bullet-key")),
+        ),
+    ):
+        command.return_value.run.return_value = chart
+        result = await generate_chart(request, ctx=ctx)
+
+    assert result.success is True, result.error
+    command.assert_called_once()
+    persisted = __import__("json").loads(command.call_args.args[0]["params"])
+    assert persisted.get("groupby", []) == []
+    assert persisted["metric"]["column"]["column_name"] == "sales"
+    assert persisted["ranges"] == "100000,300000,600000"
+    assert persisted["show_labels"] is True
+
+
+@pytest.mark.parametrize(
+    ("aliases", "expected"),
+    [
+        ({"dimensions": None}, []),
+        ({"groupby": None}, []),
+        ({"dimensions": None, "groupby": None}, []),
+        ({"dimensions": None, "groupby": ["Region"]}, ["Region"]),
+        ({"dimensions": ["Region"], "groupby": None}, ["Region"]),
+        ({"dimensions": [], "groupby": None}, []),
+    ],
+)
+def test_bullet_null_dimension_alias_is_absent(
+    aliases: dict[str, Any], expected: list[str]
+) -> None:
+    """Null aliases neither conflict with nor shadow a populated hierarchy."""
+    config = BulletChartConfig.model_validate({"metric": _simple_metric(), **aliases})
+    assert [column.name for column in config.dimensions or []] == expected
+    if not expected and aliases.get("dimensions") != []:
+        assert "dimensions" not in config.model_fields_set
+
+
+@pytest.mark.parametrize("dataset_rebind", [False])
+@pytest.mark.parametrize("order_by", [[], [{"column": "Region", "ascending": True}]])
+@pytest.mark.parametrize("sql_dimension", [False, True])
+def test_update_chart_preview_tool_preserves_omitted_bullet_state(
+    order_by: list[dict[str, Any]],
+    dataset_rebind: bool,
+    sql_dimension: bool,
+) -> None:
+    dimension: str | dict[str, Any] = (
+        {
+            "expressionType": "SQL",
+            "sqlExpression": "UPPER(Region)",
+            "label": "RegionUpper",
+        }
+        if sql_dimension
+        else "Region"
+    )
+    if sql_dimension and order_by:
+        order_by = [{"column": "RegionUpper", "ascending": True}]
+    request = UpdateChartPreviewRequest(
+        form_data_key="previous_bullet_key",
+        dataset_id=7,
+        config=BulletChartConfig(metric=_simple_metric("revenue"), order_by=order_by),
+        generate_preview=False,
+    )
+    dataset = _orm_dataset()
+    previous: dict[str, Any] = {
+        "datasource": "6__table" if dataset_rebind else "7__table",
+        "viz_type": "bullet",
+        "metric": "old_metric",
+        "groupby": [dimension, "Team"],
+        "ranges": "100,250",
+        "show_labels": True,
+        MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SQL",
+                "sqlExpression": "Status = 'Active'  ",
+            },
+            {
+                "clause": "HAVING",
+                "expressionType": "SIMPLE",
+                "subject": "SavedRevenue",
+                "operator": ">",
+                "comparator": 100,
+            },
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "OrderDate",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "No filter",
+            },
+        ],
+    }
+    if dataset_rebind:
+        previous["adhoc_filters"] = previous["adhoc_filters"][1:]
+    link = MagicMock(
+        return_value="http://localhost:8088/explore/?form_data_key=new_bullet_key"
+    )
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=_tool_user(),
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart_preview._find_dataset",
+            return_value=dataset,
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart_preview."
+            "_get_previous_form_data",
+            return_value=previous,
+        ),
+        patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart_preview.has_dataset_access",
+            return_value=True,
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+            return_value=True,
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart_preview.validate_and_compile",
+            return_value=SimpleNamespace(success=True),
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart_preview."
+            "generate_explore_link",
+            link,
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart_preview."
+            "analyze_chart_capabilities",
+            return_value=None,
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart_preview."
+            "analyze_chart_semantics",
+            return_value=None,
+        ),
+    ):
+        result = asyncio.run(update_chart_preview(request, ctx=MagicMock()))
+
+    assert result["success"] is True, result
+    preview_form_data = link.call_args.args[1]
+    assert preview_form_data["metric"]["column"]["column_name"] == "Revenue"
+    assert preview_form_data["groupby"] == [dimension, "Team"]
+    assert preview_form_data["ranges"] == "100,250"
+    assert preview_form_data["show_labels"] is True
+    assert preview_form_data["adhoc_filters"] == previous["adhoc_filters"]
+
+    if order_by:
+        assert preview_form_data["orderby"] == [[dimension, True]]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("dimensions", [[], ["Region"]])
+@pytest.mark.parametrize("sort_update", [False, True])
+@pytest.mark.parametrize("aliases", [{}, {"dimensions": None}, {"groupby": None}])
+async def test_update_chart_tool_persists_native_bullet_round_trip(
+    dimensions: list[str], aliases: dict[str, Any], sort_update: bool
+) -> None:
+    """Omitted/null dimensions preserve both empty and populated saved hierarchies."""
+    existing = {
+        "viz_type": "bullet",
+        "metric": "old_metric",
+        "groupby": dimensions,
+        "ranges": "100,250",
+        "range_labels": "Low",
+        "show_legend": True,
+    }
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+        viz_type="bullet",
+        uuid="bullet-uuid",
+    )
+    updated_chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        viz_type="bullet",
+        uuid="bullet-uuid",
+    )
+    command = MagicMock()
+    command.return_value.run.return_value = updated_chart
+    request = UpdateChartRequest(
+        identifier=9,
+        config={
+            "chart_type": "bullet",
+            "metric": _simple_metric("NewRevenue"),
+            **aliases,
+            **(
+                {"order_by": [{"column": "Region", "ascending": True}]}
+                if sort_update and dimensions
+                else {}
+            ),
+        },
+        generate_preview=False,
+        preview_formats=[],
+    )
+    ctx = MagicMock()
+    ctx.warning = AsyncMock()
+    ctx.error = AsyncMock()
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=_tool_user(),
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart.find_chart_by_identifier",
+            return_value=chart,
+        ),
+        patch(
+            "superset.mcp_service.auth.check_chart_data_access",
+            return_value=SimpleNamespace(is_valid=True, error=None),
+        ),
+        patch.object(
+            DatasetValidator,
+            "normalize_column_names",
+            side_effect=lambda config, dataset_id: config,
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart."
+            "_validate_update_against_dataset",
+            return_value=None,
+        ),
+        patch("superset.commands.chart.update.UpdateChartCommand", command),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart.analyze_chart_capabilities",
+            return_value=None,
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart.analyze_chart_semantics",
+            return_value=None,
+        ),
+    ):
+        result = await update_chart(request, ctx=ctx)
+
+    assert result.success is True
+    payload = command.call_args.args[1]
+    persisted = __import__("json").loads(payload["params"])
+    assert persisted["metric"]["column"]["column_name"] == "NewRevenue"
+    assert persisted["groupby"] == dimensions
+    assert persisted["ranges"] == "100,250"
+    assert persisted["range_labels"] == "Low"
+    assert persisted["show_legend"] is True
+
+    if sort_update and dimensions:
+        assert persisted["orderby"] == [["Region", True]]
+
+
+def test_bullet_exact_case_dimensions_survive_normalization_and_query() -> None:
+    from superset.mcp_service.chart.registry import get
+
+    context = DatasetContext(
+        id=7,
+        table_name="sales",
+        schema=None,
+        database_name="main",
+        available_columns=[
+            {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+            {"name": "Region", "type": "VARCHAR"},
+            {"name": "region", "type": "VARCHAR"},
+        ],
+        available_metrics=[],
+    )
+    config = BulletChartConfig(
+        metric={"name": "Revenue", "aggregate": "SUM", "label": "REGION"},
+        dimensions=[{"name": "Region"}, {"name": "region"}],
+        order_by=[{"column": "Region"}, {"column": "region"}],
+    )
+    plugin = get("bullet")
+    assert plugin is not None
+    normalized = plugin.normalize_column_refs(config, context)
+    assert [item.column for item in normalized.order_by] == ["Region", "region"]
+    form_data = map_bullet_config(normalized)
+    assert build_query_dicts_from_form_data(form_data, 7, "table")[0]["columns"] == [
+        "Region",
+        "region",
+    ]
+    model = resolve_bullet_render_model(
+        [{"Region": "North", "region": "South", "REGION": 42}],
+        form_data,
+    )
+    assert model.dimensions == ["Region", "region"]
+    assert model.metric_field == "REGION"
+    with pytest.raises(
+        AmbiguousDatasetReferenceError, match="Bullet dimension reference 'rEgIoN'"
+    ):
+        plugin.normalize_column_refs(
+            BulletChartConfig(metric=_simple_metric(), dimensions=[{"name": "rEgIoN"}]),
+            context,
+        )
+    with pytest.raises(ValidationError, match="ambiguous"):
+        BulletChartConfig(
+            metric=_simple_metric(),
+            dimensions=[{"name": "Region"}, {"name": "region"}],
+            order_by=[{"column": "REGION"}],
+        )
+
+
+@pytest.mark.parametrize(
+    "ambiguous_config,role",
+    [
+        ({"dimensions": [{"name": "rEgIoN"}]}, "dimension"),
+        (
+            {"filters": [{"column": "rEgIoN", "operator": "=", "value": "North"}]},
+            "filter column",
+        ),
+        ({"temporal_column": "oRdErEd_At"}, "temporal column"),
+    ],
+)
+def test_bullet_normalization_raises_named_ambiguity_error(
+    ambiguous_config: dict[str, Any], role: str
+) -> None:
+    """Normalization must propagate Bullet ambiguity, not warn and carry on.
+
+    ``_normalize_column_names`` re-raises only named normalization errors and
+    downgrades every other ``ValueError`` to a warning, returning the original
+    request. A bare ``ValueError`` here would leave the unresolved reference in
+    place, which is exactly the silent field selection the canonicalization
+    exists to prevent.
+    """
+    context = DatasetContext(
+        id=7,
+        table_name="sales",
+        schema=None,
+        database_name="main",
+        available_columns=[
+            {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+            {"name": "Region", "type": "VARCHAR"},
+            {"name": "region", "type": "VARCHAR"},
+            {"name": "Ordered_At", "type": "TIMESTAMP", "is_temporal": True},
+            {"name": "ordered_at", "type": "TIMESTAMP", "is_temporal": True},
+        ],
+        available_metrics=[],
+    )
+    request = GenerateChartRequest.model_validate(
+        {
+            "dataset_id": 7,
+            "config": {
+                "chart_type": "bullet",
+                "metric": {"name": "Revenue", "aggregate": "SUM"},
+                **ambiguous_config,
+            },
+        }
+    )
+
+    with pytest.raises(AmbiguousDatasetReferenceError) as excinfo:
+        ValidationPipeline._normalize_column_names(
+            request, context, typed_config=request.config
+        )
+
+    error = excinfo.value
+    assert error.reference_kind == f"Bullet {role}"
+    assert len(error.matches) > 1
+    assert "differ only by case" in str(error)
+    # Callers that predate the named exception catch ValueError; the subclass
+    # keeps them working while making the pipeline re-raise.
+    assert isinstance(error, ValueError)
+
+
+def test_bullet_ignores_foreign_sort_flag_after_master_merge() -> None:
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "groupby": ["Region"],
+        "sort_by_metric": True,
+        "orderby": [["Region", True]],
+    }
+    query = build_query_dicts_from_form_data(form_data, 7, "table")[0]
+    assert query["orderby"] == [["Region", True]]
+
+
+@pytest.mark.parametrize(
+    "rebind, viz_type, target",
+    [
+        (False, "bullet", "Missing"),
+        (True, "bullet", "Region"),
+        (False, "table", "Region"),
+    ],
+)
+def test_bullet_deferred_sort_rejects_unknown_or_unavailable_hierarchy(
+    rebind: bool,
+    viz_type: str,
+    target: str,
+) -> None:
+    """Deferral must not allow unknown roles or inherit from another contract."""
+    config = BulletChartConfig(metric=_simple_metric(), order_by=[{"column": target}])
+    with pytest.raises(ValueError, match="unknown"):
+        BulletChartPlugin().resolve_update_config(
+            config,
+            {"viz_type": viz_type, "groupby": ["Region"]},
+            dataset_rebind=rebind,
+        )
+    with pytest.raises(ValueError, match="unknown"):
+        map_config_to_form_data(config)
+
+
+def test_bullet_deferred_sort_resolves_saved_dimension_before_metric_alias() -> None:
+    """A saved physical dimension takes precedence over a metric input alias."""
+    config = BulletChartConfig(
+        metric={"name": "Revenue", "aggregate": "SUM", "label": "Total"},
+        order_by=[{"column": "Revenue"}, {"column": "Total"}],
+    )
+    resolved = BulletChartPlugin().resolve_update_config(
+        config,
+        {"viz_type": "bullet", "groupby": ["Revenue"]},
+        dataset_rebind=False,
+    )
+    mapped = map_bullet_config(resolved)
+    assert mapped["orderby"] == [["Revenue", False], [mapped["metric"], False]]
+    with pytest.raises(ValueError, match="duplicate outputs"):
+        map_bullet_config(config)
+
+
+@pytest.mark.parametrize("control", ["ranges", "markers", "marker_lines"])
+@pytest.mark.parametrize("value", ["10, nope, , NaN,20", "nope, NaN"])
+def test_saved_bullet_numeric_controls_drop_non_numeric_tokens(
+    control: str, value: str
+) -> None:
+    """Native previews and unrelated updates retain Explore's lenient tokens."""
+    form_data = {
+        "viz_type": "bullet",
+        "metric": "SavedRevenue",
+        "groupby": [],
+        control: value,
+        "show_legend": True,
+    }
+    expected = [10.0, 20.0] if value.startswith("10") else []
+    model = resolve_bullet_render_model([{"SavedRevenue": 5}], form_data)
+    assert getattr(model, control) == (
+        expected or [0.0, 5.5] if control == "ranges" else expected
+    )
+    native = BulletChartConfig.model_validate(form_data)
+    assert getattr(native, control) == expected
+    with pytest.raises(ValidationError):
+        BulletChartConfig.model_validate(
+            {"metric": _simple_metric(), control: ["nope"]}
+        )
+    config = BulletChartConfig(
+        metric={"name": "SavedRevenue", "saved_metric": True}, show_legend=False
+    )
+    chart = SimpleNamespace(
+        id=1,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(form_data),
+    )
+    request = UpdateChartRequest(identifier=1, config=config)
+    with patch(
+        "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+        return_value=None,
+    ):
+        payload = _build_update_payload(request, chart, config)
+    assert isinstance(payload, dict)
+    merged = __import__("json").loads(payload["params"])
+    validated = validate_merged_bullet_form_data(merged, config)
+    assert validated is not None
+    assert getattr(validated, control) == expected
+    assert merged[control] == value
+
+
+def test_bullet_schema_error_hint_does_not_require_matching_label_counts() -> None:
+    """General validation guidance must not contradict per-index label fallback."""
+    from superset.mcp_service.chart.plugins.bullet import BulletChartPlugin
+
+    hint = BulletChartPlugin().schema_error_hint()
+    assert hint is not None
+    assert hint.details is not None
+    assert "must align" not in hint.details
+    assert "optional" in hint.details.lower()
+    BulletChartConfig(
+        metric=_simple_metric(),
+        ranges=[10, 20],
+        range_labels=["Low"],
+        markers=[12],
+        marker_labels=["Plan", "Ignored"],
+    )
+
+
+@pytest.mark.parametrize(
+    "categories",
+    [
+        [None, "null"],
+        [True, "true"],
+        [12, "12"],
+        ["North, Blue", "North, Blue"],
+    ],
+)
+def test_bullet_vega_colliding_labels_keep_distinct_rows(categories: list[Any]) -> None:
+    """Display text must not collapse separate query rows onto one band."""
+    preview = _generate_vega_lite_preview_from_data(
+        [
+            {"Region": category, "Revenue": index + 1}
+            for index, category in enumerate(categories)
+        ],
+        {"viz_type": "bullet", "metric": "Revenue", "groupby": ["Region"]},
+    )
+    spec = preview.specification
+    bar = next(layer for layer in spec["layer"] if layer["mark"]["type"] == "bar")
+    row_field = bar["encoding"]["y"]["field"]
+    label_field = bar["encoding"]["tooltip"][0]["field"]
+    values = spec["data"]["values"]
+    assert len({row[row_field] for row in values}) == len(categories)
+    assert row_field != label_field
+    assert values[0][label_field] == values[1][label_field]
+    assert "labelExpr" in bar["encoding"]["y"]["axis"]
+    assert all(layer["encoding"]["y"]["field"] == row_field for layer in spec["layer"])
+
+
+def test_bullet_vega_translates_signed_smart_number_in_axis_and_tooltip() -> None:
+    """Explore pseudo-formats require valid D3 format projections."""
+    preview = _generate_vega_lite_preview_from_data(
+        [{"Revenue": 1234}],
+        {
+            "viz_type": "bullet",
+            "metric": "Revenue",
+            "y_axis_format": "SMART_NUMBER_SIGNED",
+        },
+    )
+    bar = next(
+        layer
+        for layer in preview.specification["layer"]
+        if layer["mark"]["type"] == "bar"
+    )
+    assert bar["encoding"]["x"]["axis"]["format"] == "+~s"
+    assert bar["encoding"]["tooltip"][1]["format"] == "+~s"
+
+
+def test_bullet_time_range_update_replaces_unmarked_native_binding() -> None:
+    existing: dict[str, object] = {
+        "viz_type": "bullet",
+        "metric": "SavedRevenue",
+        "groupby": ["Region"],
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "OrderDate",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "Last year",
+            }
+        ],
+    }
+    for time_range in ("Last 30 days", "Last 7 days"):
+        config = BulletChartConfig(metric=_simple_metric(), time_range=time_range)
+        with (
+            patch(
+                "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+                return_value=_orm_dataset(),
+            ),
+            patch(
+                "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+                return_value=True,
+            ),
+        ):
+            merged = map_config_to_form_data(config, dataset_id=7)
+        merge_update_form_data(existing, merged, config)
+
+        assert [
+            (item["subject"], item["comparator"]) for item in merged["adhoc_filters"]
+        ] == [("OrderDate", time_range)]
+        assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "OrderDate"
+        # The next update sees the recorded provenance and a single binding.
+        existing = {**merged}
+
+
+async def _run_saved_bullet_update(
+    existing: dict[str, Any], config: dict[str, Any], *, dataset_id: int | None = None
+) -> dict[str, Any]:
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=6 if dataset_id else 7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+        viz_type="bullet",
+        uuid="bullet-uuid",
+    )
+    updated_chart = SimpleNamespace(
+        id=9,
+        datasource_id=dataset_id or 7,
+        slice_name="Saved Bullet",
+        viz_type="bullet",
+        uuid="bullet-uuid",
+    )
+    command = MagicMock()
+    command.return_value.run.return_value = updated_chart
+    request = UpdateChartRequest(
+        identifier=9,
+        config={"chart_type": "bullet", **config},
+        dataset_id=dataset_id,
+        generate_preview=False,
+        preview_formats=[],
+    )
+    ctx = MagicMock()
+    ctx.warning = AsyncMock()
+    ctx.error = AsyncMock()
+    with (
+        patch("superset.mcp_service.auth.has_dataset_access", return_value=True),
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=_tool_user(),
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart.find_chart_by_identifier",
+            return_value=chart,
+        ),
+        patch(
+            "superset.mcp_service.auth.check_chart_data_access",
+            return_value=SimpleNamespace(is_valid=True, error=None),
+        ),
+        patch(
+            "superset.mcp_service.chart.compile._compile_chart",
+            return_value=CompileResult(success=True),
+        ) as compile_chart,
+        patch(
+            "superset.daos.dataset.DatasetDAO.find_by_id", return_value=_orm_dataset()
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+            return_value=_orm_dataset(),
+        ),
+        patch("superset.commands.chart.update.UpdateChartCommand", command),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart.analyze_chart_capabilities",
+            return_value=None,
+        ),
+        patch(
+            "superset.mcp_service.chart.tool.update_chart.analyze_chart_semantics",
+            return_value=None,
+        ),
+    ):
+        result = await update_chart(request, ctx=ctx)
+
+    assert result.success is True, result
+    persisted = __import__("json").loads(command.call_args.args[1]["params"])
+    assert compile_chart.call_args.args[0] == persisted
+    return persisted
+
+
+@pytest.mark.asyncio
+async def test_saved_bullet_clearing_dimensions_drops_their_sorts_only() -> None:
+    metric_sort = ["SavedRevenue", False]
+    persisted = await _run_saved_bullet_update(
+        {
+            "viz_type": "bullet",
+            "metric": "SavedRevenue",
+            "groupby": ["Region"],
+            "orderby": [["Region", True], metric_sort],
+        },
+        {"metric": _simple_metric("Revenue"), "dimensions": []},
+    )
+
+    assert persisted["groupby"] == []
+    assert persisted["orderby"] == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("metric_key", ["metric", "metrics"])
+@pytest.mark.parametrize("adhoc", [False, True])
+@pytest.mark.parametrize("metric_source", ["saved", "new"])
+async def test_saved_bullet_removed_dimension_keeps_colliding_metric_sort(
+    metric_key: str, adhoc: bool, metric_source: str
+) -> None:
+    """Only final metric output labels survive dimension removal."""
+    saved_label = "Revenue" if metric_source == "saved" else "OldRevenue"
+    saved_metric = {"label": saved_label} if adhoc else saved_label
+    metric_sort = ["Revenue", False]
+    persisted = await _run_saved_bullet_update(
+        {
+            "viz_type": "bullet",
+            metric_key: [saved_metric] if metric_key == "metrics" else saved_metric,
+            "groupby": ["revenue", "Country", "Region"],
+            "orderby": [metric_sort, ["Country", True], ["Region", True]],
+        },
+        {
+            "metric": {
+                **_simple_metric(),
+                "label": "NewRevenue" if metric_source == "saved" else "Revenue",
+            },
+            "dimensions": [{"name": "Country"}],
+        },
+    )
+
+    assert persisted["groupby"] == ["Country"]
+    assert persisted["orderby"] == (
+        [metric_sort, ["Country", True]]
+        if metric_source == "new"
+        else [["Country", True]]
+    )
+
+
+def test_bullet_sanitized_rows_keep_exact_source_metric() -> None:
+    plugin = BulletChartPlugin()
+    form_data = {"viz_type": "bullet", "metric": "Revenue", "groupby": ["Region"]}
+
+    rows, error = plugin.sanitize_data_rows(
+        [
+            {"Region": "North", "Revenue": 9007199254740993, "Other": "x"},
+            {"Region": "South", "Revenue": "9007199254740993", "Other": "y"},
+            {"Region": "East", "Revenue": Decimal("1.10"), "Other": "z"},
+        ],
+        form_data,
+    )
+
+    assert error is None
+    assert [row["Revenue"] for row in rows] == [
+        9007199254740993,
+        "9007199254740993",
+        Decimal("1.10"),
+    ]
+    assert [row["Region"] for row in rows] == ["North", "South", "East"]
+    assert all(row["Other"] is None for row in rows)
+
+
+@pytest.mark.parametrize(
+    ("aggregate", "output"),
+    [
+        (None, "SUM(Revenue)"),
+        ("STDDEV", "STDDEV_SAMP(Revenue)"),
+        ("VAR", "VAR_SAMP(Revenue)"),
+    ],
+)
+def test_bullet_output_roles_match_mapped_aggregate(
+    aggregate: str | None, output: str
+) -> None:
+    """Validation and mapping agree on defaulted and aliased metric outputs."""
+    metric = {"name": "Revenue"}
+    if aggregate is not None:
+        metric["aggregate"] = aggregate
+    config = BulletChartConfig(
+        metric=metric,
+        dimensions=[{"name": "Revenue"}],
+        order_by=[{"column": output, "ascending": False}],
+    )
+    mapped = map_bullet_config(config)
+    assert mapped["metric"]["label"] == output
+    assert mapped["orderby"] == [[mapped["metric"], False]]
+
+
+@pytest.mark.asyncio
+async def test_saved_bullet_removes_only_exact_obsolete_dimension_sort() -> None:
+    """Case-distinct dimensions retain only their exact final output sorts."""
+    persisted = await _run_saved_bullet_update(
+        {
+            "viz_type": "bullet",
+            "metric": "SavedRevenue",
+            "groupby": ["Region", "region"],
+            "orderby": [["Region", True], ["region", False]],
+        },
+        {"metric": _simple_metric(), "dimensions": [{"name": "region"}]},
+    )
+    assert persisted["orderby"] == [["region", False]]
+
+
+@pytest.mark.parametrize("path", ["save", "preview"])
+def test_bullet_presentation_update_preserves_query_metadata(path: str) -> None:
+    """Save and preview retain omitted SQL template inputs on the same dataset."""
+    existing = {
+        "viz_type": "bullet",
+        "metric": "SavedRevenue",
+        "url_params": {"region": "EU"},
+    }
+    chart = SimpleNamespace(
+        id=9,
+        datasource_id=7,
+        slice_name="Saved Bullet",
+        params=__import__("json").dumps(existing),
+    )
+    config = BulletChartConfig(
+        metric={"name": "SavedRevenue", "saved_metric": True}, show_labels=True
+    )
+    request = UpdateChartRequest(identifier=9, config=config, generate_preview=False)
+    if path == "save":
+        payload = _build_update_payload(request, chart, config)
+        assert isinstance(payload, dict)
+        merged = __import__("json").loads(payload["params"])
+    else:
+        merged = _build_preview_form_data(request, chart, config)
+    assert isinstance(merged, dict)
+    assert merged["url_params"] == {"region": "EU"}
+
+
+@pytest.mark.parametrize("field", ["Revenue.total", "Revenue[total]", r"Revenue\total"])
+def test_bullet_vega_metric_references_literal_result_key(field: str) -> None:
+    """Vega field paths escape punctuation without changing literal result keys."""
+    preview = _generate_vega_lite_preview_from_data(
+        [{"Region": "North", field: 10}],
+        {"viz_type": "bullet", "metric": field, "groupby": ["Region"]},
+    )
+    assert isinstance(preview, VegaLitePreview)
+    spec = preview.specification
+    escaped = "".join("\\" + char if char in ".[]\\" else char for char in field)
+    bar = next(layer for layer in spec["layer"] if layer["mark"]["type"] == "bar")
+    assert bar["encoding"]["x"]["field"] == escaped
+    assert bar["encoding"]["tooltip"][1]["field"] == escaped
+    assert spec["data"]["values"][0][field] == 10
+
+
+@pytest.mark.parametrize("role", ["ranges", "markers", "marker_lines"])
+def test_bullet_native_thresholds_use_javascript_number_tokens(role: str) -> None:
+    """Saved threshold strings follow the frontend Number tokenizer grammar."""
+    model = resolve_bullet_render_model(
+        [{"Revenue": 10}],
+        {
+            "viz_type": "bullet",
+            "metric": "Revenue",
+            role: "0x64,0b11,0o10,1_000,+0x10,-0b1,1e2,.5,1.,NaN,inf,"
+            "\ufeff0x10\ufeff,\u008520,\x1c30,١٠",
+        },
+    )
+    assert getattr(model, role) == [100, 3, 8, 100, 0.5, 1, 16]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("native_metric_sort", [False, True])
+async def test_saved_bullet_replaced_metric_prunes_sort_with_omitted_hierarchy(
+    native_metric_sort: bool,
+) -> None:
+    """Metric replacement prunes stale native sorts after restoring dimensions."""
+    old_metric = map_bullet_config(
+        BulletChartConfig(metric={**_simple_metric(), "label": "OldRevenue"})
+    )["metric"]
+    persisted = await _run_saved_bullet_update(
+        {
+            "viz_type": "bullet",
+            "metric": old_metric,
+            "groupby": ["Region"],
+            "orderby": [
+                [old_metric if native_metric_sort else "OldRevenue", False],
+                ["Region", True],
+            ],
+        },
+        {"metric": _simple_metric()},
+    )
+    assert persisted["orderby"] == [["Region", True]]
+
+
+@pytest.mark.parametrize("value", ["1_000", "123", "١٢٣", "\x1c100"])
+def test_bullet_metric_rejects_decimal_only_strings(value: str) -> None:
+    """Compilation and previews reject spellings that JavaScript Number rejects."""
+    rows = [{"Revenue": value}]
+    result = _compile_bullet_with_result({"queries": [{"data": rows}]})
+    assert result.success is False
+    assert result.error_obj is not None
+    assert result.error_obj.error_type == "MalformedBulletOutput"
+    form_data = map_bullet_config(
+        BulletChartConfig(
+            metric={"name": "amount", "aggregate": "SUM", "label": "Revenue"}
+        )
+    )
+    with pytest.raises(BulletOutputError, match="non-numeric text"):
+        resolve_bullet_render_model(rows, form_data)
+
+
+@pytest.mark.parametrize(
+    "value, expected",
+    [
+        ("1e3", 1000),
+        ("\ufeff+4.25\u00a0", 4.25),
+        ("0x64", 100),
+        ("0b101", 5),
+        ("0o10", 8),
+        ("1.000000000000000001", 1),
+    ],
+)
+def test_bullet_metric_accepts_javascript_numeric_strings(
+    value: str, expected: float
+) -> None:
+    """Finite browser number spellings remain compatible with exact wire strings."""
+    model = resolve_bullet_render_model([{"Revenue": value}], {"metric": "Revenue"})
+    assert model.measures == [expected]
+
+
+@pytest.mark.parametrize("inert_first", [True, False])
+def test_bullet_native_temporal_range_keeps_its_own_subject(inert_first: bool) -> None:
+    """An inert temporal placeholder cannot steal an active range's subject."""
+    filters = [
+        {
+            "expressionType": "SIMPLE",
+            "subject": "OrderDate",
+            "operator": "TEMPORAL_RANGE",
+            "comparator": "No filter",
+        },
+        {
+            "expressionType": "SIMPLE",
+            "subject": "ShipDate",
+            "operator": "TEMPORAL_RANGE",
+            "comparator": "Last 30 days",
+        },
+    ]
+    config = BulletChartConfig.model_validate(
+        {
+            "viz_type": "bullet",
+            "metric": "Revenue",
+            "adhoc_filters": filters if inert_first else filters[::-1],
+        }
+    )
+    assert config.temporal_column == "ShipDate"
+    assert config.time_range == "Last 30 days"
+    mapped = map_bullet_config(config)
+    temporal = [
+        item for item in mapped["adhoc_filters"] if item["operator"] == "TEMPORAL_RANGE"
+    ]
+    assert [(item["subject"], item["comparator"]) for item in temporal] == [
+        ("ShipDate", "Last 30 days")
+    ]
+
+
+@pytest.mark.parametrize(
+    "extra",
+    [
+        {"temporal_column": "OrderDate"},
+        {"time_range": "Last year"},
+        {
+            "adhoc_filters": [
+                {
+                    "expressionType": "SIMPLE",
+                    "subject": "OrderDate",
+                    "operator": "TEMPORAL_RANGE",
+                    "comparator": "Last year",
+                },
+                {
+                    "expressionType": "SIMPLE",
+                    "subject": "ShipDate",
+                    "operator": "TEMPORAL_RANGE",
+                    "comparator": "Last 30 days",
+                },
+            ]
+        },
+    ],
+)
+def test_bullet_native_temporal_ranges_reject_lossy_typed_adaptation(
+    extra: dict[str, Any],
+) -> None:
+    """The typed temporal pair cannot silently overwrite another predicate."""
+    native = {
+        "viz_type": "bullet",
+        "metric": "Revenue",
+        "adhoc_filters": [
+            {
+                "expressionType": "SIMPLE",
+                "subject": "ShipDate",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "Last 30 days",
+            }
+        ],
+    }
+    with pytest.raises(ValidationError, match="temporal"):
+        BulletChartConfig.model_validate({**native, **extra})
+
+
+@pytest.mark.parametrize(
+    "format_", [".1000000000e", ".1000000000f", ".1000000000g", ".1000000000s", ".21e"]
+)
+def test_bullet_format_precision_is_bounded_before_formatter_runs(format_: str) -> None:
+    """Small zero-valued results cannot trigger unbounded format allocation."""
+    with patch(
+        "superset.mcp_service.chart.preview_utils._format_bullet_number"
+    ) as formatter:
+        with pytest.raises(BulletOutputError) as failure:
+            resolve_bullet_render_model(
+                [{"Revenue": 0}], {"metric": "Revenue", "y_axis_format": format_}
+            )
+    assert failure.value.error_type == "UnsupportedFormat"
+    formatter.assert_not_called()
+
+
+def test_bullet_format_accepts_bounded_precision() -> None:
+    """The frontend's maximum fixed/exponential precision remains usable."""
+    model = resolve_bullet_render_model(
+        [{"Revenue": 0}], {"metric": "Revenue", "y_axis_format": ".20e"}
+    )
+    assert model.y_axis_format == ".20e"
+
+
+@pytest.mark.asyncio
+async def test_saved_bullet_labels_update_preserves_url_params() -> None:
+    """Query metadata must survive schema validation, compilation and persistence."""
+    persisted = await _run_saved_bullet_update(
+        {
+            "viz_type": "bullet",
+            "metric": "SavedRevenue",
+            "url_params": {"region": "EU"},
+        },
+        {
+            "metric": {"name": "SavedRevenue", "saved_metric": True},
+            "show_labels": True,
+        },
+    )
+    assert persisted["url_params"] == {"region": "EU"}
+    assert persisted["show_labels"] is True
+
+
+@pytest.mark.asyncio
+async def test_saved_bullet_labels_update_preserves_sql_hierarchy() -> None:
+    """Inherited Explore dimensions remain native, not typed physical columns."""
+    dimension = {
+        "expressionType": "SQL",
+        "sqlExpression": "UPPER(Region)",
+        "label": "RegionUpper",
+    }
+    persisted = await _run_saved_bullet_update(
+        {
+            "viz_type": "bullet",
+            "metric": "SavedRevenue",
+            "groupby": [dimension],
+            "orderby": [[dimension, True]],
+        },
+        {
+            "metric": {"name": "SavedRevenue", "saved_metric": True},
+            "show_labels": True,
+        },
+    )
+    assert persisted["groupby"] == [dimension]
+    assert persisted["orderby"] == [[dimension, True]]
+    query = build_query_dicts_from_form_data(persisted, 7, "table")[0]
+    assert query["columns"] == [dimension]
+    assert query["orderby"] == [[dimension, True]]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("metric_change", [False, True])
+@pytest.mark.parametrize("sort_target", [None, "", "metric", "RegionUpper"])
+@pytest.mark.parametrize("aliases", [{}, {"dimensions": None}, {"groupby": None}])
+async def test_saved_bullet_sql_hierarchy_update_with_explicit_sort(
+    metric_change: bool, sort_target: str | None, aliases: dict[str, Any]
+) -> None:
+    """Sort and metric updates preserve SQL dimensions through the native contract."""
+    dimension = {
+        "expressionType": "SQL",
+        "sqlExpression": "UPPER(Region)",
+        "label": "RegionUpper",
+    }
+    metric: dict[str, Any] = (
+        {"name": "Revenue", "aggregate": "SUM"}
+        if metric_change
+        else {"name": "SavedRevenue", "saved_metric": True}
+    )
+    metric_label = "SUM(Revenue)" if metric_change else "SavedRevenue"
+    config: dict[str, Any] = {"metric": metric, **aliases}
+    if sort_target is not None:
+        config["order_by"] = (
+            [{"column": metric_label if sort_target == "metric" else sort_target}]
+            if sort_target
+            else []
+        )
+    persisted = await _run_saved_bullet_update(
+        {"viz_type": "bullet", "metric": "SavedRevenue", "groupby": [dimension]},
+        config,
+    )
+    assert persisted["groupby"] == [dimension]
+    query = build_query_dicts_from_form_data(persisted, 7, "table")[0]
+    assert query["columns"] == [dimension]
+    if sort_target:
+        target = persisted["metric"] if sort_target == "metric" else dimension
+        assert persisted["orderby"] == query["orderby"] == [[target, False]]
+    elif sort_target == "":
+        assert persisted["orderby"] == []
+        assert not query.get("orderby")
+
+
+@pytest.mark.parametrize("dimension_field", ["dimensions", "groupby"])
+@pytest.mark.parametrize(
+    "dimension",
+    [
+        {
+            "expressionType": "SQL",
+            "sqlExpression": "UPPER(Region)",
+            "label": "RegionUpper",
+        },
+        {"sql_expression": "UPPER(Region)", "label": "RegionUpper"},
+    ],
+)
+def test_bullet_update_rejects_caller_sql_dimension_with_metric_sort(
+    dimension_field: str,
+    dimension: dict[str, str],
+) -> None:
+    """Providing a sort does not make a caller-authored SQL dimension acceptable."""
+    with pytest.raises(ValidationError):
+        UpdateChartRequest(
+            identifier=9,
+            config={
+                "chart_type": "bullet",
+                "metric": {"name": "SavedRevenue", "saved_metric": True},
+                dimension_field: [dimension],
+                "order_by": [{"column": "SavedRevenue"}],
+            },
+        )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("dataset_id", [None])
+@pytest.mark.parametrize("expression_type", ["SIMPLE", "SQL"])
+async def test_saved_bullet_metric_sort_rebinds_same_label_expression(
+    dataset_id: int | None, expression_type: str
+) -> None:
+    """An inherited output sort must execute the replacement metric expression."""
+    old_metric = {
+        "expressionType": expression_type,
+        "label": "Amount",
+        **(
+            {"column": {"column_name": "OldRevenue"}, "aggregate": "SUM"}
+            if expression_type == "SIMPLE"
+            else {"sqlExpression": "SUM(OldRevenue)"}
+        ),
+    }
+    persisted = await _run_saved_bullet_update(
+        {
+            "viz_type": "bullet",
+            "metric": old_metric,
+            "groupby": ["Region"],
+            "orderby": [[old_metric, False]],
+        },
+        {"metric": {"name": "Revenue", "aggregate": "SUM", "label": "Amount"}},
+        dataset_id=dataset_id,
+    )
+    assert persisted["orderby"] == [[persisted["metric"], False]]
+    query = build_query_dicts_from_form_data(persisted, 7, "table")[0]
+    assert query["orderby"] == [[persisted["metric"], False]]
+
+
+def test_bullet_native_hierarchy_still_validates_against_dataset() -> None:
+    """Excluding inherited roles from typed validation cannot hide missing columns."""
+    from superset.mcp_service.chart.compile import validate_and_compile
+
+    config = BulletChartConfig(metric=_simple_metric(), show_labels=True)
+    form_data = {**map_bullet_config(config), "groupby": ["MissingRegion"]}
+    validated = validate_merged_bullet_form_data(form_data, config)
+    assert validated is not None
+    result = validate_and_compile(
+        validated, form_data, _orm_dataset(), run_compile_check=False
+    )
+    assert not result.success
+    assert result.error_obj is not None
+    assert "MissingRegion" in result.error_obj.message
+
+
+def test_bullet_native_metadata_and_dimensions_do_not_weaken_typed_authoring() -> None:
+    """Native-only state cannot be newly supplied through the typed config surface."""
+    with pytest.raises(ValidationError, match="url_params"):
... 3838 lines suppressed ...