diff --git a/src/backend/migrations/versions/014_vision_fk_ondelete.py b/src/backend/migrations/versions/014_vision_fk_ondelete.py
new file mode 100644
index 0000000..4bef791
--- /dev/null
+++ b/src/backend/migrations/versions/014_vision_fk_ondelete.py
@@ -0,0 +1,76 @@
+"""vision results and reference images cascade with their task
+
+Both `vision_results.task_id` and `vision_reference_images.task_id` were
+created (012, 013) with a plain, unnamed foreign key - no ON DELETE clause,
+so deleting a task with any camera history or reference image attached fails
+on a strict FK check (MySQL) instead of taking the vision rows with it, the
+way `production_events.run_id` already does for a closed production run.
+
+The original constraint has no name to drop by (SQLAlchemy leaves inline
+`ForeignKey(...)` unnamed unless told otherwise, and MySQL then assigns its
+own `
_ibfk_N`, which is not portable to reflect on). `sa.inspect`
+looks it up at migration time instead of guessing it - the same way on
+MySQL and on SQLite (batch mode), where an anonymous FK reflects with
+`name=None` and there is nothing to drop before adding the new one.
+
+Reference images on disk are not touched here - deleting a task's row does
+not, on its own, know to also delete the file next to it; that gap is
+declared open in issue #38 point 6's write-up (`vision_service` has no
+single point today that intercepts a task deletion).
+
+Revision ID: 014_vision_fk_ondelete
+Revises: 013_reference_images
+Create Date: 2026-09-21
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+revision: str = '014_vision_fk_ondelete'
+down_revision: Union[str, None] = '013_reference_images'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+_NEW_FK_NAMES = {
+ 'vision_results': 'fk_vision_results_task_id',
+ 'vision_reference_images': 'fk_vision_reference_images_task_id',
+}
+
+
+def _existing_task_id_fk_name(bind, table_name: str) -> Union[str, None]:
+ """The name reflection gives the pre-existing `task_id` FK, if any.
+
+ None on SQLite for a constraint that was never named - batch mode then
+ has nothing to drop, and just adds the new named one alongside it.
+ """
+ inspector = sa.inspect(bind)
+ for fk in inspector.get_foreign_keys(table_name):
+ if fk.get("constrained_columns") == ["task_id"]:
+ return fk.get("name")
+ return None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ for table_name, new_name in _NEW_FK_NAMES.items():
+ existing_name = _existing_task_id_fk_name(bind, table_name)
+ with op.batch_alter_table(table_name) as batch:
+ if existing_name:
+ batch.drop_constraint(existing_name, type_='foreignkey')
+ batch.create_foreign_key(
+ new_name, 'recipe_tasks', ['task_id'], ['id'],
+ ondelete='CASCADE',
+ )
+
+
+def downgrade() -> None:
+ # Reverse order: reference_images before results, mirroring upgrade().
+ for table_name, name in reversed(list(_NEW_FK_NAMES.items())):
+ with op.batch_alter_table(table_name) as batch:
+ batch.drop_constraint(name, type_='foreignkey')
+ batch.create_foreign_key(
+ name, 'recipe_tasks', ['task_id'], ['id'],
+ )
diff --git a/src/backend/models/orm/vision.py b/src/backend/models/orm/vision.py
index cdada5c..f51bae1 100644
--- a/src/backend/models/orm/vision.py
+++ b/src/backend/models/orm/vision.py
@@ -13,7 +13,8 @@ class VisionResult(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(
- Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
+ Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
+ nullable=False, index=True,
)
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
@@ -48,7 +49,8 @@ class VisionReferenceImage(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(
- Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
+ Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
+ nullable=False, index=True,
)
path: Mapped[str] = mapped_column(String(500), nullable=False)
station_id: Mapped[Optional[int]] = mapped_column(
diff --git a/src/backend/tests/test_vision_graph.py b/src/backend/tests/test_vision_graph.py
index ec7a151..924a410 100644
--- a/src/backend/tests/test_vision_graph.py
+++ b/src/backend/tests/test_vision_graph.py
@@ -4,6 +4,7 @@ from sqlalchemy import select
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask
+from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.tests.conftest import create_test_recipe
@@ -69,3 +70,11 @@ def test_the_typo_is_gone():
"""`xf_compare` era un refuso: si corregge finché nessuna riga lo usa."""
assert "dxf_compare" in TASK_TYPES
assert "xf_compare" not in TASK_TYPES
+
+
+@pytest.mark.parametrize("model", [VisionResult, VisionReferenceImage])
+def test_task_id_cascades_when_the_task_is_deleted(model):
+ """#38.6: cancellare un task con esecuzioni/immagini non deve fallire su
+ un vincolo FK MySQL - la riga di visione deve seguirlo."""
+ (fk,) = model.__table__.columns["task_id"].foreign_keys
+ assert fk.ondelete == "CASCADE"