fix(retention): Pro pays for unlimited history; the cron never knew Pro existed - #878
Merged
Merged
Conversation
…ro existed The landing page sells "Unlimited message history — nothing expires at 30 days" as the headline Pro feature. `deleteOlderThan` filtered on exactly two things: message age, and an env list of operator-pinned pod ids. A paying customer's messages were deleted on precisely the free-tier schedule. Worse than the flat window: under storage pressure the service steps the window DOWN toward a one-day floor. A Pro subscriber could have lost all but 24 hours of history while being billed for keeping it forever. Nobody has lost paid-for data — the first Pro account is hours old and has nothing older than 30 days. The breach would have landed about a month after the first paying customer's oldest message, silently. The mechanism the previous comment asked for. `deleteOlderThan` now takes `protectedPodIds`, UNIONED with the env list rather than replacing it — operator-pinned and paid pods are exempt for different reasons and neither should mask the other. The set is resolved once per run and threaded through EVERY tier, including the step-down, which is the path that would otherwise walk a paying user to the floor. ANY Pro member protects a pod, not just its creator: a conversation with one free and one paying participant cannot be half-deleted, or the Pro user watches their own history vanish. The property worth more than the feature: if the entitlement lookup throws, the whole run ABORTS before a single row is deleted. Destroying paid data because Mongo blinked is unrecoverable; skipping one night of cleanup is not. Nine new/updated tests; verified by reverting the fix and watching them fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
This was referenced Aug 6, 2026
lilyshen0722
added a commit
that referenced
this pull request
Aug 6, 2026
#878 protected a pod if ANY member was Pro. That reads more generous and behaves worse: membership is cheap and unilateral, so a single Pro admin who has joined everything silently confers unlimited retention on the whole instance. Measured on live data before changing it — one Pro admin protected 95 of 235 pods, 78% of all messages. Retention was being decided by who happened to join a room rather than by who owns it, and the only way to remove protection would have been to remove a person. Retention is a property of the room, as it is in every other team tool: a paid user in someone else's free workspace gets that workspace's policy. Same live data after: 61 pods protected, 34 lose protection — all of them pods a Pro user joined but did not create. Every pod has createdBy (verified: 0 without), so nothing falls through the selector. The honest cost is that a Pro user does not get unlimited history in pods they did not create, so the copy now says so — "Unlimited history in pods you create", in both locales, plus the two billing-panel notes. Shipping the mechanism without the copy would have been the third promise-drifts-from-code bug in one day, after the "Free in beta" badge and the tax-exclusive price. Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Caught by Sam while reviewing the billing work: "did we even filter user during pg data clean up?"
No. We did not.
The gap
The landing page sells "Unlimited message history — nothing expires at 30
days" as the headline Pro feature, and
#875started charging for it.Message.deleteOlderThanfiltered on exactly two things:Message age, and an env var. Nothing that knows the Pro tier exists. A paying
customer's messages were deleted on precisely the free-tier schedule.
Worse than the flat window: under storage pressure
pgRetentionServicestepsthe window down toward a one-day floor. A Pro subscriber could have been
left with 24 hours of history while being billed to keep it forever.
Verified live before fixing — the cron is scheduled daily at 03:00 UTC, running
a 30-day window, exempting only the two showroom pods.
Exposure
Nobody has lost paid-for data. The first Pro account is hours old and has
nothing older than 30 days. The breach would have landed roughly a month after
the first paying customer's oldest message, silently, with the copy still
promising otherwise.
The fix
The mechanism the existing comment in
Message.tsexplicitly asked for:deleteOlderThan(days, protectedPodIds)— unioned with the env list, notreplacing it. Operator-pinned and paid pods are exempt for different reasons
and neither should mask the other.
step-down — that loop is the path that would otherwise walk a paying user
to the floor, and fixing only the first call would have looked correct.
one free and one paying participant cannot be half-deleted, or the Pro user
watches their own history vanish.
The property worth more than the feature
If the entitlement lookup throws, the run aborts before a single row is
deleted. Destroying paid-for data because Mongo blinked is unrecoverable;
skipping one night of cleanup is not.
Verification
Nine new/updated tests across the SQL boundary and the service. Reverted the
fix and confirmed they fail. Typecheck clean.
Related: #875, #876, #877.