feat(billing): product-side quota enforcement (14.7)#102
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):QuotaResourcein lockstep with billing-edgeMeterKindwire strings (storage.bytes/sync.egress.bytes),planQuotaLimitBytesmirroringquota_for(free 0/0 · plus 20 GiB/10 GiB · pro 200 GiB/100 GiB · team 1 TiB/1 TiB · enterprise unmetered),quotaVerdictmirroringquota_status(over= strictlyused > 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/summarycarries 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). Egressusedis 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): optionaluploadQuotadep, checked afterreadAsset, before any DEK recovery. Blocked ⇒ new distinct outcomeUploadBoundOutcome.QuotaExceeded(never fails the local write/bind — bytes just don't leave the device; the next drain retries).WebSocketRelayPort.gatedAdmission(), new). Open/self-hosted nodes are never quota-gated. Metered+gated with unreadable usage ⇒ skip (fail-closed for paid writes).N quota-skipped); the per-bind path logs one throttled warn per 5 minutes.QuotaService.Surfacing
BillingOverviewView.quota(QuotaStateView) → "Hosted storage used: X of Y" row, an over-quotarole="alert"warning line, and a "Hosted sync egress: up to Y per month" row. Inert/unlinked shape renders no quota rows.SyncStatusSnapshot.attachmentSyncPausedReason(new enumAttachmentSyncPauseReason.StorageQuota) rides the existingsync-status:snapshotbroadcast;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.Allowedwithout even reading usage;state()returns the inert shape (no ceilings, neverover).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,notifyQuotaChangedemit + disposed no-op (+3)sync/websocket-relay-port.test.ts—gatedAdmissionlifecycle (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
gatedAdmission()is a boolean on the relay port — no crypto/credential imports touch the relay-blind fence.🤖 Generated with Claude Code