Conversations suite

Chats as first-class objects, added to a live multi-tenant assistant with no client changes.

multi-tenant state migration additive design identity enforcement soft delete test-gated deploy scoped retrieval

The problem and the design

Each user had a single thread, so a new chat wiped everything before it. The fix was conversations as first-class objects: per-user chats that can be managed, searched, scoped to specific documents, and exported.

The result

All five phases shipped in a single night, behind the orchestrator's full test gate of 1,143 tests at the time, with the history directory backed up before deploy and live-probed after. The change was additive by design: chat id is an optional request parameter that defaults to the active chat, so every existing client and tool kept working with no schema change and no new infrastructure.

The feature runs in the Home AI orchestrator.

The five phases

PhaseWhat shippedRationale
1: Core Per-chat storage, per-user index, switch-and-persist. Existing threads migrated to chat "default"; their running per-chat summaries followed them. Migration kept every user's history byte-identical. Active chat is a server-side pointer so all clients agree. Identity enforcement: a chat id resolves only under the authenticated user's own directory.
2: Management Rename, archive (hidden from switcher by default), soft delete to per-user trash. Auto-naming: a background call to a small local model generates a three-to-six-word title, fail-soft so it never blocks an answer. Nothing hard-deletes user content. A manual rename that lands first always wins over auto-title.
3: Search Plain-text search across a user's own chats, server-side, ranked by recency. Archived chats are searchable. Trash is not. Embedding search was available and I did not use it. The retrieval machinery is tuned for documents, and what a user wants from a chat list is to find the conversation again.
4: Scoping and export Pin a document to a chat (capped at ten per chat); cited sources in an answer are click-to-pin toggles. Export a chat to markdown, honoring the current view: the model's reasoning traces and verbose steps are included when that view is on; older messages export as plain text. Pins are a retrieval priority boost added to the keyword score before the relevance cutoff decides which results pass, never a filter. Answers from the deep-reasoning path keep their reasoning trail on the message; quick answers stay lean.
5: Polish Full-height sidebar replaced the dropdown: rows with active-row actions, in-list search, a show-archived footer, persisted toggle preference. Hidden by default on narrow screens. Cross-chat rules stated in the UI: memory crosses chats, context and pins do not.

What was kept invariant

The change had to be additive. Every existing client used the active chat as default behavior, so making chat id an optional parameter meant no client or tool needed to change.

Storage is flat JSON on the existing documents share. A database file over SMB has caused file-locking problems here before, so I stayed away from one. Every new endpoint sits behind identity enforcement. Memory is global per user; history is per chat; uploads are per user with per-chat pinning layered on top.

Design decisions and tradeoffs

Why pins are priority boosts

A pinned document must influence retrieval without removing other results. A filter either lets something through or does not; a priority boost shifts rank while preserving the full candidate set. That matters when an assignment's rubric is pinned alongside other sources: the rubric rises, but relevant context from elsewhere does not disappear.

Why soft delete only

In a multi-tenant system with one operator, an accidental hard delete has no recovery path. Removal moves content to a per-user trash where it remains recoverable, and a retention sweep is a separate operation an admin has to run. The trash is excluded from search so deleted content does not surface during normal use.

The migration risk

Moving from one thread per user to indexed per-chat storage required touching every existing user's history. The acceptance criterion was clear: after migration, every user's history must be byte-identical. The first touch triggers the move, so an inactive user is not affected until they actually use the system, and the rolling summary follows its chat, so summaries became per-chat without a separate migration step.

The lab has a handful of users, and the system works for them and for me. The onboarding page shows how a non-technical user enters the lab in the first place.