Skip to content

fix(retention): exempt publicly-linked pods + point the showroom at a live room - #862

Merged
lilyshen0722 merged 1 commit into
mainfrom
fix/showroom-milestone-and-retention-exempt
Aug 6, 2026
Merged

fix(retention): exempt publicly-linked pods + point the showroom at a live room#862
lilyshen0722 merged 1 commit into
mainfrom
fix/showroom-milestone-and-retention-exempt

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Closes the dead-showroom incident found during YC-interview prep, and the retention interaction that caused it.

What happened

"Watch a live room" on the landing page rendered "No messages yet". Nothing and nobody cleared that pod: pgRetentionService runs a daily unconditional DELETE FROM messages WHERE created_at < NOW() - 30 days, and the showroom's content aged out. Verified: PG's lowest surviving message id is 49166 with the sequence's high-water mark intact — everything before 2026-07-06 03:07 was deleted by policy, ~49k rows, which is 30 days of normal turnover and not an incident. The interaction is the bug: any publicly-linked room built on chat messages self-destructs on a rolling window. The replacement room's oldest messages were 3 days from aging out.

The change

  1. Showroom build-arg → Eng Milestone (6a507c9b…): four weeks of real multi-agent engineering, already publicRead, already served by the showcase/:podId route. The showcase opens at the latest messages (an ADR design consult), so the default view is strong.
  2. PG_RETENTION_EXEMPT_POD_IDS honored by Message.deleteOlderThan, wired as backend.env.retentionExemptPodIds; dev exempts the showroom + HQ. Env list rather than a pod flag deliberately: the paid tier being designed makes retention a per-account entitlement, and that mechanism should own per-pod retention. This is the smallest stopgap that cannot drift into a second tier system.

Verification

  • 5 new tests at the SQL boundary (the service test mocks deleteOlderThan away, so without these the exemption had no test anywhere)
  • The unset-env path is byte-identical to the query that has run for months, pinned by test — provably a no-op for every deployment that never sets the var
  • helm template renders the env var correctly from dev values
  • Existing 10 pgRetentionService tests untouched and green; typecheck clean

… live room

Two changes, one incident. The landing page's "Watch a live room" CTA led to
"No messages yet": the pod it points at was silently emptied by the 30-day PG
message-retention cron (pgRetentionService, default window, unconditional
DELETE). Nothing cleared it by hand — it aged out. Any showcase built on chat
messages self-destructs on a rolling window, and the replacement room (Eng
Milestone, oldest message July 10) would have started thinning in 3 days.

1. REACT_APP_SHOWCASE_POD_ID -> 6a507c9b792f1ed2cbfec648 (Eng Milestone —
   4 weeks of real multi-agent engineering: Fable planning and reviewing,
   Codex implementing, humans intervening in the open).

2. Message.deleteOlderThan honors PG_RETENTION_EXEMPT_POD_IDS (comma list),
   wired through the chart as backend.env.retentionExemptPodIds; dev values
   exempt the showroom and HQ. Env-var rather than a pod flag deliberately:
   the paid tier under design makes retention a per-account entitlement, and
   that mechanism should own per-pod retention when it lands. An env list is
   the smallest stopgap that cannot drift into a second tier system.

The unset path stays byte-identical to the query that has run for months —
pinned by test — so this is provably a no-op for deployments that never set
the var. 5 new tests; the 10 existing pgRetentionService tests untouched.
@lilyshen0722
lilyshen0722 merged commit cbdff08 into main Aug 6, 2026
12 checks passed
samxu01 pushed a commit that referenced this pull request Aug 6, 2026
The pricing section described a metered-CI-minutes model we are not building
and did not mention the two limits that now exist in code. Rewritten in both
locales to match the tier as decided:

  Cloud free  — unlimited BYO agents, private/invited pods, 30 DAYS of
                message history (pgRetentionService, live), no card
  Pro $12     — everything free has, unlimited history, Community listing
                (gated on entitlements.pro, #874), 10 hosted agent seats
                marked "coming soon, included when it lands", priority support

Three deliberate choices.

**"Flat. However many agents you run — we never charge per agent."** The
nearest competitor charges per seat AND meters each agent at 0.1 seat, so a
user with three agents pays more there than here. Leading with the sticker
price invites the wrong comparison; leading with FLAT states the actual
difference, and it is the same difference the headline "Humans are seats.
Agents never are" already claims.

**Hosted seats listed but marked coming soon.** They are in the plan the
buyer is choosing, and the gate lifts later — saying so is honest and keeps
the tier legible. Saying nothing would make Pro read as "history plus a
listing toggle."

**30-day history is now stated on the free card.** It has been enforced by
the retention cron for months and appeared nowhere in the product. A limit
users discover by losing data is the worst way to learn it (see #862, where
it silently emptied our own landing-page showroom).

Dropped the "metered agent-hours pool" item: it described billing that does
not exist, and pricing copy that promises a mechanism nobody built is a
phantom contract on the most consequential page.

en/zh-CN key parity asserted; landing suites green; typecheck clean.
lilyshen0722 added a commit that referenced this pull request Aug 6, 2026
…874)

* feat(pricing): Community listing requires the Pro entitlement (#768)

Listing a pod to Community is a paid capability. Gated on
`entitlements.pro` rather than on payment state directly, so the check is
identical however billing is wired later — today an admin sets the flag, the
same shape as the invite-code path that already gates `cloudAgents`.

Reverses the permission #872 shipped an hour ago, deliberately and at Sam's
call: that PR moved listing from admin-only to any-owner, which was the right
fix for the inert creation choice and the wrong permanent home for a paid
capability. The route, the invariants and the audit trail are unchanged; only
who may promote moves.

DEMOTION IS NEVER GATED, and that asymmetry is the point. A user whose
subscription lapses must always be able to make their own room private again.
Paywalling the exit would turn a billing event into an unwanted permanent
public disclosure — the pod stays world-readable until they pay. Free users
keep `private` on demand, forever.

Admins bypass, matching every other entitlement check. A user with no
entitlements object at all reads as free rather than throwing (pinned).

Explicitly NOT gated: BYO agents. "Agents you bring connect free and
unlimited" is the standing promise on the pricing page, and a per-agent cap is
precisely the model this pricing exists to avoid. The paid tier adds hosted
seats and unlimited history; it never subtracts what free already had.

19/19 on the route, including that a free owner is refused without any write
landing, that a missing entitlements object is treated as free, and that a
free owner can still always demote.

* feat(landing): pricing says what the tiers actually are

The pricing section described a metered-CI-minutes model we are not building
and did not mention the two limits that now exist in code. Rewritten in both
locales to match the tier as decided:

  Cloud free  — unlimited BYO agents, private/invited pods, 30 DAYS of
                message history (pgRetentionService, live), no card
  Pro $12     — everything free has, unlimited history, Community listing
                (gated on entitlements.pro, #874), 10 hosted agent seats
                marked "coming soon, included when it lands", priority support

Three deliberate choices.

**"Flat. However many agents you run — we never charge per agent."** The
nearest competitor charges per seat AND meters each agent at 0.1 seat, so a
user with three agents pays more there than here. Leading with the sticker
price invites the wrong comparison; leading with FLAT states the actual
difference, and it is the same difference the headline "Humans are seats.
Agents never are" already claims.

**Hosted seats listed but marked coming soon.** They are in the plan the
buyer is choosing, and the gate lifts later — saying so is honest and keeps
the tier legible. Saying nothing would make Pro read as "history plus a
listing toggle."

**30-day history is now stated on the free card.** It has been enforced by
the retention cron for months and appeared nowhere in the product. A limit
users discover by losing data is the worst way to learn it (see #862, where
it silently emptied our own landing-page showroom).

Dropped the "metered agent-hours pool" item: it described billing that does
not exist, and pricing copy that promises a mechanism nobody built is a
phantom contract on the most consequential page.

en/zh-CN key parity asserted; landing suites green; typecheck clean.

* feat(pricing): admin can grant Pro; fix the /compare ribbon

Completes the tier plumbing and fixes a live layout bug found while checking
the pricing pages.

**Tier data structure.** `entitlements.pro` lands beside the existing
`cloudAgents` flag — the model comment already said future entitlements slot
in there. `/api/auth/user` returns the whole doc, so the client learns its
tier with no new endpoint.

**Granting it.** `PATCH /api/admin/users/:userId/entitlements` already
existed but hard-coded `cloudAgents`; it now takes either key or both. Until
billing exists, this route IS the subscription.

The property worth the tests: a partial patch never clears the key it did not
name. Revoking a lapsed subscription must not silently switch off someone's
hosted agents, and granting Pro must not wipe `cloudAgents`. Both directions
pinned, plus non-boolean rejection, bots refused, and a pre-existing account
with no `entitlements` object at all.

**/compare rendered as a ~110px ribbon**, live. `.v2-compare__head` carried
both `.v2-landing__section` — whose padding is
`max(24px, calc((100% - 1120px) / 2))` — and its own `max-width: 920px`.
Percentage padding resolves against the PARENT, so on a wide viewport that is
200-400px per side; capping the border box at 920px left a sliver for text.
The landing page never hit it because its sections do not set max-width on
the element that carries the padding.

Fixed by constraining the CHILDREN instead, and guarded in
v2-layout-invariants — the rule is: never put max-width on the element that
carries the centring padding.

Test-mock note: this route uses CJS `require('express-rate-limit')` while
routes/pods.ts uses the ESM default import, so the limiter mock has to be
callable and carry `.default`. Cost a failing suite; documented inline.

11 new admin-route tests, 23 layout invariants, typecheck clean both sides.
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