"""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. Phase 122 (task 05): ``image_url`` — the image BYTES route (``/api/documents//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//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 ```` block #: only when true. is_image: bool = False #: Phase 122 (task 04) — the image bytes route #: (``/api/documents//image``) for the viewer's ````. #: 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 ````-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: , failed: true, error: }`` — 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) 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/"`` 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/``) 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/.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=`` — 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 `` 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