phase: 83_chat_save_payload_limits

All verification criteria are confirmed green. Final report:

**Phase 83 — final verification pass (all tasks already complete; verified, nothing to fix)**

- Verified `app/schemas.py` carries every A1 cap (text/thinking 32 000, sources 20, suggestions 50×200, tools 50, SourceRef 120/1000/500, ToolCall 100/2000, messages ≤200) — boundary-only, `app/api/chats.py` untouched
- Verified all test pins exist and pass: 32 unit boundary tests (both edges of every cap + round-trip), 4 oversized-422 integration pins (text/201-msgs/21-sources/PUT, each with nothing-stored/row-unchanged assertions), A3 SSE done-event pin at column maxima, dedicated E2E (anon 40 000-char POST → 422 + small save → 201)
- `uv run pytest tests/unit/test_schemas.py -v` — 32 passed
- `uv run pytest tests/integration/test_chats_api.py` — exit 0; A3 pin — passed
- `uv run pytest tests/e2e/test_chat_save_payload_limits.py -v --no-cov` — 2 passed (isolation); `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` — 5 passed
- `uv run pytest` — exit 0 (~1 704 tests, 0 fail/skip); `uv run pytest --cov=app` — TOTAL 99%, `app/schemas.py` 100% (>90% ✓)
- `uv run ruff check . && uv run pyright` — clean (0 errors)
- `git diff --stat` — only `app/schemas.py`, 3 test files (+`test_chat_api.py` A3 pin, sanctioned by task 02), phase files; no `app/api/chats.py`/`alembic`/`frontend`/`pyproject`/`uv.lock` diff ✓
- All completion criteria met; commit + phase move left to the harness per pipeline rules (changes stay in working tree)
- Deviation note: list caps use `max_length` instead of `max_items` — identical in pydantic 2.13 (`max_items` is a deprecated alias); both boundaries behaviorally pinned
- Next pending phase: `84_docs_push_error_sanitization`
This commit is contained in:
2026-09-08 00:33:21 -04:00
parent e29d68d9f0
commit fa189dede7
21 changed files with 1103 additions and 16 deletions
+132
View File
@@ -0,0 +1,132 @@
"""Phase 83 E2E (Playwright): the anonymous saved-chat payload
boundary (SEC-05) — the audit vector through a real browser's network
layer.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_save_payload_limits.py -v --no-cov
The contract under test (phase 83 — boundary-only hardening of the
PUBLIC write surface ``POST/PUT /api/chats``; the caps live in
``app/schemas.py`` and FastAPI rejects before the handler runs, A2):
* **Oversized save 422s, anonymously** — the exact audit vector (one
40_000-char message, NO session, driven through the page's
``page.request`` context — the browser's own network layer) is
rejected at the schema boundary with a 422 and NOTHING is stored
(the admin list carries no row for the probe's distinctive
auto-title — the shared e2e DB may hold other suites' rows, so the
"nothing stored" proof is title-scoped, the house convention);
* **Small save still 201s** — a normal in-cap save through the same
anonymous network layer lands 201 with a valid ``id``: the boundary
tightened the DoS surface without breaking the real (in-cap) flow
the UI produces.
No LLM dependency — both endpoints are DB-only (the session-scoped
mock LLM stays up as an ``app_server`` dependency but is never
called).
"""
from __future__ import annotations
import uuid
import httpx
from playwright.sync_api import Page, expect
from e2e.auth_helpers import login
#: The probe's distinctive marker: if the oversized row had been
#: stored, its auto-title (first user message, whitespace-collapsed,
#: 120-char cap) would start with exactly this string — unique in the
#: shared e2e DB.
PROBE_MARKER = "payload-limit-probe (phase 83 e2e)"
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _settled_anonymous(page: Page, app_url: str) -> None:
"""Land on the chat page in the settled anonymous state (fresh
context — no login): the whoami round-trip has landed, so the
``page.request`` calls below carry no session cookie (the write
surface is public — anonymity is the audit vector)."""
page.goto(app_url + "/")
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
def test_anonymous_oversized_save_422s_and_stores_nothing(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_settled_anonymous(page, app_url)
text = PROBE_MARKER + " " + "x" * (40_000 - len(PROBE_MARKER) - 1)
assert len(text) == 40_000
# The audit vector, end-to-end and anonymous: one 40_000-char
# message through the page's request context (no session cookie).
r = page.request.post(
app_url + "/api/chats",
data={
"messages": [
{"who": "user", "text": text},
{"who": "brain", "text": "ok"},
]
},
)
assert r.status == 422, (
f"the oversized body must 422 at the schema boundary: {r.text()}"
)
# Nothing stored: the list surface is admin-only, so sign in now
# (the 422 above happened BEFORE any session existed — the write
# surface is public, exactly the audit vector) and prove no row
# carries the probe's distinctive auto-title.
login(page, app_url, next="/")
cookies = _admin_cookies(page)
body = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert body.status_code == 200
rows = body.json()["chats"]
assert not any(c["title"].startswith(PROBE_MARKER) for c in rows), (
f"the oversized probe must not have been stored: {rows}"
)
def test_small_anonymous_save_still_201s(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_settled_anonymous(page, app_url)
q = "How do I prune deleted docs? (phase 83 e2e small save)"
created: str | None = None
try:
# The happy path from the same anonymous network layer: the
# boundary tightened the DoS surface without breaking the
# real (in-cap) save flow the UI produces.
r = page.request.post(
app_url + "/api/chats",
data={
"messages": [
{"who": "user", "text": q},
{"who": "brain", "text": "Use --prune. (phase 83 e2e)"},
]
},
)
assert r.status == 201, f"the in-cap save must still land: {r.text()}"
body = r.json()
uuid.UUID(body["id"]) # a valid row id
assert body["title"] == q # auto-title = first user message
assert body["message_count"] == 2
created = body["id"]
finally:
if created is not None:
login(page, app_url, next="/")
httpx.delete(
f"{app_url}/api/chats/{created}", timeout=10, cookies=_admin_cookies(page)
)
+33
View File
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, cast
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from sqlalchemy import delete, func, select, text
from app.api import chat as chat_api
@@ -32,6 +33,7 @@ from app.rag import agent
from app.rag.agent import AGENT_TOOLS
from app.rag.importer import import_sources
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece
from app.schemas import ChatDoneEvent, SourceRef
from tests.conftest import ADMIN_PASSWORD
if TYPE_CHECKING:
@@ -1406,3 +1408,34 @@ def test_history_rejects_more_than_100_entries(client, db) -> None:
},
)
assert r.status_code == 422
def test_done_event_serializes_column_maximum_source_refs() -> None:
"""Phase 83 A3 pin: ``SourceRef`` is SHARED by the SSE ``done``
event and the saved-chat surface — the boundary caps added there
(``source`` ≤ 120, ``path`` ≤ 1000, ``title`` ≤ 500) mirror the
``documents`` column lengths EXACTLY, so a server-built event from
a full-length row (values at the column maxima) still constructs
and serializes byte-identical: the SSE contract is provably
unaffected. The one-over caps raise — only client-saved refs can
ever trip a cap, never a server-built ref."""
event = ChatDoneEvent(
deflected=False,
sources=[SourceRef(source="s" * 120, path="p" * 1000, title="t" * 500)],
suggestions=[],
)
assert event.model_dump() == {
"type": "done",
"deflected": False,
"sources": [{"source": "s" * 120, "path": "p" * 1000, "title": "t" * 500}],
"suggestions": [],
}
# The caps sit exactly ON the column maxima: one over any of them
# is rejected (a row could never hold such a value in the first
# place — the string columns enforce the same lengths).
with pytest.raises(ValidationError):
SourceRef(source="s" * 121, path="p" * 1000, title="t" * 500)
with pytest.raises(ValidationError):
SourceRef(source="s" * 120, path="p" * 1001, title="t" * 500)
with pytest.raises(ValidationError):
SourceRef(source="s" * 120, path="p" * 1000, title="t" * 501)
+91
View File
@@ -1062,3 +1062,94 @@ def test_page_route_serves_shared_html_when_present(
assert r.status_code == 200
assert r.text == "<html>shared page</html>"
assert "text/html" in r.headers["content-type"]
# ---------- payload boundary caps (phase 83, SEC-05) — the anonymous
# write surface 422s on oversized bodies and stores NOTHING (the caps
# live in app/schemas.py, A2 boundary-only: FastAPI rejects before the
# handler runs — no route change, nothing ever lands in the JSONB) ----------
def test_post_rejects_text_over_32000_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""The audit vector (SEC-05): one 32_001-char message 422s at the
boundary (``ChatMessage.text`` mirrors ``HistoryTurn``'s 32 000
cap) and NOTHING is stored — the admin list is unchanged (the happy
path for in-cap bodies stays the existing guest/admin pins)."""
baseline = admin_client.get("/api/chats").json()["chats"]
r = client.post(
"/api/chats",
json={"messages": [_user("a" * 32_001), _user("follow-up")]},
)
assert r.status_code == 422
listing = admin_client.get("/api/chats").json()["chats"]
assert listing == baseline, "an oversized POST must not store a row"
def test_post_rejects_more_than_200_messages_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""The unbounded message list is the second DoS lever (SEC-05):
201 minimally-valid messages 422 (``SavedChatCreate.messages``
``max_items=200``) and nothing lands."""
baseline = len(admin_client.get("/api/chats").json()["chats"])
r = client.post(
"/api/chats", json={"messages": [_user(f"question {i}") for i in range(201)]}
)
assert r.status_code == 422
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
def test_post_rejects_sources_over_20_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""One brain message carrying a 21-item ``sources`` list (all valid
``SourceRef`` shapes) 422 (``ChatMessage.sources`` ``max_items=20``
— top-N docs + agent reads) and nothing lands."""
baseline = len(admin_client.get("/api/chats").json()["chats"])
sources = [
{"source": f"src{i}", "path": f"docs{i}.md", "title": f"Doc {i}"}
for i in range(21)
]
r = client.post(
"/api/chats",
json={
"messages": [
_user("hi"),
{"who": "brain", "text": "answer", "sources": sources},
]
},
)
assert r.status_code == 422
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
def test_put_rejects_oversized_message_and_leaves_row_unchanged(
client: TestClient, admin_client: TestClient
) -> None:
"""The re-Save path is gated by the SAME caps (A1: create AND update
carry the bounds): a 32_001-char message 422s and the row keeps its
original payload byte-for-byte (the stored shape is untouched — the
rejected body never reaches the JSONB)."""
created = client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
r = client.put(
f"/api/chats/{created['id']}",
json={"messages": [_user("b" * 32_001)]},
)
assert r.status_code == 422
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["messages"] == _expect(_simple_conversation())
assert got.json()["message_count"] == 2 # original count, not the rejected 1
+395
View File
@@ -0,0 +1,395 @@
"""Unit: schema boundary caps for the saved-chat surface (phase 83, task 01).
The first schema-boundary suite: every A1 cap in ``app/schemas.py`` is
pinned at BOTH boundaries — a value exactly at the cap validates and one
past it raises a pydantic ``ValidationError`` naming the offending field
(the 422-shaped boundary response, house style, phase 56 precedent).
Plus the regression pin: a realistic ``bor.chat.v1`` payload validates
cleanly and round-trips ``model_dump()`` (the stored-shape contract —
the caps added value bounds only, no key/shape change).
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.schemas import (
ChatMessage,
SavedChatCreate,
SavedChatUpdate,
SourceRef,
ToolCall,
)
# --- boundary constants (phase 83, A1 — mirror sources in app/schemas.py) ---
TEXT_CAP = 32_000 # mirrors HistoryTurn.text / .thinking
SOURCES_CAP = 20
SUGGESTIONS_CAP = 50
CHIP_CAP = 200
TOOLS_CAP = 50
SOURCE_CAP = 120 # documents.source String(120)
PATH_CAP = 1000 # documents.path String(1000)
TITLE_CAP = 500 # documents.title String(500)
NAME_CAP = 100 # ToolCall.name
ARGUMENT_CAP = 2000 # ToolCall.argument
MESSAGES_CAP = 200 # SavedChatCreate/Update.messages
def _source_ref() -> dict:
return {"source": "Homelab", "path": "kubernetes.md", "title": "K8s"}
def _tool_call() -> dict:
return {"name": "read", "argument": "Homelab/kubernetes.md"}
def _user_message(text: str = "How did I install k3s?") -> ChatMessage:
return ChatMessage.model_validate({"who": "user", "text": text})
def _failed_loc(exc: ValidationError, *loc: object) -> None:
"""Assert the first ``ValidationError`` names exactly the given
field path (``loc`` tuple, e.g. ``("text",)`` or
``("messages", 0, "text")``) — the 422 must point at the offender."""
assert exc.errors()[0]["loc"] == tuple(loc), exc.errors()
# ---------------------------------------------------------------------------
# ChatMessage.text / .thinking
# ---------------------------------------------------------------------------
def test_chat_message_text_at_cap_validates() -> None:
msg = _user_message("a" * TEXT_CAP)
assert len(msg.text) == TEXT_CAP
def test_chat_message_text_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
_user_message("a" * (TEXT_CAP + 1))
_failed_loc(exc.value, "text")
def test_chat_message_thinking_at_cap_validates() -> None:
msg = ChatMessage.model_validate({"who": "brain", "text": "ok", "thinking": "t" * TEXT_CAP})
assert len(msg.thinking or "") == TEXT_CAP
def test_chat_message_thinking_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "thinking": "t" * (TEXT_CAP + 1)}
)
_failed_loc(exc.value, "thinking")
def test_chat_message_thinking_none_still_valid() -> None:
assert _user_message().thinking is None
assert ChatMessage.model_validate(
{"who": "brain", "text": "ok", "thinking": None}
).thinking is None
# ---------------------------------------------------------------------------
# ChatMessage.sources / .suggestions / .tools
# ---------------------------------------------------------------------------
def test_chat_message_sources_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "sources": [_source_ref() for _ in range(SOURCES_CAP)]}
)
assert len(msg.sources or []) == SOURCES_CAP
def test_chat_message_sources_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"sources": [_source_ref() for _ in range(SOURCES_CAP + 1)],
}
)
_failed_loc(exc.value, "sources")
def test_chat_message_suggestions_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "suggestions": [f"chip {i}" for i in range(SUGGESTIONS_CAP)]}
)
assert len(msg.suggestions or []) == SUGGESTIONS_CAP
def test_chat_message_suggestions_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"suggestions": [f"chip {i}" for i in range(SUGGESTIONS_CAP + 1)],
}
)
_failed_loc(exc.value, "suggestions")
def test_chat_message_suggestion_chip_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "suggestions": ["c" * CHIP_CAP]}
)
assert msg.suggestions is not None
assert len(msg.suggestions[0]) == CHIP_CAP
def test_chat_message_suggestion_chip_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "suggestions": ["c" * (CHIP_CAP + 1)]}
)
# the offender is the ITEM (the list itself is well under max_items).
_failed_loc(exc.value, "suggestions", 0)
def test_chat_message_tools_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "tools": [_tool_call() for _ in range(TOOLS_CAP)]}
)
assert len(msg.tools or []) == TOOLS_CAP
def test_chat_message_tools_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "tools": [_tool_call() for _ in range(TOOLS_CAP + 1)]}
)
_failed_loc(exc.value, "tools")
# ---------------------------------------------------------------------------
# SourceRef (documents column-length mirrors)
# ---------------------------------------------------------------------------
def test_source_ref_source_at_cap_validates() -> None:
ref = SourceRef.model_validate(
{"source": "s" * SOURCE_CAP, "path": "a.md", "title": "A"}
)
assert len(ref.source) == SOURCE_CAP
def test_source_ref_source_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SourceRef.model_validate({"source": "s" * (SOURCE_CAP + 1), "path": "a.md", "title": "A"})
_failed_loc(exc.value, "source")
def test_source_ref_path_at_cap_validates() -> None:
ref = SourceRef.model_validate(
{"source": "Homelab", "path": "p" * PATH_CAP, "title": "A"}
)
assert len(ref.path) == PATH_CAP
def test_source_ref_path_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SourceRef.model_validate(
{"source": "Homelab", "path": "p" * (PATH_CAP + 1), "title": "A"}
)
_failed_loc(exc.value, "path")
def test_source_ref_title_at_cap_validates() -> None:
ref = SourceRef.model_validate(
{"source": "Homelab", "path": "a.md", "title": "t" * TITLE_CAP}
)
assert len(ref.title) == TITLE_CAP
def test_source_ref_title_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SourceRef.model_validate(
{"source": "Homelab", "path": "a.md", "title": "t" * (TITLE_CAP + 1)}
)
_failed_loc(exc.value, "title")
# ---------------------------------------------------------------------------
# ToolCall
# ---------------------------------------------------------------------------
def test_tool_call_name_at_cap_validates() -> None:
call = ToolCall.model_validate({"name": "n" * NAME_CAP, "argument": None})
assert len(call.name) == NAME_CAP
def test_tool_call_name_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ToolCall.model_validate({"name": "n" * (NAME_CAP + 1), "argument": None})
_failed_loc(exc.value, "name")
def test_tool_call_argument_at_cap_validates() -> None:
call = ToolCall.model_validate({"name": "read", "argument": "a" * ARGUMENT_CAP})
assert len(call.argument or "") == ARGUMENT_CAP
def test_tool_call_argument_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ToolCall.model_validate({"name": "read", "argument": "a" * (ARGUMENT_CAP + 1)})
_failed_loc(exc.value, "argument")
def test_tool_call_argument_none_still_valid() -> None:
assert ToolCall.model_validate({"name": "ls", "argument": None}).argument is None
assert ToolCall.model_validate({"name": "ls"}).argument is None
# ---------------------------------------------------------------------------
# SavedChatCreate / SavedChatUpdate .messages
# ---------------------------------------------------------------------------
def _minimal_messages(n: int) -> list[dict]:
return [{"who": "user" if i % 2 == 0 else "brain", "text": f"m{i}"} for i in range(n)]
def test_saved_chat_create_messages_at_cap_validates() -> None:
payload = SavedChatCreate.model_validate(
{"title": "big", "messages": _minimal_messages(MESSAGES_CAP)}
)
assert len(payload.messages) == MESSAGES_CAP
def test_saved_chat_create_messages_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SavedChatCreate.model_validate(
{"title": "bigger", "messages": _minimal_messages(MESSAGES_CAP + 1)}
)
_failed_loc(exc.value, "messages")
def test_saved_chat_update_messages_at_cap_validates() -> None:
payload = SavedChatUpdate.model_validate({"messages": _minimal_messages(MESSAGES_CAP)})
assert len(payload.messages) == MESSAGES_CAP
def test_saved_chat_update_messages_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SavedChatUpdate.model_validate({"messages": _minimal_messages(MESSAGES_CAP + 1)})
_failed_loc(exc.value, "messages")
# ---------------------------------------------------------------------------
# JSON-shape regression: same KEYS as before, value bounds only
# ---------------------------------------------------------------------------
def test_extra_keys_still_forbidden() -> None:
"""``extra="forbid"`` is untouched — a stray key is still a 422-shaped
rejection (the accepted/rejected KEYS did not change with phase 83)."""
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate({"who": "user", "text": "hi", "html": "<b>x</b>"})
_failed_loc(exc.value, "html")
def test_minimal_message_still_validates() -> None:
"""Optional keys may be ABSENT exactly as pre-phase-83."""
msg = ChatMessage.model_validate({"who": "user", "text": "hi"})
assert (msg.sources, msg.deflected, msg.suggestions, msg.thinking, msg.tools, msg.stopped) == (
None,
None,
None,
None,
None,
None,
)
# ---------------------------------------------------------------------------
# Realistic-payload round-trip (the stored-shape contract)
# ---------------------------------------------------------------------------
def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"""A full ``bor.chat.v1``-shaped record (4–8 messages mixing
user/brain, one brain message with ``thinking`` + ``tools`` +
``sources``, one with ``suggestions`` + ``stopped``) validates
cleanly and ``model_dump()`` of the messages equals the input dict —
``None``-keys preserved (the phase-50/51 byte-identical stored shape;
the caps added value bounds only, no key/shape change)."""
messages: list[dict] = [
{
"who": "user",
"text": "How did I install k3s on the new node?",
"sources": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
},
{
"who": "brain",
"text": "Your k3s cluster runs on three nodes — here's how it happened.",
"sources": [
{"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes Cluster"},
{"source": "Deployments", "path": "k3s-install.md", "title": "k3s Install Notes"},
],
"deflected": False,
"suggestions": None,
"thinking": "The kubernetes doc covers the cluster layout…",
"tools": [
{"name": "read", "argument": "Homelab/kubernetes.md"},
{"name": "ls", "argument": None},
],
"stopped": None,
},
{
"who": "user",
"text": "And what ports does Traefik expose?",
"sources": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
},
{
"who": "brain",
"text": "Traefik exposes 80/443 on every node.",
"sources": None,
"deflected": None,
"suggestions": ["What is the Traefik dashboard password?"],
"thinking": None,
"tools": None,
"stopped": True, # the owner stopped the generation mid-answer
},
]
payload = SavedChatCreate.model_validate({"title": "k3s install", "messages": messages})
dumped = payload.model_dump()
# the stored shape is byte-identical: every message dict — None-keys
# and all — round-trips unchanged (and the top-level record keeps its
# ``share`` default).
assert dumped["messages"] == messages
assert dumped["share"] is False
def test_realistic_payload_round_trips_through_update_model() -> None:
"""Same record re-Saved through ``SavedChatUpdate`` (the re-Save
upsert surface) — identical stored shape."""
msg = {
"who": "brain",
"text": "answer",
"sources": [_source_ref()],
"deflected": None,
"suggestions": ["follow-up?"],
"thinking": "scratchpad",
"tools": [_tool_call()],
"stopped": None,
}
payload = SavedChatUpdate.model_validate({"messages": [msg]})
assert payload.model_dump()["messages"] == [msg]