Skip to content

fix(retention): Pro pays for unlimited history; the cron never knew Pro existed - #878

Merged
lilyshen0722 merged 1 commit into
mainfrom
fix/retention-respects-pro
Aug 6, 2026
Merged

fix(retention): Pro pays for unlimited history; the cron never knew Pro existed#878
lilyshen0722 merged 1 commit into
mainfrom
fix/retention-respects-pro

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

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 #875 started charging for it.
Message.deleteOlderThan filtered on exactly two things:

DELETE FROM messages
WHERE created_at < NOW() - $1::interval
  AND pod_id != ALL($2)   -- an env list of operator-pinned pods

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 pgRetentionService steps
the 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.ts explicitly asked for:

Env-var rather than a pod flag, deliberately, for now: the paid tier being
designed makes retention a per-account entitlement, and THAT mechanism should
own per-pod retention when it lands.

  • deleteOlderThan(days, protectedPodIds)unioned with the env list, not
    replacing it. Operator-pinned and paid pods are exempt for different reasons
    and neither should mask the other.
  • Resolved once per run and threaded through every tier including the
    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.
  • 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 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.

…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
@lilyshen0722
lilyshen0722 merged commit 26d90b8 into main Aug 6, 2026
11 checks passed
@lilyshen0722
lilyshen0722 deleted the fix/retention-respects-pro branch August 6, 2026 10:32
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant