Skip to content

Commit d8fda07

Browse files
committed
fix(webapp): stop an ordinary transcript write from rewriting a stored message
`storeChatMessages` ended in `onConflictDoUpdate`, so `persistMessages` and `persistTurn` — which are handed a whole snapshot — treated any differing body under an existing message id as a deliberate finalisation. A stale snapshot carrying `wake:watch_1:fired`, the watch consent record, the deterministic confirmation or an investigation settlement card with a different body would overwrite the durable row that was already recorded. The proxy caps body size and metadata but does not rewrite message ids, so this was not an internal-bug-only exposure. The same clause updated only the `message` JSONB and never the `role` column, so `chat_messages.role` could end up disagreeing with `message.role` — and the UI reads one while the quota query reads the other. Ordinary transcript writes are now insert-only. Changing a stored message is its own operation, `finalizeChatMessage`, guarded on chat id, message id and role. `role` is verified rather than updated, and verified on both sides: the stored column must match `expectedRole` and so must the incoming body's own `role`, so the two cannot drift. A finalisation that matches nothing returns false; one whose body contradicts `expectedRole` throws. No production caller depended on the implicit finalisation. Every existing finalisation-shaped path already writes through an insert-only append: `settleInvestigationAndCloseCard`, `settleInvestigationStateAndCloseCard` and the watch request/confirmation/refusal records all use `appendChatMessageOnce(ByChatId)`. Also: re-sending a snapshot no longer reserves positions for messages that are already stored. The chat row is held, the missing ids are read under that lock, and only those get slots. A 40-message chat grown one turn at a time used to burn 1+2+…+40 = 820 slots for its 40 rows; it now burns 40. Deltas would be the proper fix, but that reaches into the agent's turn hooks and is a larger change than this pass. Two smaller repairs in the same file: `messageIdOf`/`messageRoleOf` now fail fast and name the chat and the offending message instead of casting unchecked and surfacing a `NOT NULL` violation from the driver; and a batch carrying the same message id twice throws instead of silently keeping the first, since that is an impossible state and a silent pick is how the upstream bug would stay invisible. The comment on `reserveMessagePositions` claiming the row lock is "released with the statement" was wrong — Postgres holds it to commit — and now says what is true.
1 parent aad8498 commit d8fda07

3 files changed

Lines changed: 368 additions & 33 deletions

File tree

apps/webapp/test/dashboardAgentTranscriptStore.test.ts

Lines changed: 245 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
countUserMessages,
44
createChat,
55
createDashboardAgentDb,
6+
finalizeChatMessage,
67
getChatMessages,
78
getInvestigation,
89
investigationSettlementMessageId,
@@ -92,6 +93,35 @@ async function rows(prisma: PrismaClient, chatId: string): Promise<StoredRow[]>
9293
);
9394
}
9495

96+
/** The position allocator itself: what a wasted reservation is visible in. */
97+
async function nextPosition(prisma: PrismaClient, chatId: string): Promise<number> {
98+
const rows = await prisma.$queryRawUnsafe<{ next_message_position: number }[]>(
99+
`select next_message_position from trigger_dashboard_agent.chats where id = $1`,
100+
chatId
101+
);
102+
return rows[0]!.next_message_position;
103+
}
104+
105+
async function chatStamps(
106+
prisma: PrismaClient,
107+
chatId: string
108+
): Promise<{ last_message_at: Date | null; updated_at: Date }[]> {
109+
return prisma.$queryRawUnsafe(
110+
`select last_message_at, updated_at from trigger_dashboard_agent.chats where id = $1`,
111+
chatId
112+
);
113+
}
114+
115+
/** The structural column, which the JSONB payload must never be able to contradict. */
116+
async function roleOf(prisma: PrismaClient, chatId: string, messageId: string): Promise<string> {
117+
const rows = await prisma.$queryRawUnsafe<{ role: string }[]>(
118+
`select role from trigger_dashboard_agent.chat_messages where chat_id = $1 and message_id = $2`,
119+
chatId,
120+
messageId
121+
);
122+
return rows[0]!.role;
123+
}
124+
95125
function openState(): InvestigationState {
96126
return investigationStateSchema.parse({
97127
outcome: "in_progress",
@@ -231,32 +261,241 @@ describe("invariant 2: concurrent different messages get distinct positions", ()
231261
);
232262
});
233263

234-
describe("invariant 3: a controlled update changes the body and nothing else", () => {
264+
describe("invariant 3: an ordinary transcript write can never change a stored message", () => {
235265
postgresTest(
236-
"finalising a message keeps its id, its position and its row",
266+
"a differing body under an existing id leaves the durable row exactly as it was",
237267
async ({ prisma, postgresContainer }) => {
238-
const chatId = "chat_finalise";
268+
const chatId = "chat_no_implicit_update";
239269
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
240270

271+
await persistMessages(agentDb, { chatId, messages: [textMessage("u1")] });
272+
// A durable event: the wake that actually fired.
273+
await appendChatMessageOnceByChatId(agentDb, {
274+
chatId,
275+
message: textMessage("wake:watch_1:fired", "The watch on send-order-receipt resolved."),
276+
});
277+
const before = await rows(prisma, chatId);
278+
279+
// A stale snapshot carrying the same id with a different body. `persistMessages` is
280+
// not a finalisation, so it must not be able to rewrite it.
241281
await persistMessages(agentDb, {
242282
chatId,
243-
messages: [textMessage("u1"), textMessage("a1", "still working")],
283+
messages: [textMessage("u1"), textMessage("wake:watch_1:fired", "something else entirely")],
244284
});
285+
286+
expect(await rows(prisma, chatId)).toEqual(before);
287+
},
288+
30_000
289+
);
290+
291+
postgresTest(
292+
"persistTurn cannot rewrite a stored message either",
293+
async ({ prisma, postgresContainer }) => {
294+
const chatId = "chat_no_implicit_update_turn";
295+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
296+
297+
await persistMessages(agentDb, { chatId, messages: [textMessage("a1", "the answer")] });
245298
const before = await rows(prisma, chatId);
246299

300+
await persistTurn(agentDb, {
301+
chatId,
302+
messages: [textMessage("a1", "a different answer")],
303+
session: { publicAccessToken: "pat_store" },
304+
});
305+
306+
expect(await rows(prisma, chatId)).toEqual(before);
307+
},
308+
30_000
309+
);
310+
311+
postgresTest(
312+
"a batch carrying the same id twice is refused rather than silently picking one",
313+
async ({ prisma, postgresContainer }) => {
314+
const chatId = "chat_dup_in_batch";
315+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
316+
317+
await expect(
318+
persistMessages(agentDb, {
319+
chatId,
320+
messages: [textMessage("a1", "first"), textMessage("a1", "second")],
321+
})
322+
).rejects.toThrow(/message id a1 twice in one batch/);
323+
324+
// And nothing landed: the throw is before any reservation.
325+
expect(await rows(prisma, chatId)).toHaveLength(0);
326+
expect(await nextPosition(prisma, chatId)).toBe(1);
327+
},
328+
30_000
329+
);
330+
331+
postgresTest(
332+
"a message with no id or no role is refused by name, not by a NOT NULL violation",
333+
async ({ prisma, postgresContainer }) => {
334+
const chatId = "chat_malformed";
335+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
336+
337+
await expect(
338+
persistMessages(agentDb, { chatId, messages: [{ role: "user", parts: [] }] })
339+
).rejects.toThrow(/Chat chat_malformed was handed a message with no id: .*"role":"user"/);
340+
341+
await expect(
342+
persistMessages(agentDb, { chatId, messages: [{ id: "a1", parts: [] }] })
343+
).rejects.toThrow(/Chat chat_malformed was handed a message with no role: .*"id":"a1"/);
344+
},
345+
30_000
346+
);
347+
});
348+
349+
describe("invariant 4: a controlled finalisation changes the body and nothing else", () => {
350+
postgresTest(
351+
"finalising a message keeps its id, its position and its role",
352+
async ({ prisma, postgresContainer }) => {
353+
const chatId = "chat_finalise";
354+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
355+
247356
await persistMessages(agentDb, {
248357
chatId,
249-
messages: [textMessage("u1"), textMessage("a1", "here is the answer")],
358+
messages: [textMessage("u1"), textMessage("a1", "still working")],
250359
});
360+
const before = await rows(prisma, chatId);
361+
362+
expect(
363+
await finalizeChatMessage(agentDb, {
364+
chatId,
365+
messageId: "a1",
366+
expectedRole: "assistant",
367+
message: textMessage("a1", "here is the answer"),
368+
})
369+
).toBe(true);
251370

252371
const after = await rows(prisma, chatId);
253372
expect(after).toHaveLength(2);
254373
expect(after.map((row) => [row.message_id, row.position])).toEqual(
255374
before.map((row) => [row.message_id, row.position])
256375
);
257-
// Only the one message that changed changed.
376+
// Only the one message named changed.
258377
expect(after[0]!.message).toEqual(before[0]!.message);
259378
expect(after[1]!.message).toMatchObject({ parts: [{ text: "here is the answer" }] });
379+
expect(await roleOf(prisma, chatId, "a1")).toBe("assistant");
380+
},
381+
30_000
382+
);
383+
384+
postgresTest(
385+
"a finalisation aimed at the wrong role writes nothing",
386+
async ({ prisma, postgresContainer }) => {
387+
const chatId = "chat_finalise_role";
388+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
389+
390+
await persistMessages(agentDb, { chatId, messages: [textMessage("a1", "still working")] });
391+
const before = await rows(prisma, chatId);
392+
393+
// The stored row is an assistant message, so a user finalisation is not its own.
394+
expect(
395+
await finalizeChatMessage(agentDb, {
396+
chatId,
397+
messageId: "a1",
398+
expectedRole: "user",
399+
message: { id: "a1", role: "user", parts: [{ type: "text", text: "hijacked" }] },
400+
})
401+
).toBe(false);
402+
403+
expect(await rows(prisma, chatId)).toEqual(before);
404+
expect(await roleOf(prisma, chatId, "a1")).toBe("assistant");
405+
},
406+
30_000
407+
);
408+
409+
postgresTest(
410+
"the row's role and the body's role cannot be made to disagree",
411+
async ({ prisma, postgresContainer }) => {
412+
const chatId = "chat_finalise_drift";
413+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
414+
415+
await persistMessages(agentDb, { chatId, messages: [textMessage("a1")] });
416+
417+
// The column says assistant, the body would say user. Refused outright rather
418+
// than stored as a row whose column and payload disagree.
419+
await expect(
420+
finalizeChatMessage(agentDb, {
421+
chatId,
422+
messageId: "a1",
423+
expectedRole: "assistant",
424+
message: { id: "a1", role: "user", parts: [] },
425+
})
426+
).rejects.toThrow(/expected role assistant but its body carries user/);
427+
428+
expect(await roleOf(prisma, chatId, "a1")).toBe("assistant");
429+
expect((await rows(prisma, chatId))[0]!.message).toMatchObject({ role: "assistant" });
430+
},
431+
30_000
432+
);
433+
434+
postgresTest(
435+
"a finalisation of a message that isn't there writes nothing",
436+
async ({ prisma, postgresContainer }) => {
437+
const chatId = "chat_finalise_missing";
438+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
439+
440+
expect(
441+
await finalizeChatMessage(agentDb, {
442+
chatId,
443+
messageId: "never-stored",
444+
expectedRole: "assistant",
445+
message: textMessage("never-stored"),
446+
})
447+
).toBe(false);
448+
expect(await rows(prisma, chatId)).toHaveLength(0);
449+
},
450+
30_000
451+
);
452+
});
453+
454+
describe("invariant 5: re-sending a snapshot is free", () => {
455+
postgresTest(
456+
"a re-sent snapshot reserves no position, touches no row and writes no timestamp",
457+
async ({ prisma, postgresContainer }) => {
458+
const chatId = "chat_snapshot_free";
459+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
460+
461+
const snapshot = Array.from({ length: 6 }, (_, i) => textMessage(`m${i}`));
462+
await persistMessages(agentDb, { chatId, messages: snapshot });
463+
464+
const before = await rows(prisma, chatId);
465+
const positionBefore = await nextPosition(prisma, chatId);
466+
const chatBefore = await chatStamps(prisma, chatId);
467+
468+
await persistMessages(agentDb, { chatId, messages: snapshot });
469+
await persistTurn(agentDb, {
470+
chatId,
471+
messages: snapshot,
472+
session: { publicAccessToken: "pat_store" },
473+
});
474+
475+
expect(await rows(prisma, chatId)).toEqual(before);
476+
// The allocator is the observable cost: a re-send that reserved slots would grow it.
477+
expect(await nextPosition(prisma, chatId)).toBe(positionBefore);
478+
expect(await chatStamps(prisma, chatId)).toEqual(chatBefore);
479+
},
480+
30_000
481+
);
482+
483+
postgresTest(
484+
"a transcript grown by re-sent snapshots spends one position per message",
485+
async ({ prisma, postgresContainer }) => {
486+
const chatId = "chat_snapshot_slots";
487+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
488+
489+
// The real write pattern: every turn hands over the whole transcript again. With
490+
// the old insert-everything path this cost 1+2+…+40 = 820 slots for 40 rows.
491+
const snapshot: ReturnType<typeof textMessage>[] = [];
492+
for (let i = 0; i < 40; i++) {
493+
snapshot.push(textMessage(`m${i}`));
494+
await persistMessages(agentDb, { chatId, messages: [...snapshot] });
495+
}
496+
497+
expect(await rows(prisma, chatId)).toHaveLength(40);
498+
expect(await nextPosition(prisma, chatId)).toBe(41);
260499
},
261500
30_000
262501
);

internal-packages/dashboard-agent-db/README.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,27 @@ of truth.
2727

2828
## Tables
2929

30-
- `chats` — one row per conversation: org/user scope, title, a `messages` JSONB
31-
display copy of the transcript, and `metadata` (the project/env context the chat
32-
ran in). Soft-deleted via `deleted_at`, pinned via `pinned_at`, read-marked via
33-
`last_read_at` (NULL = never read, so every watch wake in it counts as unread).
30+
- `chats` — one row per conversation: org/user scope, title, `metadata` (the
31+
project/env context the chat ran in), and `next_message_position`, the allocator the
32+
transcript's ordering comes from. No transcript of its own. Soft-deleted via
33+
`deleted_at`, pinned via `pinned_at`, read-marked via `last_read_at` (NULL = never
34+
read, so every watch wake in it counts as unread).
35+
- `chat_messages` — the transcript, one row per message. Identity is
36+
`(chat_id, message_id)` and order is `position`, unique per chat and reserved from
37+
`chats.next_message_position` by the same single statement that reads it, so
38+
concurrent writers get disjoint contiguous ranges. `role` is lifted out of the
39+
payload so the message-quota count is an index scan.
40+
41+
Three write modes, and only the third may change a message the chat already holds:
42+
a new message is a plain insert; a redelivered durable event (a watch wake, a
43+
settlement card) is `ON CONFLICT DO NOTHING` on `(chat_id, message_id)`, so it
44+
leaves the recorded row untouched; a deliberate finalisation is
45+
`finalizeChatMessage`, which rewrites one body under a verified `role` and never
46+
moves the id or the position. So re-sending a whole turn snapshot is a no-op.
47+
48+
Positions are monotonic, not gapless: a reservation whose insert then conflicts,
49+
or a batch that rolls back, leaves the slot unused. Only the relative order
50+
matters, so a gap is expected and harmless.
3451
- `chat_sessions` — live transport state keyed by `chat_id`: the session-scoped
3552
`public_access_token` and `last_event_id` for resume. Separate table so the
3653
secret token is isolated from list queries and the hot per-turn write stays off

0 commit comments

Comments
 (0)