ADR-0019 — Chat: streaming transport, persistence, <think>/LaTeX, and the G3 gate (M4 Phase 3)¶
Status: Accepted Date: 2026-06-28 Sprint: v0.4 (M4 Phase 3) Authors: solution-architect (design), tech-writer (formatting) Features: F6 (multi-conversation persistent chat), F7 (
<think>display), F8 (LaTeX→Unicode), F14 (context window budget), F16-rest (GFM, multi-provider, timeout), G3 (streaming perf gate) Invariants in force: I3, I4, I6, I7 Supersedes: nothing. Extends: ADR-0007 (chat()stub → real body), ADR-0008 (chatoperation already resolvable), ADR-0017/0018 (shell + NavRail Chat slot), ADR-0009 (Usage/cost accounting).
1. Context¶
M4 Phase 3 adds the Chat feature. The contract surfaces it must fill already exist:
InferenceProvider.chat(messages, retrieval_context) -> AsyncIterator[str]is locked inbackend/app/ingest/provider/base.pyand stubbed (raise NotImplementedError) on all three backends. ADR-0007 §6 explicitly reserved the v0.4 body change as non-breaking: we fill bodies, we do not touch the ABC.resolve_provider_config("chat", vault_id)already resolves achat-operation row by precedence (operation+vault > vault > global) inbackend/app/provider_config_service.py. Chat selection is therefore already capability/config-driven (I6) — no new resolver code.UsageAccumulator+_record_usage()already give us per-call token/cost accounting out of band (ADR-0009). Chat reuses it verbatim.- The frontend shell (ADR-0017) and NavRail (ADR-0018) already reserve a disabled
chatrail item and aSectionRouterkeyed offactiveSection. Phase 3 enables the slot — it does not restructure the shell.
Verified dev-environment ground truth (drives the defaults below):
- Ollama is reachable from the backend container at
host.docker.internal:11434with a real generative modelqwen2.5:3b+bge-m3. Chat runs real end-to-end viaOllamaProviderin dev. provider_confighas 2 API rows but no Anthropic key in dev → the API path won't actually run locally. Local/Ollama is the working dev path and the default for the Phase-3 human checkpoint.- There is no
/chatendpoint yet. - F5 4-phase retrieval +
[n]citations is M5 — out of scope here. Phase 3 ships a deliberately minimal, honest context strategy and defers citations.
This ADR locks the contract so ai-agent-engineer (backend: /chat + the three chat() bodies) and
frontend-engineer (chat UI) can build in parallel against a frozen interface.
2. Decisions¶
2.1 Transport — NDJSON over a chunked POST /chat/stream (not SSE, not WebSocket)¶
POST /chat/stream returns 200 OK with media-type: application/x-ndjson and a chunked body:
one JSON object per line (\n-delimited), flushed as produced. The browser consumes it with
fetch() + response.body.getReader() (a ReadableStream).
Why NDJSON-over-POST and not SSE:
- POST carries a JSON body natively. The chat request is a non-trivial body (
messages[],conversation_id,context_window, scope). The browserEventSourceAPI (SSE) is GET-only and cannot send a request body — it would force the conversation into query params or a side-channel.fetch+ReadableStreamreads an SSE/NDJSON stream from a POST cleanly. - NDJSON is a strict superset of what we need and trivially typed. Each line is a discriminated-union
event (
{type: "token"|"think"|"done"|"error", ...}). Nodata:/event:framing rules, no reconnection/Last-Event-IDsemantics (which we do not want — a dropped chat stream is abandoned, not resumed). FastAPI emits it with a plainStreamingResponseover anasyncgenerator. - WebSocket is rejected: chat is a request→stream→done lifecycle, not a long-lived bidirectional channel. A WS would add connection-state management, a second transport in the stack, and a heavier test surface for zero benefit. (ADR-0010 already deferred MCP-HTTP for similar simplicity reasons.)
Trade-off accepted: NDJSON has no built-in auto-reconnect (SSE does). We explicitly do not want mid-stream resume for chat (a partial assistant turn is discarded; the user re-sends or hits Regenerate). This keeps the server stateless per stream and matches I7 (a bounded, abandonable run).
Headers: X-Accel-Buffering: no and Cache-Control: no-cache are set so no proxy buffers the
stream. CORS already exposes what the browser needs (no new exposed header required).
2.2 Endpoint contract¶
POST /chat/stream
Content-Type: application/json
→ 200 OK, Content-Type: application/x-ndjson (chunked, one JSON event per line)
→ 404 if conversation_id is provided but unknown
→ 422 on body validation failure
→ 503 if no chat provider_config resolves (I6 — never silently default a backend)
Request body (ChatRequest):
| Field | Type | Notes |
|---|---|---|
conversation_id |
uuid \| null |
null = start a new conversation (server creates one, returns its id in the done event). |
messages |
list[ChatMessageIn] |
The turn(s) to send. Each {role: "user"\|"assistant"\|"system", content: str}. Reuses the existing backend-neutral Message shape (ingest/schemas.py) — keeps I6. |
vault_id |
str \| null |
Defaults to settings.vault_id. Used for chat-op provider resolution + light context (§2.3). |
context_window |
int \| null |
F14. Overrides the configured window for this turn (4096..1_000_000). If null, falls back to provider_config / 32K default (AC-F14-2). |
operation |
Literal["chat"] |
Fixed to "chat"; present so the same abstraction can route ingest-vs-chat differently (CLAUDE.md §5: "ingest via Local, chat via API"). |
The server never accepts provider_type / model_id in the body. The backend chooses the provider
via resolve_provider_config("chat", vault_id) (I6). The active provider shown in the UI (providerStore)
is informational; the backend is the source of truth.
Streamed event schema (one per NDJSON line — discriminated on type):
// 0..N content deltas (raw text, appended verbatim to the buffer; NO parse here — I3)
{ "type": "token", "delta": "the next chunk of visible text" }
// 0..N reasoning deltas, emitted ONLY while inside a <think> span (F7, §2.4)
{ "type": "think", "delta": "the next chunk of reasoning text" }
// exactly ONE terminal event on success
{ "type": "done",
"conversation_id": "uuid", // echoes/returns the (possibly new) conversation id
"message_id": "uuid", // id of the persisted assistant message
"input_tokens": 812,
"output_tokens": 256,
"total_cost_usd": 0.0000, // 0.0 for local/cli (ADR-0009); 4dp display on FE (I7)
"iterations_used": 1, // 1 for a plain chat turn; reserved for future fallback
"finish_reason": "stop" | "length" | "timeout"
}
// emitted INSTEAD of done on failure (and the stream then closes)
{ "type": "error",
"code": "provider_timeout" | "provider_error" | "no_provider" | "budget_exceeded",
"message": "human-readable detail",
"total_cost_usd": 0.0000 // cost incurred before the failure (I7 still logged)
}
Bounding (I7). The chat run is bounded by two caps, both from the resolved chat
provider_config row (never hardcoded):
token_budget— the server stops the stream and emitserror.budget_exceeded(ordonewithfinish_reason: "length"if the model stops first) when cumulative tokens reach the budget.timeout_seconds— the F16 chat timeout (default 60s). On timeout the server cancels the upstream request, emitserror.provider_timeout, and logs the call as a provider failure (feeds the ADR-0009 bounded single-fallback mechanism; chat fallback is at most one retry, same as ingest).
Every chat turn writes one ingest_runs-style cost log line at minimum (total_cost_usd logged per
run, I7). Chat does not create ingest_runs rows (those are the F1-INGEST-VIEW ledger for ingest);
chat cost is logged to the structured logger and returned in the done event. A dedicated
chat_runs table is explicitly NOT created in M4 — the per-message token/cost columns on the
messages table (§2.5) are the persistent chat-cost record.
2.3 Retrieval strategy for Phase 3 — Option (b)-lite: single light system context, no 4-phase pipeline, no citations¶
F5 (tokenized → graph-expansion → budget → assembly) and [n] citation resolution are M5. For
Phase 3 we choose the simplest honest option that yields somewhat-grounded answers:
- The server builds one
retrieval_contextstring injected as a system message ahead of the user turn. It is the concatenation, in priority order and budget-capped (§2.6), of: vault/purpose.md(if present) — the vault goal/scope (this is the F2 idea but used only as a light grounding header here; full F2 injection is M6, so we keep it to a short prefix, not the full provider-context contract).vault/wiki/overview.md(if present) — the auto-generated catalogue summary (K3/F3).-
The currently-selected page body, iff the client passed a
selected_page_id— deferred: not in M4 (no content API is wired into chat yet; ADR-0017 leftGET /pages/{id}/contentas a reserved fast-follow). For M4 we inject only purpose.md + overview.md. -
No vector search, no graph expansion, no
[n]citations in chat for M4. Thedoneevent carries nocitations[]field in M4; the column is reserved onmessages(§2.5) but always[].
Explicitly deferred to M5 (do not build in M4): Qdrant/bge-m3 retrieval for chat, graph-expansion of
retrieved seeds, the 60/20/5/15 retrieval slice being filled from real RAG, [n] citation markers +
hover-to-source, and "save-to-wiki with citations". (Save-to-wiki itself — F6 — is in M4 but routes
the raw message text through POST /ingest/trigger; see §2.7.)
Why this is the honest minimum: purpose.md + overview.md are cheap file reads already on disk, give the model real vault grounding, and require zero new retrieval machinery. Pretending to do RAG (e.g. a half-built vector call) would create a fake seam we'd rip out in M5.
2.4 <think> detection — server-side span split, streaming-safe (F7)¶
Reasoning models (qwen2.5:3b may emit <think>…</think>; design for when a reasoning model does) wrap
their chain-of-thought in <think>…</think>. We split it on the server, during streaming, with a
tiny stateful scanner so the client never parses tags:
- The server maintains a 2-state machine (
OUTSIDE/INSIDE_THINK) over the raw token stream. - Text outside
<think>…</think>→ emitted as{type:"token"}. Text inside →{type:"think"}. - Partial-tag safety: the scanner buffers a trailing fragment that could be the start of a tag
(up to
len("</think>")-1 = 7chars) and does not emit it until it can decide. This guarantees a tag split across two model chunks (...<thi|nk>...) is never mis-emitted as visible content. - The full raw content (including the literal
<think>…</think>) is persisted to the assistantmessagesrow (AC-F7-2: stored un-mutated, re-derivable). The split is a transport convenience, not a data mutation. On reload, the client re-derives think-vs-content from the stored raw string with the same trivial split (a pure string scan at render time — not per token).
Frontend: the <think> deltas accumulate into a ThinkBlock that is collapsed by default
(AC-F7-1) and "closes" when the first {type:"token"} arrives (i.e., </think> was seen server-side).
No regex-per-token on the client.
2.5 Conversation persistence — Postgres conversations + messages tables (Alembic 0007)¶
F6 says "multi-conversation persistent" and AC-F6-1 requires "a page refresh restores the last active conversation". localStorage cannot satisfy multi-device LiveSync, and the cost ledger (I7) belongs in the system of record. We therefore persist in Postgres (consistent with ADR-0002: Postgres is the system of record).
New tables (Alembic migration 0007_chat_tables.py, added to models.py — D2 regen via make er):
conversations
id UUID PK default gen_random_uuid()
vault_id String NOT NULL -- scope, matches pages/edges pattern
title Text NULL -- user-set or first-prompt-derived
created_at TIMESTAMPTZ NOT NULL default now()
updated_at TIMESTAMPTZ NOT NULL default now() onupdate now()
deleted_at TIMESTAMPTZ NULL -- soft-delete (matches ADR-0005 pattern)
INDEX (vault_id, updated_at DESC) WHERE deleted_at IS NULL
messages
id UUID PK default gen_random_uuid()
conversation_id UUID FK → conversations.id NOT NULL
role Text NOT NULL -- 'user' | 'assistant' | 'system'
content Text NOT NULL -- RAW content, incl. literal <think>…</think>
citations JSONB NULL -- RESERVED (M5); always [] in M4
provider_type Text NULL -- audit: which backend produced an assistant msg
model_id Text NULL -- audit
input_tokens Integer NOT NULL default 0 -- I7 persistent cost record
output_tokens Integer NOT NULL default 0
total_cost_usd Numeric(10,4) NOT NULL default 0 -- 0.0000 for local/cli (ADR-0009)
created_at TIMESTAMPTZ NOT NULL default now()
INDEX (conversation_id, created_at ASC)
REST (added to main.py):
| Method | Path | Purpose |
|---|---|---|
GET |
/conversations |
List conversations for a vault, updated_at DESC, paginated (limit/offset). Excludes soft-deleted. |
POST |
/conversations |
Create an empty conversation {vault_id, title?} → 201 with the row. (Also implicitly created by /chat/stream when conversation_id is null.) |
GET |
/conversations/{id}/messages |
Ordered (created_at ASC) message history for one conversation; 404 if unknown/deleted. |
DELETE |
/conversations/{id} |
Soft-delete (set deleted_at); 204; 404 if unknown. |
Write path inside /chat/stream: the server persists the user message immediately, streams the
assistant turn, and persists the assistant message (full raw content + token/cost columns) on
done. On error, the assistant message is not persisted (the user message remains so Regenerate
works). conversations.updated_at is bumped on each turn (drives "last active conversation" restore).
Regenerate (AC-F6-4): client calls DELETE-of-last-assistant semantics by re-POSTing
/chat/stream with the same conversation_id and the prior user turn; the server deletes the last
assistant message row for that conversation before streaming the replacement. (A small
regenerate: true flag on ChatRequest triggers this server-side delete-then-stream; no separate
endpoint.)
2.6 F8 LaTeX→Unicode + GFM — parse exactly once, at stream END (I3 / G3)¶
This is the I3-critical decision. During streaming the client shows raw text, fast. No markdown
parse, no LaTeX conversion, no syntax highlight per token. On the done event (stream end) and only
then, the completed message is rendered:
- GFM: rendered with
marked+marked-gfm-heading-id(orremark+remark-gfm— engineer's choice, but one library, no custom parser; AC-F16-gfm-1). Output sanitized with DOMPurify before injection. - LaTeX→Unicode: a pure lookup-table converter (a small in-repo
latexToUnicode(s)util) covers AC-F8-2: Greek (\alpha→α …\Omega→Ω), operators (\sum→∑\prod→∏\int→∫\partial→∂\nabla→∇), arrows (\to→→\leftarrow→←\leftrightarrow→↔\Rightarrow→⇒\Leftrightarrow→⇔), and common sub/superscripts (x^2→x²,H_2O→H₂O via Unicode super/subscript ranges). Inline math only (\( … \)/ single-$). Unconvertible / display math ($$…$$,\[…\], matrices) is left as a fenced code block, never silently dropped (AC-F8-3). No KaTeX/MathJax dependency — the F8 spec is "LaTeX→Unicode", and a lookup table is sufficient, dependency-free, and trivially testable.
The single-parse guarantee (AC-G3-2): the chatStore holds the streaming text in a raw string
buffer. The MarkdownView for a completed message calls renderMarkdown(raw) =
marked(latexToUnicode(raw)) once, memoized on the (immutable, post-done) raw string. The
streaming message is rendered by StreamingMessage, which prints the raw buffer in a
white-space: pre-wrap block with no parser at all. When done fires, the message moves from
"streaming" to "settled" and is rendered once by MarkdownView. Parse count over a stream of N tokens =
exactly 1 (verified by a vitest spy, AC-G3-2).
2.7 Save-to-wiki (F6, AC-F6-5)¶
A "Save to wiki" button on an assistant message POSTs the message content to the existing
POST /ingest/trigger path — but trigger today takes a file_path, not inline text. Decision: add a
thin POST /ingest/from-text companion (or extend /ingest/trigger with an optional text field)
that writes the message body to vault/raw/sources/chat-<message_id>.md and runs the same
ingest_file seam (ADR-0003). This reuses the entire orchestrated ingest loop (I1/I6) with zero new
ingest logic — it only adds a file-materialization step. The ingest result (page title + link) is shown
inline. [n] citations carried into the saved page are M5 (M4 saves plain text).
3. Frontend components & store¶
All chat UI lives under frontend/src/components/chat/. The NavRail chat item is enabled
(remove disabled: true in nav/NavRail.tsx) and Section in graphStore.ts gains "chat";
SectionRouter.tsx adds an activeSection === "chat" branch rendering <ChatSection/>.
| Component | File | Responsibility |
|---|---|---|
ChatSection |
chat/ChatSection.tsx |
Section root: ConversationList (left) + active conversation (center: MessageList + MessageInput). Plugs into SectionRouter. |
ConversationList |
chat/ConversationList.tsx |
GET /conversations; create/switch/delete; virtualized if >50 (I4). |
MessageList |
chat/MessageList.tsx |
VIRTUALIZED (TanStack Virtual, I4) — ≤30 mounted DOM rows regardless of length (AC-F6-6). Renders settled messages via MarkdownView, the in-flight one via StreamingMessage. |
StreamingMessage |
chat/StreamingMessage.tsx |
Renders the raw streaming buffer in pre-wrap — no parser (I3). Hosts the live ThinkBlock. |
MarkdownView |
chat/MarkdownView.tsx |
Settled-message render: marked(latexToUnicode(raw)) → DOMPurify → dangerouslySetInnerHTML. Memoized on raw (parse-once). |
ThinkBlock |
chat/ThinkBlock.tsx |
Collapsible "Reasoning" section, collapsed by default (F7). |
MessageInput |
chat/MessageInput.tsx |
Plain <textarea> (I4: CodeMirror is reserved for the editor, not the chat input; no WYSIWYG). Enter=send, Shift+Enter=newline. Pre-fillable by ScenarioTemplates (F1). |
latexToUnicode |
chat/latexToUnicode.ts |
Pure lookup-table converter (F8). Unit-tested. |
chatStore (NEW, separate Zustand store — frontend/src/store/chatStore.ts):
Separate from graphStore/providerStore/ingestStore so streaming never re-renders the graph,
tree, ingest list, or settings (I3). Shape (selector-only access, shallow on collections):
interface ChatState {
conversations: ConversationSummary[];
activeConversationId: string | null;
messages: ChatMessage[]; // settled messages for the active conversation
// ── streaming buffers (the ONLY fields that mutate per token) ──
streamingContent: string; // raw visible-text buffer (append-only; no parse)
streamingThink: string; // raw reasoning buffer (append-only)
isStreaming: boolean;
streamError: string | null;
lastUsage: { inputTokens: number; outputTokens: number; totalCostUsd: number } | null;
}
I3 streaming discipline (AC-G3-3 / AC-G3-4):
appendToken(delta)/appendThink(delta)doset(s => ({ streamingContent: s.streamingContent + delta })). OnlyStreamingMessagesubscribes (viaselectStreamingContent); the settledmessagesarray and the wholeMessageListvirtualizer do not re-subscribe to the streaming buffer → existing messages do not re-render per token (AC-G3-4).- No selector derives parsed markdown from the streaming buffer → zero parser selector recomputes
during stream (AC-G3-3: 100 appends ⇒ 0 parse-selector calls; 1 after
done). - On
done: the buffer is committed as one new entry inmessages(settled), buffers cleared,isStreaming=false.MarkdownViewparses once.
Streaming consumption lives in a chat/useChatStream.ts hook: fetch('/chat/stream', {method:'POST', body})
→ res.body.getReader() → a TextDecoder + line-buffer that splits on \n, JSON.parses each line,
and dispatches token/think/done/error to the store. An AbortController cancels on unmount /
new send (F16 timeout surfaces as an error event; a client-side abort is also wired for safety).
Provider tie-in: the chat UI reads the active provider name from providerStore
(selectActiveProvider) for display only. It does not send provider info to /chat/stream (I6 — the
backend resolves chat-op config). Changing the provider in the existing ProviderSelector (writing a
scope=vault/global row) changes which backend the next chat turn uses, with no reload (AC-F17-UI-5).
4. G3 streaming performance gate — what it asserts & how the impl satisfies it¶
| Assertion | Mechanism | Why the design satisfies it |
|---|---|---|
| AC-G3-1 No main-thread long task >50ms during stream | Playwright + PerformanceObserver({entryTypes:['longtask']}) over a live Ollama (qwen2.5:3b) stream |
Per-token work is string += delta + one Zustand set on a leaf component. No parse, no layout thrash, no graph touch. |
AC-G3-2 Markdown+LaTeX parse fires exactly once (on done) |
vitest spy on renderMarkdown |
StreamingMessage has no parser; MarkdownView parses once, memoized on the immutable settled raw. |
| AC-G3-3 Zero parser-selector recomputes per token; 1 after end | vitest: 100 simulated appendToken calls |
Streaming buffer is a raw string; no selector derives parsed output from it. |
| AC-G3-4 Existing messages don't re-render on a new token | vitest render-count spy | MessageList virtualizer + settled messages array do not subscribe to streaming buffers; only StreamingMessage does. |
| Bounded DOM | TanStack Virtual (I4) | ≤30 mounted message rows (AC-F6-6); ConversationList virtualized >50. |
G3 is mandatory at end of Phase 3 (not deferred-to-live without explicit orchestrator waiver, per PM scope §4 Phase 3 and DoD gate #2). The dev path for the live assertion is Local/Ollama qwen2.5:3b.
5. Build order (parallelizable across two engineers)¶
ai-agent-engineer (backend, F17 domain):
- Alembic
0007_chat_tables.py+Conversation/Messagemodels inmodels.py→make er(D2). chat/service module: light-context builder (purpose.md + overview.md, budget-capped §2.6), the<think>span scanner (§2.4), the bounded-stream wrapper (token_budget + timeout, I7).- Implement the three
chat()bodies (ollama.pystreaming/api/chatstream:true;api.pyAnthropic SSE + OpenAI-compat SSE;cli.pymay remainNotImplementedErrorin M4 if delegated chat is out of dev scope — document it, don't fake it). Usage pushed via_record_usage(ADR-0009). POST /chat/stream(NDJSONStreamingResponse) +GET/POST /conversations,GET /conversations/{id}/messages,DELETE /conversations/{id},POST /ingest/from-text→make openapi(D4).
frontend-engineer (chat UI):
chatStore.ts+ selectors +useChatStream.ts(can be built against the §2.2 schema before the backend is live, using a mock NDJSON reader).- Components in the §3 table; enable NavRail
chatitem; add"chat"toSection+SectionRouter. latexToUnicode.ts+MarkdownView(parse-once) +ThinkBlock.- i18n keys for chat (
nav.chatalready exists; addchat.*toen.json/it.json, key parity).
Shared gate: vitest (G3-2/3/4, F7, F8) green; Playwright G3-1 green on live Ollama; D5
shell-chat-streaming.png; architect review for I3/I4/I6/I7.
6. AC mapping¶
| AC | Satisfied by |
|---|---|
| AC-F6-1 (persistent, restore last) | §2.5 Postgres tables + updated_at DESC restore |
| AC-F6-2 (multi-conversation, switch <100ms) | ConversationList + messages swap from store |
AC-F6-3 ([n] citations) |
Deferred to M5 (§2.3); citations column reserved, [] in M4 |
| AC-F6-4 (regenerate) | §2.5 regenerate:true → server delete-last-assistant + re-stream |
| AC-F6-5 (save-to-wiki) | §2.7 POST /ingest/from-text reusing ingest_file |
| AC-F6-6 (virtualized ≤30 DOM) | §3 MessageList TanStack Virtual |
AC-F7-1..4 (<think>) |
§2.4 server span split + collapsed ThinkBlock + raw persisted |
| AC-F8-1..4 (LaTeX→Unicode) | §2.6 latexToUnicode lookup table, parse-once on done |
| AC-F14-1..5 (context window) | §2.2 context_window; §2.6 60/20/5/15 budget; truncate oldest |
| AC-F16-gfm-1 | §2.6 marked/remark-gfm + DOMPurify |
| AC-F16-timeout-1 | §2.2 timeout_seconds (60s chat default) → error.provider_timeout |
| AC-F17-UI-5 (no reload on provider change) | §3 backend resolves chat-op config; next turn uses it |
| AC-G3-1..4 | §4 |
7. Do-NOT list (rejection triggers in PR review)¶
- Do NOT parse markdown/LaTeX per token. Parse fires exactly once on
done(I3 / AC-G3-2). Amarked(...)/latexToUnicode(...)call inside a token handler is an automatic PR rejection. - Do NOT subscribe the message list or settled
messagesto the streaming buffer. OnlyStreamingMessagereadsstreamingContent/streamingThink(I3 / AC-G3-4). - Do NOT use SSE
EventSourceor WebSocket. Transport is NDJSON overPOST /chat/stream(§2.1). - Do NOT send
provider_type/model_idfrom the client, and do NOT hardcode a provider/model in the backend. Chat resolves viaresolve_provider_config("chat", vault_id)(I6). - Do NOT add an unbounded stream. Every chat run is capped by
token_budget+timeout_secondsfromprovider_config;total_cost_usdis logged + returned (I7). - Do NOT use CodeMirror or any WYSIWYG/contentEditable for the chat input. It is a plain
<textarea>(I4; CodeMirror is reserved for the editor). - Do NOT strip
<think>from stored content. Persist the raw assistant string un-mutated; the split is transport-only (AC-F7-2). - Do NOT build F5 retrieval, vector search for chat, or
[n]citation resolution. M5. M4 context = purpose.md + overview.md only (§2.3). - Do NOT create a
chat_runstable or write chat turns intoingest_runs. Chat cost lives on themessagescolumns + thedoneevent (§2.2). - Do NOT skip D-artifacts:
make er(D2, new tables) andmake openapi(D4, new endpoints) must be regenerated with zero drift (I8).
8. Consequences¶
Positive. Chat runs real end-to-end in dev via Ollama/qwen2.5:3b with no mock dependency. The
NDJSON+POST transport is one StreamingResponse + one ReadableStream reader — minimal surface, fully
typed, no reconnect/state machine. The chat() ABC is filled, not changed (ADR-0007 §6 honored). I3 is
satisfied by construction (raw buffer + parse-once + separate store). Persistence reuses the
Postgres-as-system-of-record principle and gives I7 a durable per-message cost record.
Negative / accepted. No mid-stream resume (NDJSON has none; we don't want it for chat). qwen2.5:3b
may not emit <think> — the F7 path is built and tested against a fixture so it's correct when a
reasoning model is selected. Chat answers are only lightly grounded (purpose.md + overview.md) until M5
brings real retrieval + citations — this is an explicit, documented M4→M5 seam, not a hidden gap.
Follow-ups (M5). F5 4-phase retrieval feeds retrieval_context; [n] citations populate the
reserved messages.citations column and the done event; save-to-wiki carries citations; the
selected_page_id context injection (needs GET /pages/{id}/content, ADR-0017 fast-follow) lands.