⏳ feat: Add Ephemeral Retention Mode for Forced Temporary Chats - #13811
⏳ feat: Add Ephemeral Retention Mode for Forced Temporary Chats#13811berry-13 wants to merge 60 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3f6757b041
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2de5b1f164
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5481e9a498
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
LibreChat/api/server/routes/convos.js
Line 222 in aae969b
When interface.retentionMode: "ephemeral" is enabled and a user pins or unpins an older non-temporary conversation, this route still calls saveConvo with only userId, unlike the archive/update routes above. Since saveConvo only forces isTemporary/expiredAt when it receives interfaceConfig, the pin write preserves the conversation as permanent and visible, bypassing the forced-temporary policy. Add configMiddleware here and pass req.config.interfaceConfig into saveConvo.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7b4b36de6f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
f63be38 to
596c5b4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 596c5b4118
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4b170f5ff3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 144ad1e5b3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f91cbfeb66
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1cb50554df
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3378917bdf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5dfae72636
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 18119a74ec
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e798bc5919
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
e798bc5 to
94642c8
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 94642c8cd2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1bfb90c4e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 920814a3d4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c4aa8ae7ab
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
c4aa8ae to
b960221
Compare
|
@codex review |
applyForcedRetention (message edit/feedback/share/tag writes) converts a chat to isTemporary: true, which visibleProjectConversationFilter excludes, but unlike saveConvo it never recomputed the owning project's cached stats. A pre-existing project chat converted this way left the project's conversationCount and lastConversationId pointing at a chat the workspace no longer shows. After the cascade, recompute the stats of every touched chat's project (the single conversation for applyForcedRetention, all tagged chats for applyForcedRetentionToTag), matching saveConvo's retention-visibility refresh. Scoped to conversations carrying a chatProjectId, so it is a no-op for chats outside any project.
saveConvo's inline forced-retention backfill converted the conversation and capped its messages and shared links but left File records at expiredAt: null, so archive/pin/title updates on a pre-existing chat under ephemeral mode left its uploads in storage after the conversation and messages TTL out. File cleanup only sweeps rows whose own expiredAt is set. Cap the conversation's files alongside messages and shares, matching the other forced-retention cascades.
cascadeForcedConversationRetention converted the parent conversation first and only backfilled its messages, shares, and files when that update modified a row. If a child backfill threw after the parent was converted, a later forced-retention write saw the parent already conforming (modifiedCount 0) and skipped the children, leaving not-yet-updated messages, shares, or files permanent. Gate on an in-memory conversationNeedsForcedRetention check and backfill the children before marking the parent conforming, so a failed child leaves the conversation non-conforming and the next write re-runs the whole cascade. The early return preserves the no-op-when-already-conforming behavior.
Opening a pre-existing permanent conversation under ephemeral retention only flips the composer isTemporary atom; the loaded conversation object stays non-temporary until a write converts it server-side. BookmarkMenu gated solely on isTemporaryConversation(conversation), so it kept exposing bookmarking on a chat that is already effectively temporary. Gate the menu on the forced-retention flag as well (matching how the composer already treats the chat), so permanent-chat actions are hidden until the server catches up.
The migration loaded getAppConfig() with no tenant and swept every conversation
under runAsSystem, so in a multi-tenant deployment with per-tenant retention
config overrides it force-expired all tenants' chats using the system config's
window, even tenants that never enabled ephemeral retention.
Enumerate tenants from the conversations, and for each run getAppConfig and the
sweep inside tenantStorage.run({ tenantId }) so every query is scoped to that
tenant; skip a tenant whose resolved config is not ephemeral. Single-tenant and
pre-tenancy deployments (no tenantId) keep the existing system-config sweep, and
untenanted rows in a mixed deployment are left untouched (they cannot be scoped)
with a warning rather than converted cross-tenant.
saveConvo's findOneAndUpdate wrote isTemporary/expiredAt on the conversation before the message/share/file backfills ran, so a child failure left the parent conforming; the next save reloaded it, conversationNeedsForcedRetention returned false, and the skipped child rows stayed permanent even though retries appeared to succeed. Run the child backfills before the parent conversion, matching the cascade and sweep ordering, so a failed child leaves the conversation non-conforming and the next save re-runs the whole backfill.
…ect chats The migration sweep converted project-assigned conversations to isTemporary: true, which visibleProjectConversationFilter excludes, but never recomputed the owning projects' cached stats, leaving conversationCount and lastConversationId pointing at chats the project view no longer shows. Collect each converted conversation's project membership in the sweep (deduped user/chatProjectId pairs returned alongside the counts; the sweep cannot refresh directly without a circular dependency on the chat-project methods) and have the migration recompute every affected project's stats inside the tenant context, reporting the refreshed count.
The migration used distinct('tenantId') to detect untenanted rows, but distinct
only enumerates stored field values: pre-tenancy conversations missing the field
entirely contribute nothing to it, so in a mixed deployment they were silently
left permanent with no skippedUntenanted warning.
Count untenanted rows directly with countDocuments({ tenantId: null }) (which
matches both explicit null and missing fields, plus empty strings via $or) so
the warning fires whenever unscopeable rows exist, and report how many.
Both the migration sweep and the convert-on-touch cascade skipped a parent that already satisfied the forced-retention gap filter, so an already-temporary chat with dependent rows that lag behind it (permanent shares or later-window files created before the mode switch, or left by a partial earlier backfill) kept those children past the parent's TTL. Cap the children independently of the parent gap check: the single cascade runs the message/share/file caps before its parent conversion regardless of whether the parent needs converting (each cap is an indexed no-op once children conform; the bulk tag/project cascade already worked this way), and the sweep gains an alignment pass that walks already-conforming temporary conversations and caps their children to each parent's own deadline, reported via a new `aligned` count. The cap helpers now return their modified counts to support that reporting.
The migration evaluates one config per tenant, but runtime requests resolve config with role/user principals, so a deployment using principal-scoped DB config overrides can have users whose effective retention mode or window differs from the tenant default. Sweeping such a tenant with the tenant-level config would skip users who are ephemeral, or worse, force-expire chats of users whose override opts out. Detect active non-base config overrides that touch interface.retentionMode or interface.temporaryChatRetention (including tombstones removing them) and skip the tenant with an explanatory error listing the principals; --force sweeps with the tenant-level config anyway. Skip reasons now surface in the CLI output.
saveConvo only ran the message/share/file caps when the touched conversation itself needed forced conversion, so an archive/pin/title save on an already-temporary chat with an earlier deadline skipped the child caps and any lagging rows from before the mode switch or a partial backfill kept expiredAt: null or a later deadline past the parent's TTL. Run the child caps whenever forced retention resolves an active deadline for a pre-existing conversation, matching the single cascade's self-healing behavior; each cap is an indexed no-op once the chat's children conform. The old "leaves messages untouched once ephemeral" expectation pinned the skip this change removes, so it now asserts the permanent row is healed instead.
saveMessage's forced-retention cascade converts an old project-assigned chat to isTemporary: true, but message-only writes (branch/artifact/abort saves) have no saveConvo afterward to recompute the project's cached stats, so the count and lastConversationId kept pointing at a chat the project view no longer shows. Make cascadeForcedConversationRetention report whether it converted the parent row and refresh the owning project's stats from saveMessage when it did. The applyForcedRetention refresh is gated on the same signal, so conforming parents no longer trigger a redundant stats recompute on every edit/feedback write.
Message-attachment uploads create File rows without a conversationId — they are referenced only from Message.files[].file_id and the conversation's files array (only Code Interpreter outputs set conversationId). The conversation-scoped file caps therefore missed regular attachments entirely: converting a pre-existing chat left their expiredAt null, so storage cleanup never swept them after the conversation and messages expired. Collect the referenced file ids (conversation.files plus one pass over the chat's messages) and extend the file caps to match file_id as well as conversationId, in the single cascade, saveConvo, the bulk tag/project cascade, and both sweep passes. The id scan runs at conversion/alignment time only — post-conversion uploads always receive a deadline at upload, so conforming- parent writes keep the cheap conversationId-scoped cap. A file shared across chats is capped to the earliest converting chat's deadline, consistent with cap-don't-extend.
The share route enforced retention after createSharedLink and the permission grant. If the cascade threw at that point, the client got a 500 with a live share already created, and a retry never reached the cascade again because createSharedLink rejects when an active share exists — leaving a valid share whose source conversation stays non-temporary and non-expiring under ephemeral mode. Convert the source conversation first: a failed share attempt (or an existing-share retry) still converts the touched chat, and the share expiration resolution now reads the converted conversation's deadline.
PUT /api/tags/:tag renamed the tag first and ran the forced-retention cascade with the new tag afterwards. A cascade failure returned 500 with the rename already committed, and a retried PUT /:oldTag hit the 404 path because the old tag no longer exists, so the tagged conversations' messages, shares, and files could never be backfilled. Run the cascade with the old tag before the rename, mirroring the DELETE route: the old tag selects the same conversations the renamed tag will carry, enforcing on a nonexistent tag is a no-op, and a failed rename now retries cleanly with retention already applied.
collectConversationFileIds only read Message.files, but agent/tool outputs (code artifacts, generated images) are stored on Message.attachments with their own file_id references, and those File rows also commonly lack a conversationId. A forced-retention conversion or migration could therefore convert the conversation and messages while leaving attachment file metadata at expiredAt: null, so storage cleanup never swept it. Match messages carrying either field and collect file ids from both arrays.
Two gaps in the referenced-file-id capping: The file_id branch matched File rows with no ownership check, and file ids inside message/conversation documents are caller-supplied (saveMessage spreads request params into the update). A crafted or imported message referencing another user's file_id would therefore set that user's File.expiredAt during a forced-retention cascade or migration, and the storage sweeper would later delete their object. The referenced-id branch now always filters on File.user (dropped entirely when the caller's user id is not a castable ObjectId string — fail closed), in both capConversationFiles and the bulk tag/project scope. The conversationId branch stays unscoped: those rows are server-created by the conversation's own processes, and an id collision can only shorten the colliding owner's files. Assistants conversations persist thread uploads on Conversation.file_ids (saveUserMessage/syncMessages), which the parent projections never read, so files stored only there kept expiredAt: null after conversion. A shared conversationSeedFileIds helper now seeds the id collection from both files and file_ids across the cascade, saveConvo, the bulk cascade, and both sweep passes.
Regular message edits now load app config and stamp ephemeral retention on the message and conversation. Bookmark controls also hide when temporary mode is forced on a still-permanent chat.
A text edit that omits `index` reached `updateMessage`, which matches on
`{ messageId, user }` alone, so the route's conversationId could be paired
with a message from another of the caller's chats and the retention re-stamp
would move it there. Reject the mismatch instead.
Artifact edits stamped forced retention onto the message but never onto its
conversation, leaving the chat permanently visible while its edited message
expired. Both edit routes now share one parent-retention helper, which never
upserts a conversation that is already gone.
Ephemeral retention marks imported, forked, and duplicated chats temporary, which excludes them from every bookmark-filtered conversation query, and TTL deletion never decrements tag counts. Counting their tags therefore left permanent totals for chats that can never appear under the filter.
Rebasing onto dev left duplicate and dead imports in the data-schemas specs, and the earlier reduction of ephemeral retention reverted createFile to the plain upsert without dropping the two tests that asserted its pipeline-only casting and monotonic expiredAt. Restore the imports the surviving tests need and remove the tests for behavior the method no longer has.
01dfa71 to
9438549
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9438549980
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * @returns {TMessage} The newly created branch message | ||
| */ | ||
| router.post('/branch', async (req, res) => { | ||
| router.post('/branch', configMiddleware, async (req, res) => { |
There was a problem hiding this comment.
Restamp the parent when creating branch messages
When ephemeral mode is enabled after permanent conversations already exist, this middleware makes saveMessage mark the new branch message temporary, but the route never applies retention to sourceMessage.conversationId. Creating a parallel-response branch can therefore leave the parent conversation permanent and visible while its newly created branch expires; call the same parent-retention helper used by the artifact and edit routes after saving the branch.
Useful? React with 👍 / 👎.
Summary
Closes #10052.
Adds an admin option to force Temporary Chat always on for every user.
retentionModetemporary(default)allephemeral(new)Configuration
What changes
RetentionMode.EPHEMERALand shared helpers for all-data and forced-temporary retention.isTemporary: trueand applies the existing temporary-chat expiration behavior in the normal conversation and message save methods.The default
temporarymode and the existingallmode remain unchanged.Existing data and scope
This PR intentionally does not include a migration, startup sweep, reconciliation pass, or graph-wide cascade.
Enabling
ephemeraldoes not proactively convert pre-existing permanent conversations. A normal conversation/message save applies the policy to the records being saved, while metadata-only operations such as delete, tag rename, project membership, and share updates do not act as conversion triggers. Deployments that require retroactive retention should handle that as a separately designed and operated migration.This boundary keeps the feature equivalent to “Temporary Chat is always on” instead of turning it into a general-purpose data-retention migration engine.
Concurrency and consistency
This mode reuses the existing Temporary Chat write and TTL behavior. Each MongoDB document write is atomic, but conversation, message, file, and shared-link writes are not one cross-document transaction. This PR does not promise monotonic parent/child deadline capping or graph-wide reconciliation. MongoDB TTL deletion is asynchronous.
Docs: LibreChat-AI/librechat.ai#611
Change Type
Testing
Local verification:
packages/data-schemas: 52 suites / 1,852 tests passed.packages/data-provider: 25 suites / 1,358 tests passed.packages/api: 275 suites / 7,151 tests passed, with the repository's normal integration-test exclusions.git diff --checkis clean.Test configuration
Start a new chat. The badge is locked on, the saved conversation and messages are temporary with an expiration, and the chat does not appear in history or search. Imports, forks, duplicates, file uploads, and Assistants/Threads messages receive the same policy.
Checklist