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
+57 -16
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Literal
from typing import Annotated, Any, Literal
from pydantic import (
BaseModel,
@@ -96,9 +96,21 @@ class WhoamiResponse(BaseModel):
class SourceRef(BaseModel):
source: str
path: str
title: str
"""One indexed document as cited in an answer (the SSE ``done``
event's ``sources`` item — PLAN §4 — and, since phase 50, the
``sources`` list of a saved-chat message).
Phase 83 (SEC-05): the caps mirror the ``documents`` column lengths
(``source`` ``String(120)``, ``path`` ``String(1000)``, ``title``
``String(500)``). A :class:`SourceRef` is built from ``documents``
rows server-side, so every server-built SSE ref fits by construction
(A3: the SSE path is provably unaffected); the cap binds only
client-saved refs — bounded at the boundary with a 422.
"""
source: str = Field(max_length=120)
path: str = Field(max_length=1000)
title: str = Field(max_length=500)
class ChatThinkingEvent(BaseModel):
@@ -432,10 +444,23 @@ class ToolCall(BaseModel):
tool" lines pixel-identical (phase 50). Saved chats persisting the
pre-phase-70 tool names still validate — ``name`` is opaque
(no migration, locked).
Phase 83 (SEC-05) bounds the anonymous write surface: ``name`` ≤
100 (a tool name longer than that is not a real call — the
``AGENT_TOOLS`` names are short) and ``argument`` ≤ 2000 (the
combined ``source/path`` identity is ≤ 120 + 1 + 1000; 2 000 is 2×
headroom for a grep pattern).
"""
name: str
argument: str | None = None
name: str = Field(max_length=100)
argument: str | None = Field(default=None, max_length=2000)
#: One suggestion chip (phase 83, A1): a short deterministic string —
#: ``derive_suggestions``'s output runs to ~80 chars, so 200 chars is
#: the boundary sanity cap. Annotated alias: the JSON shape stays a
#: plain string (only the value bound is added).
_Chip = Annotated[str, Field(max_length=200)]
class ChatMessage(BaseModel):
@@ -449,17 +474,28 @@ class ChatMessage(BaseModel):
payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a
422, so nothing outside this shape can poison a restored
conversation.
Phase 83 (SEC-05) bounds the anonymous write surface (``POST/PUT
/api/chats`` is public — the row id is the credential, phase 55 A1):
``text`` / ``thinking`` carry :class:`HistoryTurn`'s 32 000 caps
(a single message longer than that is already rejected on the chat
path, so a saved chat can never legitimately carry more), and the
nested lists get length caps (``max_length``) sized to the realistic
``bor.chat.v1`` record the UI produces (``sources`` ≤ 20 — top-N docs
+ agent reads; ``suggestions`` ≤ 50 chips of ≤ 200 chars; ``tools``
≤ 50 — one entry per tool call, the round cap is 10). Only value
bounds were added — the accepted/rejected KEYS are unchanged.
"""
model_config = ConfigDict(extra="forbid")
who: Literal["user", "brain"]
text: str = Field(min_length=1)
sources: list[SourceRef] | None = None
text: str = Field(min_length=1, max_length=32_000)
sources: list[SourceRef] | None = Field(default=None, max_length=20)
deflected: bool | None = None
suggestions: list[str] | None = None
thinking: str | None = None
tools: list[ToolCall] | None = None
suggestions: list[_Chip] | None = Field(default=None, max_length=50)
thinking: str | None = Field(default=None, max_length=32_000)
tools: list[ToolCall] | None = Field(default=None, max_length=50)
stopped: bool | None = None
@@ -470,7 +506,10 @@ class SavedChatCreate(BaseModel):
``title`` is optional: when absent or blank the API auto-titles the
row (the first user message's text, whitespace-collapsed, truncated
to 120 chars — the owner-locked convention). ``messages`` must be
non-empty — a saved chat with nothing to restore is meaningless.
non-empty — a saved chat with nothing to restore is meaningless —
and, since phase 83 (SEC-05), at most 200 items: well past any realistic
conversation (the chat history budget itself is 40 turns) and far
below a DoS-sized list on the anonymous write surface.
``share`` (phase 51, owner-locked 2026-08-29): when true, the row is
shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on
@@ -482,7 +521,7 @@ class SavedChatCreate(BaseModel):
"""
title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1)
messages: list[ChatMessage] = Field(min_length=1, max_length=200)
share: bool = False
@@ -491,12 +530,14 @@ class SavedChatUpdate(BaseModel):
``messages`` is a full replacement (the re-Save upsert semantics —
re-Saving the same conversation updates the same row, never a new
one). ``title`` is replaced only when supplied — an absent (or
blank) ``title`` keeps the row's current title.
one) and carries the same bounds as :class:`SavedChatCreate.messages`
(phase 83, SEC-05: non-empty, ≤ 200 items). ``title`` is replaced
only when supplied — an absent (or blank) ``title`` keeps the row's
current title.
"""
title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1)
messages: list[ChatMessage] = Field(min_length=1, max_length=200)
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any: