diff --git a/chat/templates/_delete_impact_modal.html b/chat/templates/_delete_impact_modal.html
new file mode 100644
index 0000000..e5bab40
--- /dev/null
+++ b/chat/templates/_delete_impact_modal.html
@@ -0,0 +1,34 @@
+{# T110.3: delete-impact modal partial.
+
+Rendered from :func:`chat.web.drawer.delete_preview` via a Jinja2
+TemplateResponse so HTML autoescape covers user-controllable fields
+(item.kind, item.description, notes) automatically — the prior
+f-string assembly required explicit html.escape() calls (T110.2)
+which become redundant under autoescape.
+
+Inputs:
+ ``chat_id`` — the URL chat id (used to build the confirm form action).
+ ``impact`` — an :class:`~chat.services.delete_impact.ImpactReport`.
+#}
+
+
Delete event {{ impact.target_event_id }}?
+
This will discard {{ impact.cascading|length }} events. Cascade:
+
+ {% if impact.cascading %}
+ {% for item in impact.cascading %}
+ - {{ item.kind }}: {{ item.description }}
+ {% endfor %}
+ {% else %}
+ - none
+ {% endif %}
+
+
+ {% for note in impact.notes %}
+ - {{ note }}
+ {% endfor %}
+
+
+
diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html
index 8cfdd5f..6bbfeeb 100644
--- a/chat/templates/_drawer.html
+++ b/chat/templates/_drawer.html
@@ -547,6 +547,25 @@
{% endif %}
+ {# T110.4: bulk significance re-rate. Move every memory in this chat
+ at level_from to level_to with one manual_edit event per row, so
+ the audit trail stays per-memory. #}
+
+ Bulk re-rate significance
+
+
diff --git a/chat/web/drawer.py b/chat/web/drawer.py
index 5396ae8..5f94957 100644
--- a/chat/web/drawer.py
+++ b/chat/web/drawer.py
@@ -411,6 +411,64 @@ async def edit_memory_significance(
return await drawer(chat_id, request, conn)
+@router.post(
+ "/chats/{chat_id}/drawer/memory/significance/bulk",
+ response_class=HTMLResponse,
+)
+async def bulk_re_rate_significance(
+ chat_id: str,
+ request: Request,
+ level_from: int = Form(...),
+ level_to: int = Form(...),
+ conn=Depends(get_conn),
+):
+ """T110.4: bulk re-rate every memory in this chat at ``level_from``
+ to ``level_to``.
+
+ Fans out into one ``manual_edit`` event per matching memory rather
+ than a single bulk event so the §6.4 audit trail stays per-row —
+ each affected memory carries its own ``prior_value -> new_value``
+ snapshot, so an inverse edit can restore an individual row without
+ needing to inspect a bulk payload's member list. The drawer's
+ significance-distribution panel surfaces the new buckets on the
+ refreshed partial.
+
+ Both levels are clamped to 0..3 (matching ``edit_memory_significance``)
+ and a no-op (``level_from == level_to``) is rejected with 400 so a
+ misclick can't pad the event log with empty edits.
+ """
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+
+ lf = max(0, min(3, int(level_from)))
+ lt = max(0, min(3, int(level_to)))
+ if lf == lt:
+ raise HTTPException(
+ status_code=400,
+ detail=f"level_from and level_to must differ (both = {lf})",
+ )
+
+ rows = conn.execute(
+ "SELECT id FROM memories WHERE chat_id = ? AND significance = ? "
+ "ORDER BY id ASC",
+ (chat_id, lf),
+ ).fetchall()
+ for row in rows:
+ memory_id = int(row[0])
+ append_and_apply(
+ conn,
+ kind="manual_edit",
+ payload={
+ "target_kind": "memory_significance",
+ "target_id": memory_id,
+ "prior_value": lf,
+ "new_value": lt,
+ },
+ )
+ return await drawer(chat_id, request, conn)
+
+
@router.post(
"/chats/{chat_id}/drawer/memory/{memory_id}/pin",
response_class=HTMLResponse,
@@ -1234,28 +1292,18 @@ async def delete_preview(
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
+ # T110.3: render via the ``_delete_impact_modal.html`` Jinja partial
+ # so HTML autoescape covers user-controllable fields (item.kind,
+ # item.description, notes) automatically. The prior implementation
+ # built the modal HTML via raw f-string concatenation and required
+ # explicit ``html.escape()`` calls (T110.2) on each interpolated
+ # field; under autoescape those calls become redundant. Mirrors the
+ # rewind-preview style in :func:`chat.web.turns.rewind_preview`.
+ return TEMPLATES.TemplateResponse(
+ request,
+ "_delete_impact_modal.html",
+ {"chat_id": chat_id, "impact": report},
)
- 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(
@@ -1278,7 +1326,19 @@ async def delete_turn(
A snapshot is taken before truncation (inside ``execute_rewind``)
so the user can recover via the snapshot index.
+
+ T110.1 guards ``event_id <= 0``: a stale tab or hand-crafted request
+ posting ``event_id=0`` would otherwise compute ``after_event_id=-1``
+ and silently truncate the entire log. ``id`` is auto-assigned by
+ SQLite starting at 1 so any caller's "real" id is always >= 1; a
+ zero or negative value can only mean a client bug, surfaced as 400.
"""
+ if int(event_id) <= 0:
+ raise HTTPException(
+ status_code=400,
+ detail=f"event_id must be a positive integer, got {event_id}",
+ )
+
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py
index f94f266..be4d854 100644
--- a/tests/test_drawer_phase4.py
+++ b/tests/test_drawer_phase4.py
@@ -458,6 +458,183 @@ def test_t98_4_delete_invokes_rewind_and_drops_cascade(client, tmp_path):
assert row is None, f"event {ev_id} should have been deleted"
+def test_delete_impact_modal_uses_jinja_partial(client, tmp_path):
+ """T110.3: the modal HTML is rendered from a Jinja partial
+ (`_delete_impact_modal.html`) rather than f-string concatenation in
+ Python. Verify the partial-rendered shape: the wrapping
+ ``delete-impact-modal`` div, the cascade list, and the confirm form.
+
+ The partial inherits Jinja2 autoescape so HTML safety follows
+ automatically — the explicit ``html.escape()`` calls from T110.2
+ become redundant once this lands.
+ """
+ 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
+
+ # Markup shape that the partial produces. Double-quoted attributes
+ # signal Jinja rendering (the prior f-string used single quotes).
+ assert '' in body
+ assert '
' in body
+ # The confirm form still posts to the same delete route.
+ assert f"/chats/chat_bot_a/drawer/turn/delete/{user_id}" in body
+ assert "Confirm delete" in body
+
+
+def test_delete_impact_modal_escapes_user_controllable_strings(client, tmp_path):
+ """T110.2: defense-in-depth — fields embedded in the modal HTML come
+ from event payloads (turn prose, scene timestamps, etc.) which are
+ ultimately user-controllable. Wrap them with ``html.escape`` so a
+ payload like ```` renders as inert text and
+ doesn't leak through into the rendered modal as actual markup.
+ """
+ db = tmp_path / "test.db"
+ _seed_chat(db)
+
+ # Seed a user_turn whose prose contains an HTML-script payload. The
+ # modal renders ``description = "turn N (you: )"`` so
+ # the prose flows verbatim into the cascade list - .
+ with open_db(db) as conn:
+ evil_id = append_and_apply(
+ conn,
+ kind="user_turn",
+ payload={
+ "chat_id": "chat_bot_a",
+ "prose": "",
+ "segments": [],
+ },
+ )
+
+ response = client.get(
+ f"/chats/chat_bot_a/drawer/turn/delete-preview/{evil_id}"
+ )
+ assert response.status_code == 200
+ body = response.text
+
+ # Raw