Files
brain-of-reese/app/schemas.py
T
ducoterra d731169b8b
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 105_hidden_folders_toggle
All completion criteria verified. Everything is green.

**Phase 105 final verification pass — all criteria verified**

- Verified the full implementation in the working tree: `git_sources.include_hidden` column + alembic `0019` (dev DB at head, column present), `iter_importable_files`/`import_sources` flag support with `str(root)`-keyed map used by both walk and progress pre-walk, `GitSourcePatchIn` rename with optional fields, sync/CLI pipeline wiring (OR-collision), and the per-row "Hidden" checkbox + tag + error line on the Sources page
- Unit + integration: `uv run pytest` → exit 0 (2148 tests collected, all pass; this sandbox occasionally swallows pytest's final status line — exit codes verified)
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (3879 stmts, 15 miss) — >90% gate ✓
- Dedicated E2E: `uv run pytest tests/e2e/test_hidden_folders_toggle.py -v --no-cov` → **6 passed in 23.20s** (DB up, in isolation)
- Regression E2E in isolation: `test_source_ignore_paths` 6 passed, `test_git_sources_admin` 6 passed, `test_local_directory_sources` 3 passed, `test_sync_button` 3 passed, `test_smoke` 3 passed
- Lint/types: `uv run ruff check .` + `uv run pyright` → clean (0 errors/warnings)

**Completion criteria:** (1) checkbox persists via PATCH 200 → "hidden on" tag + GET round-trips `include_hidden: true`; failure path reverts box + `role="alert"` canned message ✓; (2) flag OFF byte-identical (only `visible.md` indexed), ON indexes `.hidden/note.md` into the KB catalog, `EXCLUDED_DIRS` excluded both states ✓; (3) A2: flag OFF → `detail.pruned==1`, doc gone from catalog ✓; (4) PATCH bool-only/list-only/both/neither no-op, phase-89 fixed 422s unchanged, 404, anonymous 403 (incl. bool-only body) ✓; (5) env-fallback rows render no checkbox, WCAG-clean (aria-label, keyboard focus, visible label, text tag) ✓; (6) full gate green ✓; (7) commit left to the harness per instructions (no `git add`/`commit` run; phase files untouched).

**Deviations:** none — no defects found; no code changes were needed on this pass.

**Next pending phase:** `.agents/phases/todo/98_sync_summary_visibility`.
2026-09-12 21:26:30 -04:00

1056 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pydantic request/response schemas (API contract)."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
SerializerFunctionWrapHandler,
field_validator,
model_serializer,
)
class HealthResponse(BaseModel):
status: str
db: str
version: str
environment: str
class SuggestionList(BaseModel):
suggestions: list[str]
class HistoryTurn(BaseModel):
"""One prior chat turn the client sends with ``POST /api/chat``
(phase 74, TODO L4).
The endpoint stays stateless (A10): the client's ``bor.chat.v1``
conversation record (minus the question about to be asked) is
provided in the request body as ``history`` so a follow-up question
reaches the model together with the exchange so far — and, for
preserve-thinking models, with the prior brain turns' thinking (the
record has carried the ``thinking`` key since phase 17).
``thinking`` travels to the model as ``reasoning_content`` on the
assistant message (the wire convention :mod:`app.rag.llm` already
documents for the response side) — only when non-empty (A4).
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; long
answers (and the scratchpads that ride along as ``thinking``) both
run to tens of kilobytes of text, so both share the same loose
boundary cap. These are boundary sanity caps only — the real
trimming budget is the settings pair ``history_max_turns`` /
``history_max_chars`` (``app.config``, A3: a capped-out turn is
dropped whole, never truncated). The old 4000-char text cap was
stricter than the 24_000-char default total budget and rejected
any second turn in a chat whose history held a long answer (422 —
found by the phase-42 E2E suite on the phase-76 shell).
"""
who: Literal["user", "brain"]
text: str = Field(min_length=1, max_length=32_000)
thinking: str | None = Field(default=None, max_length=32000)
class ChatRequest(BaseModel):
"""``POST /api/chat`` body: the current question plus the optional
prior turns (phase 74 — the client-provided history, stateless per
A10).
``history`` is the client's earlier turns, oldest first (the
``bor.chat.v1`` record minus the current question); the mapper
(:func:`app.rag.prompts.history_to_messages`) trims it newest-first
against the settings budgets and maps it to model messages. The
schema-level ``max_length=100`` is a DoS sanity ceiling only — the
config budgets do the real trimming (A3). Absent or empty keeps the
request byte-identical to pre-phase-74: the model sees exactly the
two-message ``[system, user]`` request.
"""
message: str = Field(min_length=1, max_length=4000)
history: list[HistoryTurn] = Field(default_factory=list, max_length=100)
class LoginRequest(BaseModel):
"""``POST /api/login`` body (phase 16): the single admin's password.
An empty or wrong password is a 401 with one generic detail — never a
422 that would hint at input-shape differences.
"""
password: str = ""
class WhoamiResponse(BaseModel):
"""``GET /api/whoami`` (phase 16; phase 79 added the ``user`` role)
— drives all UI gating. ``authenticated`` is true for admin AND
user; the UI's admin-only surfaces key off ``role === "admin"``.
"""
authenticated: bool
role: str # "admin" | "user" | "anonymous"
class SourceRef(BaseModel):
"""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):
"""SSE thinking event: one chunk of the model's reasoning (phase 17).
PLAN §4 extension (A15, owner permission 2026-08-23): frames of the
shape ``{type: "thinking", text: str}`` stream ahead of the
``delta`` frames in practice (the model reasons before it answers). The
client renders them in a collapsible "Thinking" block; the ``done``
event shape is unchanged and thinking text never travels on it.
Sibling of :class:`ChatErrorEvent`.
"""
type: str = "thinking"
text: str
class ChatToolEvent(BaseModel):
"""SSE frame for one agent tool call (phase 37, PLAN §4 extension).
A15 extension (owner permission 2026-08-26; the grep added in phase
68; phase 70 aligned the surface to the harness-trained
``ls`` / ``read`` / ``grep`` — owner permission 2026-09-03): a
grounded turn may call the server-side document tools (``ls`` /
``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested
call streams as ``{type: "tool", name: str, argument: str | null}``
ahead of the answer's ``delta`` frames. ``argument`` is the single
string argument the model passed — ``read``'s ``path`` (the combined
``source/path``), ``grep``'s ``pattern``, ``ls``'s ``path`` — or
null (a non-string value, a model error the backend refuses, and an
omitted argument both yield null). The client renders each frame as
a "calling tool" line/state (phase 37 task 05); the ``delta`` /
``done`` shapes are unchanged — the read document is reflected in
``done.sources`` instead (a grep adds no source: it is a locator,
locked A5).
"""
type: Literal["tool"] = "tool"
name: str # "ls" | "read" | "grep" (whatever AGENT_TOOLS names)
argument: str | None = None # the single string argument passed, or null
class ChatToolResultEvent(BaseModel):
"""SSE frame for one executed tool call whose result was truncated
(phase 95, ``TODO.md`` L5).
A15 extension (owner permission 2026-09-10 — recorded in the phase 95
overview ``00_phase.md``; PLAN.md is being redone by the owner): the
SSE event-type list grows from six to SEVEN — ``thinking``,
``tool``, ``retry``, ``delta``, ``done``, ``error`` and this
optional ``tool_result``. The frame is strictly ADDITIVE: existing
frames and clients are untouched (a client that does not know the
type simply ignores it), and it is emitted ONLY for a truncated
``read`` — one frame per truncated read, carrying the counts the UI
renders as "(truncated — showing N of M chars)". It always follows
the matching :class:`ChatToolEvent` frame for the same call (the
line is already on screen; the marker lands a beat later — the
phase-37/48 tool-line lifecycle is untouched). ``argument`` is the
combined ``source/path`` the model passed (identical to the matching
``tool`` frame's argument, so the client can match the two); a
non-truncated read streams NO frame of this type.
"""
type: str = "tool_result"
name: str # the tool that was executed (always "read" today)
argument: str | None = None # the model's argument (combined source/path)
truncated: bool = True # always True on a sent frame (the emission trigger)
chars_shown: int = Field(ge=0) # the cap kept (settings.read_max_chars)
chars_total: int = Field(ge=0) # the document's true length
class ChatDoneEvent(BaseModel):
"""Final SSE event of a chat turn: metadata for the finished answer."""
type: str = "done"
deflected: bool
sources: list[SourceRef]
suggestions: list[str] = []
class ChatErrorEvent(BaseModel):
"""SSE error event: a turn that cannot complete (PLAN §4).
The client's loading-feedback state machine (phase 06) keys off this
exact shape — ``{type: "error", detail: str}`` — to flip to the error
state and re-enable the send button.
"""
type: str = "error"
detail: str
class ChatRetryEvent(BaseModel):
"""SSE retry event: an LLM request is restarted before the first token
(phase 67, owner-locked 2026-09-01).
Sibling of :class:`ChatErrorEvent`, but transient — the client shows a
live status on the existing ``#send-status`` line (locked A4:
"Communication interrupted — retrying (n of N)…") and the send button
stays the Stop control; it never flips the state machine to error. It
is only ever sent when the failed attempt had NOT streamed a single
output frame yet (locked A2: no thinking/tool/delta emitted) — once
tokens are flowing, a failure is terminal (the ``error`` frame) and
this event cannot appear.
``attempt`` is the 1-based number of the attempt the endpoint is about
to try next (what the endpoint sends — the first failure of a
4-attempt budget carries ``attempt=2``); ``max_attempts`` is the total
attempt budget (``llm_retries + 1``).
"""
type: Literal["retry"] = "retry"
attempt: int
max_attempts: int
class DocSummary(BaseModel):
"""One indexed document as shown on the Sources page / API."""
id: str
source: str
path: str
title: str
chunks: int
indexed_at: str
class DocList(BaseModel):
"""Response of ``GET /api/docs`` (empty list → designed empty state)."""
documents: list[DocSummary]
class KbTreeFile(BaseModel):
"""One file node of the KB drill-down tree (phase 97, task 02).
``kind`` is the wire discriminator (``"file"`` — the ``00_phase.md``
JSON shape is the contract). ``path`` is SOURCE-RELATIVE (the RAG
view prefixes the source in its breadcrumb); ``title`` /
``chunks`` (content + ``is_summary`` chunks — the same count
``GET /api/docs`` returns) / ``indexed_at`` (ISO-8601) are verbatim
from the catalogue row the endpoint reads.
"""
kind: Literal["file"] = "file"
path: str
title: str
chunks: int = Field(ge=0)
indexed_at: str
class KbTreeFolder(BaseModel):
"""One folder node of the KB drill-down tree (phase 97, task 02).
``path`` is the source-relative folder (never ``""`` — the SOURCE
node IS the root); ``documents`` is the recursive subtree count
(the phase-94 ``ls`` count rule: every path equal to the folder or
starting with ``folder + "/"`` — the file sharing a folder's name
counts); ``summary`` is the stored ``folder_summaries`` row (AI or
manual — any row) or null; ``children`` are the direct subfolders
(path order) followed by the direct files (catalog order) — the
recursive union (Pydantic v2 resolves it with
``from __future__ import annotations``).
"""
kind: Literal["folder"] = "folder"
path: str
documents: int = Field(ge=0)
summary: str | None = None
children: list[KbTreeFolder | KbTreeFile] = Field(default_factory=list)
class KbTreeSource(BaseModel):
"""One source node of the KB drill-down tree (phase 97, task 02).
Sources list the REGISTERED names first (registry order — each
always present, a registered 0-document source lists with
``documents: 0`` and no children), then the indexed-only sources
(alphabetical) — the phase-97 superset rule. ``documents`` is the
source's whole recursive count; ``summary`` is the stored
``(source, "")`` source-root row or null; ``children`` are the
source's direct subfolders + direct files (same shape as a folder
node's).
"""
name: str
documents: int = Field(ge=0)
summary: str | None = None
children: list[KbTreeFolder | KbTreeFile] = Field(default_factory=list)
class KbTree(BaseModel):
"""Response of ``GET /api/docs/tree`` (phase 97, task 02).
The FULL recursive tree in ONE fetch — the RAG view (admin) drills
client-side, zero per-level fetches (the ``00_phase.md`` "The tree
endpoint" contract).
"""
sources: list[KbTreeSource]
class DocContent(BaseModel):
"""One indexed document's full content (feeds the viewer page, phase 10)."""
source: str
path: str
title: str
format: str
#: Lite-model summary (phase 30) — non-markdown A9 docs only; None for
#: markdown documents, pre-phase-30 rows, and the fail-soft path where
#: summary generation failed but the document was still indexed.
summary: str | None = None
content: str
indexed_at: str
chunks: int
class SummaryUpdate(BaseModel):
"""``PATCH /api/documents/summary`` body (phase 57, task 01).
``source`` / ``path`` name the indexed document (the same pair the
public ``GET /api/documents/content`` looks up); ``summary`` is the
raw new text. The API strips it before storing — an
empty/whitespace-only value is the *clear* operation (a first-class
action, phase 57 D4), not a 422. Unconstrained on purpose: unknown
pairs must 404 as "document not found" (row-lookup semantics),
exactly like the public content endpoint.
"""
source: str
path: str
summary: str
class SummaryResult(BaseModel):
"""``PATCH /api/documents/summary`` response (phase 57, task 01).
``summary`` is the stored text after the change (``null`` after a
clear — the viewer's summary box hides on null) and ``chunks`` the
document's post-change total chunk count: an update leaves the
content chunks untouched (the count is unchanged — only the single
``is_summary`` chunk is replaced), a clear drops one (the
``is_summary`` chunk is deleted).
"""
source: str
path: str
summary: str | None
chunks: int
class FolderSummaryUpdate(BaseModel):
"""``PATCH /api/folders/summary`` body (phase 97, task 03).
``source`` / ``folder_path`` name the folder whose stored
description is edited — ``folder_path = ""`` is the SOURCE ROOT
(the phase-94 ``folder_summaries`` convention). ``summary`` is the
raw new text: the API strips it before storing, and an
empty/whitespace-only value is the *clear* operation (row deleted
— the reset path, the phase-57 analog), not a 422. Unconstrained on
purpose: an unknown source or a folder with no indexed descendant
must 404 (``source not found`` / ``folder not found`` —
registry/row-lookup semantics), and traversal strings such as
``../../etc`` are simply not prefixes of any indexed path (the
DB-only rule of the ``/documents/content`` lookup — no filesystem
access).
"""
source: str
folder_path: str
summary: str
class FolderSummaryResult(BaseModel):
"""``PATCH /api/folders/summary`` response (phase 97, task 03).
``summary`` is the stored text after the change — ``null`` after a
clear (the RAG view hides the level block / empties the description
cell on null). Every non-empty save was stored with
``manually_edited = true`` (the flag itself is not echoed — the
response mirrors the phase-57 ``SummaryResult`` shape minus the
chunk count, which a folder description has no role in).
"""
source: str
folder_path: str
summary: str | None
class SteeringNoteIn(BaseModel):
"""``POST /api/steering`` body: one tuning instruction (phase 15).
The note is trimmed *before* the length constraints run, so a
whitespace-only body is a 422 and a 2000-char note with surrounding
spaces still passes.
"""
note: str = Field(min_length=1, max_length=2000)
@field_validator("note", mode="before")
@classmethod
def _trim_note(cls, v: object) -> object:
return v.strip() if isinstance(v, str) else v
class SteeringNoteUpdate(BaseModel):
"""``PUT /api/steering/{id}`` body: a new tuning instruction (phase 27).
Mirrors :class:`SteeringNoteIn` — the note is trimmed *before* the
length constraints run, so an empty/whitespace body is a 422 and a
full replacement that is ≤2000 chars after the trim still passes.
"""
note: str = Field(min_length=1, max_length=2000)
@field_validator("note", mode="before")
@classmethod
def _trim_note(cls, v: object) -> object:
return v.strip() if isinstance(v, str) else v
class SteeringNote(BaseModel):
"""One stored steering note (API shape — ISO-8601 ``created_at``)."""
id: uuid.UUID
note: str
created_at: datetime
class SteeringNoteList(BaseModel):
"""``GET /api/steering`` response: all notes, newest first."""
notes: list[SteeringNote]
class GitSourceIn(BaseModel):
"""``POST /api/git-sources`` body (phase 35, task 02; ``kind``, phase 38).
``kind`` selects the source kind and which field carries its location:
* ``"git"`` (default) — ``url`` is the repo URL. Mirrors the
phase-35 contract: trimmed *before* the length constraints run, so a
whitespace-only body is a 422 and a URL with surrounding spaces is
stored clean. Shape validation (``https://``, ``ssh://``, ``git@``)
and the kind-field rules (url present, no path) happen in the API
layer so the 422/409 details stay fixed strings that never echo the
input (credential safety).
* ``"local"`` — ``path`` is an existing directory on the server.
Trimmed here; the API layer then ``expanduser()``s it and requires an
absolute existing directory (else 422 naming the path — the path is
not a secret, unlike a git URL) and no ``url``.
``ignore_paths`` (phase 89) is optional at create time (absent →
``[]``) and carries the RAW box lines — trimming/normalization happens
in the API layer, not the schema, so the A4 422 details stay fixed
strings (the router's credential-safety discipline, applied for
consistency).
``include_hidden`` (phase 105) is optional at create time (absent →
stored ``False`` — A4).
"""
kind: Literal["git", "local"] = "git"
url: str | None = Field(default=None, min_length=1, max_length=500)
path: str | None = Field(default=None, min_length=1, max_length=2000)
ignore_paths: list[str] | None = Field(default=None)
include_hidden: bool | None = Field(default=None)
@field_validator("url", mode="before")
@classmethod
def _trim_url(cls, v: object) -> object:
return v.strip() if isinstance(v, str) else v
@field_validator("path", mode="before")
@classmethod
def _trim_path(cls, v: object) -> object:
return v.strip() if isinstance(v, str) else v
class GitSourceOut(BaseModel):
"""One created git source as returned by ``POST`` (phase 35, task 02).
``id`` / ``added_at`` are non-null for a stored row. ``url`` is the
row's location column: the repo URL for ``kind=git`` rows and, for
``kind=local`` rows, the stored (expanded) directory path — the
phase-35 response shape is unchanged by phase 38, so a local 201
reports its path in ``url`` and the full row (``kind`` + ``path``)
via ``GET``. ``ignore_paths`` (phase 89) is the stored, normalized
list — non-null (a row created without it reports ``[]``).
``include_hidden`` (phase 105) is the stored flag — a row created
without it reports ``False`` (A4).
"""
id: uuid.UUID | None
url: str
added_at: datetime | None
ignore_paths: list[str]
include_hidden: bool
class GitSourceRow(BaseModel):
"""One row of ``GET /api/git-sources`` (phase 35; ``kind``/``path``,
phase 38, task 02).
``kind`` discriminates the row: git rows (and the git-only
``BOR_GIT_SOURCES`` env-fallback rows) carry ``url`` and
``path: null``; local rows carry ``path`` (the absolute directory,
expanded) and the same string in ``url`` (the table's NOT-NULL
location column). ``id`` / ``added_at`` are nullable: env-fallback
rows (table empty) carry neither. ``ignore_paths`` (phase 89) is the
row's stored, normalized list — env-fallback rows (no DB row to
store a list on) report ``[]``. ``include_hidden`` (phase 105) is
the row's stored flag — env-fallback rows (no DB row to store a flag
on) report ``False`` (the ``ignore_paths: []`` precedent).
"""
id: uuid.UUID | None
kind: Literal["git", "local"]
url: str
path: str | None
added_at: datetime | None
ignore_paths: list[str]
include_hidden: bool
class GitSourcePatchIn(BaseModel):
"""``PATCH /api/git-sources/{source_id}`` body (phase 89 A5;
extended phase 105).
Each field is independent and OPTIONAL: absent/None leaves the
row's value unchanged; PRESENT applies. ``ignore_paths`` when
present keeps the phase-89 A5 REPLACE semantics (the body list,
normalized + A4-validated, becomes the row's whole list — empty
list clears all; every pre-phase-105 client always sends the
list, so their behavior is byte-identical). ``include_hidden``
(phase 105) when present sets the stored flag. Both absent →
200 no-op (the row is untouched).
"""
ignore_paths: list[str] | None = Field(default=None)
include_hidden: bool | None = Field(default=None)
class GitSourceList(BaseModel):
"""``GET /api/git-sources`` response (phase 35, task 02).
``from_env`` is True only when the ``git_sources`` table is empty and
the list comes from ``BOR_GIT_SOURCES`` (the phase's locked fallback
— env rows are git-only and report ``kind: "git"``, ``path: null``);
once the table has rows the env var is ignored and ``from_env`` is
False — the UI is the source of truth.
"""
sources: list[GitSourceRow]
from_env: bool
class UploadAccepted(BaseModel):
"""``POST /api/git-sources/upload`` 202 response (phase 64, task 03).
The archive is **safely on disk** — this is the "successfully
uploaded" moment the Sources page toasts on (phase 64 A2). The rest
(unpack → swap → row upsert — and nothing else: no model check, no
import, no overview refresh, phase 90 A1 — the scan is the RAG
page's "Sync sources" button's job) runs in a background task behind
``GET /api/git-sources/upload/status``, whose ``success`` ``detail``
carries the no-count ``{"message": "uploaded"}`` payload (phase 90
A2 — the status key set is unchanged; the UI composes the user
copy).
"""
detail: str = "upload received"
name: str
class ToolCall(BaseModel):
"""One agent tool-call record (the phase-37 ``tools`` record shape).
Mirrors the ``{name, argument}`` pair the SSE ``tool`` frames carry
(PLAN §4 extension; phase 70): ``argument`` is the single string
argument the model passed (``read``'s combined ``source/path``,
``grep``'s pattern, ``ls``'s scope) or null. Stored inside
:class:`ChatMessage.tools` so a saved chat restores the "calling
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).
Phase 95 (task 02): the truncation marker the UI renders next to the
Reading line rides the SAME record — ``truncated`` (default False:
the pre-phase-95 shape) + the two non-negative counts. Small
additive fields with defaults, no migration (``ChatMessage.tools``
is JSON) — a saved chat written before phase 95 (no fields) validates
UNCHANGED (the phase-50 backward-compat rule) and renders without
the marker.
"""
name: str = Field(max_length=100)
argument: str | None = Field(default=None, max_length=2000)
truncated: bool = False
chars_shown: int | None = Field(default=None, ge=0)
chars_total: int | None = Field(default=None, ge=0)
#: 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):
"""One conversation record in the ``bor.chat.v1`` localStorage shape
(phase 14) — the stored ``messages`` payload of a saved chat (phase 50).
``{who, text, sources?, deflected?, suggestions?, thinking?, tools?,
stopped?}`` — raw text, never HTML, so a saved chat restores
pixel-identical through the existing ``renderStoredMessage`` path.
``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped
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, max_length=32_000)
sources: list[SourceRef] | None = Field(default=None, max_length=20)
deflected: bool | 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
class SavedChatCreate(BaseModel):
"""``POST /api/chats`` body (phase 50, task 02; ``share``, phase 51
task 02).
``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 —
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
the fresh row before the INSERT, so one request saves AND shares
(the chat page's Share button on an unsaved conversation, the
save-then-share contract). The response then carries ``share_url``
(see :class:`SavedChatOut`). Default false — a plain Save is
unchanged by phase 51.
"""
title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1, max_length=200)
share: bool = False
class SavedChatUpdate(BaseModel):
"""``PUT /api/chats/{chat_id}`` body (phase 50, task 02).
``messages`` is a full replacement (the re-Save upsert semantics —
re-Saving the same conversation updates the same row, never a new
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, max_length=200)
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat
exposes no share surface at all, and the History column renders the
unshared state from the key's absence.
A ``mode="wrap"`` model serializer: the default (recursive) dump runs
first, then only the TOP-LEVEL key is dropped when null. The
recursion matters — a route-level ``response_model_exclude_none``
would also drop the nested ``ChatMessage`` nulls (``sources: null``
and friends), which the byte-identical round-trip contract (phase
50) forbids.
"""
data = handler(model)
if data.get("share_url") is None:
data.pop("share_url", None)
return data
class SavedChatOut(BaseModel):
"""One saved chat, full payload (create/get/put response, phase 50;
``share_url``, phase 51 task 02; ``stale``, phase 53 task 03).
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
— the restore path is pixel-identical by construction.
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
shared, ABSENT from the JSON when unshared (``None`` → dropped by
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
``stale`` (phase 53): true iff the row's ``sources_version`` stamp
is behind the current ``sources_meta`` generation — the answer
predates the latest KB-changing sync. Computed server-side (the
client never does staleness math); ``SharedChatOut`` deliberately
carries no staleness surface (the public snapshot is frozen by
design, phase 51).
"""
id: uuid.UUID
title: str
created_at: datetime
updated_at: datetime
message_count: int
messages: list[ChatMessage]
share_url: str | None = None
#: Required (no default): the API must always compute staleness
#: server-side — there is no wire shape without the flag.
stale: bool
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_absent_share_url(self, handler)
class SavedChatRow(BaseModel):
"""One row of ``GET /api/chats`` (the History page's list shape,
phase 50; ``share_url``, phase 51 task 02).
No payloads in the list — the row carries only what the table needs
(Title, Messages count, Updated). ``share_url`` is populated here so
the History page's Share column renders straight from ``GET
/api/chats`` — no second fetch per row (``None`` → absent, the same
omission rule as :class:`SavedChatOut`). ``stale`` (phase 53) feeds
the History page's Stale column the same way: one ``GET`` powers
every column.
"""
id: uuid.UUID
title: str
updated_at: datetime
message_count: int
share_url: str | None = None
#: Required (no default) — see :attr:`SavedChatOut.stale`.
stale: bool
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_absent_share_url(self, handler)
class SavedChatList(BaseModel):
"""``GET /api/chats`` response: saved chats, latest activity first
(``updated_at desc, id desc``)."""
chats: list[SavedChatRow]
class SharedChatOut(BaseModel):
"""``GET /api/shared/{token}`` body (phase 51, task 01) — the PUBLIC
read shape of a shared chat.
Deliberately minimal: ``title`` + ``messages`` only. No id, no
timestamps, no token, no ``message_count`` — a shared chat is a
content snapshot, not a handle: nothing in the body can be turned
back into an admin-surface request, and the token itself never
round-trips (it is the URL, not data).
"""
title: str
messages: list[ChatMessage]
class ShareOut(BaseModel):
"""``POST /api/chats/{chat_id}/share`` response (phase 51, task 01).
``share_url`` is the path (``/shared/<token>``) the UI copies into
the clipboard — the owner's own origin supplies the scheme/host.
Idempotent: a re-share returns the existing, unchanged token.
"""
chat_id: uuid.UUID
share_url: str
class UnshareOut(BaseModel):
"""``POST /api/chats/{chat_id}/unshare`` response (phase 51, task 01).
``shared: false`` is reported unconditionally — the endpoint is
idempotent, so an already-unshared chat unshares cleanly (200).
"""
chat_id: uuid.UUID
shared: bool
class DocDraftCreate(BaseModel):
"""``POST /api/doc-drafts`` body (phase 59, task 02): one completed
chat answer about to become documentation.
``title`` arrives client-side as the last user question
(whitespace-collapsed, ≤120 chars — the chat auto-title convention,
phase 50); ``path`` as ``docs/<slug>.md``; ``body`` is the answer's
raw markdown (never HTML — the ``bor.chat.v1`` record's ``text``,
the phase-50/51 round-trip convention). The path guard-rails (task
02 — repo-relative, no ``..``, no absolute path) run in the API
layer so the 422 details stay fixed strings; the max lengths mirror
the ``documents`` table (title 500, path 1000).
"""
title: str = Field(min_length=1, max_length=500)
path: str = Field(min_length=1, max_length=1000)
body: str = Field(min_length=1)
class DocDraftUpdate(BaseModel):
"""``PUT /api/doc-drafts/{token}`` body (phase 59, task 02): a
partial update — each field is replaced only when supplied (absent
keeps the row's current value; present must be non-empty — the
``SavedChatUpdate`` optional-title pattern, extended to all three
editable fields). The same path guard-rails as create run in the
API layer when ``path`` is supplied.
"""
title: str | None = Field(default=None, min_length=1, max_length=500)
path: str | None = Field(default=None, min_length=1, max_length=1000)
body: str | None = Field(default=None, min_length=1)
class DocDraft(BaseModel):
"""One draft row, full payload (create/get/put response, phase 59).
``token`` is the URL credential (``/doc-edit.html?draft=<token>``
— the unguessable ``uuid4``, the share-token trust model, phase
51). ``status`` is ``draft`` until the push endpoint commits +
pushes the file, then ``pushed`` with ``branch`` / ``commit_sha``
recorded (both NULL while still a draft). Datetimes serialize
ISO-8601 on the wire (pydantic default).
"""
token: uuid.UUID
title: str
path: str
body: str
status: str
branch: str | None = None
commit_sha: str | None = None
created_at: datetime
updated_at: datetime
class DocDraftPushed(BaseModel):
"""``POST /api/doc-drafts/{token}/push`` success response (phase 59,
task 04): the commit + ``git push --ff-only`` landed — ``branch``
is the ``BOR_DOCS_BRANCH`` name and ``commit_sha`` the pushed
branch's new HEAD (the edit screen's branch + sha feedback; it must
equal ``git rev-parse <branch>`` in the repo — the E2E source of
truth is the bare repo's state, not the UI alone).
"""
status: Literal["pushed"] = "pushed"
branch: str
commit_sha: str
class TokenCreateRequest(BaseModel):
"""``POST /api/tokens`` body (phase 79, task 02): one named token
to generate and hand out.
``label`` is the hand-out name (e.g. "alice") — display-only, NOT
unique (two tokens may share a label). Trimmed *before* the length
constraints run, so a whitespace-only body is a 422 and a label with
surrounding spaces is stored clean (the ``SteeringNoteIn`` house
``ValueError`` pattern — fail loud at the boundary).
"""
label: str = Field(min_length=1, max_length=120)
@field_validator("label", mode="before")
@classmethod
def _trim_label(cls, v: object) -> object:
return v.strip() if isinstance(v, str) else v
class TokenCreated(BaseModel):
"""``POST /api/tokens`` 201 response (phase 79, task 02).
The ONLY schema in the codebase that carries the plaintext ``token``
— the wire moment it exists exactly once (A4). Every other response
shape (the list row, whoami, …) exposes display fields only: the
stored credential is the hash, and the hash itself is never a wire
field either.
"""
id: uuid.UUID
label: str
token: str
created_at: datetime
class TokenListItem(BaseModel):
"""One row of ``GET /api/tokens`` (phase 79, task 02).
Deliberately secret-free: NO ``token`` field and NO ``token_hash``
field exist on this shape — the list never carries a credential in
either form (A4). ``revoked`` is derived server-side from
``revoked_at is not None`` (the UI renders the Active/Revoked state
from the flag, not the timestamp).
``revoked_at`` (phase 101, D5 — wire-additive, defaults null): null
while the token is active; once revoked, the ISO-8601 timestamp the
revoked table renders (the revocation date). The flag stays the
client's table-split key; this field is display data only.
"""
id: uuid.UUID
label: str
created_at: datetime
last_used_at: datetime | None
revoked: bool
revoked_at: datetime | None = None
class TokenList(BaseModel):
"""``GET /api/tokens`` response: all tokens, newest first
(``created_at desc, id desc``)."""
tokens: list[TokenListItem]
class TokenAuthRequest(BaseModel):
"""``POST /api/token-auth`` body (phase 79, task 03): the plaintext
token a handed-out user presents at the in-app gate.
Deliberately NO min-length validator: an empty/whitespace token is a
MALFORMED login attempt — the endpoint 401s ``invalid token`` (the
phase-16 generic-401 pattern, one message for every failure: no
enumeration). A 422 here would hint at input-shape differences on a
credential endpoint, so the shape is just ``str`` and the endpoint
owns the ``token.strip()`` check.
"""
token: str
class UiSettingsIn(BaseModel):
"""``PUT /api/ui-settings`` body (phase 91, task 01): a FULL
replacement of the single ``ui_settings`` row.
Every field is ``str | None`` — present = a new value (strings are
trimmed; empty after the trim is the CLEAR operation, stored as
NULL; colors must be ``#rrggbb`` and are lowercased on store),
``null``/absent = "back to the default" (stored as NULL — the Reset
button's all-null PUT is exactly the "defaults" operation). The
API layer runs the trim/length/hex validation so the 422 details
name the offending field (the house fixed-detail style); the
built-in→NULL normalization (a color equal to its built-in is
stored as NULL — "save the defaults" must leave the row empty, the
no-op injection contract) happens there too, next to the palette
it normalizes against.
"""
app_name: str | None = None
input_placeholder: str | None = None
footer_text: str | None = None
bg: str | None = None
surface: str | None = None
ink: str | None = None
ink_soft: str | None = None
line: str | None = None
grid_line: str | None = None
brand: str | None = None
brand_soft: str | None = None
brand_ink: str | None = None
# The 8 semantic state colors (phase 93 — B3 revised): the same
# contract as the 9 identity colors (``null`` = back to the
# built-in; ``#rrggbb`` is validated + lowercased by the API).
ok_bg: str | None = None
ok_ink: str | None = None
err_bg: str | None = None
err_ink: str | None = None
err_line: str | None = None
accent_bg: str | None = None
accent_ink: str | None = None
accent_line: str | None = None
class UiSettingsOut(BaseModel):
"""Effective UI settings (``GET``/``PUT /api/ui-settings`` response,
phase 91, task 01; 17 colors since phase 93).
All 20 values (3 strings + 17 palette colors — 9 identity + 8
semantic state), all non-null strings: the resolver's
DB-over-env / DB-over-built-in merge (B1), so the tab always shows
the LIVE theme — a fresh (row-missing) deployment reports the env
strings and the built-in palette (the semantic colors report their
built-ins — the effective values, the NULL = built-in rule).
"""
app_name: str
input_placeholder: str
footer_text: str
bg: str
surface: str
ink: str
ink_soft: str
line: str
grid_line: str
brand: str
brand_soft: str
brand_ink: str
ok_bg: str
ok_ink: str
err_bg: str
err_ink: str
err_line: str
accent_bg: str
accent_ink: str
accent_line: str