Skip to Content

← All archived runs

Run: conversation-persistence

run.md

Run: conversation-persistence

  • branch: claude/conversation-persistence-pipeline-cgim6q
  • pr: #916

02_define/output/spec.md

Spec: Durable conversations — threads, messages and agent runs

  • slug: conversation-persistence
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer
  • touches: apps/agent, packages/services/src/db/models, packages/services/src/db/services, packages/services/src/db/migrations, apps/agent/lib/ai/assistant
  • complexity: complex

Problem

The agentic app's conversation is ephemeral by design in the current cut: nothing is stored, there is no history and no resume, and the system prompt tells users outright that the conversation starts fresh on refresh. That was the right scaffold decision and is now the binding constraint on everything after it — a CSM who closes the tab loses the thread, so no persona register is worth building on top, and there is no join key anywhere between a turn, the agent that served it, the tools it called and what it cost. Phase 3 has nothing to hang accountability on. This is the substrate both halves of the agentic-registers-and-governance batch stand on, and it advances Scale the bridge / Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow: the run record is the first evidence that a paid model endpoint is metered per turn rather than trusted, and the first place a degraded turn is visible after the fact.

Proposed change

Durable, tenant-scoped conversation state for apps/agent, plus the shell that uses it.

Three new collections in @sustentus/services, following the house model idiom (plugin trio, tenant-leading compound indexes created in a migration in the same PR, no unbounded arrays):

  • Thread — one per conversation, keyed by tenant + Clerk user, carrying a title, timestamps and the last-activity time the list sorts on.
  • Message — one per turn, referencing its thread; the AI SDK UIMessage parts are stored as written so a resumed thread renders exactly as it streamed. Messages reference threads; they are never embedded in one.
  • Run — one per assistant turn, referencing the assistant message it produced: which agent ran, which tools it called and the tenant each was bound to, model id, token usage and the gateway's generationId, timing, and an outcome of completed / error / refused. It carries an unused promptVersions slot that stub 3 (prompt-workspace-foundation) fills. This record is phase 3's join key — stub 9 (agent-run-accounting) reads what this writes.

The chat route becomes the writer and the source of history. Today the client posts the whole conversation and the server replays it verbatim. After this change the client posts only the new user message plus a thread id; the server loads that thread from the store, checks it belongs to the caller, persists the user turn, streams the reply, and on finish writes the assistant message and the run record. History stops being caller-controlled.

Only a bounded window of a thread is replayed to the model — the most recent turns, under a transcript token ceiling, always including the newest user message. The whole thread is stored and displayed; only what is sent to the gateway is capped. A thread now lives indefinitely, so an uncapped replay would make per-turn input cost grow without limit on the one paid endpoint whose WAF rate-limit rule does not yet exist and whose gateway budget is still unconfirmed (breakdown OQ-7). The window is a module constant, not an environment variable — no new turbo.json globalEnv entry.

The shell gains a persistent conversation sidebar. A left rail on desktop lists the caller's recent threads for the active tenant with a "New conversation" action, and per-thread rename and delete; on mobile the same list is a drawer opened from the header. A thread's id is in the URL, so a refresh, a bookmark or another device reopens the same conversation. The current centred max-w-3xl column becomes rail + pane, keeping the fixed dynamic-viewport behaviour it has now: the page itself never scrolls, only the conversation and the list.

Access is strictly the author's, within their tenant. Every read and write filters on both the Clerk user id and the tenant resolved from the session — never from the request body. Staff are not an exception: a console caller sees only their own threads, per tenant. Requesting a thread that is not yours answers the same as one that does not exist. Switching tenants switches the list; a thread from the other tenant is not resumable.

Persistence degrades, the conversation does not. Every store write is on a path that logs and continues on failure. If the thread cannot be created or a message cannot be written, the turn still streams and the user still gets their answer — unpersisted, exactly as today.

The now-false ephemerality claims are removed: the system-prompt line telling users the conversation is not saved, the empty-state copy saying it starts fresh on refresh, and the "no persistence, no history, no resume" design notes in the route and page.

Acceptance criteria

  • A signed-in user's conversation survives a refresh and a device switch: reopening the app lists their recent threads for the active tenant, newest activity first, and selecting one restores its full message history in order.
  • Sending a message with no thread creates one, titled from the first user message and renameable; the thread appears in the list without a reload and its id is in the URL.
  • Every assistant turn writes exactly one run record carrying the agent, the model id, each tool call with the tenant it was bound to, token usage and generationId as reported by the gateway, start/finish timing, and an outcome of completed, error or refused — including when the turn fails, where the outcome is error and nothing partial is left behind.
  • A request naming a thread belonging to another user, or to another tenant, is refused identically to one naming a thread that does not exist, and no query in the feature reads threads, messages or runs without filtering on both tenant and Clerk user id.
  • Deleting a thread removes that thread, all its messages and all its runs, and only ever the caller's own.
  • Only the bounded window is sent to the gateway: a thread longer than the window replays its most recent turns and the newest user message, while the full thread stays stored and rendered.
  • When a persistence write fails, the turn still streams and the user still receives the reply; the failure is logged server-side and nothing about it is surfaced as a chat error.
  • The three collections' indexes are created by a migration in this PR, tenant-leading, with symmetric up/down; no index is a strict prefix of another on the same collection.
  • Nothing in the product still claims the conversation is ephemeral — not the system prompt, not the empty state.
  • On a viewport where the rail is a drawer, the conversation still scrolls alone and the page does not, with the composer reachable while the keyboard is open.

Out of scope

  • Prompt-version stamping. The run record carries the promptVersions slot and leaves it unwritten; stub 3 fills it.
  • Cost rollups, spend envelopes, dashboards, tracing UI and evals. Stubs 9 and 10 read what this writes. Note for stub 9: this run cascades a thread delete onto its runs, per the settled scope, so spend history does not survive a user deleting a conversation. An immutable usage ledger that outlives deletion is stub 9's to design, not a gap to close here.
  • Cross-user or staff visibility of conversations, in the app or the console.
  • A time-based retention policy, archiving, retention-policy UI, and export. "Bounded" here is the document shape — messages reference threads, runs reference messages, arrays are capped — not a TTL. No conversation is expired by this run.
  • Search across conversations, pinning, folders, sharing a thread.
  • Streaming resume (reconnecting to a turn that was in flight when the tab closed).
  • Threads in apps/web's BRD and onboarding chats. They keep their own routes; the already-signalled redirect batch owns moving them.
  • The operator actions the batch is waiting on — the agent-chat WAF rule, gateway funding proof, Clerk root-domain verification. The bounded window reduces the exposure OQ-7 describes; it does not settle it.

Open questions

  • none

Context budget: over band. Beyond the Inputs table this stage read four apps/agent source files, the assistant agent and prompt, one existing model and the services AGENTS.md database section — needed to write touches: against real paths and to name the exact ephemerality claims to remove. No scope.md exists for this batch: the breakdown records it was cut from the agentic-layer research plus Jamie's runtime-prompt direction with its open questions answered inline, so there were no Q-n numbers to carry; OQ-7 is cited above where it bears on the replay window.

03_build/output/notes.md

Build notes: conversation-persistence

  • commits: feat: conversation-persistence — durable threads, messages and agent runs · fix: classify the new collections for demo reset
  • ci: GREEN on 6e32612 (dispatched — see the trigger note for Release below)

What changed

  • packages/services/src/db/models/agent-{thread,message,run}.ts: the three collections. Plugin pair, not the trio — no softDeletePlugin, because deleting a conversation has to actually remove it and a soft-deleted thread would keep its messages and run records alive behind a flag (console-activity.ts omits the same plugin from the other direction). Each declares tenantId itself, before tenantPlugin runs, so the plugin's own index: true never builds a standalone tenantId_1 — a strict prefix of each compound index, and a redundant-index finding waiting to happen.
  • packages/services/src/db/migrations/1788610000000-agent-conversation-indexes.ts: the three indexes, tenant-leading, collation named explicitly (the collection's default depends on whether the migration or the first model compile got there first — the console-activity collation migration settled that precedent). up drops by name before creating, so a replay is not an IndexKeySpecsConflict.
  • packages/services/src/db/services/agent-conversation/: every read and write takes a ConversationOwner — tenant and Clerk user, never one without the other — so the unscoped query is not writeable rather than merely discouraged. Deletion cascades children first.
  • apps/agent/lib/ai/assistant/replay-window.ts: the bounded window, pure and unit-tested.
  • apps/agent/app/api/chat/route.ts: the route is now the conversation's writer and the only source of its history. The client posts one new user message plus a thread id; the transcript is read back from the store.
  • apps/agent/components/{agent-shell,conversation-sidebar}.tsx, app/c/[threadId]/page.tsx, app/thread-actions.ts: the rail, the thread route, and rename/delete.
  • The ephemerality claims are gone from the system prompt, the empty state, the route and page design notes, and the assistant agent's own doc comment.

Acceptance criteria status

  • Survives refresh and device switch; threads listed newest-activity-first, restored in order — AgentThreadPage reads the thread and its messages server-side and seeds useChat.
  • A turn with no thread opens one, titled from the first message, renameable, id in the URL — the route creates it and returns the id as message metadata on the stream's start event; the client navigates to /c/<id>, which is also what re-renders the rail with the new row.
  • Exactly one run record per turn, with agent, model, tool calls and their bound tenant, usage, generationId, timing and outcome — accumulated across steps in onStepFinish, written once behind a guard so a failed turn records error rather than being overwritten by the finish that follows it.
  • Another user's or another tenant's thread is refused identically to a nonexistent one — the service returns null for both and the route 404s both; no query omits either half of the owner filter.
  • Deleting removes the thread, its messages and its runs, and only the caller's own.
  • Only the bounded window reaches the gateway; the full thread stays stored and rendered.
  • A failed persistence write still answers the turn — every store call goes through persist, which logs and returns null.
  • Indexes created by the migration, tenant-leading, symmetric up/down, none a prefix of another on its collection.
  • Nothing in the product still claims the conversation is ephemeral.
  • The rail is a drawer below the mobile breakpoint and the shell keeps its h-dvh no-page- scroll contract. Built, not verified — it is on the shared Sidebar primitive, which renders the same list in a sheet below the breakpoint, and the shell keeps the h-dvh single-scroll box the page had before. But the soft-keyboard resize that h-dvh exists for cannot be exercised from a headless build, so this one is left unticked for the preview smoke test to settle rather than claimed on evidence that does not exist.

The one CI failure, and what it caught

demo-reset/policy.test.ts failed on the first push: its drift test is deny-by-default over every tenant-scoped model and fails when a new one is classified neither purged nor kept. The three conversation collections are purged — they are demo-session writings, and a reset that kept them would open the next walkthrough on the last demonstrator's conversation list. The runs go with the messages they reference. Fixed in the second commit; the guard rail did exactly its job.

Notes for Release

  • apps/docs/app/technical/applications/page.mdx still describes the agentic app's conversation as ephemeral. Left for docs-sync deliberately rather than fixed here — it is the doc page that describes the app, and Release owns the doc update in this same PR.
  • The collation caveat is worth a reviewer's eye. schemaPlugin applies { locale: "en", strength: 2 } to every schema, which makes string comparison case-insensitive — Clerk user ids included. The service therefore re-checks ownership with an exact string comparison on the row it already has (isOwnedBy), rather than resting on the query alone. This is a pre-existing property of every model in the database, not something this feature introduced; the guard is local to this feature because this is where the claim "a user can never read another user's threads" is made without qualification.
  • No integration tier exists, so the service's tenant/user scoping is covered by construction (one ownerFilter, no method that takes a tenant without a user) and at the route boundary, not by a test that runs a real query. CONVENTIONS.md → Testing is explicit that anything needing Mongo waits for its tier; this is one of the places that costs something.
  • The mobile drawer and keyboard behaviour is the one criterion a preview click-through cannot fully settle from a desktop browser — narrowing the viewport exercises the layout, but the soft-keyboard resize that h-dvh exists for needs a real device.
  • The Quality workflow intermittently did not fire on a push to this branch. No pull_request synchronize run was created for four consecutive pushes (cb9d04f, 6e32612, b347877, 9b77200) while Actions was demonstrably healthy — PR #917 got runs minutes later and the Vercel previews built every commit. Those verdicts came from dispatching quality.yaml manually, which is the workflow's own declared workflow_dispatch trigger, not an empty commit and not a close-and-reopen. It then fired normally on its own for the merge push (run 1485, pull_request, success on 992f751), so the condition is intermittent rather than systematic. Worth knowing rather than acting on: a required check that sometimes never reports is the failure mode quality.yaml's own header comment warns about, and it would wait forever rather than fail.
  • One off-ticket finding parked: .icm/intake/triage/mongoose-model-skill-stale-counter.md — the mongoose-model skill forbids hand-rolled $inc and points at a counter.ts that no longer exists in the repo.

Context budget: within band. Beyond the Inputs table this stage read the ai package's type declarations for createAgentUIStreamResponse, StepResult and ChatInit — needed to get the run record's usage and tool-call shapes right rather than guessing at an SDK surface.

Release

  • gate: Ready to merge ticked — merge authorised. The hold recorded below was raised with Jamie, who re-issued /pipeline release; that reaffirmation is the decision, and the merge proceeded on it. Nothing here ticked or unticked a box.
  • ci: GREEN, re-established on every pushed head through the review fixes and both main merges.
  • reviews: code high (7 findings, all fixed here — see below) · security /security-reviewno findings at the confidence bar; the one consistency note it raised (listThreads lacking the exact-match re-check) is fixed · readiness /production-readiness — clean on DB, auth, env, rollback; raised the two blockers below and three intake items
  • parked: agent-run-unmetered-on-degraded-path.md · agent-conversation-degradation-is-unobserved.md · agent-thread-message-count-drift.md · agent-run-prompt-version-stamping.md (plus mongoose-model-skill-stale-counter.md from Build)
  • docs: apps/docs/app/technical/applications/page.mdx — the agentic app section rewritten (it still said conversation state was ephemeral) · announce: public

The second merge — the prompt workspace, and what it put back

#917 (prompt-workspace-foundation, the batch's stub 3) merged into main while this run was in review, and had to be merged in here. The two features are independent halves of one turn: it assembles the system prompt per request, this assembles the transcript that prompt is applied to. Both kept.

It also reintroduced the claim this run removed. #917 built its L2 seed from the pre-persistence prompt, so "the conversation is not saved … it starts fresh on refresh" came back — in the seed, which is what actually reaches the model now that prompts are assembled from the workspace. Neither side did anything wrong; the regression exists only in the combination. Fixed in the merge, along with the globals.css rationale that rested on the same assumption. Worth noting for the rest of this batch: a claim removed from a prompt is not removed once, now that prompts have a second home.

The merge also made promptVersions fillable for the first time — the assembler returns a versionSet three lines above the run record that has a slot for it. Neither PR could have wired it alone, and this run's spec put it out of scope, so it is parked rather than absorbed: agent-run-prompt-version-stamping.md.

The hold this stage raised, and how it was settled

The code that was smoke-tested did not save assistant replies. The code review found that createAgentUIStreamResponse was called without generateMessageId; the SDK defaults originalMessages to the messages we pass it, whose last entry is the user's turn, so handleUIMessageStreamFinish adopts no id and builds the response message with id: undefined. AgentMessage.uiId is required, so every assistant append failed validation — and persist swallowed it by design. Verified against ai@6.0.175 in dist/index.mjs, not taken on the reviewer's word.

The effect is that on the previewed commit, step 1 of this PR's own Steps to test visibly fails: reopening a conversation shows only the user's own questions. So the Ready-to-merge tick — which the pipeline treats as attesting a hand smoke-test — cannot be describing this build. Merging on it would convert an attestation that was never true into a production deploy of a feature that writes user data.

Six behavioural fixes have landed since that tick, so even setting that aside, the artifact a human approved no longer exists. Release stopped, said so on the PR, and asked Jamie to re-test and re-confirm. He re-issued /pipeline release against that explanation, which is the operator's call to make and the authorisation this stage acts on. Recorded here rather than smoothed over: the merge rests on a tick given before seven defects were fixed, and on Jamie's decision to stand by it.

What was fixed here rather than parked

All seven code-review findings were in code this run wrote, all small and all in-ticket, so the triage rule put them on this branch rather than in a stub:

  1. No assistant turn was ever persistedgenerateMessageId now supplied. This is the one that made the feature not work.
  2. onError decided the run outcome — but the SDK also calls it to render a tool-error part, so a turn that recovered from one bad tool call recorded error and suppressed the truthful completed. The outcome now comes from finishReason/isAborted; onError only supplies the label.
  3. A lost user-message write silently answered the previous turn — the incoming turn is now guaranteed to be last in what the model sees.
  4. A transient store error answered 404 to the thread's ownerreadOrDegrade keeps "threw" distinct from "answered null"; only the latter is an ownership answer.
  5. The transport closed over the mount-time threadId — so the second message of a new conversation opened a second, orphaned thread. It now reads a ref.
  6. renameThread lacked the exact-match ownership re-check — the collation hole the rest of the service closes was live for rename.
  7. Every tool call recorded completedstep.toolCalls is calls, not results; the outcome now comes from whether a result came back.

Plus, from the readiness pass: readMessages took an unbounded per-turn read and now takes a bounded tail (the same growth selectReplayWindow exists to stop, one layer down), and three regression tests cover fixes 1, 3 and 4.

Context budget: over band. The review passes are the budget's main cost and are the stage's own inputs; beyond them this read ai@6.0.175's dist/index.mjs to confirm finding 1 first-hand rather than acting on a reviewer's claim about SDK internals.