Skip to content

feat(cloud): wire the dunning lifecycle, read-only mode and lifecycle email (0418) - #673

Merged
crs48 merged 6 commits into
mainfrom
claude/0418-xnet-cloud-to-production-backups-billing-dunning
Aug 1, 2026
Merged

feat(cloud): wire the dunning lifecycle, read-only mode and lifecycle email (0418)#673
crs48 merged 6 commits into
mainfrom
claude/0418-xnet-cloud-to-production-backups-billing-dunning

Conversation

@crs48

@crs48 crs48 commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Implements Phase 1 of exploration 0418 — the code half of what stands between the staging control plane and charging a real person $5/month.

The problem this fixes

The non-payment state machine was written, exhaustively tested, and inert. reconcileBilling — the timer half — had zero non-test callers. In production a failed payment opened grace, set a deadline, and then nothing ever happened again. The lifecycle we documented did not exist.

Two things made it unfixable by just adding a caller:

  • read_only had no lever. The hub had no read-only mode, so the gentlest, most user-respecting rung was the one step we could not take.
  • We had no voice. No mail transport existed anywhere in the repo. Stripe emails about the money; only we can say what happened to someone's hub.

What landed

writesEnabled on PlanEntitlements The read-only lever. Major bump — signed wire contract.
Hub enforcement Hono middleware (507 billing_read_only) and NodeRelayService
reconcile/billing-driver.ts Executes decided actions; the missing caller
billing/notify.ts Five lifecycle emails, first mail transport in the repo
stageExportBundle Dated retention hold before any deletion (Charter §6)
BackupHealth Replaces Boolean(env.R2_BUCKET)
deploy-cloud.yml Production job, manual dispatch only, live-mode secrets

Decisions worth reviewing

Read-only is gated in two places. The change log arrives over the sync socket, so an HTTP-only guard would have stopped nothing — it would have looked like a feature and been theatre.

Absent writesEnabled means enabled. verifyEntitlements normalizes a missing field to true. Treating it as false would brick every hub in the fleet the moment this ships. Only an explicit false blocks writes. A self-hosted hub has no HUB_PLAN and is structurally unreachable by this — asserted by test, not by inspection.

A failed notice means the step is not applied. pending_deletion notifies before recording, because a final notice nobody received is worse than a clock that never started. A tenant with no resolvable email address makes the notifier throw rather than skip: we do not degrade an account we cannot warn.

delete ships off behind XNET_CLOUD_DUNNING_DELETE_ENABLED, and the control plane refuses to boot with it armed and no mail transport. The funnel runs to pending_deletion and stops, which is safe to sit in indefinitely.

stageExportBundle was built differently than the exploration planned. It cannot produce a .xnetpack: the hub's export needs a UCAN signed by the user's data DID and we hold no such key. Anything that could build a readable bundle would mean we could read their data. It records a dated retention hold on the encrypted replica instead, and the final-notice email tells the user to export from a device they already have. The doc has been corrected to say so.

reconcileInputFor exists because of a real trap. DunningState names the field state; BillingReconcileInput names it billingState. The obvious { ...tenant.billing, nowMs } spread compiles, leaves billingState undefined, and makes every healthy tenant look eligible for reactivate on every tick. A test caught it; routing every caller through one function means nobody finds it twice.

Also

The orphan audit called every running restore drill an orphan — a nightly false positive, which is how a check stops being read. Drill hubs now get their own bucket. drillSampleSize scales with the fleet instead of provisioning 20 throwaway Cloud Run services a night regardless of size.

The pricing FAQ now says what the backups actually are: continuous asynchronous replication, seconds not zero, with roughly the last second at risk on abrupt machine loss. That is a weaker claim than before and a true one.

Verification

  • pnpm typecheck — clean
  • pnpm lint — 0 errors
  • pnpm build — 57/57
  • pnpm test — 11,887 passing

Two unrelated failures in the full run (packages/history/src/scrub-perf.test.ts, apps/web/src/boot/demo-seed.test.ts). I confirmed the demo-seed one fails identically on a clean origin/main worktree; the perf test passes in isolation. Neither file nor its dependencies are in this diff.

Not in this PR

Everything left in 0418 needs a credential, a DNS record, or a provider dashboard — tagged [operator] in the checklist. The Phase 2/3 UI overhaul is deliberately sequenced after the first paying users, per the exploration's own recommendation: the first five users are people you talk to directly, and their feedback beats weeks of redesign against zero usage data.

🤖 Generated with Claude Code

xNet Test added 6 commits August 1, 2026 12:28
…illing, dunning and UI

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Signed-off-by: xNet Test <test@xnet.dev>
Adds `writesEnabled` to PlanEntitlements and enforces it in the hub, so the
non-payment lifecycle's gentlest step (grace lapsed -> read-only) has something
to actually pull. Reads, exports and the billing routes keep working.

Two invariants, both tested:
- Absent means enabled. verifyEntitlements normalizes a missing field to true so
  tokens signed before this existed keep working; only an explicit false blocks.
- Self-host never sees false. No HUB_PLAN means the field is never consulted.

Gated in two places because HTTP alone would be theatre: a Hono middleware for
mutating routes, and NodeRelayService for the sync socket, which is where the
change log — the primary write path — actually arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Signed-off-by: xNet Test <test@xnet.dev>
The timer half of the non-payment state machine had no caller: reconcileBilling
was written, tested and inert, so a failed payment opened grace and nothing ever
closed it. This connects it.

- reconcile/billing-driver.ts executes decided actions against the control plane.
  Every transition notifies; a failed notice means the step is NOT applied, so
  the tenant stays put and the next tick retries. pending_deletion notifies
  BEFORE recording, because a final notice nobody received is worse than a clock
  that never started.
- ControlPlane gains setBillingState, setWritesEnabled (live setEnv flip, no data
  movement) and reactivateTenant, which restores writes before waking a cold hub
  so it does not boot read-only.
- billing/notify.ts adds the first mail transport in the repo. A tenant with no
  resolvable address THROWS rather than skipping — we do not degrade an account
  we cannot warn. Grace stays deliberately silent; Stripe already emailed.
- stageExportBundle records a dated retention hold on the encrypted R2 replica.
  It cannot build a .xnetpack: the hub's export needs a UCAN signed by the user's
  DID and we hold no such key, which is the property worth keeping.
- delete ships behind XNET_CLOUD_DUNNING_DELETE_ENABLED, and the control plane
  refuses to boot with it armed and no mail transport configured.

reconcileInputFor exists because DunningState says  while
BillingReconcileInput says ; the obvious spread compiles and makes
every healthy tenant look eligible for reactivate. A test caught it once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Signed-off-by: xNet Test <test@xnet.dev>
backupsConfigured was Boolean(env.R2_BUCKET) — a bucket name reported as if it
were a working backup. BackupHealth keeps off/unproven/healthy/failing distinct,
derived from the last restore drill, and unproven maps to null on the public
status page rather than true: a bucket nobody has restored from is not a promise
we have earned.

Also:
- drillSampleSize scales the nightly sample with the fleet instead of a constant
  20, which drilled a 3-tenant fleet repeatedly and a 500-tenant fleet at 4%,
  provisioning 20 throwaway Cloud Run services either way.
- findOrphans reports drill hubs in their own bucket. They never have a tenant
  record, so the audit called every running drill an orphan — a nightly false
  positive, which is how a check stops being read.

Signed-off-by: xNet Test <test@xnet.dev>
… banner

- deploy-cloud.yml gains a production job: manual dispatch only (no push
  trigger), its own CLOUD_PROD_DEPLOY_ENABLED variable and reviewer-protected
  environment, and DISTINCT Stripe live secret names — sharing a name with
  staging is how test keys reach production or live keys charge a real card
  during a test run. It refuses to deploy without resend-api-key present, since
  production emails a tenant before degrading or deleting their data.
- The pricing FAQ now says what the backups actually are: continuous async
  replication, seconds not zero, with the last second at risk on abrupt loss —
  plus what a failed payment does, step by step with the real windows.
- privacy.astro documents lifecycle email and says plainly that you cannot
  unsubscribe from it, because we will not change your data on a schedule you
  were never told about. terms.astro states the grace/read-only/pause/delete
  sequence.
- hub-error-banner.ts maps write rejections to real copy. 'Sync error' reads as
  'something is broken and your data is at risk' when the truth is 'a card
  expired'. Unknown codes return null rather than inventing reassurance.

Signed-off-by: xNet Test <test@xnet.dev>
All Phase 1 CODE is done. Remaining Phase 1 items need a credential, a DNS
record or a provider dashboard and are tagged [operator] / [needs live env];
Phase 2 and 3 (the UI overhaul) are deliberately sequenced after the first
paying users, per the exploration's own recommendation.

Also corrects two things the implementation proved wrong about the doc:
- the example reconcile job used the inline spread that silently breaks
  billingState; it now shows reconcileInputFor, as shipped.
- stageExportBundle cannot produce a .xnetpack from the control plane, and the
  checklist now says why rather than claiming it does.

Signed-off-by: xNet Test <test@xnet.dev>
@crs48
crs48 temporarily deployed to pr-673 August 1, 2026 20:14 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@crs48, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7823a673-4500-44ab-82c0-8bad37716707

📥 Commits

Reviewing files that changed from the base of the PR and between 3bfe0db and ee0d3b9.

📒 Files selected for processing (32)
  • .changeset/billing-read-only-0418.md
  • .github/workflows/deploy-cloud.yml
  • apps/cloud/src/backup/schedule.test.ts
  • apps/cloud/src/backup/schedule.ts
  • apps/cloud/src/billing/notify.test.ts
  • apps/cloud/src/billing/notify.ts
  • apps/cloud/src/control-plane.ts
  • apps/cloud/src/index.ts
  • apps/cloud/src/provisioner/orphan-audit.test.ts
  • apps/cloud/src/provisioner/orphan-audit.ts
  • apps/cloud/src/reconcile/billing-driver.test.ts
  • apps/cloud/src/reconcile/billing-driver.ts
  • apps/cloud/src/server.ts
  • apps/web/src/lib/hub-error-banner.test.ts
  • apps/web/src/lib/hub-error-banner.ts
  • docs/explorations/0418_[-]_XNET_CLOUD_TO_PRODUCTION_BACKUPS_BILLING_DUNNING_AND_ONE_UI.md
  • packages/entitlements/src/entitlements.test.ts
  • packages/entitlements/src/entitlements.ts
  • packages/entitlements/src/plans.ts
  • packages/hub/src/config.plan.test.ts
  • packages/hub/src/config.ts
  • packages/hub/src/server.ts
  • packages/hub/src/services/node-relay.ts
  • packages/hub/src/services/read-only.test.ts
  • packages/hub/src/services/read-only.ts
  • packages/hub/src/types.ts
  • scripts/cloud-env-schema.mjs
  • site/public/llms-full.txt
  • site/src/data/changelog/2026-08-01-a-failed-payment-no-longer-silently-stal.json
  • site/src/data/pricing.ts
  • site/src/pages/privacy.astro
  • site/src/pages/terms.astro

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

🖼️ UI changes in this PR

No UI changes detected in this PR.

github-actions Bot added a commit that referenced this pull request Aug 1, 2026
github-actions Bot added a commit that referenced this pull request Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Preview removed for PR #673.

@crs48
crs48 merged commit 6d38460 into main Aug 1, 2026
23 checks passed
@crs48
crs48 deleted the claude/0418-xnet-cloud-to-production-backups-billing-dunning branch August 1, 2026 20:23
github-actions Bot added a commit that referenced this pull request Aug 1, 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