Files
chat/tests/test_chat_created_non_idempotent.py
Joseph Doherty 3a81e540a1 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
2026-04-27 14:51:49 -04:00

70 lines
2.9 KiB
Python

"""Pin the contract: ``_apply_chat_created`` is NOT replay-safe.
See ``docs/audits/2026-04-27-project-callers.md`` for the full audit.
The handler at ``chat/state/world.py:_apply_chat_created`` uses raw
``INSERT INTO chats ...`` and ``INSERT INTO chat_state ...`` with no
``OR REPLACE``/``OR IGNORE``. Running ``project()`` twice over the same
``chat_created`` event MUST raise ``sqlite3.IntegrityError`` on the
second pass — this is the bug that produced the 500 fixed in commit
``0f8bf94`` (and the latent equivalents fixed in this commit).
Pinning the contract here means any future "make it idempotent" change
to the handler MUST update this test, which forces a deliberate review
of the trade-offs: most notably, that ``chat_state`` columns mutated by
later events (``time_skip_elision`` bumps ``time``;
``scene_opened``/``scene_closed`` toggle ``active_scene_id``) would be
silently overwritten by an ``INSERT OR REPLACE`` on every replay. The
audit explains why we keep the handler raw-INSERT and enforce the rule
at the call site via ``append_and_apply`` instead.
"""
from __future__ import annotations
import sqlite3
import pytest
from chat.db.connection import open_db
from chat.db.migrate import apply_migrations
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
import chat.state.world # noqa: F401 — import registers the handler
def _chat_payload():
return {
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"guest_bot_id": None,
"initial_time": "2026-04-27T12:00:00+00:00",
"narrative_anchor": "Day 1 noon",
"weather": "clear",
}
def test_chat_created_handler_is_not_replay_safe(tmp_path):
"""A second projection over an extra ``chat_created`` for the same id raises.
This is the exact failure shape from incident ``0f8bf94``: a raw
INSERT against ``chats.id`` (PK) trips ``UNIQUE constraint failed``
on the second pass. If this test ever starts FAILING (i.e. the
second project() succeeds), someone has changed the handler to be
idempotent — read the audit before approving.
"""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
# First chat_created + first project: must succeed.
append_event(conn, kind="chat_created", payload=_chat_payload())
project(conn)
# Append a SECOND chat_created with the same id. project() will
# walk both, re-INSERT the same chats row, and trip the UNIQUE
# constraint on chats.id.
append_event(conn, kind="chat_created", payload=_chat_payload())
with pytest.raises(sqlite3.IntegrityError) as exc_info:
project(conn)
# Match on the column to make sure we caught the *intended*
# constraint, not some unrelated FK/check failure that happens
# to also be an IntegrityError.
assert "chats.id" in str(exc_info.value)