Skip to content

feat(entitlements): add UserMeter storage-byte shadow - #1118

Merged
kody-bot merged 19 commits into
mainfrom
cursor/meter-do-38c8
Aug 1, 2026
Merged

kody-bot merged 19 commits into
mainfrom
cursor/meter-do-38c8

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Aug 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • add schema-v4 UserMeter storage-byte shadow and future-cutover RPCs
  • keep D1 as the sole storage-byte read, enforcement, and reconciliation authority
  • best-effort shadow successful non-email D1 reserves into UserMeter
  • expose the optional non-authoritative shadow in account export, first page only
  • make conditional D1 reserve contention retry bounded and fail closed
  • preserve StorageRunner composed bucket checks and current cron/mailbox ownership boundaries

Validation

Deployment notes

This is an additive expand deployment, not the authority flip. D1 remains the sole storage-byte authority. A later contract PR may flip authority only after mailbox-do and cron-restructure pass env into their owned paths.

System recap — extends User meter (medium risk)

Mode: recap · Base: main @ 59c65750 · Head: 3c13b53d

Classification: extends — adds a non-authoritative storage-byte shadow and cutover RPCs without changing production enforcement authority.

Primitives touched

Primitive Group Impact
user-meter Storage & infrastructure extends — schema-v4 storage shadow and export
entitlements Identity & auth composes — D1 reserve remains authoritative
account-export User-facing assistant primitives extends — additive first-page storageBytesShadow

System map

D1 remains the storage-byte contract; selected non-email writes asynchronously shadow its latest value into UserMeter.

Legend: green = composes (wiring only) · amber = extended by this PR · red = new primitive · gray = context (unchanged, included only when an edge crosses it).

flowchart LR
	entitlements["entitlements<br/>Plans & entitlements"]:::touched
	d1["d1-app-db<br/>D1 app database"]:::untouched
	meter["user-meter<br/>User meter"]:::extended
	export["account-export<br/>Account data export"]:::extended
	entitlements -->|"authoritative bounded-retry storage reserve"| d1
	entitlements -->|"best-effort latest-value shadow"| meter
	export -->|"optional first-page storageBytesShadow"| meter
	classDef touched fill:#1a7f37,color:#fff
	classDef extended fill:#9a6700,color:#fff
	classDef added fill:#cf222e,color:#fff
	classDef untouched fill:#57606a,color:#fff
Loading

Invariants

  • D1 remains the sole storage-byte enforcement and usage authority
  • real-user contention never succeeds without reserving
  • UserMeter shadow cannot fail writes
  • email and scheduled-handler lane wiring are untouched

Conductor report

  • STATUS: done
  • What shipped: UserMeter schema-v4 storage-byte shadow/cutover support, additive export, and fail-closed D1 reservation retries while retaining D1 authority.
  • Risk: medium — additive derived state and export shape only; authority flip remains high risk.
  • Merged/deployed: yes / yes; PR #1118, post-merge validation, and production deploy completed successfully.
  • Sibling-track spill: none. email/** and index.ts were untouched.
  • Dependencies: future storage authority flip requires mailbox-do and cron-restructure handoffs. Next independent roadmap: slice 4 service liveness, then slice 5 deletion fencing.
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Added storage-byte usage tracking and reservation support.
    • Account exports now include available storage-byte details.
    • Storage checks combine authoritative usage with relevant storage estimates.
  • Bug Fixes

    • Reconciliation now keeps storage usage accurate while tolerating best-effort synchronization failures.
    • Deferred updates prevent storage writes from being delayed unnecessarily.
  • Documentation

    • Expanded architecture and entitlement guidance for storage-byte tracking, reconciliation, exports, retention, and enforcement.

cursoragent and others added 18 commits July 31, 2026 22:46
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
# Conflicts:
#	packages/worker/src/email/inbound.ts
#	packages/worker/src/email/outbound.ts
#	packages/worker/worker-configuration.d.ts

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
# Conflicts:
#	docs/contributing/architecture/data-storage.md
#	packages/worker/src/account/export.node.test.ts
#	packages/worker/src/account/export.ts

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

UserMeter schema v4 adds an optional, revisioned D1 storage-byte shadow. D1 remains authoritative. Entitlement flows update the shadow asynchronously, account exports include it, and documentation defines storage, reconciliation, export, and purge behavior.

Changes

D1 Storage Shadowing

Layer / File(s) Summary
UserMeter storage-byte state
packages/worker/src/entitlements/user-meter-do.ts, packages/worker/src/test-support/user-meter.ts, packages/worker/src/entitlements/user-meter.workers.test.ts
Adds schema-v4 storage-byte state with initialization, reads, reservations, absolute updates, revision tracking, normalization, export, purge, and test-support coverage.
D1-authoritative entitlement integration
packages/worker/src/entitlements/service.ts, packages/worker/src/entitlements/d1-storage-reconciliation.ts, packages/worker/src/app/account-usage-data.node.test.ts, packages/worker/src/storage-runner.entitlement.node.test.ts, packages/worker/src/storage-runner.workers.test.ts, packages/worker/src/entitlements/d1-storage-reconciliation.workers.test.ts
Keeps D1 authoritative for storage usage and reconciliation. Successful reservations and reconciliations perform best-effort asynchronous shadow updates.
Entitlement caller propagation
packages/worker/src/mcp/memory/service.ts, packages/worker/src/mcp/secrets/service.ts, packages/worker/src/mcp/values/service.ts, packages/worker/src/mcp/values/service.node.test.ts, packages/worker/src/package-registry/service.ts, packages/worker/src/package-registry/service.node.test.ts
Passes UserMeterEnv and optional waitUntil callbacks through memory, secret, value, and package operations.
Export and account surfaces
packages/worker/src/account/export.ts, packages/worker/src/account/export.node.test.ts, packages/worker/src/account/user-owned-surfaces.ts
Includes storageBytesShadow in export results, pagination, manifest counts, and surface descriptions.
Architecture contract documentation
docs/contributing/architecture/data-storage.md, docs/contributing/architecture/entitlements.md, docs/contributing/architecture/primitives.yaml
Documents D1 authority, UserMeter shadow semantics, schema-v4 storage, reconciliation, export, purge, and StorageRunner estimate handling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StorageCaller
  participant EntitlementService
  participant D1
  participant UserMeter
  StorageCaller->>EntitlementService: validate storage entitlement
  EntitlementService->>D1: reserve or read authoritative bytes
  D1-->>EntitlementService: updated byte total
  EntitlementService-->>StorageCaller: return entitlement result
  EntitlementService->>UserMeter: asynchronously mirror byte total
  UserMeter-->>EntitlementService: persist shadow state
Loading

Possibly related PRs

  • kentcdodds/kody#1115: Adds the daily-quota UserMeter infrastructure extended by this storage-byte shadow.
  • kentcdodds/kody#1064: Modifies storage-byte accounting in the same entitlement service.
  • kentcdodds/kody#1003: Modifies the atomic secret-write flow extended with metering environment propagation.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a UserMeter storage-byte shadow for entitlements.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/meter-do-38c8

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.

@kody-bot
kody-bot marked this pull request as ready for review August 1, 2026 07:04
@cursor

cursor Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai review

@github-actions

github-actions Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-1118.kody-a99.workers.dev

Worker: kody-pr-1118
D1: kody-pr-1118-db
KV: kody-pr-1118-oauth-kv

Mocks:

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/worker/src/entitlements/d1-storage-reconciliation.workers.test.ts (1)

178-189: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the throwing UserMeter double into test-support/user-meter.ts.

This failingMeterEnv stub is duplicated in packages/worker/src/entitlements/user-meter.workers.test.ts at lines 780-791 and, per the graph context, in packages/worker/src/account/export.node.test.ts at lines 181-187. All three copies bind idFromName from the real namespace and return a setStorageBytes that throws.

packages/worker/src/test-support/user-meter.ts already hosts createWaitUntilDrain and withPatchedDbPrepare. A createFailingUserMeterEnv(env) helper there would keep the failure contract in one place.

♻️ Proposed helper

Add to packages/worker/src/test-support/user-meter.ts:

export function createFailingUserMeterEnv<T>(env: T & {
	USER_METER: DurableObjectNamespace
}): T {
	return {
		...env,
		USER_METER: {
			idFromName: env.USER_METER.idFromName.bind(env.USER_METER),
			get() {
				return {
					async setStorageBytes() {
						throw new Error('shadow write failed')
					},
				}
			},
		},
	} as unknown as T
}

Then in this file:

 	consoleWarn.mockImplementation(() => {})
-	const failingMeterEnv = {
-		USER_METER: {
-			idFromName: env.USER_METER.idFromName.bind(env.USER_METER),
-			get() {
-				return {
-					async setStorageBytes() {
-						throw new Error('shadow write failed')
-					},
-				}
-			},
-		},
-	} as unknown as typeof env
+	const failingMeterEnv = createFailingUserMeterEnv(env)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/entitlements/d1-storage-reconciliation.workers.test.ts`
around lines 178 - 189, Extract the duplicated failing USER_METER stub into a
createFailingUserMeterEnv helper in test-support/user-meter.ts, preserving the
bound idFromName behavior and shadow write failed error. Replace the local
failingMeterEnv construction in the reconciliation test and the equivalent
copies in the other referenced tests with this shared helper.
packages/worker/src/entitlements/service.ts (1)

765-777: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider gating the shadow on updated.

The shadow runs even when result.meta.changes is 0. If the users row was deleted between listUsersForD1StorageReconciliation and this update, readUserD1StorageBytes returns 0 and the shadow writes a zero-byte state into that user's UserMeter. That recreates per-user shadow state for a user who no longer has a D1 row.

Skipping the shadow when updated is false keeps the shadow strictly derived from an existing authoritative row.

♻️ Proposed change
 	const updated = (result.meta.changes ?? 0) > 0
-	if (input.env) {
+	if (input.env && updated) {
 		try {
 			await shadowUserMeterStorageBytesFromD1({
 				db: input.db,
 				env: input.env,
 				userId: input.userId,
 			})
 		} catch (error) {
 			console.warn('entitlement-storage-bytes-shadow-failed', error)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/entitlements/service.ts` around lines 765 - 777, Gate the
shadowUserMeterStorageBytesFromD1 call in the surrounding update flow on both
input.env and updated, so it runs only after a row change was applied. Preserve
the existing error handling and return behavior, while avoiding shadow writes
when result.meta.changes is zero.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/worker/src/entitlements/user-meter-do.ts`:
- Around line 841-852: Update exportCounters so storageBytesShadow is populated
only when input.startAfter is absent, returning null on subsequent user-meter
pages. Preserve the existing storage row mapping for the first page and leave
the counters and other page data unchanged.

In `@packages/worker/src/entitlements/user-meter.workers.test.ts`:
- Around line 825-843: Update the concurrent entitlement test around
assertWithinStorageBytesEntitlement to avoid exact reserved and denied counts,
since fallback reads can succeed during concurrent commits. Assert bounds that
reflect the expected range instead, and retain the exact final-total assertion
from readUserD1StorageBytes as the authoritative invariant.

---

Nitpick comments:
In `@packages/worker/src/entitlements/d1-storage-reconciliation.workers.test.ts`:
- Around line 178-189: Extract the duplicated failing USER_METER stub into a
createFailingUserMeterEnv helper in test-support/user-meter.ts, preserving the
bound idFromName behavior and shadow write failed error. Replace the local
failingMeterEnv construction in the reconciliation test and the equivalent
copies in the other referenced tests with this shared helper.

In `@packages/worker/src/entitlements/service.ts`:
- Around line 765-777: Gate the shadowUserMeterStorageBytesFromD1 call in the
surrounding update flow on both input.env and updated, so it runs only after a
row change was applied. Preserve the existing error handling and return
behavior, while avoiding shadow writes when result.meta.changes is zero.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8d0c950e-c36f-47e2-922d-433e9c52bbd6

📥 Commits

Reviewing files that changed from the base of the PR and between 59c6575 and eaaa509.

📒 Files selected for processing (21)
  • docs/contributing/architecture/data-storage.md
  • docs/contributing/architecture/entitlements.md
  • docs/contributing/architecture/primitives.yaml
  • packages/worker/src/account/export.node.test.ts
  • packages/worker/src/account/export.ts
  • packages/worker/src/account/user-owned-surfaces.ts
  • packages/worker/src/app/account-usage-data.node.test.ts
  • packages/worker/src/entitlements/d1-storage-reconciliation.ts
  • packages/worker/src/entitlements/d1-storage-reconciliation.workers.test.ts
  • packages/worker/src/entitlements/service.ts
  • packages/worker/src/entitlements/user-meter-do.ts
  • packages/worker/src/entitlements/user-meter.workers.test.ts
  • packages/worker/src/mcp/memory/service.ts
  • packages/worker/src/mcp/secrets/service.ts
  • packages/worker/src/mcp/values/service.node.test.ts
  • packages/worker/src/mcp/values/service.ts
  • packages/worker/src/package-registry/service.node.test.ts
  • packages/worker/src/package-registry/service.ts
  • packages/worker/src/storage-runner.entitlement.node.test.ts
  • packages/worker/src/storage-runner.workers.test.ts
  • packages/worker/src/test-support/user-meter.ts

Comment thread packages/worker/src/entitlements/user-meter-do.ts Outdated
Comment on lines +825 to +843
const attempts = await Promise.all(
Array.from({ length: 20 }, () =>
assertWithinStorageBytesEntitlement({
db: env.APP_DB,
userId: user.userId,
email: user.email,
requested: 5,
}).then(
() => 'reserved' as const,
(error: unknown) => error,
),
),
)
const reserved = attempts.filter((result) => result === 'reserved')
const denied = attempts.filter(
(result) => result instanceof EntitlementLimitError,
)
expect(reserved).toHaveLength(2)
expect(denied).toHaveLength(18)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The exact reserved count can be flaky.

A denied attempt does not fail immediately. It falls through to the fallback read in assertWithinStorageBytesEntitlement (service.ts lines 1044-1051) and returns without an error when current + requested <= limit.

With 20 concurrent attempts, an attempt whose conditional UPDATE lost can still read a pre-commit current of limit - 10 or limit - 5. That attempt returns successfully and is counted as reserved, so expect(reserved).toHaveLength(2) fails while D1 is still correct at limit.

The invariant the test protects is the final D1 total. Consider asserting reserved and denied as bounds and keeping the exact assertion on readUserD1StorageBytes.

💚 Proposed change to remove the timing dependency
 	const reserved = attempts.filter((result) => result === 'reserved')
 	const denied = attempts.filter(
 		(result) => result instanceof EntitlementLimitError,
 	)
-	expect(reserved).toHaveLength(2)
-	expect(denied).toHaveLength(18)
+	expect(reserved.length).toBeGreaterThanOrEqual(2)
+	expect(reserved.length + denied.length).toBe(20)
 	await expect(
 		readUserD1StorageBytes({ db: env.APP_DB, userId: user.userId }),
 	).resolves.toBe(limit)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const attempts = await Promise.all(
Array.from({ length: 20 }, () =>
assertWithinStorageBytesEntitlement({
db: env.APP_DB,
userId: user.userId,
email: user.email,
requested: 5,
}).then(
() => 'reserved' as const,
(error: unknown) => error,
),
),
)
const reserved = attempts.filter((result) => result === 'reserved')
const denied = attempts.filter(
(result) => result instanceof EntitlementLimitError,
)
expect(reserved).toHaveLength(2)
expect(denied).toHaveLength(18)
const attempts = await Promise.all(
Array.from({ length: 20 }, () =>
assertWithinStorageBytesEntitlement({
db: env.APP_DB,
userId: user.userId,
email: user.email,
requested: 5,
}).then(
() => 'reserved' as const,
(error: unknown) => error,
),
),
)
const reserved = attempts.filter((result) => result === 'reserved')
const denied = attempts.filter(
(result) => result instanceof EntitlementLimitError,
)
expect(reserved.length).toBeGreaterThanOrEqual(2)
expect(reserved.length + denied.length).toBe(20)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/entitlements/user-meter.workers.test.ts` around lines 825
- 843, Update the concurrent entitlement test around
assertWithinStorageBytesEntitlement to avoid exact reserved and denied counts,
since fallback reads can succeed during concurrent commits. Assert bounds that
reflect the expected range instead, and retain the exact final-total assertion
from readUserD1StorageBytes as the authoritative invariant.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kody-bot
kody-bot merged commit e3ab7b3 into main Aug 1, 2026
10 checks passed
@kody-bot
kody-bot deleted the cursor/meter-do-38c8 branch August 1, 2026 07:24
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.

3 participants