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:
+57
-16
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user