Skip to content

Seamless cloud plan upgrades + over-quota downgrade guardrails (0216) - #250

Merged
crs48 merged 7 commits into
mainfrom
claude/seamless-plan-downgrade
Jun 24, 2026
Merged

Seamless cloud plan upgrades + over-quota downgrade guardrails (0216)#250
crs48 merged 7 commits into
mainfrom
claude/seamless-plan-downgrade

Conversation

@crs48

@crs48 crs48 commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Implements exploration 0216 — make the managed-hub upgrade/downgrade path clear, clean, and safe.

The problem

changePlan flipped the storage quota in place with no usage check, so a downgrade silently shrank the quota under live data. A Family tenant (250 GiB) holding 200 GiB who switched to Personal (25 GiB) just had their quota set to 25 GiB — and the hub started 507-ing the next write, with no warning.

What this does

Upgrades stay seamless. An in-tier quota increase is still a pure live entitlement flip — no usage read, no migration prompt. Your data just gets more room.

Downgrades are guarded (Layer 1 — block, don't surprise).

  • PlanChangeResult gains an over-quota variant. changePlan now refuses a capacity reduction that would shrink the quota below stored data — or whose usage can't be measured (cold/asleep hub) — instead of silently flipping it down.
  • currentUsageBytes() reads a fresh, uncached hub /health (injectable readUsageBytes seam for tests).
  • POST /account/plan renders an over-quota notice with two honest exits: Free up space (recommended) or Wipe & start fresh. New POST /account/plan/wipe (explicit confirm=wipe) calls wipeAndChangePlan — destroy + re-provision an empty hub at the smaller plan, preserving billing + data identity. The dashboard double-confirms before posting.

Graceful over-quota signal (Layer 2 — never auto-delete).

  • DashboardLive.overQuota (because storagePct saturates at 100) drives a read-only banner on the hub card: "Your data is safe, but new writes are paused until you free up space or upgrade." This is the Google One model, explicitly not Dropbox's delete-your-oldest-files model. Cancellation already routes to suspendTenant, which retains the R2 replica.

Correctness fix. FileService was constructed without the plan quota, so uploads checked against a hardcoded 5 GiB instead of config.defaultQuota. Now wired to the plan quota, so files count against the same quota the dashboard meter shows.

Tests

  • control-plane.test.ts: over-quota block (measured + unmeasurable), downgrade-fits flip, upgrade never reads usage, wipeAndChangePlan (same-tier and cross-tier).
  • server.test.ts: over-quota notice (no flip, tenant unchanged), wipe route requires confirm.
  • hub-status.test.ts: overQuota true/false/null.
  • files.quota.test.ts: FileService enforces the configured quota.
  • All 211 cloud unit + 439 hub integration tests pass; xnet-cloud/@xnetjs/hub/@xnetjs/entitlements typecheck clean; prettier + eslint clean.

Deferred (called out in the doc)

  • End-of-period downgrade scheduling via Stripe subscription schedules (needs subscription-schedule wiring in the gateway).
  • Reclaimable-bytes estimate + on-demand SQLite compaction (so "free up space" moves the meter despite the append-only log) — fast follow.
  • A formal grace-window timer for the cancellation read-only state.

🤖 Generated with Claude Code

xNet Test added 7 commits June 24, 2026 09:32
- Add 'over-quota' variant to PlanChangeResult; changePlan now refuses a
  capacity reduction that would shrink the quota below stored data (or whose
  usage can't be measured), instead of silently flipping it down.
- currentUsageBytes() reads fresh hub /health; injectable for tests.
- wipeAndChangePlan(): destroy + re-provision an empty hub at the smaller plan
  (the 'start fresh' escape hatch); preserves billing + data identity.
- Fix FileService to enforce the plan quota (config.defaultQuota) instead of its
  hardcoded 5 GiB default, so files count against the same quota as backups.

Exploration 0216.
… banner

- POST /account/plan now renders an over-quota notice (free up space, or wipe &
  start fresh) instead of redirecting, when changePlan reports 'over-quota'.
- New POST /account/plan/wipe (explicit confirm required) calls wipeAndChangePlan.
- renderOverQuotaNotice: usage vs target, reclaim amount, distinct copy when usage
  couldn't be measured (cold hub), and a double-confirmed wipe form.
- planChangeCard copy reframed: upgrades = instant more space; downgrades must fit.
- DashboardLive.overQuota signal (storagePct saturates at 100) + a read-only
  banner on the hub card when over quota.
- buildControlPlane gains an injectable readUsageBytes seam (tests).

Exploration 0216.
@crs48
crs48 temporarily deployed to pr-250 June 24, 2026 16:52 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Preview removed for PR #250.

github-actions Bot added a commit that referenced this pull request Jun 24, 2026
@crs48
crs48 merged commit eae54f8 into main Jun 24, 2026
13 checks passed
@crs48
crs48 deleted the claude/seamless-plan-downgrade branch June 24, 2026 17:05
github-actions Bot added a commit that referenced this pull request Jun 24, 2026
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