feat(coach): document chats live in the Coach view and history

Asking the Coach about a document opened a separate, half-finished chat panel
that never appeared in Coach history. The bespoke drawer is gone; a document
conversation now opens the real Coach view, appears in the Coach list with a
document badge and a "Chatting about: <title>" note, and is read back through
the same history rail as any other thread.

The prompt-injection fence is unchanged and stays server-side. A document turn
is still sent through the hardened fenced endpoint (no tools, no health
snapshot, grounded in the document text only); the coach tool route continues
to fetch with documentId null, so a document-scoped conversation can never be
continued through the tool loop even from a hostile client. Only the surface
moved into the Coach; the boundary did not.

The relaxed conversation-list and detail filters drop only the documentId
predicate — userId scoping is unchanged, so a document thread is still visible
only to its owner. CoachConversationDTO gains documentId + documentTitle
(server-derived, title/filename only); no schema change (documentId exists
since 0230). OpenAPI regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 11:01:59 +02:00
1 parent 57539283c8
commit 5b24ca0fa1
28 files changed
+605 -1112

No files matched your search

+10 -5
View File
@@ -33,11 +33,16 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
const { id } = await ctx.params;
if (!id) return apiError("coach.conversation.notFound", 404);
// v1.27.33 — Coach detail is scoped to health threads (documentId null); a
// document chat is read through its own document-scoped route, not here.
const detail = await fetchConversationWithMessages(auth.user.id, id, {
documentId: null,
});
// v1.28.51 (Documents R3, Design A) — the Coach detail READER now returns
// both health and doc-scoped threads so `/coach?c=<id>` can display a document
// conversation inside the real coach chrome. Dropping the `documentId: null`
// opt removes the scope filter while `auth.user.id` stays narrowed, so a
// caller can only ever read their OWN thread (a foreign id is still 404).
// This reader only decrypts + returns persisted messages — no tool loop, no
// snapshot — so surfacing a doc thread here does not reopen the injection
// surface the fenced SEND path (documents route) guards. The DTO carries
// `documentId` + `documentTitle` so the client picks the fenced send path.
const detail = await fetchConversationWithMessages(auth.user.id, id);
if (!detail) {
return apiError("coach.conversation.notFound", 404);
}
+16 -5
View File
@@ -304,8 +304,15 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
let priorSummary: string | null = null;
if (conversationId) {
// v1.27.33 — scope to Coach threads (documentId null); a document chat can
// never be loaded through the Coach route.
// v1.27.33 / v1.28.51 — the SEND path stays scoped to Coach threads
// (`documentId: null`) DELIBERATELY, even though the read paths (rail list +
// detail GET) were relaxed to surface doc-scoped threads in the Coach UI.
// This is the load-bearing prompt-injection fence: a document chat runs its
// tool-loop + health-snapshot NEVER — a doc turn is routed by the client to
// the hardened `/api/documents/inbound/[id]/chat` endpoint. Should a
// doc-scoped conversation id ever reach THIS tool route, the null-scope
// fetch below returns nothing and the turn 404s rather than executing an
// injected instruction against the coach's write tools. Do not relax.
const existing = await fetchConversationWithMessages(
userId,
conversationId,
@@ -1437,9 +1444,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
userId: auth.user.id,
cursor,
limit: Number.isFinite(limit) ? (limit as number) : undefined,
// v1.27.33 — the Coach rail shows only health threads; document chats
// (documentId set) live on the document sheet and are filtered out here.
documentId: null,
// v1.28.51 (Documents R3, Design A) — the rail now surfaces BOTH health
// threads and doc-scoped threads (the DTO carries `documentId` +
// `documentTitle` so the client badges the fenced ones). Omitting the
// `documentId` key drops the filter entirely — a union of both scopes —
// while `userId` stays narrowed from the session, so the relaxation never
// widens ownership. Doc turns still POST to the hardened document endpoint,
// never this route (see the `documentId: null` guard on the POST path).
});
annotate({