"""Pydantic request/response schemas (API contract).""" from __future__ import annotations import uuid from datetime import datetime from typing import Annotated, Any, Literal from pydantic import ( BaseModel, ConfigDict, Field, SerializerFunctionWrapHandler, field_validator, model_serializer, ) class HealthResponse(BaseModel): status: str db: str version: str environment: str class SuggestionList(BaseModel): suggestions: list[str] class HistoryTurn(BaseModel): """One prior chat turn the client sends with ``POST /api/chat`` (phase 74, TODO L4). The endpoint stays stateless (A10): the client's ``bor.chat.v1`` conversation record (minus the question about to be asked) is provided in the request body as ``history`` so a follow-up question reaches the model together with the exchange so far — and, for preserve-thinking models, with the prior brain turns' thinking (the record has carried the ``thinking`` key since phase 17). ``thinking`` travels to the model as ``reasoning_content`` on the assistant message (the wire convention :mod:`app.rag.llm` already documents for the response side) — only when non-empty (A4). ``text`` mirrors :attr:`ChatMessage.text`'s answer shape; long answers (and the scratchpads that ride along as ``thinking``) both run to tens of kilobytes of text, so both share the same loose boundary cap. These are boundary sanity caps only — the real trimming budget is the settings pair ``history_max_turns`` / ``history_max_chars`` (``app.config``, A3: a capped-out turn is dropped whole, never truncated). The old 4000-char text cap was stricter than the 24_000-char default total budget and rejected any second turn in a chat whose history held a long answer (422 — found by the phase-42 E2E suite on the phase-76 shell). """ who: Literal["user", "brain"] text: str = Field(min_length=1, max_length=32_000) thinking: str | None = Field(default=None, max_length=32000) class ChatRequest(BaseModel): """``POST /api/chat`` body: the current question plus the optional prior turns (phase 74 — the client-provided history, stateless per A10). ``history`` is the client's earlier turns, oldest first (the ``bor.chat.v1`` record minus the current question); the mapper (:func:`app.rag.prompts.history_to_messages`) trims it newest-first against the settings budgets and maps it to model messages. The schema-level ``max_length=100`` is a DoS sanity ceiling only — the config budgets do the real trimming (A3). Absent or empty keeps the request byte-identical to pre-phase-74: the model sees exactly the two-message ``[system, user]`` request. """ message: str = Field(min_length=1, max_length=4000) history: list[HistoryTurn] = Field(default_factory=list, max_length=100) class LoginRequest(BaseModel): """``POST /api/login`` body (phase 16): the single admin's password. An empty or wrong password is a 401 with one generic detail — never a 422 that would hint at input-shape differences. """ password: str = "" class WhoamiResponse(BaseModel): """``GET /api/whoami`` (phase 16; phase 79 added the ``user`` role) — drives all UI gating. ``authenticated`` is true for admin AND user; the UI's admin-only surfaces key off ``role === "admin"``. """ authenticated: bool role: str # "admin" | "user" | "anonymous" class SourceRef(BaseModel): """One indexed document as cited in an answer (the SSE ``done`` event's ``sources`` item — PLAN §4 — and, since phase 50, the ``sources`` list of a saved-chat message). Phase 83 (SEC-05): the caps mirror the ``documents`` column lengths (``source`` ``String(120)``, ``path`` ``String(1000)``, ``title`` ``String(500)``). A :class:`SourceRef` is built from ``documents`` rows server-side, so every server-built SSE ref fits by construction (A3: the SSE path is provably unaffected); the cap binds only client-saved refs — bounded at the boundary with a 422. """ source: str = Field(max_length=120) path: str = Field(max_length=1000) title: str = Field(max_length=500) class ChatThinkingEvent(BaseModel): """SSE thinking event: one chunk of the model's reasoning (phase 17). PLAN §4 extension (A15, owner permission 2026-08-23): frames of the shape ``{type: "thinking", text: str}`` stream ahead of the ``delta`` frames in practice (the model reasons before it answers). The client renders them in a collapsible "Thinking" block; the ``done`` event shape is unchanged and thinking text never travels on it. Sibling of :class:`ChatErrorEvent`. """ type: str = "thinking" text: str class ChatToolEvent(BaseModel): """SSE frame for one agent tool call (phase 37, PLAN §4 extension). A15 extension (owner permission 2026-08-26; the grep added in phase 68; phase 70 aligned the surface to the harness-trained ``ls`` / ``read`` / ``grep`` — owner permission 2026-09-03): a grounded turn may call the server-side document tools (``ls`` / ``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested call streams as ``{type: "tool", name: str, argument: str | null}`` ahead of the answer's ``delta`` frames. ``argument`` is the single string argument the model passed — ``read``'s ``path`` (the combined ``source/path``), ``grep``'s ``pattern``, ``ls``'s ``path`` — or null (a non-string value, a model error the backend refuses, and an omitted argument both yield null). The client renders each frame as a "calling tool" line/state (phase 37 task 05); the ``delta`` / ``done`` shapes are unchanged — the read document is reflected in ``done.sources`` instead (a grep adds no source: it is a locator, locked A5). """ type: Literal["tool"] = "tool" name: str # "ls" | "read" | "grep" (whatever AGENT_TOOLS names) argument: str | None = None # the single string argument passed, or null class ChatToolResultEvent(BaseModel): """SSE frame for one executed tool call whose result was truncated (phase 95, ``TODO.md`` L5). A15 extension (owner permission 2026-09-10 — recorded in the phase 95 overview ``00_phase.md``; PLAN.md is being redone by the owner): the SSE event-type list grows from six to SEVEN — ``thinking``, ``tool``, ``retry``, ``delta``, ``done``, ``error`` and this optional ``tool_result``. The frame is strictly ADDITIVE: existing frames and clients are untouched (a client that does not know the type simply ignores it), and it is emitted ONLY for a truncated ``read`` — one frame per truncated read, carrying the counts the UI renders as "(truncated — showing N of M chars)". It always follows the matching :class:`ChatToolEvent` frame for the same call (the line is already on screen; the marker lands a beat later — the phase-37/48 tool-line lifecycle is untouched). ``argument`` is the combined ``source/path`` the model passed (identical to the matching ``tool`` frame's argument, so the client can match the two); a non-truncated read streams NO frame of this type. """ type: str = "tool_result" name: str # the tool that was executed (always "read" today) argument: str | None = None # the model's argument (combined source/path) truncated: bool = True # always True on a sent frame (the emission trigger) chars_shown: int = Field(ge=0) # the cap kept (settings.read_max_chars) chars_total: int = Field(ge=0) # the document's true length class ChatDoneEvent(BaseModel): """Final SSE event of a chat turn: metadata for the finished answer.""" type: str = "done" deflected: bool sources: list[SourceRef] suggestions: list[str] = [] class ChatErrorEvent(BaseModel): """SSE error event: a turn that cannot complete (PLAN §4). The client's loading-feedback state machine (phase 06) keys off this exact shape — ``{type: "error", detail: str}`` — to flip to the error state and re-enable the send button. """ type: str = "error" detail: str class ChatRetryEvent(BaseModel): """SSE retry event: an LLM request is restarted before the first token (phase 67, owner-locked 2026-09-01). Sibling of :class:`ChatErrorEvent`, but transient — the client shows a live status on the existing ``#send-status`` line (locked A4: "Communication interrupted — retrying (n of N)…") and the send button stays the Stop control; it never flips the state machine to error. It is only ever sent when the failed attempt had NOT streamed a single output frame yet (locked A2: no thinking/tool/delta emitted) — once tokens are flowing, a failure is terminal (the ``error`` frame) and this event cannot appear. ``attempt`` is the 1-based number of the attempt the endpoint is about to try next (what the endpoint sends — the first failure of a 4-attempt budget carries ``attempt=2``); ``max_attempts`` is the total attempt budget (``llm_retries + 1``). """ type: Literal["retry"] = "retry" attempt: int max_attempts: int class DocSummary(BaseModel): """One indexed document as shown on the Sources page / API.""" id: str source: str path: str title: str chunks: int indexed_at: str class DocList(BaseModel): """Response of ``GET /api/docs`` (empty list → designed empty state).""" documents: list[DocSummary] class DocContent(BaseModel): """One indexed document's full content (feeds the viewer page, phase 10).""" source: str path: str title: str format: str #: Lite-model summary (phase 30) — non-markdown A9 docs only; None for #: markdown documents, pre-phase-30 rows, and the fail-soft path where #: summary generation failed but the document was still indexed. summary: str | None = None content: str indexed_at: str chunks: int class SummaryUpdate(BaseModel): """``PATCH /api/documents/summary`` body (phase 57, task 01). ``source`` / ``path`` name the indexed document (the same pair the public ``GET /api/documents/content`` looks up); ``summary`` is the raw new text. The API strips it before storing — an empty/whitespace-only value is the *clear* operation (a first-class action, phase 57 D4), not a 422. Unconstrained on purpose: unknown pairs must 404 as "document not found" (row-lookup semantics), exactly like the public content endpoint. """ source: str path: str summary: str class SummaryResult(BaseModel): """``PATCH /api/documents/summary`` response (phase 57, task 01). ``summary`` is the stored text after the change (``null`` after a clear — the viewer's summary box hides on null) and ``chunks`` the document's post-change total chunk count: an update leaves the content chunks untouched (the count is unchanged — only the single ``is_summary`` chunk is replaced), a clear drops one (the ``is_summary`` chunk is deleted). """ source: str path: str summary: str | None chunks: int class 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). """ 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) @field_validator("url", mode="before") @classmethod def _trim_url(cls, v: object) -> object: return v.strip() if isinstance(v, str) else v @field_validator("path", mode="before") @classmethod def _trim_path(cls, v: object) -> object: return v.strip() if isinstance(v, str) else v class GitSourceOut(BaseModel): """One created git source as returned by ``POST`` (phase 35, task 02). ``id`` / ``added_at`` are non-null for a stored row. ``url`` is the row's location column: the repo URL for ``kind=git`` rows and, for ``kind=local`` rows, the stored (expanded) directory path — the phase-35 response shape is unchanged by phase 38, so a local 201 reports its path in ``url`` and the full row (``kind`` + ``path``) via ``GET``. ``ignore_paths`` (phase 89) is the stored, normalized list — non-null (a row created without it reports ``[]``). """ id: uuid.UUID | None url: str added_at: datetime | None ignore_paths: list[str] 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 ``[]``. """ id: uuid.UUID | None kind: Literal["git", "local"] url: str path: str | None added_at: datetime | None ignore_paths: list[str] class GitSourceIgnoreIn(BaseModel): """``PATCH /api/git-sources/{source_id}`` body (phase 89, A5). ``ignore_paths`` is REQUIRED — the box's lines in REPLACE semantics: the body list (normalized + A4-validated in the API layer, with the fixed-detail 422s) becomes the row's whole list — an empty list clears all; an absent field is a 422 with the model's own detail. Entries are RAW box lines: normalization + the A4 limits are enforced in the API layer so the 422 details stay fixed strings (the router's credential-safety discipline, applied for consistency). """ ignore_paths: list[str] class GitSourceList(BaseModel): """``GET /api/git-sources`` response (phase 35, task 02). ``from_env`` is True only when the ``git_sources`` table is empty and the list comes from ``BOR_GIT_SOURCES`` (the phase's locked fallback — env rows are git-only and report ``kind: "git"``, ``path: null``); once the table has rows the env var is ignored and ``from_env`` is False — the UI is the source of truth. """ sources: list[GitSourceRow] from_env: bool class UploadAccepted(BaseModel): """``POST /api/git-sources/upload`` 202 response (phase 64, task 03). The archive is **safely on disk** — this is the "successfully uploaded" moment the Sources page toasts on (phase 64 A2). The rest (unpack → swap → row upsert — and nothing else: no model check, no import, no overview refresh, phase 90 A1 — the scan is the RAG page's "Sync sources" button's job) runs in a background task behind ``GET /api/git-sources/upload/status``, whose ``success`` ``detail`` carries the no-count ``{"message": "uploaded"}`` payload (phase 90 A2 — the status key set is unchanged; the UI composes the user copy). """ detail: str = "upload received" name: str class ToolCall(BaseModel): """One agent tool-call record (the phase-37 ``tools`` record shape). Mirrors the ``{name, argument}`` pair the SSE ``tool`` frames carry (PLAN §4 extension; phase 70): ``argument`` is the single string argument the model passed (``read``'s combined ``source/path``, ``grep``'s pattern, ``ls``'s scope) or null. Stored inside :class:`ChatMessage.tools` so a saved chat restores the "calling tool" lines pixel-identical (phase 50). Saved chats persisting the pre-phase-70 tool names still validate — ``name`` is opaque (no migration, locked). Phase 83 (SEC-05) bounds the anonymous write surface: ``name`` ≤ 100 (a tool name longer than that is not a real call — the ``AGENT_TOOLS`` names are short) and ``argument`` ≤ 2000 (the combined ``source/path`` identity is ≤ 120 + 1 + 1000; 2 000 is 2× headroom for a grep pattern). Phase 95 (task 02): the truncation marker the UI renders next to the Reading line rides the SAME record — ``truncated`` (default False: the pre-phase-95 shape) + the two non-negative counts. Small additive fields with defaults, no migration (``ChatMessage.tools`` is JSON) — a saved chat written before phase 95 (no fields) validates UNCHANGED (the phase-50 backward-compat rule) and renders without the marker. """ name: str = Field(max_length=100) argument: str | None = Field(default=None, max_length=2000) truncated: bool = False chars_shown: int | None = Field(default=None, ge=0) chars_total: int | None = Field(default=None, ge=0) #: One suggestion chip (phase 83, A1): a short deterministic string — #: ``derive_suggestions``'s output runs to ~80 chars, so 200 chars is #: the boundary sanity cap. Annotated alias: the JSON shape stays a #: plain string (only the value bound is added). _Chip = Annotated[str, Field(max_length=200)] class ChatMessage(BaseModel): """One conversation record in the ``bor.chat.v1`` localStorage shape (phase 14) — the stored ``messages`` payload of a saved chat (phase 50). ``{who, text, sources?, deflected?, suggestions?, thinking?, tools?, stopped?}`` — raw text, never HTML, so a saved chat restores pixel-identical through the existing ``renderStoredMessage`` path. ``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped payload, e.g. a stray ````-ish extra key) at the boundary with a 422, so nothing outside this shape can poison a restored conversation. Phase 83 (SEC-05) bounds the anonymous write surface (``POST/PUT /api/chats`` is public — the row id is the credential, phase 55 A1): ``text`` / ``thinking`` carry :class:`HistoryTurn`'s 32 000 caps (a single message longer than that is already rejected on the chat path, so a saved chat can never legitimately carry more), and the nested lists get length caps (``max_length``) sized to the realistic ``bor.chat.v1`` record the UI produces (``sources`` ≤ 20 — top-N docs + agent reads; ``suggestions`` ≤ 50 chips of ≤ 200 chars; ``tools`` ≤ 50 — one entry per tool call, the round cap is 10). Only value bounds were added — the accepted/rejected KEYS are unchanged. """ model_config = ConfigDict(extra="forbid") who: Literal["user", "brain"] text: str = Field(min_length=1, max_length=32_000) sources: list[SourceRef] | None = Field(default=None, max_length=20) deflected: bool | None = None suggestions: list[_Chip] | None = Field(default=None, max_length=50) thinking: str | None = Field(default=None, max_length=32_000) tools: list[ToolCall] | None = Field(default=None, max_length=50) stopped: bool | None = None class SavedChatCreate(BaseModel): """``POST /api/chats`` body (phase 50, task 02; ``share``, phase 51 task 02). ``title`` is optional: when absent or blank the API auto-titles the row (the first user message's text, whitespace-collapsed, truncated to 120 chars — the owner-locked convention). ``messages`` must be non-empty — a saved chat with nothing to restore is meaningless — and, since phase 83 (SEC-05), at most 200 items: well past any realistic conversation (the chat history budget itself is 40 turns) and far below a DoS-sized list on the anonymous write surface. ``share`` (phase 51, owner-locked 2026-08-29): when true, the row is shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on the fresh row before the INSERT, so one request saves AND shares (the chat page's Share button on an unsaved conversation, the save-then-share contract). The response then carries ``share_url`` (see :class:`SavedChatOut`). Default false — a plain Save is unchanged by phase 51. """ title: str | None = Field(default=None, max_length=500) messages: list[ChatMessage] = Field(min_length=1, max_length=200) share: bool = False class SavedChatUpdate(BaseModel): """``PUT /api/chats/{chat_id}`` body (phase 50, task 02). ``messages`` is a full replacement (the re-Save upsert semantics — re-Saving the same conversation updates the same row, never a new one) and carries the same bounds as :class:`SavedChatCreate.messages` (phase 83, SEC-05: non-empty, ≤ 200 items). ``title`` is replaced only when supplied — an absent (or blank) ``title`` keeps the row's current title. """ title: str | None = Field(default=None, max_length=500) messages: list[ChatMessage] = Field(min_length=1, max_length=200) def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any: """The ``share_url`` omission rule (phase 51, task 02): ``None`` → ABSENT from the JSON (not ``"share_url": null``) — an unshared chat exposes no share surface at all, and the History column renders the unshared state from the key's absence. A ``mode="wrap"`` model serializer: the default (recursive) dump runs first, then only the TOP-LEVEL key is dropped when null. The recursion matters — a route-level ``response_model_exclude_none`` would also drop the nested ``ChatMessage`` nulls (``sources: null`` and friends), which the byte-identical round-trip contract (phase 50) forbids. """ data = handler(model) if data.get("share_url") is None: data.pop("share_url", None) return data class SavedChatOut(BaseModel): """One saved chat, full payload (create/get/put response, phase 50; ``share_url``, phase 51 task 02; ``stale``, phase 53 task 03). ``messages`` round-trips the ``bor.chat.v1`` record list losslessly — the restore path is pixel-identical by construction. ``share_url`` (phase 51): ``"/shared/"`` 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). """ id: uuid.UUID label: str created_at: datetime last_used_at: datetime | None revoked: bool 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