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 b965e7a..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,
diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py
index 20b428e..be4d854 100644
--- a/tests/test_drawer_phase4.py
+++ b/tests/test_drawer_phase4.py
@@ -523,6 +523,95 @@ def test_delete_impact_modal_escapes_user_controllable_strings(client, tmp_path)
assert "<script>alert" in body
+def test_bulk_significance_re_rate_emits_manual_edit_per_memory(client, tmp_path):
+ """T110.4: bulk significance re-rate fans out into one
+ ``manual_edit`` event per matching memory โ preserving the per-row
+ audit trail (and reversibility) instead of collapsing everything
+ into a single bulk event.
+
+ Seed five memories at significance 0, bulk re-rate 0 -> 2, and
+ verify five new ``memory_significance`` ``manual_edit`` rows landed
+ AND every memory now sits at significance 2.
+ """
+ db = tmp_path / "test.db"
+ _seed_chat(db)
+
+ # Five memories at significance 0.
+ with open_db(db) as conn:
+ for i in range(5):
+ append_and_apply(
+ conn,
+ kind="memory_written",
+ payload={
+ "owner_id": "bot_a",
+ "chat_id": "chat_bot_a",
+ "pov_summary": f"low-sig memory {i}",
+ "witness_you": 1,
+ "witness_host": 1,
+ "witness_guest": 0,
+ "significance": 0,
+ },
+ )
+ # Plus one memory at significance 1 to verify the re-rate is
+ # scoped to ``level_from`` and doesn't sweep the whole chat.
+ append_and_apply(
+ conn,
+ kind="memory_written",
+ payload={
+ "owner_id": "bot_a",
+ "chat_id": "chat_bot_a",
+ "pov_summary": "already-rated memory",
+ "witness_you": 1,
+ "witness_host": 1,
+ "witness_guest": 0,
+ "significance": 1,
+ },
+ )
+ prior_manual_edits = conn.execute(
+ "SELECT COUNT(*) FROM event_log WHERE kind = 'manual_edit'"
+ ).fetchone()[0]
+
+ response = client.post(
+ "/chats/chat_bot_a/drawer/memory/significance/bulk",
+ data={"level_from": "0", "level_to": "2"},
+ )
+ assert response.status_code == 200
+
+ with open_db(db) as conn:
+ # Five new manual_edit rows, one per matching memory.
+ new_manual_edits = conn.execute(
+ "SELECT COUNT(*) FROM event_log WHERE kind = 'manual_edit'"
+ ).fetchone()[0]
+ assert new_manual_edits - prior_manual_edits == 5
+
+ # Every emitted edit is a memory_significance edit with prior=0
+ # and new=2.
+ import json as _json
+
+ rows = conn.execute(
+ "SELECT payload_json FROM event_log "
+ "WHERE kind = 'manual_edit' "
+ "ORDER BY id DESC LIMIT 5"
+ ).fetchall()
+ for r in rows:
+ payload = _json.loads(r[0])
+ assert payload["target_kind"] == "memory_significance"
+ assert payload["prior_value"] == 0
+ assert payload["new_value"] == 2
+
+ # Projection caught up โ five memories at sig=2, the untouched
+ # one stays at sig=1, none remain at sig=0.
+ dist = dict(
+ conn.execute(
+ "SELECT significance, COUNT(*) FROM memories "
+ "WHERE chat_id = 'chat_bot_a' GROUP BY significance"
+ ).fetchall()
+ )
+ assert dist.get(0, 0) == 0
+ assert dist.get(1, 0) == 1
+ assert dist.get(2, 0) == 5
+
+
def test_delete_turn_with_event_id_zero_returns_400(client, tmp_path):
"""T110.1: ``event_id <= 0`` is an obvious client error and must NOT
silently rewind the entire log via ``after_event_id = -1``. The route