Skip to content

feat(billing): product-side quota enforcement (14.7)#102

Merged
th3-br41n merged 1 commit into
mainfrom
feat/14.7-quota-enforcement
Jul 4, 2026
Merged

feat(billing): product-side quota enforcement (14.7)#102
th3-br41n merged 1 commit into
mainfrom
feat/14.7-quota-enforcement

Conversation

@th3-br41n

@th3-br41n th3-br41n commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Product-side quota enforcement for iteration 14.7 — the shell now reacts to quota state (billing-edge landed the metering/quota backend earlier; the sync node meters asset PUT/GET). Fail-open for reads, fail-closed for paid writes, fully inert in free-local mode.

Quota model

  • shared/quota-types.ts (renderer-safe): QuotaResource in lockstep with billing-edge MeterKind wire strings (storage.bytes / sync.egress.bytes), planQuotaLimitBytes mirroring quota_for (free 0/0 · plus 20 GiB/10 GiB · pro 200 GiB/100 GiB · team 1 TiB/1 TiB · enterprise unmetered), quotaVerdict mirroring quota_status (over = strictly used > limit).
  • main/billing/quota-service.ts: QuotaService — verdict state + the upload gate + the pause signal.

Usage source (documented decision)

Local ingest-side estimate only — the sum of bound asset bytes in the vault's encrypted asset store. billing-edge exposes no usage-read endpoint (/v1/account/summary carries no usage; the only {used, limit, over} surface is the write path /v1/usage/ingest, which is service-token-authed — hosted infra only, the desktop client cannot call it). Egress used is therefore unknown product-side and surfaced ceiling-only; the hosted node's metering stays authoritative.

Enforcement points (uploads only — reads never gated)

  • uploadBoundAssetIfPending (shared by the Asset-B4 connect-time drain and the immediate per-bind push): optional uploadQuota dep, checked after readAsset, before any DEK recovery. Blocked ⇒ new distinct outcome UploadBoundOutcome.QuotaExceeded (never fails the local write/bind — bytes just don't leave the device; the next drain retries).
  • Gate arms only when: account linked and plan metered and the live relay completed the SYNC-4b gated-admission handshake (WebSocketRelayPort.gatedAdmission(), new). Open/self-hosted nodes are never quota-gated. Metered+gated with unreadable usage ⇒ skip (fail-closed for paid writes).
  • Logging: the drain logs one tally line per pass (now with N quota-skipped); the per-bind path logs one throttled warn per 5 minutes.
  • Serve-on-miss / restore / downloads are untouched — no read path takes a dependency on QuotaService.

Surfacing

  • Settings → Billing: BillingOverviewView.quota (QuotaStateView) → "Hosted storage used: X of Y" row, an over-quota role="alert" warning line, and a "Hosted sync egress: up to Y per month" row. Inert/unlinked shape renders no quota rows.
  • Sync status: SyncStatusSnapshot.attachmentSyncPausedReason (new enum AttachmentSyncPauseReason.StorageQuota) rides the existing sync-status:snapshot broadcast; QuotaService.onChange → SyncStatusStore.notifyQuotaChanged() re-emits immediately (state-edge, bypasses the 500 ms coalesce). The sync-status popover and the Settings → Sync live-status group show "Attachment sync paused: storage quota reached."

Free-local / no-account mode (test-pinned)

No linked account (or unmetered plan, or entitlement read failure) ⇒ UploadQuotaDecision.Allowed without even reading usage; state() returns the inert shape (no ceilings, never over).

Tests

  • shared/quota-types.test.ts — ceiling table pinned to billing-edge numbers, wire values, strict-over verdict math (+9)
  • main/billing/quota-service.test.ts — inert-when-unlinked/unmetered/ungated, would-exceed math, fail-closed on unknown usage, usage-cache TTL, pause-signal flips + reset (+15)
  • assets/upload-on-bind.test.ts — QuotaExceeded outcome (no DEK recovery, no puts, no manifest), UsageUnknown skip, inert-when-absent, drain tally (+3, drain case extended)
  • sync/sync-status-store.test.ts — snapshot field pass-through/default/throw-safe, notifyQuotaChanged emit + disposed no-op (+3)
  • sync/websocket-relay-port.test.tsgatedAdmission lifecycle (false → auth-ok true → drop false) (+1)
  • billing-account.test.ts — overview carries quota; fail-soft null on throw (+1)
  • billing-section.test.tsx — used-vs-ceiling + egress rows, over-quota alert, inert/unavailable render nothing (+4)
  • sync-status-popover.test.tsx — paused line shown/hidden (+1)

bun run typecheck (packages + apps), bun run lint, bun run verify, and the affected suites (121 files / 1106 tests across main/sync, main/assets, main/billing, main/ipc, renderer/settings, renderer/dashboard, shared) all green.

Security notes

  • No new IPC channels; extended payloads are renderer-safe data only (verdicts, an enum reason). The refresh credential custody is unchanged.
  • gatedAdmission() is a boolean on the relay port — no crypto/credential imports touch the relay-blind fence.
  • Fail-closed only applies to paid writes; a quota outage can never brick local-first writes or reads.

🤖 Generated with Claude Code

Shell-side reaction to quota state, completing the 14.7 residue (the
billing-edge metering/quota backend and the sync node's asset PUT/GET
metering already landed). Fail-open for reads, fail-closed for paid
writes, fully inert in free-local mode.

- shared/quota-types.ts: QuotaResource in lockstep with billing-edge
  MeterKind wire strings; planQuotaLimitBytes mirroring quota_for
  (free 0 / plus 20 GiB / pro 200 GiB / team 1 TiB / enterprise
  unmetered for storage; 0/10/100 GiB/1 TiB for egress); quotaVerdict
  mirroring quota_status (over = strictly used > limit).
- main/billing/quota-service.ts: QuotaService — panel state + the
  upload gate + the attachment-sync pause signal. Usage source is the
  LOCAL ingest-side estimate (bound asset bytes): billing-edge exposes
  no usage-read endpoint and /v1/usage/ingest is service-token-authed
  (hosted infra only). Egress is surfaced ceiling-only, never enforced
  (enforcing it would gate reads).
- upload-on-bind: optional uploadQuota dep shared by the connect-time
  drain and the immediate per-bind push; blocked => new distinct
  UploadBoundOutcome.QuotaExceeded before any DEK recovery; drain
  tally gains quotaExceeded; the local write/bind never fails.
- The gate arms only against an entitlement-gated relay:
  WebSocketRelayPort.gatedAdmission() (auth-ok observed on the live
  socket). Open/self-hosted nodes are never quota-gated. Metered+gated
  with unreadable usage => skip (fail-closed for paid writes).
- Surfacing: BillingOverviewView.quota drives used-vs-ceiling rows +
  an over-quota alert in Settings -> Billing; SyncStatusSnapshot
  gains attachmentSyncPausedReason (QuotaService.onChange ->
  SyncStatusStore.notifyQuotaChanged() state-edge re-emit) shown in
  the sync-status popover and Settings -> Sync.
- Logging: one drain tally line per pass + a per-bind warn throttled
  to once per 5 minutes.
- Also formats tests/visual/specs/chat-rich-composer.spec.ts, which
  landed unformatted on main and failed the lint baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@th3-br41n th3-br41n merged commit d4bfa63 into main Jul 4, 2026
2 of 3 checks passed
@th3-br41n th3-br41n deleted the feat/14.7-quota-enforcement branch July 4, 2026 00:25
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