conversation-persistencerun.md02_define/output/spec.mdThe 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.
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):
UIMessage parts are stored as
written so a resumed thread renders exactly as it streamed. Messages reference threads; they are
never embedded in one.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.
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.up/down; no index is a strict prefix of another on the same collection.promptVersions slot and leaves it
unwritten; stub 3 fills it.apps/web's BRD and onboarding chats. They keep their own routes; the
already-signalled redirect batch owns moving them.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.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.mdpackages/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.AgentThreadPage reads the thread and its messages server-side and seeds useChat.start event;
the client navigates to /c/<id>, which is also what re-renders the rail with the new row.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.persist,
which logs and returns null.up/down, none a prefix of
another on its collection.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.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.
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.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.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.h-dvh exists for needs a real device.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..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.
/pipeline release; that reaffirmation is the decision, and the merge proceeded on
it. Nothing here ticked or unticked a box.main merges./security-review — no
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 itemsagent-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)apps/docs/app/technical/applications/page.mdx — the agentic app section rewritten (it
still said conversation state was ephemeral) · announce: public#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 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.
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:
generateMessageId now supplied. This is the one that
made the feature not work.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.readOrDegrade keeps "threw"
distinct from "answered null"; only the latter is an ownership answer.threadId — so the second message of a new
conversation opened a second, orphaned thread. It now reads a ref.renameThread lacked the exact-match ownership re-check — the collation hole the rest of the
service closes was live for rename.completed — step.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.