Skip to content

Tiered retention: make prune windows a property of the customer plan, with a never-prune cold-storage tier #341

Description

@khaliqgant

Summary

Make relaycast retention a property of the customer's plan rather than a deployment-wide default. Premium plans get a longer window; a top tier gets never-prune with cold storage. Free plans get a short window, which also gives abandoned workspaces a principled expiry.

Decision by @khaliqgant, 2026-08-18.

The engine already does this. Nobody is using it.

This is worth stating first because it changes the size of the work:

workspaces.retention is a per-workspace JSON column typed WorkspaceRetentionSettings, carrying independent TTLs:

messageTtlDays · deliveryTtlDays · messageLogTtlDays · workspaceEventTtlDays

and from packages/engine/src/engine/retention.ts:

null disables pruning for that table.

That is exactly the never-prune tier, already implemented.

The 30-day figure is not a limit. It is a deployment-wide default passed at a single call site in the Workers cron:

pruneExpired(db, { defaults: { messageTtlDays: 30 } })

with per-workspace overrides honoured inside pruneExpired.

So there is no engine change here. The work is a plan → retention mapping and something that writes the column.

Scope

  1. Define the tiers — the retention window per plan, and which plan gets null (never prune). This is a pricing decision, not an engineering one; the issue should record the chosen numbers.
  2. Write workspaces.retention when a plan changes. Provisioning and plan-change need to set it; today nothing does.
  3. Cold storage for the never-prune tier — archive the aged slice to R2, not D1. D1 is at 1.99 GB and is the component under strain in the 2026-08-18 incident; R2 is priced for this. The Cloudflare file adapter is gaining R2 object lifecycle now, so the archive has a natural home.
  4. Surface the boundary. Any consumer that reads back history — replay in particular — must display the retention boundary, so a truncated read announces itself rather than looking complete. A pointer to a conversation that has aged out is worse than no pointer, because it reads as coverage.

Blocked on attribution

You cannot tier what you cannot attribute. A workspace row carries no notion of which customer owns it, so a tier cannot be applied, enforced, or billed.

#339 is therefore a prerequisite, not a parallel nicety. Two of its open review threads concern a CHECK constraint that accepts a usage_classification with no classificationSource — a classified row with no provenance for the classification. That is precisely the field a billing tier keys on, so it should be corrected rather than merged past.

Two consequences worth recording

  • Workspace bloat becomes self-limiting. A free tier with a short TTL, combined with workspaces.expires_at from feat: add workspace lifecycle controls #338, expires abandoned workspaces by policy and continuously — replacing a one-off backfill over a hand-written "inert" predicate. Production currently holds 41,443 workspaces, of which 25,014 have no agents, messages, files, nodes or usage records.
  • Session replay stops needing its own retention decision. "Replay reaches back as far as your plan's retention" is a pricing sentence. See relay#1522 and the intent-trajectory-lineage workstream, where this decision supersedes a three-way choice between a null override, copying the slice at PR time, and accepting a 30-day bound.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions