From 7a923ef404d8272af560870723896600a455139f Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:26:41 +0200 Subject: [PATCH] fix: cap forced-retention edits to the message's earlier expiry The edit/feedback path calls applyForcedRetention with capExpiryToConversation, which unconditionally set the touched message's expiredAt to the parent/fresh forced deadline. When that message already expired sooner (an older message keeps its creation-time deadline while later saves refreshed the parent's), the $set extended it past its own schedule, overwriting the earlier deadline that capForcedRetentionToParent had just preserved. Cap the touched message to min(existing expiredAt, forcedExpiredAt) via the same $min/$ifNull pipeline forceConversationMessagesTemporary already uses, so a message with a sooner deadline keeps it and a permanent message still receives the forced one. --- .../data-schemas/src/methods/message.spec.ts | 36 +++++++++++++++++++ packages/data-schemas/src/methods/message.ts | 12 ++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index bc642693f7..f54d625ac6 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -1128,6 +1128,42 @@ describe('Message Operations', () => { } }); + it('preserves an edited message expiring sooner than its parent under forced retention', async () => { + const conversationId = uuidv4(); + const editedMessageId = uuidv4(); + const messageDeadline = new Date(Date.now() + 60 * 60 * 1000); + const parentDeadline = new Date(Date.now() + 12 * 60 * 60 * 1000); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + title: 'Carried-over chat', + isTemporary: false, + expiredAt: parentDeadline, + }); + await Message.create({ + messageId: editedMessageId, + conversationId, + user: 'user123', + text: 'edited', + isTemporary: false, + expiredAt: messageDeadline, + }); + + await applyForcedRetention( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { conversationId, messageId: editedMessageId }, + { context: 'edit', capExpiryToConversation: true }, + ); + + const message = await Message.findOne({ messageId: editedMessageId, user: 'user123' }).lean(); + expect(message?.isTemporary).toBe(true); + expect(message?.expiredAt?.getTime()).toBe(messageDeadline.getTime()); + }); + it('converts a permanent conversation and its messages without a messageId (tag write)', async () => { const conversationId = uuidv4(); await Conversation().create({ diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 176d3ab503..c666b818cc 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -422,10 +422,14 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa } if (typeof messageId === 'string' && messageId.length > 0) { - await Message.updateOne( - { messageId, user: userId }, - { $set: { isTemporary: true, expiredAt: forcedExpiredAt } }, - ); + await Message.updateOne({ messageId, user: userId }, [ + { + $set: { + isTemporary: true, + expiredAt: { $min: [{ $ifNull: ['$expiredAt', forcedExpiredAt] }, forcedExpiredAt] }, + }, + }, + ]); } await cascadeForcedConversationRetention( Conversation,