diff --git a/chat/state/manual_edit.py b/chat/state/manual_edit.py
index e796a7a..57e6c3b 100644
--- a/chat/state/manual_edit.py
+++ b/chat/state/manual_edit.py
@@ -6,7 +6,7 @@ be reversed by emitting an inverse ``manual_edit`` later. This module
applies the new value to the appropriate target table; the snapshot of
``prior_value`` is taken by the route handler before this fires.
-Phase 1 covers four target kinds:
+Phase 1 covers five target kinds:
- ``edge_affinity`` and ``edge_trust`` — slider edits on a specific edge,
clamped to 0..100.
- ``memory_significance`` — dropdown edit, clamped to 0..3.
@@ -17,8 +17,12 @@ Phase 1 covers four target kinds:
field. Driven by T27 from the classifier's ``relationship_summary``
output combined with the prior summary.
-Other §6.4 editable fields (activity verb / attention / posture,
-knowledge_facts list manipulation) are deferred to Phase 1.5.
+T72.1 (Phase 2.5) adds one list-shaped edit:
+- ``edge_knowledge_fact`` — add/remove a single fact on an edge's
+ ``knowledge_json`` list. Payload carries an ``action`` of ``"add"`` or
+ ``"remove"`` and a ``fact`` string; remove matches the first occurrence
+ by string equality so the route handler doesn't have to track fact
+ indices across re-renders.
Pin toggles intentionally use the existing ``memory_pin_changed`` event
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
@@ -27,6 +31,7 @@ the projection writes both ``pinned`` and ``auto_pinned`` atomically.
from __future__ import annotations
+import json
from sqlite3 import Connection
from chat.eventlog.log import Event
@@ -87,5 +92,33 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None:
target_id["target_id"],
),
)
+ elif kind == "edge_knowledge_fact":
+ # T72.1: add or remove a single fact on an edge's knowledge list.
+ # ``target_id`` is the {"source_id", "target_id"} edge pair;
+ # ``new_value`` carries ``{"action": "add"|"remove", "fact": str}``.
+ # Remove matches by string equality (first occurrence) so callers
+ # don't have to thread a fact_index through re-rendered drawers.
+ action = new_value["action"]
+ fact = str(new_value["fact"])
+ row = conn.execute(
+ "SELECT knowledge_json FROM edges "
+ "WHERE source_id = ? AND target_id = ?",
+ (target_id["source_id"], target_id["target_id"]),
+ ).fetchone()
+ if row is not None:
+ knowledge = json.loads(row[0])
+ if action == "add":
+ knowledge.append(fact)
+ elif action == "remove" and fact in knowledge:
+ knowledge.remove(fact)
+ conn.execute(
+ "UPDATE edges SET knowledge_json = ? "
+ "WHERE source_id = ? AND target_id = ?",
+ (
+ json.dumps(knowledge),
+ target_id["source_id"],
+ target_id["target_id"],
+ ),
+ )
# Unknown target_kind: silently no-op for v1. Future kinds (activity
- # fields, knowledge_facts list manipulation) extend the dispatch above.
+ # fields, etc.) extend the dispatch above.
diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html
index 6bfb887..b6d2033 100644
--- a/chat/templates/_drawer.html
+++ b/chat/templates/_drawer.html
@@ -156,19 +156,95 @@
Save
- {% if edge_b2y.summary %}
{{ edge_b2y.summary }}
{% endif %}
- {% if edge_b2y.knowledge %}
- Knowledge ({{ edge_b2y.knowledge|length }})
- {% for fact in edge_b2y.knowledge %}{{ fact }} {% endfor %}
-
- {% endif %}
+
+
+
+ Knowledge ({{ (edge_b2y.knowledge or [])|length }})
+ {% if edge_b2y.knowledge %}
+
+ {% for fact in edge_b2y.knowledge %}
+
+ {{ fact }}
+
+
+
+
+
+ Remove
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+
+ Add fact:
+
+
+ Add
+
+
{% endif %}
{% if edge_y2b %}
you → {{ host_bot.name }}
Affinity: {{ edge_y2b.affinity }}/100 · Trust: {{ edge_y2b.trust }}/100
- {% if edge_y2b.summary %}
{{ edge_y2b.summary }}
{% endif %}
+
+
+
+
+ Trust:
+
+ {{ edge_y2b.trust }}
+
+ Save
+
+
+
+
+
+ Summary:
+ {{ edge_y2b.summary or "" }}
+
+ Save summary
+
{% endif %}
{% if not edge_b2y and not edge_y2b %}
@@ -224,6 +300,16 @@
{{ 'Unpin' if m.pinned else 'Pin' }}
+
+ Edit POV summary
+
+
+ {{ m.pov_summary }}
+ Save
+
+
{% endfor %}
diff --git a/chat/web/drawer.py b/chat/web/drawer.py
index d38d850..341dce7 100644
--- a/chat/web/drawer.py
+++ b/chat/web/drawer.py
@@ -1,4 +1,4 @@
-"""Chat drawer — read view (T24) and inline edits (T25).
+"""Chat drawer — read view (T24) and inline edits (T25, T72).
The GET endpoint renders an HTML partial showing the current scene +
container, per-entity activity, host <-> you edges, pinned memories with
@@ -13,14 +13,16 @@ returning the refreshed drawer partial so HTMX can swap it in:
* pin toggle on a memory (emits ``memory_pin_changed`` with
``auto_pinned=0`` so a manual pin is not subject to auto-eviction).
+T72 (Phase 2.5) extends the inline-edit set to cover the remaining
+§6.4 editable fields whose state-layer support already lands in the
+``manual_edit`` projector: edge trust slider, edge summary textarea,
+memory POV summary textarea, and per-edge knowledge-fact add/remove. It
+also exposes a witness-flag toggle (``you/host/guest``) per memory row
+and a "first-meeting gate" on the Add-guest form so an existing edge
+isn't quietly overwritten by a re-seed.
+
Each ``manual_edit`` payload snapshots the prior value alongside the new
one so a later inverse edit can restore state (§6.4 final paragraph).
-
-Other §6.4 editable fields (activity verb/attention/posture, edge_trust,
-edge summary, knowledge_facts list, memory pov_summary) are deferred to
-a Phase 1.5 follow-up — the dispatch in :mod:`chat.state.manual_edit`
-already accepts more ``target_kind`` values, so adding their routes is a
-mechanical extension.
"""
from __future__ import annotations
@@ -55,6 +57,13 @@ PIN_CAP = 8
# Recent-memories list is bounded to keep the drawer cheap to render.
RECENT_LIMIT = 10
+# T72.1 caps on free-form textarea edits. Edge summaries and per-POV
+# memory summaries are drawer-driven prose — bound them so a stray paste
+# can't blow up the projected row size or the SSE drawer refresh payload.
+EDGE_SUMMARY_MAX = 2000
+MEMORY_POV_SUMMARY_MAX = 2000
+KNOWLEDGE_FACT_MAX = 500
+
@router.get("/chats/{chat_id}/drawer", response_class=HTMLResponse)
async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
@@ -342,6 +351,218 @@ async def toggle_memory_pin(
return await drawer(chat_id, request, conn)
+# --- T72.1 deferred v1 drawer edits --------------------------------------
+#
+# These four endpoints round out the §6.4 editable surface — the
+# ``manual_edit`` projector already dispatches ``edge_trust``,
+# ``edge_summary``, and ``memory_pov_summary`` (T25); ``edge_knowledge_fact``
+# is a new dispatch branch added alongside this commit. Each route follows
+# the T25 pattern: snapshot the prior value, append + apply ``manual_edit``,
+# then re-render the drawer partial.
+
+
+@router.post(
+ "/chats/{chat_id}/drawer/edge/trust",
+ response_class=HTMLResponse,
+)
+async def edit_edge_trust(
+ chat_id: str,
+ request: Request,
+ source_id: str = Form(...),
+ target_id: str = Form(...),
+ new_value: int = Form(...),
+ conn=Depends(get_conn),
+):
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+
+ if not 0 <= int(new_value) <= 100:
+ raise HTTPException(
+ status_code=400,
+ detail=f"trust must be in [0, 100], got {new_value}",
+ )
+
+ edge = get_edge(conn, source_id, target_id)
+ if edge is None:
+ raise HTTPException(
+ status_code=404,
+ detail=f"edge not found: {source_id}->{target_id}",
+ )
+
+ prior = int(edge["trust"])
+ append_and_apply(
+ conn,
+ kind="manual_edit",
+ payload={
+ "target_kind": "edge_trust",
+ "target_id": {"source_id": source_id, "target_id": target_id},
+ "prior_value": prior,
+ "new_value": int(new_value),
+ },
+ )
+ return await drawer(chat_id, request, conn)
+
+
+@router.post(
+ "/chats/{chat_id}/drawer/edge/summary",
+ response_class=HTMLResponse,
+)
+async def edit_edge_summary(
+ chat_id: str,
+ request: Request,
+ source_id: str = Form(...),
+ target_id: str = Form(...),
+ new_summary: str = Form(...),
+ conn=Depends(get_conn),
+):
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+
+ if len(new_summary) > EDGE_SUMMARY_MAX:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"edge summary exceeds {EDGE_SUMMARY_MAX} chars "
+ f"(got {len(new_summary)})"
+ ),
+ )
+
+ edge = get_edge(conn, source_id, target_id)
+ if edge is None:
+ raise HTTPException(
+ status_code=404,
+ detail=f"edge not found: {source_id}->{target_id}",
+ )
+
+ prior = edge.get("summary") or ""
+ append_and_apply(
+ conn,
+ kind="manual_edit",
+ payload={
+ "target_kind": "edge_summary",
+ "target_id": {"source_id": source_id, "target_id": target_id},
+ "prior_value": prior,
+ "new_value": new_summary,
+ },
+ )
+ return await drawer(chat_id, request, conn)
+
+
+@router.post(
+ "/chats/{chat_id}/drawer/memory/pov-summary",
+ response_class=HTMLResponse,
+)
+async def edit_memory_pov_summary(
+ chat_id: str,
+ request: Request,
+ memory_id: int = Form(...),
+ new_summary: str = Form(...),
+ conn=Depends(get_conn),
+):
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+
+ if len(new_summary) > MEMORY_POV_SUMMARY_MAX:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"memory pov_summary exceeds {MEMORY_POV_SUMMARY_MAX} chars "
+ f"(got {len(new_summary)})"
+ ),
+ )
+
+ # 404 when the memory either doesn't exist or belongs to a different
+ # chat — the drawer never surfaces cross-chat memories so editing one
+ # would be a path-traversal-style mistake.
+ row = conn.execute(
+ "SELECT pov_summary FROM memories WHERE id = ? AND chat_id = ?",
+ (int(memory_id), chat_id),
+ ).fetchone()
+ if row is None:
+ raise HTTPException(
+ status_code=404,
+ detail=f"memory not found in chat: {memory_id}",
+ )
+
+ prior = row[0] or ""
+ append_and_apply(
+ conn,
+ kind="manual_edit",
+ payload={
+ "target_kind": "memory_pov_summary",
+ "target_id": int(memory_id),
+ "prior_value": prior,
+ "new_value": new_summary,
+ },
+ )
+ return await drawer(chat_id, request, conn)
+
+
+@router.post(
+ "/chats/{chat_id}/drawer/edge/knowledge-facts",
+ response_class=HTMLResponse,
+)
+async def edit_edge_knowledge_facts(
+ chat_id: str,
+ request: Request,
+ source_id: str = Form(...),
+ target_id: str = Form(...),
+ action: str = Form(...),
+ fact: str = Form(...),
+ conn=Depends(get_conn),
+):
+ """Add or remove a single knowledge_fact on an edge.
+
+ Remove semantics are by string match (first occurrence) — the drawer
+ re-renders after every edit so threading a stable index through is
+ fragile when concurrent ``edge_update`` events can append more facts
+ between renders. The projector is a no-op when the fact isn't found,
+ keeping the route idempotent for stale form submissions.
+ """
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+
+ if action not in ("add", "remove"):
+ raise HTTPException(
+ status_code=400,
+ detail=f"action must be 'add' or 'remove', got {action!r}",
+ )
+
+ if len(fact) > KNOWLEDGE_FACT_MAX:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"fact exceeds {KNOWLEDGE_FACT_MAX} chars (got {len(fact)})"
+ ),
+ )
+ if not fact.strip():
+ raise HTTPException(status_code=400, detail="fact must not be empty")
+
+ edge = get_edge(conn, source_id, target_id)
+ if edge is None:
+ raise HTTPException(
+ status_code=404,
+ detail=f"edge not found: {source_id}->{target_id}",
+ )
+
+ prior = list(edge.get("knowledge") or [])
+ append_and_apply(
+ conn,
+ kind="manual_edit",
+ payload={
+ "target_kind": "edge_knowledge_fact",
+ "target_id": {"source_id": source_id, "target_id": target_id},
+ "prior_value": prior,
+ "new_value": {"action": action, "fact": fact},
+ },
+ )
+ return await drawer(chat_id, request, conn)
+
+
# --- T42 guest add/remove -------------------------------------------------
#
# Adding a guest fans out into up to four events: a ``guest_added`` to flip
diff --git a/tests/test_drawer_edits_extended.py b/tests/test_drawer_edits_extended.py
new file mode 100644
index 0000000..670ead6
--- /dev/null
+++ b/tests/test_drawer_edits_extended.py
@@ -0,0 +1,327 @@
+"""T72.1: deferred v1 drawer edits.
+
+T25 shipped affinity / significance / pin. T72.1 fills in the rest of the
+§6.4 editable surface whose ``manual_edit`` projector dispatch was already
+in place (or, for ``edge_knowledge_fact``, added alongside the route):
+
+* ``POST /chats/{chat_id}/drawer/edge/trust`` — slider 0..100.
+* ``POST /chats/{chat_id}/drawer/edge/summary`` — textarea, capped 2000.
+* ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped.
+* ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact.
+
+Each test asserts (a) the ``manual_edit`` event lands in the log,
+(b) the projected table reflects the new value, and (c) the response is
+the refreshed drawer partial.
+
+T72.3's witness-flag tests extend this file with the inline-edit pair.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+
+from chat.app import app
+from chat.db.connection import open_db
+from chat.eventlog.log import append_event
+from chat.eventlog.projector import project
+
+
+@pytest.fixture
+def client(tmp_path, monkeypatch):
+ cfg = tmp_path / "config.toml"
+ cfg.write_text('featherless_api_key = "test"\n')
+ monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
+ db = tmp_path / "test.db"
+ monkeypatch.setenv("CHAT_DB_PATH", str(db))
+ with TestClient(app) as c:
+ if hasattr(app.state, "background_worker"):
+ app.state.background_worker.enabled = False
+ yield c
+
+
+def _seed(db: Path) -> None:
+ """Seed a chat with one host bot, one host->you edge with a fact and
+ summary already set, and one memory authored by ``bot_a`` witnessed by
+ all three roles. Tests reach into projected state to verify edits.
+ """
+ with open_db(db) as conn:
+ append_event(
+ conn,
+ kind="bot_authored",
+ payload={
+ "id": "bot_a",
+ "name": "BotA",
+ "persona": "...",
+ "voice_samples": [],
+ "traits": [],
+ "backstory": "",
+ "initial_relationship_to_you": "",
+ "kickoff_prose": "",
+ },
+ )
+ append_event(
+ conn,
+ kind="chat_created",
+ payload={
+ "id": "chat_bot_a",
+ "host_bot_id": "bot_a",
+ "initial_time": "2026-04-26T20:00:00+00:00",
+ "narrative_anchor": "Day 1",
+ "weather": "",
+ },
+ )
+ # Materialise edge bot_a -> you with a knowledge_fact already on it
+ # so the remove path has something to consume.
+ append_event(
+ conn,
+ kind="edge_update",
+ payload={
+ "source_id": "bot_a",
+ "target_id": "you",
+ "chat_id": "chat_bot_a",
+ "affinity_delta": 0,
+ "knowledge_facts": ["studied physics together"],
+ },
+ )
+ append_event(
+ conn,
+ kind="memory_written",
+ payload={
+ "owner_id": "bot_a",
+ "chat_id": "chat_bot_a",
+ "pov_summary": "Original summary text.",
+ "witness_you": 1,
+ "witness_host": 1,
+ "witness_guest": 0,
+ "significance": 1,
+ },
+ )
+ project(conn)
+
+
+# --- T72.1 tests ----------------------------------------------------------
+
+
+def test_edit_edge_trust_emits_manual_edit_and_updates(client, tmp_path):
+ _seed(tmp_path / "test.db")
+ response = client.post(
+ "/chats/chat_bot_a/drawer/edge/trust",
+ data={"source_id": "bot_a", "target_id": "you", "new_value": "73"},
+ )
+ assert response.status_code == 200
+ # Refresh shows the new trust value somewhere in the partial.
+ assert "73" in response.text
+
+ with open_db(tmp_path / "test.db") as conn:
+ rows = conn.execute(
+ "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
+ ).fetchall()
+ assert len(rows) == 1
+ payload = json.loads(rows[0][0])
+ assert payload["target_kind"] == "edge_trust"
+ assert payload["prior_value"] == 50
+ assert payload["new_value"] == 73
+ assert payload["target_id"] == {
+ "source_id": "bot_a",
+ "target_id": "you",
+ }
+
+ from chat.state.edges import get_edge
+
+ edge = get_edge(conn, "bot_a", "you")
+ assert edge["trust"] == 73
+
+
+def test_edit_edge_trust_400_on_out_of_range(client, tmp_path):
+ _seed(tmp_path / "test.db")
+ response = client.post(
+ "/chats/chat_bot_a/drawer/edge/trust",
+ data={"source_id": "bot_a", "target_id": "you", "new_value": "150"},
+ )
+ assert response.status_code == 400
+
+
+def test_edit_edge_summary_emits_manual_edit_and_updates(client, tmp_path):
+ _seed(tmp_path / "test.db")
+ response = client.post(
+ "/chats/chat_bot_a/drawer/edge/summary",
+ data={
+ "source_id": "bot_a",
+ "target_id": "you",
+ "new_summary": "BotA respects you and shares lab notes.",
+ },
+ )
+ assert response.status_code == 200
+
+ with open_db(tmp_path / "test.db") as conn:
+ rows = conn.execute(
+ "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
+ ).fetchall()
+ assert len(rows) == 1
+ payload = json.loads(rows[0][0])
+ assert payload["target_kind"] == "edge_summary"
+ assert payload["new_value"].startswith("BotA respects")
+ assert payload["target_id"] == {
+ "source_id": "bot_a",
+ "target_id": "you",
+ }
+
+ summary = conn.execute(
+ "SELECT summary FROM edges "
+ "WHERE source_id = ? AND target_id = ?",
+ ("bot_a", "you"),
+ ).fetchone()[0]
+ assert "respects" in summary
+
+ # And the refreshed partial echoes the new summary back.
+ assert "respects" in response.text
+
+
+def test_edit_edge_summary_400_on_overflow(client, tmp_path):
+ _seed(tmp_path / "test.db")
+ response = client.post(
+ "/chats/chat_bot_a/drawer/edge/summary",
+ data={
+ "source_id": "bot_a",
+ "target_id": "you",
+ "new_summary": "x" * 2001,
+ },
+ )
+ assert response.status_code == 400
+
+
+def test_edit_memory_pov_summary_emits_manual_edit_and_updates(
+ client, tmp_path
+):
+ _seed(tmp_path / "test.db")
+ with open_db(tmp_path / "test.db") as conn:
+ memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
+
+ response = client.post(
+ "/chats/chat_bot_a/drawer/memory/pov-summary",
+ data={
+ "memory_id": str(memory_id),
+ "new_summary": "Cleaner per-POV restatement of the moment.",
+ },
+ )
+ assert response.status_code == 200
+
+ with open_db(tmp_path / "test.db") as conn:
+ rows = conn.execute(
+ "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
+ ).fetchall()
+ assert len(rows) == 1
+ payload = json.loads(rows[0][0])
+ assert payload["target_kind"] == "memory_pov_summary"
+ assert payload["prior_value"] == "Original summary text."
+ assert payload["new_value"].startswith("Cleaner per-POV")
+ assert payload["target_id"] == memory_id
+
+ pov = conn.execute(
+ "SELECT pov_summary FROM memories WHERE id = ?", (memory_id,)
+ ).fetchone()[0]
+ assert pov.startswith("Cleaner per-POV")
+
+ assert "Cleaner per-POV" in response.text
+
+
+def test_edit_memory_pov_summary_404_when_wrong_chat(client, tmp_path):
+ _seed(tmp_path / "test.db")
+ with open_db(tmp_path / "test.db") as conn:
+ memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
+ # Re-home the memory to a different chat to confirm the route's
+ # cross-chat guard fires.
+ conn.execute(
+ "UPDATE memories SET chat_id = 'other_chat' WHERE id = ?",
+ (memory_id,),
+ )
+ conn.commit()
+
+ response = client.post(
+ "/chats/chat_bot_a/drawer/memory/pov-summary",
+ data={"memory_id": str(memory_id), "new_summary": "..."},
+ )
+ assert response.status_code == 404
+
+
+def test_edit_edge_knowledge_facts_add_emits_event_and_appends(client, tmp_path):
+ _seed(tmp_path / "test.db")
+ response = client.post(
+ "/chats/chat_bot_a/drawer/edge/knowledge-facts",
+ data={
+ "source_id": "bot_a",
+ "target_id": "you",
+ "action": "add",
+ "fact": "lent you a textbook",
+ },
+ )
+ assert response.status_code == 200
+
+ with open_db(tmp_path / "test.db") as conn:
+ rows = conn.execute(
+ "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
+ ).fetchall()
+ assert len(rows) == 1
+ payload = json.loads(rows[0][0])
+ assert payload["target_kind"] == "edge_knowledge_fact"
+ assert payload["new_value"] == {
+ "action": "add",
+ "fact": "lent you a textbook",
+ }
+ # Prior value snapshots the entire knowledge list before the edit.
+ assert payload["prior_value"] == ["studied physics together"]
+
+ from chat.state.edges import get_edge
+
+ edge = get_edge(conn, "bot_a", "you")
+ assert "lent you a textbook" in edge["knowledge"]
+ assert "studied physics together" in edge["knowledge"]
+
+ assert "lent you a textbook" in response.text
+
+
+def test_edit_edge_knowledge_facts_remove_drops_matching_fact(client, tmp_path):
+ _seed(tmp_path / "test.db")
+ response = client.post(
+ "/chats/chat_bot_a/drawer/edge/knowledge-facts",
+ data={
+ "source_id": "bot_a",
+ "target_id": "you",
+ "action": "remove",
+ "fact": "studied physics together",
+ },
+ )
+ assert response.status_code == 200
+
+ with open_db(tmp_path / "test.db") as conn:
+ from chat.state.edges import get_edge
+
+ edge = get_edge(conn, "bot_a", "you")
+ assert "studied physics together" not in edge["knowledge"]
+
+ rows = conn.execute(
+ "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
+ ).fetchall()
+ payload = json.loads(rows[0][0])
+ assert payload["target_kind"] == "edge_knowledge_fact"
+ assert payload["new_value"]["action"] == "remove"
+
+
+def test_edit_edge_knowledge_facts_400_on_bad_action(client, tmp_path):
+ _seed(tmp_path / "test.db")
+ response = client.post(
+ "/chats/chat_bot_a/drawer/edge/knowledge-facts",
+ data={
+ "source_id": "bot_a",
+ "target_id": "you",
+ "action": "delete",
+ "fact": "x",
+ },
+ )
+ assert response.status_code == 400
+
+