Files
brain-of-reese/app/schemas.py
T
ducoterra 055c0b5d85
Build and Push Containers / build-and-push-app (push) Successful in 1m39s
Build and Push Containers / build-and-push-db (push) Successful in 11s
feat(rag): pass chat history with prior thinking to the LLM
Phase 74 (TODO.md L4): a follow-up question now reaches the model WITH
the conversation so far — every prior user/brain turn and the prior
thinking blocks on brain turns (preserve-thinking) — while
POST /api/chat stays stateless (A10): the client provides the history
in the request body and the server stores nothing new.

Server (task 01):
- ChatRequest.history: optional list[HistoryTurn] (who: user|brain,
  text, optional thinking) — absent/empty keeps the request
  byte-identical to pre-phase-74 (the two-message [system, user]
  request; the kill-switch semantics are pinned in the integration
  suite).
- app.rag.prompts.history_to_messages: pure mapper — walks the turns
  newest-first against the settings budgets (history_max_turns=40 /
  history_max_chars=24000, BOR_HISTORY_MAX_TURNS /
  BOR_HISTORY_MAX_CHARS); a capped turn is dropped WHOLE (never cut
  mid-answer); the kept window is returned oldest-first; brain turns
  carry their thinking as reasoning_content (A4) only when
  non-empty.
- Both branches feed it: the deflected path splices it between the
  system prompt and the current user message (the phase-71 recovery
  still rebuilds from messages[1:]), the grounded agent receives
  run_agent(..., history=hist); llm.py's message params widen to
  list[dict[str, Any]] (string-only messages stay byte-identical on
  the wire — the SDK passes message dicts through verbatim).
- The per-turn log line (PLAN §9) gains history_msgs=N after
  kb_chars=N.
- Pins: tests/unit/test_history.py (mapper: mapping, reasoning
  gating, both budgets, drop-whole, ordering, empty default),
  tests/unit/test_config.py (the two settings + env overrides),
  tests/unit/test_agent.py (the history splice + the default),
  tests/integration/test_chat_api.py (deflected AND grounded forward
  the history incl. reasoning_content, no-history byte-identity, 422
  pins, the log field).

Client (task 02):
- runTurn — the single funnel for fresh send / phase-49 retry /
  phase-53 stale-regen — sends history = the conversation record
  minus the current question, with thinking only on brain records
  that streamed one (undefined drops the key from the JSON, the
  record's convention); the question is never duplicated into the
  history.

Wire proof (task 03):
- The mock's echo my history marker (HISTORY_TRIGGER) answers with
  the deterministic history echo — history: N prior messages; last
  answer tail: <last 24 chars>; thinking: yes|no — checked BEFORE
  the DEFLECT_MODE branch (like TABLE_TRIGGER), so it fires on both
  turn branches whatever the gate says; the module docstring records
  the user/assistant-only history invariant that keeps every
  existing (tool-result-classified) marker flow unaffected.
- tests/e2e/test_llm_history.py (isolated): a grounded follow-up and
  a deflected follow-up both receive history: 2 prior messages +
  thinking: yes + the byte-exact tail of turn 1's answer (derived
  from the persisted bor.chat.v1 record — the same array the client
  maps into the body); a cold start receives history: 0 prior
  messages / last answer tail: none / thinking: no.
- Regressions green in isolation: chat_rag, chat_history (phase 50),
  agent_document_tools, harness_aligned_tools, stop_generation,
  retry_answer, response_to_docs.
2026-09-05 16:04:40 -04:00

687 lines
24 KiB
Python

"""Pydantic request/response schemas (API contract)."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import 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; the
thinking cap is looser (scratchpads run longer than answers). 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).
"""
who: Literal["user", "brain"]
text: str = Field(min_length=1, max_length=4000)
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) — drives all UI gating."""
authenticated: bool
role: str # "admin" | "anonymous"
class SourceRef(BaseModel):
source: str
path: str
title: str
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 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``.
"""
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)
@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``.
"""
id: uuid.UUID | None
url: str
added_at: datetime | None
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.
"""
id: uuid.UUID | None
kind: Literal["git", "local"]
url: str
path: str | None
added_at: datetime | None
class GitSourceList(BaseModel):
"""``GET /api/git-sources`` response (phase 35, task 02).
``from_env`` is True only when the ``git_sources`` table is empty and
the list comes from ``BOR_GIT_SOURCES`` (the phase's locked fallback
— env rows are git-only and report ``kind: "git"``, ``path: null``);
once the table has rows the env var is ignored and ``from_env`` is
False — the UI is the source of truth.
"""
sources: list[GitSourceRow]
from_env: bool
class UploadOut(BaseModel):
"""The upload run's result fields (phase 49, task 02; phase 64, task 03).
Phase 64 (task 03): ``POST /api/git-sources/upload`` answers 202 the
moment the archive is on disk; these fields become the shape of
``GET /api/git-sources/upload/status`` ``detail`` on ``success`` —
the uploaded source's name (filename minus the archive suffix) plus
the SAME count keys as the admin sync's success ``detail``
(``files``, ``added``, ``updated``, ``unchanged``, ``pruned``,
``errors``, ``chunks`` — ``app.api.sync._run_sync``) and the
``overview`` flag: the Sources page renders the same
"N added · N pruned" result line for both.
"""
source: str
files: int
added: int
updated: int
unchanged: int
pruned: int
errors: int
chunks: int
overview: 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 (owner-locked A2). The
scan itself (unpack → swap → row upsert → model check → import →
overview) runs in a background task behind
``GET /api/git-sources/upload/status``, whose ``success`` ``detail``
carries the :class:`UploadOut` fields.
"""
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).
"""
name: str
argument: str | None = None
class ChatMessage(BaseModel):
"""One conversation record in the ``bor.chat.v1`` localStorage shape
(phase 14) — the stored ``messages`` payload of a saved chat (phase 50).
``{who, text, sources?, deflected?, suggestions?, thinking?, tools?,
stopped?}`` — raw text, never HTML, so a saved chat restores
pixel-identical through the existing ``renderStoredMessage`` path.
``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped
payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a
422, so nothing outside this shape can poison a restored
conversation.
"""
model_config = ConfigDict(extra="forbid")
who: Literal["user", "brain"]
text: str = Field(min_length=1)
sources: list[SourceRef] | None = None
deflected: bool | None = None
suggestions: list[str] | None = None
thinking: str | None = None
tools: list[ToolCall] | None = None
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.
``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)
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). ``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)
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