You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/06 17:07:15 UTC

(superset) branch master updated: feat(charts): soft-delete and restore (#40129)

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

rusackas 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 a7a051777c2 feat(charts): soft-delete and restore (#40129)
a7a051777c2 is described below

commit a7a051777c2023470eb729e097380d7d5a1a93b2
Author: Mike Bridge <mi...@bridgecanada.com>
AuthorDate: Mon Jul 6 11:07:04 2026 -0600

    feat(charts): soft-delete and restore (#40129)
    
    Co-authored-by: Mike Bridge <mi...@ext.preset.io>
    Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
 UPDATING.md                                        |  20 +
 superset/charts/api.py                             | 110 +++-
 superset/charts/filters.py                         |  38 ++
 superset/commands/chart/exceptions.py              |  10 +
 superset/commands/chart/importers/v1/utils.py      | 109 +++-
 superset/commands/chart/restore.py                 |  39 ++
 superset/commands/report/exceptions.py             |  14 +
 superset/commands/report/execute.py                |  36 ++
 superset/daos/dashboard.py                         |  30 +-
 ..._12-00_7c4a8d09ca37_add_deleted_at_to_slices.py |  52 ++
 superset/models/slice.py                           |   8 +-
 superset/translations/ar/LC_MESSAGES/messages.po   |  13 +
 superset/translations/ca/LC_MESSAGES/messages.po   |  13 +
 superset/translations/cs/LC_MESSAGES/messages.po   |  13 +
 superset/translations/de/LC_MESSAGES/messages.po   |  13 +
 superset/translations/en/LC_MESSAGES/messages.po   |  13 +
 superset/translations/es/LC_MESSAGES/messages.po   |  13 +
 superset/translations/fa/LC_MESSAGES/messages.po   |  13 +
 superset/translations/fi/LC_MESSAGES/messages.po   |  13 +
 superset/translations/fr/LC_MESSAGES/messages.po   |  13 +
 superset/translations/it/LC_MESSAGES/messages.po   |  13 +
 superset/translations/ja/LC_MESSAGES/messages.po   |  13 +
 superset/translations/ko/LC_MESSAGES/messages.po   |  13 +
 superset/translations/lv/LC_MESSAGES/messages.po   |  13 +
 superset/translations/messages.pot                 |  13 +
 superset/translations/mi/LC_MESSAGES/messages.po   |  13 +
 superset/translations/nl/LC_MESSAGES/messages.po   |  13 +
 superset/translations/pl/LC_MESSAGES/messages.po   |  13 +
 superset/translations/pt/LC_MESSAGES/messages.po   |  13 +
 .../translations/pt_BR/LC_MESSAGES/messages.po     |  13 +
 superset/translations/ro/LC_MESSAGES/messages.po   |  10 +
 superset/translations/ru/LC_MESSAGES/messages.po   |  13 +
 superset/translations/sk/LC_MESSAGES/messages.po   |  13 +
 superset/translations/sl/LC_MESSAGES/messages.po   |  13 +
 superset/translations/sr/LC_MESSAGES/messages.po   |  10 +
 .../translations/sr_Latn/LC_MESSAGES/messages.po   |  10 +
 superset/translations/th/LC_MESSAGES/messages.po   |  13 +
 superset/translations/tr/LC_MESSAGES/messages.po   |  13 +
 superset/translations/uk/LC_MESSAGES/messages.po   |  13 +
 superset/translations/zh/LC_MESSAGES/messages.po   |  13 +
 .../translations/zh_TW/LC_MESSAGES/messages.po     |  13 +
 tests/integration_tests/charts/commands_tests.py   |   2 +-
 .../integration_tests/charts/soft_delete_tests.py  | 579 +++++++++++++++++++++
 .../charts/commands/importers/v1/import_test.py    | 181 +++++++
 tests/unit_tests/commands/chart/restore_test.py    |  90 ++++
 tests/unit_tests/commands/report/execute_test.py   |  70 +++
 tests/unit_tests/dao/dashboard_test.py             | 119 +++++
 .../migrations/test_add_deleted_at_to_slices.py    | 190 +++++++
 48 files changed, 2054 insertions(+), 24 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index 0388b3ce36e..ec0a06275cc 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -302,6 +302,26 @@ Schedule the cutover in a quiet window. Runtime reads use only the single config
 
 The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
 
+### Soft delete and restore for charts
+
+**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/chart/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
+
+**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If charts are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live charts in all lists, lookups, and relationship loads (including dashboards that contained them). The `POST /<uuid>/restore` endpoint and the `chart_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on win [...]
+
+With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the chart (the bulk-delete endpoint behaves the same way). The row is marked with a `deleted_at` timestamp and hidden from all list, detail, and lookup endpoints. Charts in this state are excluded from default queries and from relationship loads (e.g. `dashboard.slices`).
+
+**Operational notes:** a report schedule whose target chart is soft-deleted now fails its runs with an explicit error ("The chart this report targets was deleted...") until the chart is restored or the report re-pointed — chart deletion is blocked while a report references the chart, but a validate/commit race or a flag toggle can still produce this state. Dashboards **preserve** their membership rows for soft-deleted charts: saving a dashboard does not sever a trashed member, and restor [...]
+
+**New endpoint** — `POST /api/v1/chart/<uuid>/restore` clears `deleted_at` and returns the chart to active state. Requires `can_write on Chart` and ownership of the row (or admin). Soft-deleted charts can also be surfaced in the list endpoint via the new `chart_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to charts they own — the sam [...]
+
+**Permissions migration:** existing role grants of `can_write on Chart` cover the new restore endpoint automatically; no role migration is required.
+
+**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_slices_deleted_at`) to the `slices` table. The column add is instant; the index build runs inline (no `CONCURRENTLY`) and may briefly block writes on the `slices` table (INSERT/UPDATE/DELETE are queued while the index builds; reads are unaffected) on large Postgres deployments. MySQL InnoDB builds the index online (no blocking).
+
+**Rollback note:** if the application code is rolled back after charts have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column,  [...]
+
+**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active chart imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted  [...]
+
 - [39914](https://github.com/apache/superset/pull/39914) `ALERT_REPORT_SLACK_V2` now defaults to `True` and the legacy Slack v1 integration (`Slack` recipient type, `files.upload` API) is deprecated for removal in the next major. Slack blocked new apps from `files.upload` in May 2024 and fully retired the method for all apps on November 12, 2025; because the v1 path sends files through `files.upload`, v1 file-bearing sends now fail at the API level — only text-only `chat_postMessage` sti [...]
 
 ### Soft delete and restore for dashboards
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 0098996a502..5a872622f3f 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -35,6 +35,7 @@ from superset.charts.filters import (
     ChartAllTextFilter,
     ChartCertifiedFilter,
     ChartCreatedByMeFilter,
+    ChartDeletedStateFilter,
     ChartFavoriteFilter,
     ChartFilter,
     ChartHasCreatedByFilter,
@@ -64,12 +65,14 @@ from superset.commands.chart.exceptions import (
     ChartForbiddenError,
     ChartInvalidError,
     ChartNotFoundError,
+    ChartRestoreFailedError,
     ChartUpdateFailedError,
     DashboardsForbiddenError,
 )
 from superset.commands.chart.export import ExportChartsCommand
 from superset.commands.chart.fave import AddFavoriteChartCommand
 from superset.commands.chart.importers.dispatcher import ImportChartsCommand
+from superset.commands.chart.restore import RestoreChartCommand
 from superset.commands.chart.unfave import DelFavoriteChartCommand
 from superset.commands.chart.update import UpdateChartCommand
 from superset.commands.chart.warm_up_cache import ChartWarmUpCacheCommand
@@ -102,12 +105,16 @@ from superset.views.base_api import (
     requires_json,
     statsd_metrics,
 )
-from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners
+from superset.views.filters import (
+    BaseFilterRelatedUsers,
+    FilterRelatedOwners,
+    SoftDeleteApiMixin,
+)
 
 logger = logging.getLogger(__name__)
 
 
-class ChartRestApi(BaseSupersetModelRestApi):
+class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
     datamodel = SQLAInterface(Slice)
 
     resource_name = "chart"
@@ -124,6 +131,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
         RouteMethod.IMPORT,
         RouteMethod.RELATED,
         "bulk_delete",  # not using RouteMethod since locally defined
+        "restore",
         "viz_types",
         "favorite_status",
         "add_favorite",
@@ -134,7 +142,17 @@ class ChartRestApi(BaseSupersetModelRestApi):
         "warm_up_cache",
     }
     class_permission_name = "Chart"
-    method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
+    # Custom methods (``restore``) need an explicit entry; FAB's @protect()
+    # decorator falls back to ``can_<method>_<class>`` (i.e.
+    # ``can_restore_Chart``) when the mapping is missing, which standard
+    # roles don't carry. Mirrors the permission model documented for
+    # ``DELETE`` / ``bulk_delete``: endpoint-level ``can_write`` plus
+    # resource-level ``raise_for_ownership``. See themes/api.py for the
+    # established pattern.
+    method_permission_name = {
+        **MODEL_API_RW_METHOD_PERMISSION_MAP,
+        "restore": "write",
+    }
 
     list_columns = [
         "is_managed_externally",
@@ -222,6 +240,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
         "id": [
             ChartFavoriteFilter,
             ChartCertifiedFilter,
+            ChartDeletedStateFilter,
             ChartOwnedCreatedFavoredByMeFilter,
         ],
         "slice_name": [ChartAllTextFilter],
@@ -471,9 +490,17 @@ class ChartRestApi(BaseSupersetModelRestApi):
     )
     def delete(self, pk: int) -> Response:
         """Delete a chart.
+
+        When the ``SOFT_DELETE`` feature flag is enabled, marks the chart as
+        deleted (sets ``deleted_at``) and hides it from list/detail endpoints
+        and relationship loads; the row is preserved and recoverable via
+        ``POST /api/v1/chart/<uuid>/restore`` by an owner or admin. With the
+        flag disabled (the default), the chart is permanently hard-deleted
+        and is not recoverable.
         ---
         delete:
-          summary: Delete a chart
+          summary: Delete a chart (soft delete, recoverable via restore, when
+            the SOFT_DELETE feature flag is enabled; permanent otherwise)
           parameters:
           - in: path
             schema:
@@ -527,9 +554,17 @@ class ChartRestApi(BaseSupersetModelRestApi):
     )
     def bulk_delete(self, **kwargs: Any) -> Response:
         """Bulk delete charts.
+
+        When the ``SOFT_DELETE`` feature flag is enabled, marks each chart as
+        deleted (sets ``deleted_at``) and hides it from list/detail endpoints
+        and relationship loads; rows are preserved and recoverable via
+        ``POST /api/v1/chart/<uuid>/restore`` by an owner or admin. With the
+        flag disabled (the default), the charts are permanently hard-deleted
+        and are not recoverable.
         ---
         delete:
-          summary: Bulk delete charts
+          summary: Bulk delete charts (soft delete, recoverable via restore,
+            when the SOFT_DELETE feature flag is enabled; permanent otherwise)
           parameters:
           - in: query
             name: q
@@ -574,6 +609,62 @@ class ChartRestApi(BaseSupersetModelRestApi):
         except ChartDeleteFailedError as ex:
             return self.response_422(message=str(ex))
 
+    @expose("/<uuid>/restore", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.restore",
+        log_to_statsd=False,
+    )
+    def restore(self, uuid: str) -> Response:
+        """Restore a soft-deleted chart.
+        ---
+        post:
+          summary: Restore a soft-deleted chart
+          parameters:
+          - in: path
+            schema:
+              type: string
+              format: uuid
+            name: uuid
+          responses:
+            200:
+              description: Chart restored
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        try:
+            RestoreChartCommand(uuid).run()
+            return self.response(200, message="OK")
+        except ChartNotFoundError:
+            return self.response_404()
+        except ChartForbiddenError:
+            return self.response_403()
+        except ChartRestoreFailedError as ex:
+            logger.error(
+                "Error restoring model %s: %s",
+                self.__class__.__name__,
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
     @expose("/<pk>/cache_screenshot/", methods=("GET",))
     @protect()
     @parse_rison(screenshot_query_schema)
@@ -1103,6 +1194,15 @@ class ChartRestApi(BaseSupersetModelRestApi):
     @requires_form_data
     def import_(self) -> Response:
         """Import chart(s) with associated datasets and databases.
+
+        When the ``SOFT_DELETE`` feature flag is enabled and an imported
+        chart's UUID matches an existing **soft-deleted** chart, the import
+        restores that chart and applies the upload's contents — **even when
+        ``overwrite`` is not set**. Active charts keep the usual contract
+        (never mutated without ``overwrite=true``); a soft-deleted UUID match
+        is treated as an explicit request to bring the chart back. Requires
+        ``can_write`` and ownership of the deleted row (or admin). See
+        UPDATING.md for details.
         ---
         post:
           summary: Import chart(s) with associated datasets and databases
diff --git a/superset/charts/filters.py b/superset/charts/filters.py
index 6908a959bd7..94efb99fabd 100644
--- a/superset/charts/filters.py
+++ b/superset/charts/filters.py
@@ -32,6 +32,7 @@ from superset.utils.core import get_user_id
 from superset.utils.filters import get_dataset_access_filters
 from superset.views.base import BaseFilter
 from superset.views.base_api import BaseFavoriteFilter
+from superset.views.filters import BaseDeletedStateFilter
 
 
 class ChartAllTextFilter(BaseFilter):  # pylint: disable=too-few-public-methods
@@ -196,3 +197,40 @@ class ChartOwnedCreatedFavoredByMeFilter(BaseFilter):  # pylint: disable=too-few
                 FavStar.user_id == get_user_id(),
             )
         )
+
+
+class ChartDeletedStateFilter(  # pylint: disable=too-few-public-methods
+    BaseDeletedStateFilter
+):
+    """Rison filter for the GET list that exposes soft-deleted charts.
+
+    Soft-deleted rows are additionally scoped to the **restore audience**: only
+    the chart's owners (or admins) may enumerate them. This mirrors
+    ``RestoreChartCommand``'s ``raise_for_ownership`` check, so a read-access
+    non-owner (who can see the chart via datasource access) cannot list
+    soft-deleted charts they could never restore. Live rows are unaffected —
+    they keep their normal ``ChartFilter`` visibility. The ownership scoping is
+    part of the cross-entity deleted-state contract: only the restore audience
+    may enumerate soft-deleted rows — any entity exposing a deleted-state
+    filter is expected to apply the same scoping.
+    """
+
+    arg_name = "chart_deleted_state"
+    model = Slice
+
+    def apply(self, query: Query, value: Any) -> Query:
+        query = super().apply(query, value)
+        normalized = self._normalize(value)
+        if normalized not in {"include", "only"} or security_manager.is_admin():
+            return query
+
+        # Non-admins may only see soft-deleted charts they own. ``any()`` emits
+        # an EXISTS subquery so it composes with the base access filter without
+        # producing duplicate rows from a join.
+        owned = Slice.owners.any(security_manager.user_model.id == get_user_id())
+        if normalized == "only":
+            # ``super().apply`` already restricted to ``deleted_at IS NOT NULL``.
+            return query.filter(owned)
+        # ``include``: keep all live rows (normal access) and add only the
+        # soft-deleted rows this user owns.
+        return query.filter(or_(Slice.deleted_at.is_(None), owned))
diff --git a/superset/commands/chart/exceptions.py b/superset/commands/chart/exceptions.py
index 72ef71f466e..43fef980249 100644
--- a/superset/commands/chart/exceptions.py
+++ b/superset/commands/chart/exceptions.py
@@ -123,6 +123,16 @@ class ChartDeleteFailedError(DeleteFailedError):
     message = _("Charts could not be deleted.")
 
 
+class ChartRestoreFailedError(UpdateFailedError):
+    # Restore semantically clears ``deleted_at``; it is an UPDATE, not a new
+    # row. ``UpdateFailedError`` is the nearest typed middle-tier base in the
+    # codebase. A dedicated ``RestoreFailedError`` in
+    # ``superset/commands/exceptions.py`` would be more precise across the
+    # entity rollouts but lives in already-merged infrastructure (#39977);
+    # introducing it can be a cross-entity follow-up.
+    message = _("Chart could not be restored.")
+
+
 class ChartDeleteFailedReportsExistError(ChartDeleteFailedError):
     message = _("There are associated alerts or reports")
 
diff --git a/superset/commands/chart/importers/v1/utils.py b/superset/commands/chart/importers/v1/utils.py
index 33e95279a22..209caa74558 100644
--- a/superset/commands/chart/importers/v1/utils.py
+++ b/superset/commands/chart/importers/v1/utils.py
@@ -21,6 +21,7 @@ from typing import Any
 
 from superset import db, security_manager
 from superset.commands.exceptions import ImportFailedError
+from superset.commands.importers.v1.utils import find_existing_for_import
 from superset.migrations.shared.migrate_viz import processors
 from superset.migrations.shared.migrate_viz.base import MigrateViz
 from superset.models.slice import Slice
@@ -48,21 +49,107 @@ def import_chart(
     overwrite: bool = False,
     ignore_permissions: bool = False,
 ) -> Slice:
+    """Import a chart from a config dict, handling existing matches.
+
+    Permission model for an existing UUID match:
+
+    +--------------+---------------+---------------------+-----------------+
+    | Existing row | overwrite arg | Caller has perms?   | Outcome         |
+    +==============+===============+=====================+=================+
+    | alive        | False         | (n/a)               | return existing |
+    +--------------+---------------+---------------------+-----------------+
+    | alive        | True          | can_write + owner   | UPDATE in place |
+    +--------------+---------------+---------------------+-----------------+
+    | alive        | True          | can_write,          | raise           |
+    |              |               | not owner/admin     |                 |
+    +--------------+---------------+---------------------+-----------------+
+    | soft-deleted | False or True | can_write + owner   | restore + UPDATE|
+    +--------------+---------------+---------------------+-----------------+
+    | soft-deleted | False or True | can_write,          | raise           |
+    |              |               | not owner/admin     |                 |
+    +--------------+---------------+---------------------+-----------------+
+    | soft-deleted | False or True | not can_write       | raise (Case B)  |
+    +--------------+---------------+---------------------+-----------------+
+
+    Re-importing a soft-deleted UUID is implicitly a restore-with-update:
+    the user is bringing the chart back by uploading it again. We apply
+    the same ownership check as the explicit overwrite path so non-owners
+    cannot resurrect via re-import, and we raise rather than silently
+    returning a soft-deleted row to callers without write permission
+    (which would let them reattach dashboards to a deleted chart).
+    """
     can_write = ignore_permissions or security_manager.can_access("can_write", "Chart")
-    existing = db.session.query(Slice).filter_by(uuid=config["uuid"]).first()
+    # `user` is None for background / example-loader paths (no Flask request
+    # user). Combined with ``can_write=True`` (typically from
+    # ``ignore_permissions=True``), the ownership checks in the restore /
+    # overwrite branches below are intentionally skipped because the caller has
+    # already established trust at the command level.
     user = get_user()
-    if existing:
-        if overwrite and can_write and user:
-            if not security_manager.can_access_chart(existing) or (
-                user not in existing.owners and not security_manager.is_admin()
+
+    if existing := find_existing_for_import(Slice, config["uuid"]):
+        if existing.deleted_at is not None:
+            # RESTORE path — re-importing a soft-deleted UUID is an implicit
+            # restore-with-update, a distinct operation from overwriting an
+            # alive row, so it is handled in its own branch.
+            if not can_write:
+                # Case B: don't silently return a soft-deleted row to a caller
+                # without write permission — that would let the dashboard
+                # importer reattach to a deleted chart and produce a broken
+                # dashboard.
+                # Name the chart: a dashboard bundle imports many charts, and
+                # without the identity the operator can't tell which of N
+                # charts in the bundle hit the soft-deleted match.
+                raise ImportFailedError(
+                    f"Chart {existing.slice_name!r} (uuid {config['uuid']}) "
+                    f"was deleted and re-import requires can_write "
+                    f"permission to restore it"
+                )
+            # ``user`` is None on background / example-loader paths; combined
+            # with ``can_write`` (typically from ``ignore_permissions=True``)
+            # the ownership check is intentionally skipped because the caller
+            # already established trust.
+            if user and (
+                not security_manager.can_access_chart(existing)
+                or (user not in existing.owners and not security_manager.is_admin())
+            ):
+                raise ImportFailedError(
+                    f"Chart {existing.slice_name!r} (uuid {config['uuid']}) "
+                    f"already exists and user doesn't have permissions to "
+                    f"restore it"
+                )
+            # Restore in place (clear ``deleted_at``) rather than
+            # hard-delete-and-replace: a hard delete would cascade to
+            # dashboard_slices and other FK references, breaking the dashboards
+            # that previously embedded this chart.
+            #
+            # How the restore lands as an UPDATE: clearing
+            # ``existing.deleted_at`` marks the in-session row dirty and the
+            # explicit flush emits the ``deleted_at = NULL`` UPDATE before
+            # ``Slice.import_from_dict`` (below) does its own query-by-uuid
+            # lookup. Without the flush we would rely on autoflush ahead of that
+            # internal query — correct under default session config but a hidden
+            # contract; the explicit flush makes it robust. The lookup then
+            # finds the now-live row (the listener filters ``deleted_at IS
+            # NULL``) and ``import_from_dict`` applies the config as field
+            # updates on the existing object, preserving the PK.
+            existing.restore()
+            db.session.flush()
+            config["id"] = existing.id
+        else:
+            # OVERWRITE path — existing alive row. Without ``overwrite`` or
+            # write permission, return it unchanged (the pre-soft-delete
+            # overwrite-without-permission behaviour).
+            if not overwrite or not can_write:
+                return existing
+            if user and (
+                not security_manager.can_access_chart(existing)
+                or (user not in existing.owners and not security_manager.is_admin())
             ):
                 raise ImportFailedError(
-                    "A chart already exists and user doesn't "
-                    "have permissions to overwrite it"
+                    "A chart already exists and user doesn't have "
+                    "permissions to overwrite it"
                 )
-        if not overwrite or not can_write:
-            return existing
-        config["id"] = existing.id
+            config["id"] = existing.id
     elif not can_write:
         raise ImportFailedError(
             "Chart doesn't exist and user doesn't have permission to create charts"
@@ -80,7 +167,7 @@ def import_chart(
     if chart.id is None:
         db.session.flush()
 
-    if (user := get_user()) and user not in chart.owners:
+    if user and user not in chart.owners:
         chart.owners.append(user)
 
     return chart
diff --git a/superset/commands/chart/restore.py b/superset/commands/chart/restore.py
new file mode 100644
index 00000000000..a0979ba2a27
--- /dev/null
+++ b/superset/commands/chart/restore.py
@@ -0,0 +1,39 @@
+# 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.
+"""Command to restore a soft-deleted chart."""
+
+from superset.commands.chart.exceptions import (
+    ChartForbiddenError,
+    ChartNotFoundError,
+    ChartRestoreFailedError,
+)
+from superset.commands.restore import BaseRestoreCommand
+from superset.daos.chart import ChartDAO
+from superset.models.slice import Slice
+
+
+class RestoreChartCommand(BaseRestoreCommand[Slice]):
+    """Restore a soft-deleted chart by clearing its ``deleted_at`` field.
+
+    All transactional wrapping and validation lives in
+    ``BaseRestoreCommand``; this subclass is purely declarative.
+    """
+
+    dao = ChartDAO
+    not_found_exc = ChartNotFoundError
+    forbidden_exc = ChartForbiddenError
+    restore_failed_exc = ChartRestoreFailedError
diff --git a/superset/commands/report/exceptions.py b/superset/commands/report/exceptions.py
index 32147edfeb6..31b259bec55 100644
--- a/superset/commands/report/exceptions.py
+++ b/superset/commands/report/exceptions.py
@@ -225,6 +225,20 @@ class ReportScheduleExecuteUnexpectedError(CommandException):
     message = _("Report Schedule execution got an unexpected error.")
 
 
+class ReportScheduleTargetChartDeletedError(CommandException):
+    message = _(
+        "The chart this report targets was deleted. Restore the chart, or "
+        "update the report to point at an active chart."
+    )
+
+
+class ReportScheduleTargetDashboardDeletedError(CommandException):
+    message = _(
+        "The dashboard this report targets was deleted. Restore the "
+        "dashboard, or update the report to point at an active dashboard."
+    )
+
+
 class ReportSchedulePreviousWorkingError(CommandException):
     status = 429
     message = _("Report Schedule is still working, refusing to re-compute.")
diff --git a/superset/commands/report/execute.py b/superset/commands/report/execute.py
index f08442122f8..d1686f07254 100644
--- a/superset/commands/report/execute.py
+++ b/superset/commands/report/execute.py
@@ -44,6 +44,8 @@ from superset.commands.report.exceptions import (
     ReportScheduleScreenshotTimeout,
     ReportScheduleStateNotFoundError,
     ReportScheduleSystemErrorsException,
+    ReportScheduleTargetChartDeletedError,
+    ReportScheduleTargetDashboardDeletedError,
     ReportScheduleUnexpectedError,
     ReportScheduleWorkingTimeoutError,
 )
@@ -256,6 +258,32 @@ class BaseReportState:
         """
         Get the url for this report schedule: chart or dashboard
         """
+        # Soft delete removed the FK-level guarantee that a report's target
+        # chart exists: ``chart`` is a visibility-filtered relationship, so a
+        # chart soft-deleted after this report was created (or attached via a
+        # validate/commit race with DeleteChartCommand) loads as ``None``
+        # while ``chart_id`` is still set. Without this guard the branch
+        # below silently falls through to the dashboard path and fails
+        # opaquely; raising here surfaces a clear, actionable error inside
+        # the state-machine envelope (ERROR log row + owner notification).
+        # Every content path (_get_screenshots, _get_csv_data,
+        # _get_embedded_data, _get_notification_content) funnels through this
+        # method, so this is the single choke point.
+        if (
+            self._report_schedule.chart_id is not None
+            and self._report_schedule.chart is None
+        ):
+            raise ReportScheduleTargetChartDeletedError()
+        # Symmetric guard for dashboard targets. Dashboard soft delete lands
+        # in the sibling rollout; until then this cannot fire (a dashboard
+        # with dependent reports cannot be deleted), which makes it inert
+        # rather than wrong — and it keeps the report-target error vocabulary
+        # parallel across entities from day one.
+        if (
+            self._report_schedule.dashboard_id is not None
+            and self._report_schedule.dashboard is None
+        ):
+            raise ReportScheduleTargetDashboardDeletedError()
         force = "true" if self._report_schedule.force_screenshot else "false"
         if self._report_schedule.chart:
             if result_format in {
@@ -300,6 +328,14 @@ class BaseReportState:
         """
         Retrieve the URL for the dashboard tabs, or return the dashboard URL if no tabs are available.
         """  # noqa: E501
+        # Called directly from AsyncExecuteReportScheduleCommand.run (permalink
+        # pre-commit) without passing through _get_url, so it needs the same
+        # deleted-target guard.
+        if (
+            self._report_schedule.dashboard_id is not None
+            and self._report_schedule.dashboard is None
+        ):
+            raise ReportScheduleTargetDashboardDeletedError()
         force = "true" if self._report_schedule.force_screenshot else "false"
 
         if (
diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py
index 135788929e8..4156eff4206 100644
--- a/superset/daos/dashboard.py
+++ b/superset/daos/dashboard.py
@@ -40,6 +40,7 @@ from superset.extensions import db
 from superset.models.core import FavStar, FavStarClassName
 from superset.models.dashboard import Dashboard, dashboard_user, id_or_slug_filter
 from superset.models.embedded_dashboard import EmbeddedDashboard
+from superset.models.helpers import skip_visibility_filter
 from superset.models.slice import Slice
 from superset.utils import json
 from superset.utils.core import get_user_id
@@ -294,11 +295,30 @@ class DashboardDAO(BaseDAO[Dashboard]):
                 if isinstance(value, dict)
             ]
 
-            current_slices = (
-                db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
-            )
-
-            dashboard.slices = current_slices
+            # Bypass the soft-delete visibility filter when resolving the
+            # incoming chart ids: a dashboard's ``position_json`` may still
+            # reference a chart that is currently soft-deleted, and this
+            # assignment REBUILDS ``dashboard.slices`` wholesale. With the
+            # filter active, the hidden member would be silently dropped —
+            # deleting its ``dashboard_slices`` junction row (breaking the
+            # documented restore-reattach contract) and writing
+            # ``uuid: None`` into its position slot via ``uuid_map`` below.
+            #
+            # The bypass must be session-scoped and cover the ASSIGNMENT,
+            # not just the resolution query: assigning to
+            # ``dashboard.slices`` makes the unit of work diff the new
+            # collection against the existing one, which it lazy-loads at
+            # that moment. A filtered baseline load would exclude the
+            # trashed member, so the diff would treat it as net-new and
+            # INSERT a ``dashboard_slices`` row that still exists (soft
+            # delete never removes junction rows) — an IntegrityError on
+            # the composite primary key on every save of a dashboard
+            # containing a trashed chart.
+            with skip_visibility_filter(db.session, Slice):
+                current_slices = (
+                    db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
+                )
+                dashboard.slices = current_slices
 
             # add UUID to positions
             uuid_map = {slice.id: str(slice.uuid) for slice in current_slices}
diff --git a/superset/migrations/versions/2026-05-08_12-00_7c4a8d09ca37_add_deleted_at_to_slices.py b/superset/migrations/versions/2026-05-08_12-00_7c4a8d09ca37_add_deleted_at_to_slices.py
new file mode 100644
index 00000000000..60b8e064a08
--- /dev/null
+++ b/superset/migrations/versions/2026-05-08_12-00_7c4a8d09ca37_add_deleted_at_to_slices.py
@@ -0,0 +1,52 @@
+# 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.
+"""Add deleted_at column and index to slices for soft-delete.
+
+Adds a nullable ``deleted_at`` column and an index on it to the
+``slices`` table to support soft deletion of charts. Companion to
+the ``SoftDeleteMixin`` infrastructure shipped in PR #39977.
+
+Revision ID: 7c4a8d09ca37
+Revises: b4a3f2e1d0c9
+Create Date: 2026-05-08 12:00:00.000000
+"""
+
+from sqlalchemy import Column, DateTime
+
+from superset.migrations.shared.utils import (
+    add_columns,
+    create_index,
+    drop_columns,
+    drop_index,
+)
+
+# revision identifiers, used by Alembic.
+revision = "7c4a8d09ca37"
+down_revision = "b4a3f2e1d0c9"
+
+TABLE_NAME = "slices"
+INDEX_NAME = f"ix_{TABLE_NAME}_deleted_at"
+
+
+def upgrade() -> None:
+    add_columns(TABLE_NAME, Column("deleted_at", DateTime(), nullable=True))
+    create_index(TABLE_NAME, INDEX_NAME, ["deleted_at"])
+
+
+def downgrade() -> None:
+    drop_index(TABLE_NAME, INDEX_NAME)
+    drop_columns(TABLE_NAME, "deleted_at")
diff --git a/superset/models/slice.py b/superset/models/slice.py
index e9d977b027b..9c2e5ff7ae7 100644
--- a/superset/models/slice.py
+++ b/superset/models/slice.py
@@ -43,7 +43,11 @@ from superset_core.common.models import Chart as CoreChart
 
 from superset import db, is_feature_enabled, security_manager
 from superset.legacy import update_time_range
-from superset.models.helpers import AuditMixinNullable, ImportExportMixin
+from superset.models.helpers import (
+    AuditMixinNullable,
+    ImportExportMixin,
+    SoftDeleteMixin,
+)
 from superset.tasks.thumbnails import cache_chart_thumbnail
 from superset.tasks.utils import get_current_user
 from superset.thumbnails.digest import get_chart_digest
@@ -76,7 +80,7 @@ logger = logging.getLogger(__name__)
 
 
 class Slice(  # pylint: disable=too-many-public-methods
-    CoreChart, AuditMixinNullable, ImportExportMixin
+    CoreChart, SoftDeleteMixin, AuditMixinNullable, ImportExportMixin
 ):
     """A slice is essentially a report or a view on data"""
 
diff --git a/superset/translations/ar/LC_MESSAGES/messages.po b/superset/translations/ar/LC_MESSAGES/messages.po
index 46899c10f1c..be919c1e838 100644
--- a/superset/translations/ar/LC_MESSAGES/messages.po
+++ b/superset/translations/ar/LC_MESSAGES/messages.po
@@ -3374,6 +3374,9 @@ msgstr "تغييرات المخطط"
 msgid "Chart could not be created."
 msgstr "تعذر إنشاء مخطط."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "تعذر تحديث المخطط."
 
@@ -14825,6 +14828,11 @@ msgstr ""
 "فئة العقد المصدر المستخدمة لتعيين الألوان. إذا كانت العقدة مرتبطة بأكثر "
 "من فئة واحدة، فسيتم استخدام الأولى فقط."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -14927,6 +14935,11 @@ msgstr "معيار رمز البلد الذي يجب أن تتوقع Superset ا
 msgid "The dashboard has been saved"
 msgstr "تم حفظ لوحة التحكم"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "يبدو أن مصدر البيانات قد تم حذفه"
 
diff --git a/superset/translations/ca/LC_MESSAGES/messages.po b/superset/translations/ca/LC_MESSAGES/messages.po
index f3ca38c9060..1f3ce639b43 100644
--- a/superset/translations/ca/LC_MESSAGES/messages.po
+++ b/superset/translations/ca/LC_MESSAGES/messages.po
@@ -3292,6 +3292,9 @@ msgstr "Canvis del gràfic"
 msgid "Chart could not be created."
 msgstr "El gràfic no s'ha pogut crear."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "El gràfic no s'ha pogut actualitzar."
 
@@ -14447,6 +14450,11 @@ msgstr ""
 "La categoria de nodes font usada per assignar colors. Si un node està "
 "associat amb més d'una categoria, només la primera s'usarà."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -14541,6 +14549,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr "El dashboard s'ha desat"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "La font de dades sembla haver estat eliminada"
 
diff --git a/superset/translations/cs/LC_MESSAGES/messages.po b/superset/translations/cs/LC_MESSAGES/messages.po
index 4aa6bcc3aab..739675d3a09 100644
--- a/superset/translations/cs/LC_MESSAGES/messages.po
+++ b/superset/translations/cs/LC_MESSAGES/messages.po
@@ -3161,6 +3161,9 @@ msgstr "Změny grafu"
 msgid "Chart could not be created."
 msgstr "Graf se nepodařilo vytvořit."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Graf se nepodařilo aktualizovat."
 
@@ -13865,6 +13868,11 @@ msgstr ""
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr "Graf se stále načítá. Chvíli počkejte a zkuste to znovu."
 
@@ -13951,6 +13959,11 @@ msgstr "Standard kódu země, který by měl Superset očekávat ve sloupci [zem
 msgid "The dashboard has been saved"
 msgstr "Nástěnka byla uložena"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Zdá se, že zdroj dat byl smazán"
 
diff --git a/superset/translations/de/LC_MESSAGES/messages.po b/superset/translations/de/LC_MESSAGES/messages.po
index 202f3fee3be..f8b789bd100 100644
--- a/superset/translations/de/LC_MESSAGES/messages.po
+++ b/superset/translations/de/LC_MESSAGES/messages.po
@@ -3105,6 +3105,9 @@ msgstr "Diagrammänderungen"
 msgid "Chart could not be created."
 msgstr "Diagramm konnte nicht erstellt werden."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Diagramm konnte nicht aktualisiert werden."
 
@@ -13455,6 +13458,11 @@ msgstr ""
 "werden. Wenn ein Knoten mehr als einer Kategorie zugeordnet ist, wird nur"
 " die erste verwendet."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr ""
 "Das Diagramm wird noch geladen. Bitte warten Sie einen Moment und "
@@ -13545,6 +13553,11 @@ msgstr "Der Ländercodestandard, den Superset in der Spalte [Land] erwarten soll
 msgid "The dashboard has been saved"
 msgstr "Dashboard wurde erfolgreich gespeichert"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Die Datenquelle scheint gelöscht worden zu sein"
 
diff --git a/superset/translations/en/LC_MESSAGES/messages.po b/superset/translations/en/LC_MESSAGES/messages.po
index 75cf6c45fdf..a732cccdcc7 100644
--- a/superset/translations/en/LC_MESSAGES/messages.po
+++ b/superset/translations/en/LC_MESSAGES/messages.po
@@ -2791,6 +2791,9 @@ msgstr ""
 msgid "Chart could not be created."
 msgstr ""
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr ""
 
@@ -12366,6 +12369,11 @@ msgid ""
 "associated with more than one category, only the first will be used."
 msgstr ""
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr ""
 
@@ -12437,6 +12445,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr ""
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr ""
 
diff --git a/superset/translations/es/LC_MESSAGES/messages.po b/superset/translations/es/LC_MESSAGES/messages.po
index e4aef0f9cea..3dc171c34ab 100644
--- a/superset/translations/es/LC_MESSAGES/messages.po
+++ b/superset/translations/es/LC_MESSAGES/messages.po
@@ -3252,6 +3252,9 @@ msgstr "Cambios en el gráfico"
 msgid "Chart could not be created."
 msgstr "No se ha podido crear el gráfico."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "No se ha podido actualizar el gráfico."
 
@@ -14378,6 +14381,11 @@ msgstr ""
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr ""
 "El gráfico todavía se está cargando. Por favor, espere un momento e "
@@ -14472,6 +14480,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr "El panel de control se ha guardado"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Parece que se ha eliminado la fuente de datos"
 
diff --git a/superset/translations/fa/LC_MESSAGES/messages.po b/superset/translations/fa/LC_MESSAGES/messages.po
index a704203e419..dcefbf6f033 100644
--- a/superset/translations/fa/LC_MESSAGES/messages.po
+++ b/superset/translations/fa/LC_MESSAGES/messages.po
@@ -3274,6 +3274,9 @@ msgstr "تغییرات نمودار"
 msgid "Chart could not be created."
 msgstr "نمودار نتوانسته ایجاد شود."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "نمودار نتوانست به‌روزرسانی شود."
 
@@ -14532,6 +14535,11 @@ msgstr ""
 "دسته‌بندی گره‌های منبع که برای اختصاص رنگ‌ها استفاده می‌شود. اگر یک گره "
 "با بیش از یک دسته مرتبط باشد، تنها اولین دسته استفاده خواهد شد."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -14633,6 +14641,11 @@ msgstr "استاندارد کد کشور که سوپرسِت باید در ست
 msgid "The dashboard has been saved"
 msgstr "داشبورد ذخیره شد"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "به نظر می‌رسد منبع داده حذف شده است."
 
diff --git a/superset/translations/fi/LC_MESSAGES/messages.po b/superset/translations/fi/LC_MESSAGES/messages.po
index 275dd94e5f6..c9ef1c62571 100644
--- a/superset/translations/fi/LC_MESSAGES/messages.po
+++ b/superset/translations/fi/LC_MESSAGES/messages.po
@@ -5396,6 +5396,9 @@ msgstr "Kaaviota ei voitu luoda."
 # de, es, fa, fr, it, ja, ko, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, tr, uk,
 # zh, zh_TW]
 #, fuzzy
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Kaaviota ei voitu päivittää."
 
@@ -24396,6 +24399,11 @@ msgstr ""
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr "Kaavio latautuu vielä. Odota hetki ja yritä uudelleen."
 
@@ -24535,6 +24543,11 @@ msgstr "Maakodistandardi, jonka Superset odottaa löytävänsä [country]-sarakk
 msgid "The dashboard has been saved"
 msgstr "Koontinäyttö on tallennettu"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
 # de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
 #, fuzzy
diff --git a/superset/translations/fr/LC_MESSAGES/messages.po b/superset/translations/fr/LC_MESSAGES/messages.po
index 2efd8d16573..59e97333980 100644
--- a/superset/translations/fr/LC_MESSAGES/messages.po
+++ b/superset/translations/fr/LC_MESSAGES/messages.po
@@ -3175,6 +3175,9 @@ msgstr "Changements de graphique"
 msgid "Chart could not be created."
 msgstr "Le graphique n'a pas pu être créé."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Le graphique n'a pas pu être mis à jour."
 
@@ -14327,6 +14330,11 @@ msgstr ""
 "La catégorie de nœuds sources utilisée pour attribuer des couleurs. Si un "
 "nœud est associé à plus d’une catégorie, seul le premier sera utilisé."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -14418,6 +14426,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr "Ce tableau de bord a été sauvegardé"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "La source de données semble avoir été effacée"
 
diff --git a/superset/translations/it/LC_MESSAGES/messages.po b/superset/translations/it/LC_MESSAGES/messages.po
index 7250db62ed6..b10642cfe23 100644
--- a/superset/translations/it/LC_MESSAGES/messages.po
+++ b/superset/translations/it/LC_MESSAGES/messages.po
@@ -4813,6 +4813,9 @@ msgstr "Ultima Modifica"
 msgid "Chart could not be created."
 msgstr "La tua query non può essere salvata"
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "La tua query non può essere salvata"
 
@@ -22012,6 +22015,11 @@ msgstr ""
 "La categoria dei nodi sorgente utilizzata per assegnare i colori. Se un "
 "nodo è associato a più di una categoria, verrà utilizzata solo la prima."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -22161,6 +22169,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr "Il dashboard è stato salvato"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
 # de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ro, ru, sk, sl, sr, sr_Latn,
 # tr, uk, zh, zh_TW]
diff --git a/superset/translations/ja/LC_MESSAGES/messages.po b/superset/translations/ja/LC_MESSAGES/messages.po
index 07ca68392db..849ebfc3c1c 100644
--- a/superset/translations/ja/LC_MESSAGES/messages.po
+++ b/superset/translations/ja/LC_MESSAGES/messages.po
@@ -2886,6 +2886,9 @@ msgstr "チャートの変更"
 msgid "Chart could not be created."
 msgstr "チャートを作成できませんでした。"
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "チャートを更新できませんでした。"
 
@@ -12807,6 +12810,11 @@ msgstr "色の割り当てに使用されるソースノードのカテゴリ。
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr "チャートはまだ読み込み中です。しばらく待ってから再試行してください。"
 
@@ -12880,6 +12888,11 @@ msgstr "Supersetが「国」列で想定する国コード規格。"
 msgid "The dashboard has been saved"
 msgstr "ダッシュボードを保存しました"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "データソースが削除されたようです"
 
diff --git a/superset/translations/ko/LC_MESSAGES/messages.po b/superset/translations/ko/LC_MESSAGES/messages.po
index cab6a09c745..abf4a0836f0 100644
--- a/superset/translations/ko/LC_MESSAGES/messages.po
+++ b/superset/translations/ko/LC_MESSAGES/messages.po
@@ -4737,6 +4737,9 @@ msgstr "차트 변경 사항"
 msgid "Chart could not be created."
 msgstr "차트를 생성할 수 없습니다."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "차트를 업데이트할 수 없습니다."
 
@@ -21700,6 +21703,11 @@ msgid ""
 "associated with more than one category, only the first will be used."
 msgstr "색상을 지정하는 데 사용되는 소스 노드의 카테고리입니다. 노드가 둘 이상의 카테고리와 연결된 경우 첫 번째 카테고리만 사용됩니다."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -21842,6 +21850,11 @@ msgstr "Superset이 [country] 열에서 찾을 것으로 예상하는 국가 코
 msgid "The dashboard has been saved"
 msgstr "대시보드가 저장되었습니다"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
 # de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ro, ru, sk, sl, sr, sr_Latn,
 # tr, uk, zh, zh_TW]
diff --git a/superset/translations/lv/LC_MESSAGES/messages.po b/superset/translations/lv/LC_MESSAGES/messages.po
index 8aa385e9ebb..fef70f25dfb 100644
--- a/superset/translations/lv/LC_MESSAGES/messages.po
+++ b/superset/translations/lv/LC_MESSAGES/messages.po
@@ -3125,6 +3125,9 @@ msgstr "Diagrammas izmaiņas"
 msgid "Chart could not be created."
 msgstr "Diagrammu nevarēja izveidot."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Diagrammu nevarēja atjaunināt."
 
@@ -13548,6 +13551,11 @@ msgstr ""
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr "Diagramma vēl tiek ielādēta. Lūdzu, uzgaidiet brīdi un mēģiniet vēlreiz."
 
@@ -13633,6 +13641,11 @@ msgstr "Valsts koda standarts, ko Superset sagaida [country] kolonnā"
 msgid "The dashboard has been saved"
 msgstr "Panelis ir saglabāts"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Šķiet, ka datu avots ir dzēsts"
 
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index f87e7c2d2ae..5b93b3bdefc 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -2798,6 +2798,9 @@ msgstr ""
 msgid "Chart could not be created."
 msgstr ""
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr ""
 
@@ -12351,6 +12354,11 @@ msgid ""
 "associated with more than one category, only the first will be used."
 msgstr ""
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr ""
 
@@ -12422,6 +12430,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr ""
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr ""
 
diff --git a/superset/translations/mi/LC_MESSAGES/messages.po b/superset/translations/mi/LC_MESSAGES/messages.po
index 4eb73e2fd2d..2ef1b597691 100644
--- a/superset/translations/mi/LC_MESSAGES/messages.po
+++ b/superset/translations/mi/LC_MESSAGES/messages.po
@@ -3247,6 +3247,9 @@ msgstr "Huringa kauwhata"
 msgid "Chart could not be created."
 msgstr "Kāore i taea te hanga i te kauwhata."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Kāore i taea te whakahou i te kauwhata."
 
@@ -14356,6 +14359,11 @@ msgstr ""
 "hāngai ana tētahi pona ki te nui atu i te kotahi kāwai, ko te tuatahi "
 "anake ka whakamahia."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -14444,6 +14452,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr "Kua tiakina te papatohu"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Kua mukua pea te puna raraunga"
 
diff --git a/superset/translations/nl/LC_MESSAGES/messages.po b/superset/translations/nl/LC_MESSAGES/messages.po
index 93a7e3e3f15..a5befeee807 100644
--- a/superset/translations/nl/LC_MESSAGES/messages.po
+++ b/superset/translations/nl/LC_MESSAGES/messages.po
@@ -3379,6 +3379,9 @@ msgstr "Veranderingen in de grafiek"
 msgid "Chart could not be created."
 msgstr "Grafiek kon niet worden aangemaakt."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "De grafiek kon niet worden bijgewerkt."
 
@@ -14912,6 +14915,11 @@ msgstr ""
 "Als een knooppunt is gekoppeld met meer dan één categorie, zal alleen de "
 "eerste worden gebruikt."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -15017,6 +15025,11 @@ msgstr "De landcode die Superset verwacht te vinden in de [land] kolom"
 msgid "The dashboard has been saved"
 msgstr "Het dashboard is opgeslagen"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "De gegevensbron lijkt te zijn verwijderd"
 
diff --git a/superset/translations/pl/LC_MESSAGES/messages.po b/superset/translations/pl/LC_MESSAGES/messages.po
index 63fb1ca8a8a..e46fe1c7809 100644
--- a/superset/translations/pl/LC_MESSAGES/messages.po
+++ b/superset/translations/pl/LC_MESSAGES/messages.po
@@ -3384,6 +3384,9 @@ msgstr "Zmiany wykresu"
 msgid "Chart could not be created."
 msgstr "Nie można utworzyć wykresu."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Nie można zaktualizować wykresu."
 
@@ -15247,6 +15250,11 @@ msgstr ""
 "jest związany z więcej niż jedną kategorią, używana będzie tylko "
 "pierwsza."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -15348,6 +15356,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr "Pulpit nawigacyjny został zapisany."
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Źródło danych wydaje się zostało usunięte."
 
diff --git a/superset/translations/pt/LC_MESSAGES/messages.po b/superset/translations/pt/LC_MESSAGES/messages.po
index a709de51512..36fe4bd6a4b 100644
--- a/superset/translations/pt/LC_MESSAGES/messages.po
+++ b/superset/translations/pt/LC_MESSAGES/messages.po
@@ -4759,6 +4759,9 @@ msgstr "Modificado pela última vez"
 msgid "Chart could not be created."
 msgstr "Não foi possível gravar a sua query"
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Não foi possível gravar a sua query"
 
@@ -21413,6 +21416,11 @@ msgstr ""
 "estiver associado a mais do que uma categoria, apenas a primeira será "
 "utilizada."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -21548,6 +21556,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr "Dashboard gravado com sucesso."
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Esta origem de dados parece ter sido excluída"
 
diff --git a/superset/translations/pt_BR/LC_MESSAGES/messages.po b/superset/translations/pt_BR/LC_MESSAGES/messages.po
index 1889d1aec6c..ae3db8d64e0 100644
--- a/superset/translations/pt_BR/LC_MESSAGES/messages.po
+++ b/superset/translations/pt_BR/LC_MESSAGES/messages.po
@@ -3334,6 +3334,9 @@ msgstr "Alterações no gráfico"
 msgid "Chart could not be created."
 msgstr "Não foi possível criar o gráfico."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Não foi possível atualizar o gráfico."
 
@@ -14976,6 +14979,11 @@ msgstr ""
 "estiver associado a mais do que uma categoria, apenas a primeira será "
 "utilizada."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -15080,6 +15088,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr "O painel foi salvo"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "A Fonte de dados parece ter sido excluída"
 
diff --git a/superset/translations/ro/LC_MESSAGES/messages.po b/superset/translations/ro/LC_MESSAGES/messages.po
index 8470bc31289..bb6b77b1ff3 100644
--- a/superset/translations/ro/LC_MESSAGES/messages.po
+++ b/superset/translations/ro/LC_MESSAGES/messages.po
@@ -12046,9 +12046,19 @@ msgstr ""
 "Standardul codului de țară pe care Superset ar trebui să-l găsească în "
 "coloana [country]"
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The dashboard has been saved"
 msgstr "Panoul de control a fost salvat"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Sursa de date pare să fi fost ștearsă"
 
diff --git a/superset/translations/ru/LC_MESSAGES/messages.po b/superset/translations/ru/LC_MESSAGES/messages.po
index a32e585d9a1..02372849216 100644
--- a/superset/translations/ru/LC_MESSAGES/messages.po
+++ b/superset/translations/ru/LC_MESSAGES/messages.po
@@ -3110,6 +3110,9 @@ msgstr "Изменения диаграммы"
 msgid "Chart could not be created."
 msgstr "Не удалось создать диаграмму"
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Не удалось обновить диаграмму"
 
@@ -13527,6 +13530,11 @@ msgstr ""
 "Категория исходных вершин предназначена для задания цветов. Если вершина "
 "связана более, чем с одной категорией, только первая будет использована."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -13610,6 +13618,11 @@ msgstr "Код страны, который Superset ожидает найти 
 msgid "The dashboard has been saved"
 msgstr "Дашборд сохранен"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Похоже, источник данных был удален"
 
diff --git a/superset/translations/sk/LC_MESSAGES/messages.po b/superset/translations/sk/LC_MESSAGES/messages.po
index 87bfd1f5ff8..9b4b458f254 100644
--- a/superset/translations/sk/LC_MESSAGES/messages.po
+++ b/superset/translations/sk/LC_MESSAGES/messages.po
@@ -3161,6 +3161,9 @@ msgstr "Zmeny grafu"
 msgid "Chart could not be created."
 msgstr "Graf sa nepodarilo vytvoriť."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Graf sa nepodarilo aktualizovať."
 
@@ -13863,6 +13866,11 @@ msgstr ""
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr "Graf sa ešte načítava. Chvíľu počkajte a skúste to znova."
 
@@ -13949,6 +13957,11 @@ msgstr "Standard kódu zeme, ktorý by mel Superset očekávat ve stĺpci [zeme]
 msgid "The dashboard has been saved"
 msgstr "Nástenka bola uložena"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Zdá se, že zdroj dat bol zmazaný"
 
diff --git a/superset/translations/sl/LC_MESSAGES/messages.po b/superset/translations/sl/LC_MESSAGES/messages.po
index 42105e377c5..322cfd151ad 100644
--- a/superset/translations/sl/LC_MESSAGES/messages.po
+++ b/superset/translations/sl/LC_MESSAGES/messages.po
@@ -3300,6 +3300,9 @@ msgstr "Spremembe grafikona"
 msgid "Chart could not be created."
 msgstr "Grafikona ni mogoče ustvariti."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Grafikona ni mogoče posodobiti."
 
@@ -14552,6 +14555,11 @@ msgstr ""
 "Kategorija izvornih vozlišč, na podlagi katere je določena barva. Če je "
 "vozlišče povezano z več kot eno kategorijo, bo uporabljena samo prva."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -14648,6 +14656,11 @@ msgstr "Standard za oznake držav, ki bodo podane v stolpcu z državami"
 msgid "The dashboard has been saved"
 msgstr "Nadzorna plošča je bila shranjena"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Zdi se, da je bil podatkovni vir izbrisan"
 
diff --git a/superset/translations/sr/LC_MESSAGES/messages.po b/superset/translations/sr/LC_MESSAGES/messages.po
index 556ce79f641..0c92bafcbc3 100644
--- a/superset/translations/sr/LC_MESSAGES/messages.po
+++ b/superset/translations/sr/LC_MESSAGES/messages.po
@@ -16840,6 +16840,11 @@ msgstr ""
 "Категорија изворних чворова која се користи за додељивање боја. Ако је чвор "
 "повезан са више од једне категорије, користиће се само прва."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr "Графикон се још учитава. Сачекајте тренутак и покушајте поново."
 
@@ -16933,6 +16938,11 @@ msgstr "Стандард кода земље који Superset треба да 
 msgid "The dashboard has been saved"
 msgstr "Контролна табла је сачувана"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 # de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
 msgid "The data source seems to have been deleted"
 msgstr "Изгледа да је извор података обрисан"
diff --git a/superset/translations/sr_Latn/LC_MESSAGES/messages.po b/superset/translations/sr_Latn/LC_MESSAGES/messages.po
index 13612f898b9..c9e17ca290a 100644
--- a/superset/translations/sr_Latn/LC_MESSAGES/messages.po
+++ b/superset/translations/sr_Latn/LC_MESSAGES/messages.po
@@ -16855,6 +16855,11 @@ msgstr ""
 "Kategorija izvornih čvorova koja se koristi za dodeljivanje boja. Ako je "
 "čvor povezan sa više od jedne kategorije, koristiće se samo prva."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr "Grafikon se još učitava. Sačekajte trenutak i pokušajte ponovo."
 
@@ -16949,6 +16954,11 @@ msgstr ""
 msgid "The dashboard has been saved"
 msgstr "Kontrolna tabla je sačuvana"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 # de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
 msgid "The data source seems to have been deleted"
 msgstr "Izgleda da je izvor podataka obrisan"
diff --git a/superset/translations/th/LC_MESSAGES/messages.po b/superset/translations/th/LC_MESSAGES/messages.po
index 19942a66c36..ca33fa4f51f 100644
--- a/superset/translations/th/LC_MESSAGES/messages.po
+++ b/superset/translations/th/LC_MESSAGES/messages.po
@@ -5285,6 +5285,9 @@ msgstr "ไม่สามารถสร้างแผนภูมิได้
 # de, es, fa, fr, it, ja, ko, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, tr, uk,
 # zh, zh_TW]
 #, fuzzy
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "ไม่สามารถอัปเดตแผนภูมิได้"
 
@@ -24131,6 +24134,11 @@ msgstr ""
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr "แผนภูมิยังคงโหลดอยู่ กรุณารอสักครู่แล้วลองอีกครั้ง"
 
@@ -24268,6 +24276,11 @@ msgstr "มาตรฐานรหัสประเทศที่ Superset 
 msgid "The dashboard has been saved"
 msgstr "บันทึกแดชบอร์ดแล้ว"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
 # de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
 #, fuzzy
diff --git a/superset/translations/tr/LC_MESSAGES/messages.po b/superset/translations/tr/LC_MESSAGES/messages.po
index e2e3d328444..2fc8b0e4262 100644
--- a/superset/translations/tr/LC_MESSAGES/messages.po
+++ b/superset/translations/tr/LC_MESSAGES/messages.po
@@ -3261,6 +3261,9 @@ msgstr "Grafik değişiklikleri"
 msgid "Chart could not be created."
 msgstr "Grafik oluşturulamadı."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Grafik gencellenemedi."
 
@@ -14314,6 +14317,11 @@ msgstr ""
 "Renkler atamak için kullanılan kaynak düğümleri kategorisi. Eğer bir node"
 " bir kategoriden daha ilişkiliyse, sadece ilki kullanılacaktır."
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -14403,6 +14411,11 @@ msgstr "Superset'in [ülkede bulmayı beklemesi gereken ülke kodu standardı] s
 msgid "The dashboard has been saved"
 msgstr "Panel kurtarıldı"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Veri kaynağı silinmiş gibi görünüyor"
 
diff --git a/superset/translations/uk/LC_MESSAGES/messages.po b/superset/translations/uk/LC_MESSAGES/messages.po
index b33bbeb27ee..26cd8c2bc9c 100644
--- a/superset/translations/uk/LC_MESSAGES/messages.po
+++ b/superset/translations/uk/LC_MESSAGES/messages.po
@@ -3109,6 +3109,9 @@ msgstr "Зміни діаграми"
 msgid "Chart could not be created."
 msgstr "Не вдалося створити діаграму."
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "Не вдалося оновити діаграму."
 
@@ -13522,6 +13525,11 @@ msgstr ""
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 msgid "The chart is still loading. Please wait a moment and try again."
 msgstr "Графік ще завантажується. Зачекайте хвилину і спробуйте знову."
 
@@ -13607,6 +13615,11 @@ msgstr "Стандарт коду країни, який очікується в
 msgid "The dashboard has been saved"
 msgstr "Звіт збережено"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "Схоже, джерела даних вилучено"
 
diff --git a/superset/translations/zh/LC_MESSAGES/messages.po b/superset/translations/zh/LC_MESSAGES/messages.po
index de391e36a28..50cc9fd5bba 100644
--- a/superset/translations/zh/LC_MESSAGES/messages.po
+++ b/superset/translations/zh/LC_MESSAGES/messages.po
@@ -3370,6 +3370,9 @@ msgstr "图表变化"
 msgid "Chart could not be created."
 msgstr "您的图表无法创建。"
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "您的图表无法更新。"
 
@@ -15096,6 +15099,11 @@ msgid ""
 "associated with more than one category, only the first will be used."
 msgstr "用于分配颜色的源节点类别。如果一个节点与多个类别关联,则只使用第一个类别"
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -15194,6 +15202,11 @@ msgstr "Superset 希望能够在 [国家] 栏中找到的 国家 / 地区 的标
 msgid "The dashboard has been saved"
 msgstr "该看板已成功保存。"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "数据源已经被删除"
 
diff --git a/superset/translations/zh_TW/LC_MESSAGES/messages.po b/superset/translations/zh_TW/LC_MESSAGES/messages.po
index 2f84c608bd3..bf406403b80 100644
--- a/superset/translations/zh_TW/LC_MESSAGES/messages.po
+++ b/superset/translations/zh_TW/LC_MESSAGES/messages.po
@@ -3349,6 +3349,9 @@ msgstr "圖表變化"
 msgid "Chart could not be created."
 msgstr "您的圖表無法創建。"
 
+msgid "Chart could not be restored."
+msgstr ""
+
 msgid "Chart could not be updated."
 msgstr "您的圖表無法更新。"
 
@@ -15075,6 +15078,11 @@ msgid ""
 "with more than one category, only the first will be used."
 msgstr "用於分配颜色的源節點類别。如果一個節點與多個類别關聯,则只使用第一個類别"
 
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
 # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
 # sr_Latn]
 #, fuzzy
@@ -15169,6 +15177,11 @@ msgstr "Superset 希望能够在 [國家] 欄中找到的 國家 / 地區 的標
 msgid "The dashboard has been saved"
 msgstr "該看板已成功保存。"
 
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
 msgid "The data source seems to have been deleted"
 msgstr "數據源已經被删除"
 
diff --git a/tests/integration_tests/charts/commands_tests.py b/tests/integration_tests/charts/commands_tests.py
index 75d8f7d2a22..01063278dc4 100644
--- a/tests/integration_tests/charts/commands_tests.py
+++ b/tests/integration_tests/charts/commands_tests.py
@@ -721,7 +721,7 @@ class TestFavoriteChartCommand(SupersetTestCase):
     def test_fave_unfave_chart_command_not_found(self):
         """Test that faving / unfaving a non-existing chart raises an exception"""
         with self.client.application.test_request_context():
-            example_chart_id = 1234
+            example_chart_id = 0
 
             with override_user(security_manager.find_user("admin")):
                 with self.assertRaises(ChartNotFoundError):  # noqa: PT027
diff --git a/tests/integration_tests/charts/soft_delete_tests.py b/tests/integration_tests/charts/soft_delete_tests.py
new file mode 100644
index 00000000000..e424289e0f4
--- /dev/null
+++ b/tests/integration_tests/charts/soft_delete_tests.py
@@ -0,0 +1,579 @@
+# 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.
+"""Integration tests for chart soft-delete and restore."""
+
+from datetime import datetime
+
+from superset import security_manager
+from superset.connectors.sqla.models import SqlaTable
+from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
+from superset.extensions import db
+from superset.models.dashboard import Dashboard, dashboard_slices
+from superset.models.slice import Slice
+from superset.reports.models import (
+    ReportCreationMethod,
+    ReportSchedule,
+    ReportScheduleType,
+)
+from superset.utils import json
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.conftest import with_feature_flags
+from tests.integration_tests.constants import (
+    ADMIN_USERNAME,
+    ALPHA_USERNAME,
+    GAMMA_USERNAME,
+)
+from tests.integration_tests.insert_chart_mixin import InsertChartMixin
+
+
+def _hard_delete_chart(chart_id: int) -> None:
+    """Hard-delete a chart row regardless of soft-delete state."""
+    row = (
+        db.session.query(Slice)
+        .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
+        .filter(Slice.id == chart_id)
+        .one_or_none()
+    )
+    if row:
+        db.session.delete(row)
+        db.session.commit()
+
+
+def _hard_delete_dashboard_for_charts_test(dashboard_id: int) -> None:
+    """Hard-delete a dashboard row regardless of soft-delete state."""
+    row = (
+        db.session.query(Dashboard)
+        .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Dashboard}})
+        .filter(Dashboard.id == dashboard_id)
+        .one_or_none()
+    )
+    if row:
+        db.session.delete(row)
+        db.session.commit()
+
+
+class TestChartSoftDelete(InsertChartMixin, SupersetTestCase):
+    """Tests for chart soft-delete behaviour (T013, T016)."""
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_delete_chart_soft_deletes(self) -> None:
+        """DELETE /api/v1/chart/<pk> sets deleted_at instead of removing."""
+        admin_id = self.get_user("admin").id
+        chart = self.insert_chart("soft_delete_test", [admin_id], 1)
+        chart_id = chart.id
+        self.login(ADMIN_USERNAME)
+
+        rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+        assert rv.status_code == 200
+
+        # Row still exists in DB with deleted_at set
+        row = (
+            db.session.query(Slice)
+            .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
+            .filter(Slice.id == chart_id)
+            .one_or_none()
+        )
+        assert row is not None
+        assert row.deleted_at is not None
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_soft_deleted_chart_excluded_from_get(self) -> None:
+        """GET /api/v1/chart/<pk> returns 404 for a soft-deleted chart."""
+        admin_id = self.get_user("admin").id
+        chart = self.insert_chart("invisible_chart", [admin_id], 1)
+        chart_id = chart.id
+        self.login(ADMIN_USERNAME)
+
+        self.client.delete(f"/api/v1/chart/{chart_id}")
+        rv = self.client.get(f"/api/v1/chart/{chart_id}")
+        assert rv.status_code == 404
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_soft_deleted_chart_excluded_from_list(self) -> None:
+        """GET /api/v1/chart/ should not include soft-deleted charts."""
+        admin_id = self.get_user("admin").id
+        chart = self.insert_chart("listed_then_deleted", [admin_id], 1)
+        chart_id = chart.id
+        self.login(ADMIN_USERNAME)
+
+        self.client.delete(f"/api/v1/chart/{chart_id}")
+        rv = self.client.get("/api/v1/chart/")
+        data = json.loads(rv.data)
+        chart_ids = [c["id"] for c in data["result"]]
+        assert chart_id not in chart_ids
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_soft_deleted_chart_included_in_list_when_requested(self) -> None:
+        """GET /api/v1/chart/ with chart_deleted_state=include returns deleted charts."""  # noqa: E501
+        admin_id = self.get_user("admin").id
+        chart = self.insert_chart("listed_with_deleted", [admin_id], 1)
+        chart_id = chart.id
+        self.login(ADMIN_USERNAME)
+
+        self.client.delete(f"/api/v1/chart/{chart_id}")
+
+        rison_query = "(filters:!((col:id,opr:chart_deleted_state,value:include)))"
+        rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
+        assert rv.status_code == 200
+
+        data = json.loads(rv.data)
+        deleted_row = next(
+            (row for row in data["result"] if row["id"] == chart_id),
+            None,
+        )
+        assert deleted_row is not None
+        assert deleted_row["deleted_at"] is not None
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_only_filter_returns_only_soft_deleted_charts(self) -> None:
+        """chart_deleted_state=only excludes live rows and returns only deleted ones."""
+        admin_id = self.get_user("admin").id
+        live_chart = self.insert_chart("only_live", [admin_id], 1)
+        deleted_chart = self.insert_chart("only_deleted", [admin_id], 1)
+        live_id = live_chart.id
+        deleted_id = deleted_chart.id
+        self.login(ADMIN_USERNAME)
+
+        self.client.delete(f"/api/v1/chart/{deleted_id}")
+
+        rison_query = "(filters:!((col:id,opr:chart_deleted_state,value:only)))"
+        rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
+        assert rv.status_code == 200
+
+        data = json.loads(rv.data)
+        returned_ids = {row["id"] for row in data["result"]}
+        assert deleted_id in returned_ids
+        assert live_id not in returned_ids
+
+        # Cleanup
+        _hard_delete_chart(live_id)
+        _hard_delete_chart(deleted_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_deleted_state_list_shows_owner_their_own_deleted(self) -> None:
+        """A non-admin owner can still enumerate their own soft-deleted charts.
+        Deleted-state scoping mirrors the restore audience, so it must not lock
+        owners out of their own trash."""
+        alpha_id = self.get_user(ALPHA_USERNAME).id
+        chart = self.insert_chart("sd_owner_chart", [alpha_id], 1)
+        chart_id = chart.id
+
+        chart.deleted_at = datetime(2026, 1, 1, 12, 0, 0)
+        db.session.commit()
+
+        self.login(ALPHA_USERNAME)
+        rison_query = (
+            "(filters:!((col:id,opr:chart_deleted_state,value:only)),page_size:200)"
+        )
+        rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
+        assert rv.status_code == 200
+        ids = [c["id"] for c in json.loads(rv.data)["result"]]
+        assert chart_id in ids
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_deleted_state_list_hides_non_owned_from_read_access_user(self) -> None:
+        """A read-access non-owner must not enumerate a chart once it is
+        soft-deleted.
+
+        Gamma is granted ``datasource_access`` to the chart's dataset, so
+        ``ChartFilter`` makes the chart visible to gamma while it is live. After
+        soft-delete, the deleted-state list is scoped to the restore audience
+        (owners/admins), so gamma — who could never restore it — must not see it
+        via ``include`` or ``only``.
+        """
+        admin_id = self.get_user(ADMIN_USERNAME).id
+        chart = self.insert_chart("sd_acl_chart", [admin_id], 1)
+        chart_id = chart.id
+
+        table = db.session.query(SqlaTable).get(1)
+        gamma_role = security_manager.find_role("Gamma")
+        pvm = security_manager.add_permission_view_menu("datasource_access", table.perm)
+        gamma_role.permissions.append(pvm)
+        db.session.commit()
+
+        try:
+            # Precondition: gamma can see the chart while it is live.
+            self.login(GAMMA_USERNAME)
+            rv = self.client.get("/api/v1/chart/?q=(page_size:200)")
+            assert chart_id in [c["id"] for c in json.loads(rv.data)["result"]], (
+                "precondition: gamma should see the live chart via datasource access"
+            )
+
+            # Soft-delete directly (avoids a mid-test re-login to admin).
+            reloaded = (
+                db.session.query(Slice)
+                .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
+                .filter(Slice.id == chart_id)
+                .one()
+            )
+            reloaded.deleted_at = datetime(2026, 1, 1, 12, 0, 0)
+            db.session.commit()
+
+            # Gamma must not see the soft-deleted chart in either mode.
+            for value in ("include", "only"):
+                rison_query = (
+                    f"(filters:!((col:id,opr:chart_deleted_state,value:{value})),"
+                    "page_size:200)"
+                )
+                rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
+                assert rv.status_code == 200
+                ids = [c["id"] for c in json.loads(rv.data)["result"]]
+                assert chart_id not in ids, (
+                    "read-access non-owner must not enumerate a soft-deleted "
+                    f"chart via chart_deleted_state={value}"
+                )
+        finally:
+            pvm = security_manager.find_permission_view_menu(
+                "datasource_access", table.perm
+            )
+            if pvm:
+                security_manager.del_permission_role(gamma_role, pvm)
+            db.session.commit()
+            _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_delete_already_soft_deleted_chart_returns_404(self) -> None:
+        """DELETE on an already soft-deleted chart returns 404 (FR-008)."""
+        admin_id = self.get_user("admin").id
+        chart = self.insert_chart("double_delete_test", [admin_id], 1)
+        chart_id = chart.id
+        self.login(ADMIN_USERNAME)
+
+        rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+        assert rv.status_code == 200
+        rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+        assert rv.status_code == 404
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_delete_chart_blocked_when_report_references_it(self) -> None:
+        """DELETE /api/v1/chart/<id> returns 422 when a report references it.
+
+        Pins down the existing API protection in `DeleteChartCommand.validate()`:
+        when *any* `report_schedule` row references the chart — active or
+        paused; `ReportScheduleDAO.find_by_chart_ids` has no active-only
+        predicate — the validation raises `ChartDeleteFailedReportsExistError`
+        *before* `ChartDAO.delete()` is invoked, so no soft-delete routing
+        happens. This is the contract soft-delete inherits from the
+        pre-existing API; the validate/commit race and flag-toggle windows it
+        cannot close are handled by the defensive guard in
+        `commands/report/execute.py:_get_url`.
+        """
+        admin_id = self.get_user("admin").id
+        chart = self.insert_chart("blocked_by_report_test", [admin_id], 1)
+        chart_id = chart.id
+
+        report = ReportSchedule(
+            type=ReportScheduleType.REPORT,
+            name="blocking_report_for_chart_delete",
+            description="Report that should block chart deletion",
+            crontab="0 9 * * *",
+            chart=chart,
+            creation_method=ReportCreationMethod.ALERTS_REPORTS,
+        )
+        db.session.add(report)
+        db.session.commit()
+        report_id = report.id
+
+        self.login(ADMIN_USERNAME)
+
+        rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+        assert rv.status_code == 422
+        body = json.loads(rv.data)
+        assert "associated alerts or reports" in body.get("message", "").lower() or (
+            "associated" in body.get("message", "").lower()
+            and "report" in body.get("message", "").lower()
+        )
+        assert "blocking_report_for_chart_delete" in body.get("message", "")
+
+        # Confirm the chart was NOT soft-deleted (deleted_at remains NULL).
+        row = (
+            db.session.query(Slice)
+            .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
+            .filter(Slice.id == chart_id)
+            .one()
+        )
+        assert row.deleted_at is None
+
+        # Cleanup
+        db.session.delete(
+            db.session.query(ReportSchedule)
+            .filter(ReportSchedule.id == report_id)
+            .one()
+        )
+        db.session.commit()
+        _hard_delete_chart(chart_id)
+
+
+class TestChartRestore(InsertChartMixin, SupersetTestCase):
+    """Tests for chart restore behaviour (T025)."""
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_restore_soft_deleted_chart(self) -> None:
+        """POST /api/v1/chart/<uuid>/restore makes the chart visible again."""
+        admin_id = self.get_user("admin").id
+        chart = self.insert_chart("restore_test", [admin_id], 1)
+        chart_id = chart.id
+        chart_uuid = str(chart.uuid)
+        self.login(ADMIN_USERNAME)
+
+        self.client.delete(f"/api/v1/chart/{chart_id}")
+        rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+        assert rv.status_code == 200
+
+        rv = self.client.get(f"/api/v1/chart/{chart_id}")
+        assert rv.status_code == 200
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_restore_failure_returns_422(self) -> None:
+        """A failure during restore surfaces as a clean 422 via the
+        ``ChartRestoreFailedError`` handler rather than an unhandled 500.
+
+        ``RestoreChartCommand.run`` wraps the restore in ``@transaction``
+        and rethrows ``ChartRestoreFailedError`` on any underlying
+        SQLAlchemy error; this pins that the endpoint maps it to 422.
+        """
+        from unittest.mock import patch
+
+        from superset.commands.chart.exceptions import (
+            ChartRestoreFailedError,
+        )
+
+        admin_id = self.get_user("admin").id
+        chart = self.insert_chart("restore_fail_test", [admin_id], 1)
+        chart_id = chart.id
+        chart_uuid = str(chart.uuid)
+        self.login(ADMIN_USERNAME)
+        self.client.delete(f"/api/v1/chart/{chart_id}")
+
+        with patch(
+            "superset.commands.chart.restore.RestoreChartCommand.run",
+            side_effect=ChartRestoreFailedError(),
+        ):
+            rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+        assert rv.status_code == 422
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_restore_nonexistent_chart_returns_404(self) -> None:
+        """POST /api/v1/chart/<uuid>/restore returns 404 for unknown UUID."""
+        self.login(ADMIN_USERNAME)
+        rv = self.client.post(
+            "/api/v1/chart/00000000-0000-0000-0000-000000000000/restore"
+        )
+        assert rv.status_code == 404
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_restore_active_chart_returns_404(self) -> None:
+        """POST /api/v1/chart/<uuid>/restore on active chart returns 404."""
+        admin_id = self.get_user("admin").id
+        chart = self.insert_chart("active_restore_test", [admin_id], 1)
+        chart_id = chart.id
+        chart_uuid = str(chart.uuid)
+        self.login(ADMIN_USERNAME)
+
+        rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+        assert rv.status_code == 404
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_restore_uses_can_write_permission(self) -> None:
+        """Non-admin owner with ``can_write_Chart`` can hit the restore
+        endpoint.
+
+        Pins the permission contract: ``method_permission_name`` must map
+        ``restore`` to ``write`` so FAB's ``@protect`` resolves the gate to
+        ``can_write_Chart`` (which Alpha already carries), not the implicit
+        fallback ``can_restore_Chart`` (which no standard role carries).
+
+        Without the mapping FAB defaults to ``can_<method>_<class>`` and
+        every non-admin would get 403 here — admins bypass FAB permission
+        checks entirely, so the admin-authed restore tests above don't
+        exercise the mapping.
+        """
+        alpha = self.get_user(ALPHA_USERNAME)
+        chart = self.insert_chart("restore_perm_test", [alpha.id], 1)
+        chart_id = chart.id
+        chart_uuid = str(chart.uuid)
+
+        self.login(ALPHA_USERNAME)
+        rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+        assert rv.status_code == 200, (
+            f"Alpha owner soft-delete failed: {rv.status_code} {rv.data!r}"
+        )
+
+        rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+        assert rv.status_code == 200, (
+            f"Expected 200 from Alpha owner restore (can_write_Chart), got "
+            f"{rv.status_code}: {rv.data!r}. If 403, "
+            "method_permission_name is missing 'restore': 'write'."
+        )
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_restore_chart_reattaches_to_dashboards(self) -> None:
+        """Soft-deleting a chart preserves dashboard_slices junction rows;
+        restore makes the chart reappear in its dashboards automatically.
+
+        This is the positive test that pins down the SIP's "no cascade"
+        contract and the corrected commit ``feat(soft-delete): preserve
+        dashboard_slices on chart soft-delete (MissingChart handles UI)``.
+        Soft-delete leaves the junction intact so:
+
+          - dashboards continue to render the chart slot (frontend uses
+            ``MissingChart`` placeholder while the chart is hidden via the
+            visibility filter)
+          - on restore the chart is automatically a member of every
+            dashboard it was a member of before, with no manual
+            re-attachment step
+        """
+        admin = self.get_user("admin")
+        admin_id = admin.id
+
+        chart = self.insert_chart("reattach_test_chart", [admin_id], 1)
+        chart_id = chart.id
+        chart_uuid = str(chart.uuid)
+
+        dashboard = Dashboard(
+            dashboard_title="reattach_test_dashboard",
+            slug="slug_reattach_test",
+            owners=[admin],
+            published=True,
+        )
+        dashboard.slices = [chart]
+        db.session.add(dashboard)
+        db.session.commit()
+        dashboard_id = dashboard.id
+
+        # Sanity: the junction row exists
+        junction_count = (
+            db.session.query(dashboard_slices)
+            .filter(
+                dashboard_slices.c.dashboard_id == dashboard_id,
+                dashboard_slices.c.slice_id == chart_id,
+            )
+            .count()
+        )
+        assert junction_count == 1, "junction row should exist after dashboard creation"
+
+        self.login(ADMIN_USERNAME)
+
+        # Soft-delete the chart
+        rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+        assert rv.status_code == 200
+
+        # The junction row is preserved (no cascade)
+        junction_count_after_delete = (
+            db.session.query(dashboard_slices)
+            .filter(
+                dashboard_slices.c.dashboard_id == dashboard_id,
+                dashboard_slices.c.slice_id == chart_id,
+            )
+            .count()
+        )
+        assert junction_count_after_delete == 1, (
+            "junction row should remain intact on chart soft-delete; "
+            "MissingChart placeholder handles the UI gap"
+        )
+
+        # The dashboard's loaded `slices` collection no longer includes the
+        # soft-deleted chart (the global visibility filter applies to
+        # relationship loads via `with_loader_criteria(..., include_aliases=True)`).
+        db.session.expire_all()
+        dashboard_after_delete = (
+            db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
+        )
+        assert chart_id not in [s.id for s in dashboard_after_delete.slices], (
+            "soft-deleted chart should be filtered out of dashboard.slices "
+            "by the visibility-filter listener"
+        )
+
+        # Restore the chart
+        rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+        assert rv.status_code == 200
+
+        # The chart automatically reappears in the dashboard — junction row
+        # was preserved, so no manual reattach was needed.
+        db.session.expire_all()
+        dashboard_after_restore = (
+            db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
+        )
+        assert chart_id in [s.id for s in dashboard_after_restore.slices], (
+            "restored chart should reappear in dashboard.slices automatically; "
+            "the junction row was never removed by soft-delete"
+        )
+
+        # Cleanup
+        _hard_delete_dashboard_for_charts_test(dashboard_id)
+        _hard_delete_chart(chart_id)
+
+    @with_feature_flags(SOFT_DELETE=True)
+    def test_restore_chart_by_non_admin_owner(self) -> None:
+        """Non-admin owners can restore their own soft-deleted charts.
+
+        The unit-level restore command tests mock security; this
+        integration test exercises the FAB security wiring end-to-end
+        so a future change that breaks the owner check on a non-admin
+        path can't slip through.
+        """
+        alpha = self.get_user(ALPHA_USERNAME)
+        alpha_id = alpha.id
+
+        chart = self.insert_chart("alpha_owned_chart", [alpha_id], 1)
+        chart_id = chart.id
+        chart_uuid = str(chart.uuid)
+
+        self.login(ALPHA_USERNAME)
+        rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+        assert rv.status_code == 200
+
+        rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+        assert rv.status_code == 200, rv.data
+
+        db.session.expire_all()
+        restored = db.session.query(Slice).filter(Slice.id == chart_id).one_or_none()
+        assert restored is not None
+        assert restored.deleted_at is None
+
+        # Cleanup
+        _hard_delete_chart(chart_id)
diff --git a/tests/unit_tests/charts/commands/importers/v1/import_test.py b/tests/unit_tests/charts/commands/importers/v1/import_test.py
index 42a4afb779c..31094115dee 100644
--- a/tests/unit_tests/charts/commands/importers/v1/import_test.py
+++ b/tests/unit_tests/charts/commands/importers/v1/import_test.py
@@ -18,6 +18,7 @@
 
 import copy
 from collections.abc import Generator
+from datetime import datetime, timezone
 from unittest.mock import patch
 
 import pytest
@@ -287,6 +288,186 @@ def test_import_existing_chart_with_permission(
     mock_can_access_chart.assert_called_once_with(slice)
 
 
+def _soft_delete_existing_chart(session: Session) -> int:
+    """Soft-delete the seeded chart (by fixture UUID) and return its original id.
+
+    Shared setup for the soft-delete import tests: locate the chart, stamp
+    ``deleted_at``, flush, and return the id so callers can assert the restore
+    happened in place (same id).
+    """
+    existing = (
+        session.query(Slice).filter(Slice.uuid == chart_config["uuid"]).one_or_none()
+    )
+    assert existing is not None
+    existing.deleted_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+    session.flush()
+    return existing.id
+
+
+def test_import_soft_deleted_chart_overwrite_restores_in_place(
+    mocker: MockerFixture,
+    session_with_data: Session,
+) -> None:
+    """
+    Overwrite-importing a soft-deleted chart must restore the row in place,
+    not hard-delete-and-replace. Otherwise out-of-archive references
+    (dashboard_slices junctions, report.chart_id) would cascade away.
+    """
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mocker.patch.object(security_manager, "can_access_chart", return_value=True)
+
+    original_id = _soft_delete_existing_chart(session_with_data)
+
+    admin = User(
+        first_name="Alice",
+        last_name="Doe",
+        email="adoe@example.org",
+        username="admin",
+        roles=[Role(name="Admin")],
+    )
+
+    config = copy.deepcopy(chart_config)
+    config["datasource_id"] = 1
+    config["datasource_type"] = "table"
+
+    with override_user(admin):
+        chart = import_chart(config, overwrite=True)
+
+    assert chart.id == original_id
+    assert chart.deleted_at is None
+
+
+def test_import_soft_deleted_chart_ignore_permissions_restores_in_place(
+    mocker: MockerFixture,
+    session_with_data: Session,
+) -> None:
+    """
+    The example loader path: ignore_permissions=True with no logged-in
+    user. The if/elif structure must preserve config["id"] on the
+    fallthrough overwrite path so the example loader can re-import over
+    a soft-deleted match without colliding on the UUID unique index.
+    """
+    original_id = _soft_delete_existing_chart(session_with_data)
+
+    config = copy.deepcopy(chart_config)
+    config["datasource_id"] = 1
+    config["datasource_type"] = "table"
+
+    chart = import_chart(config, overwrite=True, ignore_permissions=True)
+
+    assert chart.id == original_id
+    assert chart.deleted_at is None
+
+
+def test_import_soft_deleted_chart_non_overwrite_restores_for_owner(
+    mocker: MockerFixture,
+    session_with_data: Session,
+) -> None:
+    """
+    Non-overwrite re-import of a soft-deleted UUID is implicitly a
+    restore-and-update: the user is bringing the chart back by uploading
+    it again. The same ownership rule as the overwrite path applies, so
+    an owner (or admin) succeeds without setting overwrite=True.
+    """
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mocker.patch.object(security_manager, "can_access_chart", return_value=True)
+
+    original_id = _soft_delete_existing_chart(session_with_data)
+
+    admin = User(
+        first_name="Alice",
+        last_name="Doe",
+        email="adoe@example.org",
+        username="admin",
+        roles=[Role(name="Admin")],
+    )
+
+    config = copy.deepcopy(chart_config)
+    config["datasource_id"] = 1
+    config["datasource_type"] = "table"
+
+    with override_user(admin):
+        chart = import_chart(config, overwrite=False)
+
+    assert chart.id == original_id
+    assert chart.deleted_at is None
+
+
+def test_import_soft_deleted_chart_non_overwrite_raises_for_non_owner(
+    mocker: MockerFixture,
+    session_with_data: Session,
+) -> None:
+    """
+    Non-overwrite re-import that would resurrect a soft-deleted chart
+    must respect ownership: a non-owner without admin role cannot
+    restore-via-import. Mirrors the explicit /restore endpoint's check.
+    """
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mocker.patch.object(security_manager, "can_access_chart", return_value=True)
+
+    _soft_delete_existing_chart(session_with_data)
+
+    non_owner = User(
+        first_name="Bob",
+        last_name="Roe",
+        email="bob@example.org",
+        username="bob",
+        roles=[Role(name="Gamma")],
+    )
+
+    with override_user(non_owner):
+        with pytest.raises(ImportFailedError) as excinfo:
+            import_chart(chart_config, overwrite=False)
+    assert "permissions to restore" in str(excinfo.value)
+
+
+def test_import_soft_deleted_chart_raises_when_caller_lacks_can_write(
+    mocker: MockerFixture,
+    session_with_data: Session,
+) -> None:
+    """
+    Case B: re-import of a soft-deleted UUID by a caller without
+    can_write must raise, not silently return the soft-deleted row.
+
+    Real-world scenario: a user has can_write Dashboard but not
+    can_write Chart, and they import a dashboard zip that references a
+    soft-deleted chart. Silently returning the row would let the
+    dashboard importer reattach to it via chart_ids[uuid] = existing.id
+    and produce a dashboard with hidden (broken) charts.
+    """
+    mocker.patch.object(security_manager, "can_access", return_value=False)
+
+    _soft_delete_existing_chart(session_with_data)
+
+    with pytest.raises(ImportFailedError) as excinfo:
+        import_chart(chart_config, overwrite=False)
+    assert "can_write" in str(excinfo.value)
+
+
+def test_import_existing_active_chart_overwrite_without_can_write_returns_existing(
+    mocker: MockerFixture,
+    session_with_data: Session,
+) -> None:
+    """
+    An *active* (not soft-deleted) chart re-imported with overwrite=True by a
+    caller without can_write must fall through to returning the existing row,
+    not raise the restore error. Case B is keyed on ``is_soft_deleted``, so the
+    fused ``needs_mutation`` condition must not pull active rows into the
+    restore-without-permission branch (pre-soft-delete overwrite behaviour).
+    """
+    mocker.patch.object(security_manager, "can_access", return_value=False)
+
+    existing = (
+        session_with_data.query(Slice).filter(Slice.uuid == chart_config["uuid"]).one()
+    )
+    assert existing.deleted_at is None
+
+    result = import_chart(chart_config, overwrite=True)
+
+    assert result.id == existing.id
+    assert result.deleted_at is None
+
+
 def test_import_tag_logic_for_charts(session_with_schema: Session):
     contents = {
         "tags.yaml": yaml.dump(
diff --git a/tests/unit_tests/commands/chart/restore_test.py b/tests/unit_tests/commands/chart/restore_test.py
new file mode 100644
index 00000000000..62309a50366
--- /dev/null
+++ b/tests/unit_tests/commands/chart/restore_test.py
@@ -0,0 +1,90 @@
+# 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.
+"""Unit tests for RestoreChartCommand."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from superset.commands.chart.exceptions import (
+    ChartForbiddenError,
+    ChartNotFoundError,
+)
+from superset.commands.chart.restore import RestoreChartCommand
+from superset.exceptions import SupersetSecurityException
+
+
+def test_restore_chart_clears_deleted_at(app_context: None) -> None:
+    """RestoreChartCommand.run() restores a soft-deleted chart."""
+    chart = MagicMock()
+    chart.deleted_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
+    chart.id = 1
+
+    with (
+        patch(
+            "superset.daos.chart.ChartDAO.find_by_id", return_value=chart
+        ) as mock_find,
+        patch("superset.commands.restore.security_manager") as mock_sec,
+    ):
+        mock_sec.raise_for_ownership.return_value = None
+
+        cmd = RestoreChartCommand("1")
+        cmd.run()
+
+    mock_find.assert_called_once()
+    chart.restore.assert_called_once()
+
+
+def test_restore_chart_not_found_raises(app_context: None) -> None:
+    """RestoreChartCommand raises ChartNotFoundError for missing chart."""
+    with patch("superset.daos.chart.ChartDAO.find_by_id", return_value=None):
+        cmd = RestoreChartCommand("999")
+        with pytest.raises(ChartNotFoundError):
+            cmd.run()
+
+
+def test_restore_active_chart_raises_not_found(app_context: None) -> None:
+    """RestoreChartCommand raises ChartNotFoundError for non-deleted chart."""
+    chart = MagicMock()
+    chart.deleted_at = None  # not soft-deleted
+
+    with patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart):
+        cmd = RestoreChartCommand("1")
+        with pytest.raises(ChartNotFoundError):
+            cmd.run()
+
+
+def test_restore_chart_forbidden_raises(app_context: None) -> None:
+    """RestoreChartCommand raises ChartForbiddenError on permission check."""
+    chart = MagicMock()
+    chart.deleted_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
+
+    def raise_security(*args: object, **kwargs: object) -> None:
+        raise SupersetSecurityException(MagicMock())
+
+    with (
+        patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart),
+        patch("superset.commands.restore.security_manager") as mock_sec,
+    ):
+        mock_sec.raise_for_ownership = raise_security
+
+        cmd = RestoreChartCommand("1")
+        with pytest.raises(ChartForbiddenError):
+            cmd.run()
diff --git a/tests/unit_tests/commands/report/execute_test.py b/tests/unit_tests/commands/report/execute_test.py
index 73937d53dd0..99d9356c653 100644
--- a/tests/unit_tests/commands/report/execute_test.py
+++ b/tests/unit_tests/commands/report/execute_test.py
@@ -2012,3 +2012,73 @@ def test_get_url_for_csv_uses_post_processed_type(
         f"CSV report URL must use type=post_processed so chart filters "
         f"(incl. time filters) are applied; got: {url}; see issue #25538"
     )
+
+
+def test_get_url_raises_when_target_chart_soft_deleted(
+    mocker: MockerFixture,
+    app_context: None,
+) -> None:
+    """A dangling chart reference must fail loudly, not fall through.
+
+    Soft delete removed the FK-level guarantee that a report's target chart
+    exists: ``ReportSchedule.chart`` is a visibility-filtered relationship,
+    so a chart soft-deleted after the report was created loads as ``None``
+    while ``chart_id`` is still set. Pre-guard, ``_get_url`` silently fell
+    through to the dashboard branch (``dashboard`` also ``None``) and failed
+    opaquely; it must instead raise the dedicated, actionable error inside
+    the state-machine envelope.
+    """
+    from superset.commands.report.exceptions import (
+        ReportScheduleTargetChartDeletedError,
+    )
+
+    report_schedule = mocker.MagicMock()
+    report_schedule.chart_id = 42
+    report_schedule.chart = None
+
+    state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
+    with pytest.raises(ReportScheduleTargetChartDeletedError):
+        state._get_url()
+
+
+def test_get_url_raises_when_target_dashboard_soft_deleted(
+    mocker: MockerFixture,
+    app_context: None,
+) -> None:
+    """Symmetric twin of the chart-target guard: a dashboard report whose
+    visibility-filtered ``dashboard`` relationship loads ``None`` while
+    ``dashboard_id`` is set must raise the dedicated error, not fall into
+    ``dashboard.id`` on ``None`` (an opaque ``AttributeError``)."""
+    from superset.commands.report.exceptions import (
+        ReportScheduleTargetDashboardDeletedError,
+    )
+
+    report_schedule = mocker.MagicMock()
+    report_schedule.chart_id = None
+    report_schedule.chart = None
+    report_schedule.dashboard_id = 7
+    report_schedule.dashboard = None
+
+    state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
+    with pytest.raises(ReportScheduleTargetDashboardDeletedError):
+        state._get_url()
+
+
+def test_get_dashboard_urls_raises_when_target_dashboard_soft_deleted(
+    mocker: MockerFixture,
+    app_context: None,
+) -> None:
+    """``get_dashboard_urls`` is entered directly from the async command's
+    permalink pre-commit (bypassing ``_get_url``), so it needs — and has —
+    the same deleted-target guard."""
+    from superset.commands.report.exceptions import (
+        ReportScheduleTargetDashboardDeletedError,
+    )
+
+    report_schedule = mocker.MagicMock()
+    report_schedule.dashboard_id = 7
+    report_schedule.dashboard = None
+
+    state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
+    with pytest.raises(ReportScheduleTargetDashboardDeletedError):
+        state.get_dashboard_urls()
diff --git a/tests/unit_tests/dao/dashboard_test.py b/tests/unit_tests/dao/dashboard_test.py
new file mode 100644
index 00000000000..b9694ee0d51
--- /dev/null
+++ b/tests/unit_tests/dao/dashboard_test.py
@@ -0,0 +1,119 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from datetime import datetime, timezone
+from typing import Any
+
+from sqlalchemy.orm.session import Session
+
+from superset import db
+from superset.connectors.sqla.models import Database, SqlaTable
+from superset.daos.dashboard import DashboardDAO
+from superset.models.dashboard import Dashboard
+from superset.models.slice import Slice
+from tests.unit_tests.conftest import with_feature_flags
+
+
+@with_feature_flags(SOFT_DELETE=True)
+def test_set_dash_metadata_preserves_soft_deleted_members(
+    session: Session,
+) -> None:
+    """Saving a dashboard must not sever a soft-deleted member chart.
+
+    ``set_dash_metadata`` rebuilds ``dashboard.slices`` wholesale from the
+    incoming position data. The soft-delete visibility filter must be
+    bypassed for the whole rebuild — the slice-resolution query AND the
+    collection assignment:
+
+    - Filtered resolution would silently drop the trashed member from the
+      new collection — deleting its ``dashboard_slices`` junction row
+      (breaking the restore-reattach contract) and writing ``uuid: None``
+      into its position slot.
+    - A filtered *baseline* load (the unit of work lazy-loads the existing
+      collection when diffing the assignment) would exclude the trashed
+      member from the old collection, so the diff treats it as net-new and
+      INSERTs a duplicate ``dashboard_slices`` row — an IntegrityError on
+      the composite PK on every save of a dashboard containing a trashed
+      chart.
+
+    The test reproduces the production shape: SOFT_DELETE enabled (the
+    listener actually filters), the collection expired (as with the fresh
+    ``find_by_id`` load in the PUT flow), and a flush afterwards so the
+    diff's SQL actually hits the composite-PK junction table. It fails on
+    either a missing resolution bypass or a query-scoped-only bypass.
+    """
+    Dashboard.metadata.create_all(session.get_bind())
+
+    dataset = SqlaTable(
+        table_name="dash_meta_table",
+        database=Database(database_name="dash_meta_db", sqlalchemy_uri="sqlite://"),
+    )
+    db.session.add(dataset)
+    db.session.flush()
+
+    live_chart = Slice(
+        slice_name="live_chart",
+        datasource_id=dataset.id,
+        datasource_type="table",
+    )
+    trashed_chart = Slice(
+        slice_name="trashed_chart",
+        datasource_id=dataset.id,
+        datasource_type="table",
+        deleted_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
+    )
+    dashboard = Dashboard(
+        dashboard_title="meta_test_dash",
+        slices=[live_chart, trashed_chart],
+        published=True,
+    )
+    db.session.add_all([live_chart, trashed_chart, dashboard])
+    db.session.flush()
+
+    # Production shape: the PUT flow loads a fresh Dashboard whose
+    # ``slices`` collection is unloaded; expiring forces the baseline
+    # reload through the visibility listener during the assignment.
+    db.session.expire(dashboard, ["slices"])
+
+    positions: dict[str, dict[str, Any]] = {
+        "CHART-live": {
+            "type": "CHART",
+            "id": "CHART-live",
+            "children": [],
+            "meta": {"chartId": live_chart.id, "width": 4, "height": 50},
+        },
+        "CHART-trashed": {
+            "type": "CHART",
+            "id": "CHART-trashed",
+            "children": [],
+            "meta": {"chartId": trashed_chart.id, "width": 4, "height": 50},
+        },
+    }
+
+    DashboardDAO.set_dash_metadata(dashboard, {"positions": positions})
+    # Flush so the collection diff's SQL reaches the composite-PK junction
+    # table — a duplicate INSERT fails here, not at assignment time.
+    db.session.flush()
+
+    member_ids = {chart.id for chart in dashboard.slices}
+    assert live_chart.id in member_ids
+    assert trashed_chart.id in member_ids, (
+        "soft-deleted member chart was severed from dashboard.slices; "
+        "set_dash_metadata must bypass the visibility filter when "
+        "resolving incoming chart ids"
+    )
+    # And the position slot kept its UUID rather than being nulled.
+    assert positions["CHART-trashed"]["meta"]["uuid"] == str(trashed_chart.uuid)
diff --git a/tests/unit_tests/migrations/test_add_deleted_at_to_slices.py b/tests/unit_tests/migrations/test_add_deleted_at_to_slices.py
new file mode 100644
index 00000000000..7a46c4da4cc
--- /dev/null
+++ b/tests/unit_tests/migrations/test_add_deleted_at_to_slices.py
@@ -0,0 +1,190 @@
+# 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.
+"""Tests for migration ``7c4a8d09ca37_add_deleted_at_to_slices``.
+
+Runs the migration's ``upgrade()`` and ``downgrade()`` against an
+in-memory SQLite engine with a real Alembic ``Operations`` context.
+The behaviour being pinned is the operator-facing contract documented
+in ``UPDATING.md``: ``downgrade()`` reverses the schema but does not
+hard-delete or otherwise mutate rows that were soft-deleted before
+the migration was reversed — those rows survive the downgrade and
+become visible to any code path that no longer applies the
+soft-delete visibility filter.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from importlib import import_module
+
+import pytest
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy import (
+    Column,
+    create_engine,
+    insert,
+    inspect,
+    Integer,
+    MetaData,
+    select,
+    String,
+    Table,
+)
+from sqlalchemy.engine import Engine
+
+migration = import_module(
+    "superset.migrations.versions."
+    "2026-05-08_12-00_7c4a8d09ca37_add_deleted_at_to_slices"
+)
+
+TABLE_NAME: str = migration.TABLE_NAME  # "slices"
+INDEX_NAME: str = migration.INDEX_NAME  # "ix_slices_deleted_at"
+
+
+@pytest.fixture
+def engine() -> Engine:
+    """In-memory SQLite seeded with a minimal pre-migration ``slices`` table.
+
+    The real ``slices`` table has many columns; the migration only touches
+    ``deleted_at`` and its index, so only the columns that participate in
+    the test are seeded.
+    """
+    engine = create_engine("sqlite:///:memory:")
+    md = MetaData()
+    Table(
+        TABLE_NAME,
+        md,
+        Column("id", Integer, primary_key=True),
+        Column("slice_name", String(250), nullable=False),
+    )
+    md.create_all(engine)
+    return engine
+
+
+def _columns(engine: Engine) -> set[str]:
+    """Current column names on the target ``slices`` table."""
+    return {col["name"] for col in inspect(engine).get_columns(TABLE_NAME)}
+
+
+def _indexes(engine: Engine) -> set[str]:
+    """Current index names on the target ``slices`` table."""
+    return {ix["name"] for ix in inspect(engine).get_indexes(TABLE_NAME)}
+
+
+def test_upgrade_adds_deleted_at_column_and_index(engine: Engine) -> None:
+    """upgrade() adds the nullable ``deleted_at`` column and its index."""
+    with engine.connect() as conn:
+        ctx = MigrationContext.configure(conn)
+        with Operations.context(ctx):
+            migration.upgrade()
+
+    assert "deleted_at" in _columns(engine), "upgrade() must add the deleted_at column"
+    assert INDEX_NAME in _indexes(engine), (
+        "upgrade() must create the supporting index on deleted_at"
+    )
+
+
+def test_downgrade_drops_deleted_at_column_and_index(engine: Engine) -> None:
+    """downgrade() removes both schema artifacts (column and index)."""
+    with engine.connect() as conn:
+        ctx = MigrationContext.configure(conn)
+        with Operations.context(ctx):
+            migration.upgrade()
+            migration.downgrade()
+
+    assert "deleted_at" not in _columns(engine), (
+        "downgrade() must drop the deleted_at column"
+    )
+    assert INDEX_NAME not in _indexes(engine), (
+        "downgrade() must drop the supporting index"
+    )
+
+
+def test_downgrade_preserves_soft_deleted_row_data(engine: Engine) -> None:
+    """Pin the operator-facing contract from ``UPDATING.md``: rows that
+    were soft-deleted before the migration is reversed survive the
+    downgrade. The ``deleted_at`` column is gone, so those rows are
+    indistinguishable from live rows to any code path that no longer
+    applies the visibility filter — operators must decide on
+    hard-delete, restore, or rename BEFORE downgrading. See the
+    "Rollback note" in ``UPDATING.md``.
+    """
+    with engine.connect() as conn:
+        ctx = MigrationContext.configure(conn)
+        with Operations.context(ctx):
+            migration.upgrade()
+
+            slices = Table(TABLE_NAME, MetaData(), autoload_with=conn)
+            conn.execute(
+                insert(slices).values(
+                    [
+                        {
+                            "id": 1,
+                            "slice_name": "live_chart",
+                            "deleted_at": None,
+                        },
+                        {
+                            "id": 2,
+                            "slice_name": "archived_chart",
+                            "deleted_at": datetime(2026, 1, 1, 12, 0, 0),
+                        },
+                    ]
+                )
+            )
+
+            migration.downgrade()
+
+            slices_after = Table(TABLE_NAME, MetaData(), autoload_with=conn)
+            rows = conn.execute(
+                select(slices_after).order_by(slices_after.c.id)
+            ).fetchall()
+
+    assert [(r.id, r.slice_name) for r in rows] == [
+        (1, "live_chart"),
+        (2, "archived_chart"),
+    ], (
+        "downgrade() must not delete or mutate row data — soft-deleted "
+        "rows become indistinguishable from live rows but they remain"
+    )
+    assert "deleted_at" not in {
+        c["name"] for c in inspect(engine).get_columns(TABLE_NAME)
+    }
+
+
+def test_upgrade_is_idempotent(engine: Engine) -> None:
+    """The migration helpers (``add_columns``, ``create_index``) are
+    idempotent skip-if-exists; running ``upgrade()`` twice must not
+    raise.
+    """
+    with engine.connect() as conn:
+        ctx = MigrationContext.configure(conn)
+        with Operations.context(ctx):
+            migration.upgrade()
+            migration.upgrade()
+
+
+def test_downgrade_is_idempotent(engine: Engine) -> None:
+    """``drop_columns`` / ``drop_index`` are skip-if-not-exists; running
+    ``downgrade()`` twice must not raise.
+    """
+    with engine.connect() as conn:
+        ctx = MigrationContext.configure(conn)
+        with Operations.context(ctx):
+            migration.upgrade()
+            migration.downgrade()
+            migration.downgrade()