diff --git a/chat/web/drawer.py b/chat/web/drawer.py
index 3a8a6d0..9ac1ab8 100644
--- a/chat/web/drawer.py
+++ b/chat/web/drawer.py
@@ -1211,6 +1211,93 @@ async def switch_branch(
return await drawer(chat_id, request, conn)
+@router.get(
+ "/chats/{chat_id}/drawer/turn/delete-preview/{event_id}",
+ response_class=HTMLResponse,
+)
+async def delete_preview(
+ chat_id: str,
+ event_id: int,
+ request: Request,
+ conn=Depends(get_conn),
+):
+ """Render an :class:`ImpactReport` for ``event_id`` as a small modal.
+
+ Read-only — :func:`compute_delete_impact` does not mutate the
+ database. The modal contains a confirmation form posting to
+ :func:`delete_turn` below; HTMX swaps the fragment into a modal
+ target on the chat page.
+ """
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+
+ report = compute_delete_impact(conn, target_event_id=int(event_id))
+
+ # Build the modal HTML directly — the impact report is small and
+ # reusing the drawer template would require a fragment include just
+ # for this surface. Mirrors the rewind-preview style in
+ # :func:`chat.web.turns.rewind_preview`.
+ items_html = "".join(
+ f"
{item.kind}: {item.description}"
+ for item in report.cascading
+ )
+ notes_html = "".join(f"{note}" for note in report.notes)
+ body = (
+ ""
+ f"
Delete event {report.target_event_id}?
"
+ f"
This will discard {len(report.cascading)} events. Cascade:
"
+ f"
"
+ f"
"
+ f"
"
+ "
"
+ )
+ return HTMLResponse(body)
+
+
+@router.post(
+ "/chats/{chat_id}/drawer/turn/delete/{event_id}",
+ response_class=HTMLResponse,
+)
+async def delete_turn(
+ chat_id: str,
+ event_id: int,
+ request: Request,
+ conn=Depends(get_conn),
+):
+ """Delete a turn (and everything after) by invoking the existing rewind path.
+
+ The :func:`chat.services.rewind.execute_rewind` API takes
+ ``after_event_id``: it removes events with id strictly greater than
+ that argument. To make ``event_id`` itself disappear we pass
+ ``after_event_id = event_id - 1`` — a thin adapter, not a
+ re-implementation of rewind.
+
+ A snapshot is taken before truncation (inside ``execute_rewind``)
+ so the user can recover via the snapshot index.
+ """
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+
+ settings = request.app.state.settings
+ execute_rewind(
+ db_path=settings.db_path,
+ data_dir=settings.data_dir,
+ after_event_id=int(event_id) - 1,
+ )
+ # ``conn`` is now stale (the rewind opened its own connection and
+ # truncated/reprojected). Re-render the drawer through a fresh open
+ # so the partial reflects the truncated state.
+ from chat.db.connection import open_db
+
+ with open_db(settings.db_path) as fresh:
+ return await drawer(chat_id, request, fresh)
+
+
@router.post(
"/chats/{chat_id}/drawer/turn/hide/{event_id}",
response_class=HTMLResponse,
diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py
index 30f3336..9ec4a66 100644
--- a/tests/test_drawer_phase4.py
+++ b/tests/test_drawer_phase4.py
@@ -383,3 +383,76 @@ def test_t98_3_hidden_turn_disappears_from_read_recent_dialogue(
after_ids = [t["event_id"] for t in after]
assert user_id not in after_ids
assert bot_id in after_ids # the unhidden bot turn still surfaces
+
+
+# ---------------------------------------------------------------------------
+# T98.4 — surgical delete with cascade preview.
+# ---------------------------------------------------------------------------
+
+
+def test_t98_4_delete_preview_returns_impact_report_html(client, tmp_path):
+ db = tmp_path / "test.db"
+ _seed_chat(db)
+ user_id, bot_id = _seed_turns(db)
+
+ response = client.get(
+ f"/chats/chat_bot_a/drawer/turn/delete-preview/{user_id}"
+ )
+ assert response.status_code == 200
+ body = response.text
+
+ # Modal markup with the event id and the cascade list.
+ assert "delete-impact-modal" in body
+ assert f"Delete event {user_id}?" in body
+ assert "delete-impact-cascade" in body
+ # Both turns ride along in the cascade — user_turn at user_id, then
+ # the assistant_turn at bot_id (>= user_id).
+ assert "user_turn" in body
+ assert "assistant_turn" in body
+ # Confirm-form posts to the delete route.
+ assert f"/drawer/turn/delete/{user_id}" in body
+
+
+def test_t98_4_delete_invokes_rewind_and_drops_cascade(client, tmp_path):
+ db = tmp_path / "test.db"
+ _seed_chat(db)
+ user_id, bot_id = _seed_turns(db)
+
+ # Append a third turn after the assistant_turn so we can verify the
+ # cascade catches everything from user_id forward.
+ with open_db(db) as conn:
+ extra_id = append_and_apply(
+ conn,
+ kind="user_turn",
+ payload={
+ "chat_id": "chat_bot_a",
+ "prose": "follow-up",
+ "segments": [],
+ },
+ )
+
+ # Sanity: all three turn rows exist.
+ with open_db(db) as conn:
+ turn_count = conn.execute(
+ "SELECT COUNT(*) FROM event_log "
+ "WHERE kind IN ('user_turn', 'assistant_turn')"
+ ).fetchone()[0]
+ assert turn_count == 3
+
+ # Delete from user_id forward.
+ response = client.post(f"/chats/chat_bot_a/drawer/turn/delete/{user_id}")
+ assert response.status_code == 200
+
+ # All three turns are gone — the rewind truncated the log past
+ # user_id - 1, removing user_id, bot_id, and extra_id.
+ with open_db(db) as conn:
+ turn_count = conn.execute(
+ "SELECT COUNT(*) FROM event_log "
+ "WHERE kind IN ('user_turn', 'assistant_turn')"
+ ).fetchone()[0]
+ assert turn_count == 0
+ for ev_id in (user_id, bot_id, extra_id):
+ row = conn.execute(
+ "SELECT 1 FROM event_log WHERE id = ?", (ev_id,)
+ ).fetchone()
+ assert row is None, f"event {ev_id} should have been deleted"