Files
brain-of-reese/app/schemas.py
T
ducoterra bef24e05e2
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 123_chat_image_questions
All gates green. Verification complete.

**Phase 123 — final verification pass (all 4 tasks already in `complete/`)**

- Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer.
- `uv run pytest` → **2796 passed**, exit 0 (unit + integration).
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%).
- `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation.
- `uv run ruff check . && uv run pyright` → clean (0 errors).

**Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run).

No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
2026-09-25 05:19:18 -04:00

1397 lines
58 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 re
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) and, since phase 123, the question's attached image (the
stored path — :attr:`image`).
``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)
#: Phase 123 (TODO L6, LOCKED A5): the STORED PATH of the question's
#: attached image — ``/api/chat-images/<uuid4-hex>.<ext>`` exactly as
#: ``POST /api/chat-images`` returns it. NEVER a raw data URL: the
#: upload endpoint already did the size/mime enforcement, and
#: re-validating a 10 MB base64 string at the schema would be the
#: anti-pattern. ``None`` (the default, every text-only question)
#: keeps the turn byte-identical to pre-phase-123.
image: str | None = Field(default=None, max_length=500)
@field_validator("image")
@classmethod
def _image_is_stored_path(cls, v: str | None) -> str | None:
"""A set ``image`` must be the upload endpoint's stored-path
shape — ``/api/chat-images/<uuid4().hex>.<ext>`` (32 hex chars,
the six image extensions; the pattern is pinned in the
phase-123 design and mirrored by the serve route's filename
guard, ``app.api.chat_images``). A data URL, a bare filename, a
traversal, a wrong extension, or a mistyped uuid all 422 with
ONE fixed detail — no echo of the input."""
if v is None:
return v
if re.fullmatch(r"/api/chat-images/[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)", v) is None:
raise ValueError("image must be an uploaded chat image path")
return v
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.
Phase 122 (task 05): ``image_url`` — the image BYTES route
(``/api/documents/<id>/image``) for a ref whose document is a
standalone image: the chat's sources block renders the compact
inline image from it (the "shown in the chat nicely" contract, TODO
L6). It is the ONLY new frame field (the doc id rides the path —
the same way the document content endpoint's ``(source, path)``
lookup does). For a TEXT document the field stays ``None`` and is
DROPPED on serialization (never ``null`` — the :class:`DocContent`
omission precedent), so a text-doc frame is byte-identical to
pre-phase. Server-built refs go through the shared
:func:`app.rag.retriever.source_ref_with_image` (one shape, both
frame tiers); a client-saved ref without the field parses with the
``None`` default (pre-phase saved chats restore unchanged).
"""
source: str = Field(max_length=120)
path: str = Field(max_length=1000)
title: str = Field(max_length=500)
#: Phase 122 (task 05) — see the class docstring. ``None`` (every
#: text doc, and every pre-phase client-saved ref) is omitted on
#: serialization — the key is ABSENT, never ``null``.
image_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
data = handler(self)
if self.image_url is None:
data.pop("image_url", None)
return data
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.
Phase 113: ``related`` — the secondary related-doc tier (documents
that scored but did not clear the usefulness bar, LOCKED A2/A4). The
UI renders it as the de-emphasized "nearby docs" row — never a
citation chip — while ``sources`` stays the citation surface. The
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
contract) and old frames without it parse with the default ``[]``.
"""
type: str = "done"
deflected: bool
sources: list[SourceRef]
related: 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.
``hint`` (phase 114, TODO L6): an optional one-line clarification the
client shows IN PLACE of its default reachability hint when present
(the "question too long" frame: the embedding model WAS reached —
only the question's length is the problem). Additive: frames without
it serialize ``"hint": null``, and old clients ignore the field
(PLAN §4 house contract — the ``ChatDoneEvent.related`` pattern).
"""
type: str = "error"
detail: str
hint: str | None = None
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
#: The document's creation date (phase 106, D8) — ISO-8601, verbatim
#: from the row (the ``indexed_at`` style); the RAG view's file table
#: renders it as the ``Created`` column (before ``Indexed``).
created_at: str
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) / ``created_at`` (phase 106, D8 — the
document's creation date) / ``indexed_at`` (ISO-8601) are verbatim
from the catalogue row the endpoint reads.
Image affordance (phase 122, task 04): for an ``is_image`` file node
the three ``is_image`` / ``image_url`` / ``summary`` keys ride the
node (the RAG view's Path cell renders the 48px thumbnail from
``image_url`` with ``alt = summary``). For a TEXT file node all
three are OMITTED from the wire shape (the
:func:`_drop_image_fields` omission rule — a pre-phase KB, which has
no image rows, serializes byte-identically to pre-phase, and the
RAG view reads ``is_image === true`` — it never expects the keys on
a text node). ``image_url`` is ``None``-omitted even on an image
node (a row whose ``image_path`` was lost renders the glyph
fallback); ``summary`` stays ``null`` on an image node (the alt
falls back to the title client-side — the fail-soft backfill
corner).
"""
kind: Literal["file"] = "file"
path: str
title: str
chunks: int = Field(ge=0)
#: The document's creation date (phase 106, D8) — ISO-8601, verbatim
#: from the catalogue row; the RAG view's file table renders it as
#: the ``Created`` column (before ``Indexed``).
created_at: str
indexed_at: str
#: Phase 122 (task 04) — true iff the file is an image document
#: (LOCKED A3). Omitted from a text node's wire shape (see the class
#: docstring); the builder sets it only for a node whose
#: ``(source, path)`` is in the endpoint's image-docs map.
is_image: bool = False
#: Phase 122 (task 04) — the image bytes route
#: (``/api/documents/<id>/image``) for the RAG view's thumbnail;
#: ``None`` (→ absent) when the row has no servable copy.
image_url: str | None = None
#: Phase 122 (task 04) — the document's summary (for an image doc,
#: the vision description — the thumbnail's ``alt``); ``None`` for a
#: fail-soft row still awaiting the backfill.
summary: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_image_fields(self, handler)
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); ``updated_at`` (phase 106, D9) is the subtree's MAX
document ``created_at`` — DERIVED in the pure tree builder as it
recurses, never stored (``null`` for a node with no documents at
all — the ``summary: str | None`` shape); ``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``).
``summary_pending`` (phase 98, D3 — ONE concept): true iff this
folder's recursive count ≥ ``MIN_DOCS_PER_FOLDER`` (1) AND it has
NO stored ``folder_summaries`` row (AI or manual — any row) —
exactly the candidate the sync-time gap-fill regenerates (the
:func:`app.rag.folder_summaries.missing_folder_summaries` set).
A 0-document folder cannot exist (a folder is a catalogue prefix
only), so every existing folder with no stored row is pending —
single-file folders included.
"""
kind: Literal["folder"] = "folder"
path: str
documents: int = Field(ge=0)
#: The subtree's MAX document ``created_at`` (phase 106, D9 —
#: derived in the pure builder, never stored); ISO-8601, ``null``
#: for a node with no documents at all.
updated_at: str | None = None
summary: str | None = None
summary_pending: bool = False
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; ``updated_at`` (phase 106, D9) is
the source's subtree MAX document ``created_at`` — DERIVED in the
pure tree builder, never stored (``null`` for a 0-document source —
the ``summary: str | None`` shape); ``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).
``summary_pending`` (phase 98, D3 — ONE concept): true iff the
source's recursive ``documents`` count ≥ ``MIN_DOCS_PER_FOLDER``
(1) AND no stored ``(source, "")`` row (AI or manual — any row) —
exactly the source-root candidate the sync-time gap-fill
regenerates (the
:func:`app.rag.folder_summaries.missing_folder_summaries` set).
A registered 0-document source is never pending (there is nothing
to summarize); a single-document source IS pending while its root
row is absent.
"""
name: str
documents: int = Field(ge=0)
#: The source's subtree MAX document ``created_at`` (phase 106,
#: D9 — derived in the pure builder, never stored); ISO-8601,
#: ``null`` for a 0-document source.
updated_at: str | None = None
summary: str | None = None
summary_pending: bool = False
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).
Every SOURCE and FOLDER node carries ``summary_pending`` (phase
98, D3): true iff its recursive document count ≥
``MIN_DOCS_PER_FOLDER`` (1) AND it has no stored ``folder_summaries``
row — exactly ``missing_folder_summaries``'s candidate set (the
marker never drifts from the gap-fill). FILE nodes carry no pending
flag (the file table has no description column) — but, since phase
122 (task 04), an image FILE node carries the thumbnail affordance
keys (``is_image`` / ``image_url`` / ``summary`` — omitted on text
nodes, see :class:`KbTreeFile`).
"""
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) — every A9 doc, markdown included
#: since phase 118 A2; None for pre-phase-30 rows, the fail-soft path
#: where summary generation failed, and until the phase-118 backfill
#: stores one on the next sync.
summary: str | None = None
#: The document's creation date (phase 106, D8) — ISO-8601, verbatim
#: from the row; the viewer's top meta row renders the ``Created``
#: badge from it (before the ``Indexed`` badge).
created_at: str
content: str
indexed_at: str
chunks: int
#: Phase 122 (task 04) — true iff the document is a standalone
#: image (LOCKED A3: ``content`` is the vision description, the
#: bytes live behind :attr:`image_url`). ALWAYS present on the wire
#: (text docs: ``false`` — the wire-additive key, the phase-106
#: ``created_at`` pattern); the viewer renders the ``<img>`` block
#: only when true.
is_image: bool = False
#: Phase 122 (task 04) — the image bytes route
#: (``/api/documents/<id>/image``) for the viewer's ``<img>``.
#: ABSENT from the wire for text docs (``None`` → dropped by the
#: serializer — never ``null``, the :func:`_drop_absent_share_url`
#: omission precedent); also absent for an image row whose
#: ``image_path`` is NULL (the viewer's onerror fallback covers it).
image_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
data = handler(self)
if self.image_url is None:
data.pop("image_url", None)
return data
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 DateUpdate(BaseModel):
"""``PATCH /api/documents/date`` body (phase 106, task 05, D7).
``source`` / ``path`` name the indexed document (the same pair the
public ``GET /api/documents/content`` looks up); ``date`` is the
owner's corrected creation date — an ISO date (``YYYY-MM-DD``) or a
full ISO datetime. **Null/absent is the CLEAR** (the "revert to
sync" operation, D7): the ``created_at_manual`` flag is dropped and
the stored date stands until the next sync refreshes it (the API is
DB-only — it cannot re-read the source). Unconstrained
``str | None`` on purpose: a MALFORMED non-null value 422s in the
handler (``datetime.fromisoformat``), so the error detail can name
the field; an unknown pair must 404 as "document not found"
(row-lookup semantics), exactly like the public content endpoint.
"""
source: str
path: str
date: str | None = None
class DateResult(BaseModel):
"""``PATCH /api/documents/date`` response (phase 106, task 05, D7).
Echoes the STORED state after the change: ``created_at`` is the
stored ISO-8601 value (a set stores the normalized parse — a
manually set FUTURE date folds to today, D3; a clear leaves the
stored date standing) and ``created_at_manual`` the flag (true
after a set, false after a clear). The viewer re-renders its
Created badge from exactly this echo — no second fetch.
"""
source: str
path: str
created_at: str
created_at_manual: bool
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).
``token`` (phase 121, LOCKED A2) is the masked private-repo
credential from the Sources page: optional at create time (absent/
None = no credential — public repo), trimmed *before* the length
constraints run (the ``_trim_url`` precedent), max 500. It is a
WRITE-ONLY field — stored in the dedicated ``git_sources.token``
column and NEVER echoed back by any output shape (``GitSourceOut``
/ ``GitSourceRow`` carry no token field by contract).
"""
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)
token: str | None = Field(default=None, max_length=500)
@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
@field_validator("token", mode="before")
@classmethod
def _trim_token(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).
There is deliberately NO ``token`` field (phase 121, LOCKED A2):
the private-repo credential is stored in the dedicated
``git_sources.token`` column and is NEVER a response field — it
never reaches the UI or any API output. ``extra="forbid"`` makes
the omission a structural contract, not an accident: constructing
this model with a ``token`` key raises, so a regression that tries
to echo the credential back cannot even build the shape.
"""
model_config = ConfigDict(extra="forbid")
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).
There is deliberately NO ``token`` field (phase 121, LOCKED A2):
same contract as :class:`GitSourceOut` — the credential never
reaches the UI or any API output, and ``extra="forbid"`` makes the
omission structural (constructing a row with a ``token`` key
raises).
"""
model_config = ConfigDict(extra="forbid")
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. ``token`` (phase
121, LOCKED A2) is TRI-STATE — the three-way semantics the masked
edit field depends on: **absent/None = no change** (keep the row's
stored credential), **non-empty = replace**, **empty string =
clear** (the UI offers replace; clear exists for API completeness).
Trimmed *before* the length constraints run (the ``GitSourceIn``
``_trim_token`` precedent — whitespace-only counts as a clear),
max 500. All absent → 200 no-op (the row is untouched).
"""
ignore_paths: list[str] | None = Field(default=None)
include_hidden: bool | None = Field(default=None)
token: str | None = Field(default=None, max_length=500)
@field_validator("token", mode="before")
@classmethod
def _trim_token(cls, v: object) -> object:
return v.strip() if isinstance(v, str) else v
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?, related?, deflected?, suggestions?,
thinking?, tools?, stopped?, failed?, error?}`` — 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.
``related`` (phase 113, the related-doc tier the UI persists with
every grounded brain record — the restore path re-renders the
de-emphasized row from it): the same :class:`SourceRef` list shape
as ``sources``, the same cap. Its absence from this model was a
phase-113 omission — the ``extra="forbid"`` boundary 422'd every
done-time auto-save carrying the key (the A2 quiet-failure path
swallowed it), leaving grounded turns' brain messages unsaved;
pinned by ``tests/integration/test_chats_api.py`` (the full brain
record carries ``related``) and the E2E ``test_share_chat``
auto-save count.
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; ``related`` ≤ 20 — the related-doc tier, same ref
shape; ``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.
Phase 120 (task 01, locked A1): a FAILED turn (network error, SSE
``error`` frame, stream drop — the client-side failure paths) stores
``{who: "brain", text: <detail or fallback>, failed: true,
error: <detail>}`` — the phase-48 ``stopped`` precedent: a marker +
the capped detail on the brain record itself, no separate error
table, no new API (``retryLastTurn``'s pop-the-last-brain-record
logic works on a failed record UNCHANGED — the question's user
record immediately precedes it). ``error`` is the persisted banner
detail, capped at 500 (the phase-83 value-bounds style). No
serializer change: ``None`` values flow as absent/None exactly like
``stopped`` today — the phase-50 byte-identical round-trip contract
covers the new keys automatically.
"""
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)
related: 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
# Phase 120 (task 01, locked A1): the failed-turn marker + the
# persisted error detail (the phase-48 ``stopped`` precedent).
failed: bool | None = None
error: str | None = Field(default=None, max_length=500)
# Phase 123 (task 01, LOCKED A5): the question's attached image —
# the STORED PATH (``/api/chat-images/<uuid>.<ext>``, from
# ``POST /api/chat-images``), on the USER record only: the
# attachment belongs to the question, so a brain record never
# carries it (the answer may cite the image doc's sources, but the
# attachment itself is the user's). The saved/shared shape gains
# this one optional key — omitted when ``None`` (the
# :meth:`_drop_image_when_absent` serializer below), so a text-only
# chat round-trips byte-identically to pre-phase-123 (the
# phase-50 contract; the phase-122 ``SourceRef.image_url``
# omission precedent). The path is ≤ 500 chars — no phase-83
# cap pressure (it is never a data URL, LOCKED A5).
image: str | None = Field(default=None, max_length=500)
@model_serializer(mode="wrap")
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
"""The phase-123 image omission rule: ``image: None`` (every
text-only record, and every pre-phase-123 record) serializes
WITHOUT the key — ABSENT, never ``null`` — so the stored
``bor.chat.v1`` JSONB and the saved/shared wire shape stay
byte-identical to pre-phase for text-only chats (the phase-50
round-trip contract). A record WITH an image keeps the path —
the user bubble, the refreshed page, and the shared chat all
render it from the served route (the image is part of the
chat's content, so it rides the same public/credential-
is-the-id trust model)."""
data = handler(self)
if self.image is None:
data.pop("image", None)
return data
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_image_fields(model: KbTreeFile, handler: SerializerFunctionWrapHandler) -> Any:
"""The phase-122 (task 04) image-affordance omission rule for
:class:`KbTreeFile` file nodes: a TEXT node (``is_image`` false) drops
ALL three image keys — a pre-phase KB (no image rows) serializes
byte-identically to pre-phase, and the RAG view's file row stays the
pre-phase bare-link cell. An IMAGE node keeps ``is_image`` +
``summary`` (a ``null`` summary is meaningful — the alt falls back
client-side) and drops ``image_url`` only when ``None`` (the
row-without-a-copy corner — never a ``null`` on the wire, the
:func:`_drop_absent_share_url` precedent)."""
data = handler(model)
if not model.is_image:
for key in ("is_image", "image_url", "summary"):
data.pop(key, None)
elif data.get("image_url") is None:
data.pop("image_url", None)
return data
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