ADR-0003: Conversation state without a database
- Status: Accepted on 2026-10-29 after spike S1 (findings).
- Date: 2026-10-29
Context
The library must be easy to embed: OPENAI_API_KEY plus CHAT_SECRET should be enough. The OpenAI Conversations API stores a whole conversation under one conv_… object: messages, tool calls and tool outputs. Each turn passes only conversation: id.
Two lessons shape the design:
- v1 lost all history when OpenAI shut down Assistants. Whatever stores conversations must sit behind an abstraction.
- Visitors don't need a list of their conversations. The widget shows only the current one and resumes it after a reload. Browsing all conversations, retention and GDPR exports are the site owner's needs.
Decision
- Default store. The OpenAI Conversations API, accessed through a
ConversationStoreport:openaiConversations()is the default;memory()is for tests.
- No database or queue is required.
- Ownership without a database. The resume token carries the conversation ID, encrypted and authenticated (JWE,
dir+ A256GCM, keyed byCHAT_SECRET), so the browser can neither read nor change it. On top of that, the conversation object storesmetadata: { bot, visitor }as defense in depth. - History on reload.
conversations.items.list(conv, { order: 'asc' })maps items to domain types and ignores unknown item types. Only user and assistant messages plus citations are shown; tool calls and reasoning stay hidden. - Dynamic context. Page URL, identity and similar go into per-request
instructions. They are never stored in the conversation. - Deletion.
DELETE <basePath>/conversationplus the "start a new conversation" button removes the conversation and its uploaded files. The conversation comes from the visitor's signed resume token, never from the URL. - Own storage is an add-on. Redis or Postgres (
@boldare/askdare-store-postgres, after 0.1) covers retention, export, ZDR/EU data residency and independence from OpenAI.
Consequences and trade-offs (documented in the README)
- No automatic retention or admin browsing in core. The API has no endpoint to list conversations, so conversations live until deleted.
- Data stays at OpenAI until deleted. Conversations are not ZDR-eligible, and responses are also stored for 30 days.
- Long conversations get expensive. They replay their full history as input, so we enforce turn limits and a stable prompt prefix for caching. Compaction is planned for 0.2 (2026-10-30): OpenAI's server-side compaction (
context_management: [{ type: 'compaction', compact_threshold }]) replaces the deprecatedtruncation, but its guide doesn't yet say how it works with theconversationparameter, so it needs a spike first. Until then, 0.1 sendstruncation: 'auto', which the API still accepts. - Attachments after reload show only as a filename or placeholder. Content of
user_datafiles cannot be downloaded back.
Evidence (spike S1, live API, 2026-10-29)
- Streaming: turns inside a conversation stream normally. Time to first token was 0.9–1.3 s on
gpt-6.1-terra. - Function calls: a dangling call breaks the next turn with a 400. A synthetic
function_call_outputrepairs it, so the step 3.2 tool loop must answer every call. - Attachments: deleting or expiring a file does not break the conversation. Images and PDFs both work with purpose
user_data. store: falsewith a conversation silently drops the turn. Always sendstore: true.items.list: about 1.9 s, with descending default order. Load history once, withorder: 'asc', and show a loading state.- Errors: a deleted conversation gives a 404 that mentions the conversation; an unknown model gives a 404 with
model_not_found. The two are mapped differently (not_foundvsbot_unavailable).