chore: audit project() callers and non-idempotent handlers

Same defect class as 0f8bf94: routes that ``append_event`` then
``project(conn)`` 500 once any prior event makes the full-log replay
hit a raw-INSERT handler (chat_created, container_created,
scene_opened, memory_written, meanwhile_scene_started, etc.).

Fixes the two remaining live-path callers:
- chat/web/bots.py (bot_create) — bot_authored
- chat/web/settings.py (settings_post) — you_authored

Both swap ``append_event`` + ``project`` → ``append_and_apply`` so only
the new event is applied through its registered handler. Unused
imports of ``append_event`` and ``project`` removed from each file.

The rewind path (chat/services/rewind.py) intentionally calls
``project()`` after wiping every projected table — that's the
canonical "rebuild from log against an empty DB" entry point and is
left unchanged.

Inventory of every projector handler that uses raw INSERT
(chat_created, container_created, scene_opened, memory_written,
meanwhile_scene_started, meanwhile_digest_created, edge_update) is
documented with the trade-offs of why we don't blindly switch them to
INSERT OR REPLACE — for autoincrement-id rows there is no key to match
on, and for chat_created a lossy overwrite would silently clobber
chat_state mutations from later events. The handler layer stays
correctly non-idempotent under event-sourcing semantics; the rule is
enforced at the call site.

Adds a regression test (tests/test_chat_created_non_idempotent.py)
that pins the contract: appending two chat_created events for the same
id and then ``project()``ing a second time MUST raise
``IntegrityError`` on chats.id. Any future "make it idempotent" change
must update the test, forcing a deliberate review.

Suite: 471 passed in 11.82s (was 470 + this regression test).

Report: docs/audits/2026-04-27-project-callers.md
This commit is contained in:
Joseph Doherty
2026-04-27 14:51:49 -04:00
parent 0f8bf94d29
commit 3a81e540a1
4 changed files with 284 additions and 8 deletions
+5 -4
View File
@@ -5,8 +5,7 @@ from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.db.connection import open_db
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.eventlog.log import append_and_apply
from chat.state.entities import get_bot, list_bots
from chat.state.world import get_chat
@@ -109,8 +108,10 @@ async def bot_create(
"initial_relationship_to_you": initial_relationship_to_you.strip(),
"kickoff_prose": kickoff_prose.strip(),
}
append_event(conn, kind="bot_authored", payload=payload)
project(conn)
# Per-event apply (NOT project()) — see docs/audits/2026-04-27-project-callers.md.
# ``project()`` replays the full log, which trips raw-INSERT handlers like
# ``_apply_chat_created`` once a second bot's events are present.
append_and_apply(conn, kind="bot_authored", payload=payload)
return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303)
+5 -4
View File
@@ -4,8 +4,7 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.eventlog.log import append_and_apply
from chat.state.entities import get_you
from chat.web.bots import get_conn
@@ -40,8 +39,10 @@ async def settings_post(
"pronouns": pronouns.strip(),
"persona": persona.strip(),
}
append_event(conn, kind="you_authored", payload=payload)
project(conn)
# Per-event apply (NOT project()) — see docs/audits/2026-04-27-project-callers.md.
# ``project()`` replays the full log, which trips raw-INSERT handlers like
# ``_apply_chat_created`` once chat events are present.
append_and_apply(conn, kind="you_authored", payload=payload)
return TEMPLATES.TemplateResponse(
request,