Skip to content

feat(account): shadow deletion leases in UserMeter - #1123

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

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

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Aug 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • add schema-v6 deletion tombstone and write-lease shadows to UserMeter
  • preserve D1 as sole fence, acquire/release, list, repair, and drain authority
  • dual-write non-email lease lifecycle without changing public errors or ALS behavior
  • reconcile stale shadows from D1 on deletion mark
  • sanitize export and preserve tombstones across purge

Validation

Deployment notes

Phase A expand only. D1 remains authority. The high-risk authority flip remains a separate PR.

System recap — extends User meter deletion fencing (medium risk)

Mode: recap · Base: main @ e0325f56 · Head: 62f6078a

Classification: extends — adds non-authoritative deletion tombstone and lease shadows without changing D1 fencing authority.

System map

D1 remains the fence; successful non-email transitions shadow into UserMeter and deletion mark reconciles the shadow from D1.

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
	writers["app-ui<br/>Mutating boundaries"]:::touched
	meter["user-meter<br/>User meter"]:::extended
	d1["d1-app-db<br/>D1 app database"]:::untouched
	writers -->|"authoritative lease lifecycle"| d1
	writers -->|"ordered shadow"| meter
	d1 -->|"mark-time active-lease reconciliation"| 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 sole fence/list/repair/drain authority
  • external lease semantics remain intact
  • stale shadows are bounded by D1-backed replacement
  • export excludes raw token/holder
  • email files were untouched

Conductor report

  • STATUS: done
  • What shipped: schema-v6 deletion-fence/write-lease shadow, D1-backed stale-shadow reconciliation, sanitized export, and tombstone-preserving purge while retaining D1 authority.
  • Risk: medium — additive dual-write/export and purge behavior; authority flip remains high risk.
  • Merged/deployed: yes / yes; PR #1123, latest-main validation, and production deploy succeeded.
  • Sibling-track spill: no email edits; latest deploy also includes mailbox-do dual-write feat(email): add Mailbox dual-write primitives #1122.
  • Dependencies: high-risk authority cutover is next; it must count D1+DO transition leases, retain D1 deleting_at, and preserve audit-first repair. It will stop green + ready-for-review without self-merge.
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Account deletion and write-lease state is now mirrored for improved visibility and recovery.
    • Account exports may include deletion status and lease information on the first page.
    • Deletion records remain protected during data purges, preventing accounts from being reopened inadvertently.
  • Bug Fixes

    • Failures in state mirroring no longer interrupt primary account operations.
    • Write-lease synchronization now preserves operation ordering and background completion behavior.
  • Documentation

    • Updated architecture and data-storage documentation for the new deletion and lease-state behavior.

cursoragent and others added 29 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>
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:
#	docs/contributing/architecture/data-storage.md

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

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 version 6 adds deletion-state and account-write-lease shadows. D1 remains authoritative while deletion and lease operations synchronize to UserMeter. Exports, purge behavior, call sites, tests, and architecture documentation now cover the shadows.

Changes

UserMeter deletion shadow

Layer / File(s) Summary
UserMeter v6 storage and RPCs
packages/worker/src/entitlements/user-meter-do.ts, packages/worker/src/entitlements/user-meter.workers.test.ts, packages/worker/src/test-support/user-meter.ts
UserMeter v6 adds deletion tombstones, write leases, pagination, bootstrap, purge preservation, export data, RPCs, and in-memory test support.
D1-to-UserMeter synchronization
packages/worker/src/account/deletion-state.ts, packages/worker/src/app/*, packages/worker/src/jobs/*, packages/worker/src/community/*, packages/worker/src/mcp*, packages/worker/src/package-registry/*, packages/worker/src/billing/*, packages/worker/src/account/deletion-state.node.test.ts
Deletion and lease operations schedule non-rejecting UserMeter shadows. Call sites pass environments and selected waitUntil callbacks. Tests cover failure isolation and completion timing.
Deletion shadow account exports
packages/worker/src/account/export.ts, packages/worker/src/account/export.node.test.ts
Exports count deletion-shadow entries and include deletionShadow only on the first UserMeter page.
Architecture and surface documentation
docs/contributing/architecture/*, packages/worker/src/account/user-owned-surfaces.ts, packages/worker/src/app/account-deletion.node.test.ts
Documentation and account-deletion fixtures describe D1 authority, shadow synchronization, purge behavior, export behavior, and the UserMeter test binding.

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

Sequence Diagram(s)

sequenceDiagram
  participant D1
  participant DeletionState
  participant UserMeter
  participant waitUntil
  D1->>DeletionState: complete deletion or lease operation
  DeletionState->>UserMeter: invoke shadow RPC
  DeletionState->>waitUntil: schedule promise when available
  UserMeter-->>DeletionState: complete or fail without changing D1 result
Loading

Possibly related PRs

  • kentcdodds/kody#1115: Extends the UserMeter implementation, export and purge behavior, and account-deletion integration.
  • kentcdodds/kody#1118: Extends the UserMeter schema, export paths, test stubs, and shadow-state architecture.
  • kentcdodds/kody#1119: Adds related UserMeter shadow state, first-page export behavior, purge handling, RPCs, and D1-authoritative synchronization.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.12% 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 deletion-lease shadowing to UserMeter.
✨ 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.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kody-bot
kody-bot marked this pull request as ready for review August 1, 2026 09:38
@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-1123.kody-a99.workers.dev

Worker: kody-pr-1123
D1: kody-pr-1123-db
KV: kody-pr-1123-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/user-meter-do.ts (1)

1400-1418: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Derive created from rowsWritten instead of value equality.

created is currently true whenever the stored timestamp equals the input timestamp. If a row already exists with the same deletingAt value, this reports created: true although no row was written. bootstrapDeletionState maps this flag to deletingAtApplied, so the bootstrap report can be misleading.

♻️ Proposed change
-		this.ctx.storage.sql.exec(
+		const cursor = this.ctx.storage.sql.exec(
 			`INSERT INTO deletion_state (id, deleting_at)
 			VALUES (?, ?)
 			ON CONFLICT(id) DO NOTHING`,
 			deletionStateRowId,
 			deletingAt,
 		)
-		const stored = this.readDeletingAt() ?? deletingAt
-		return { deletingAt: stored, created: stored === deletingAt }
+		const stored = this.readDeletingAt() ?? deletingAt
+		return { deletingAt: stored, created: cursor.rowsWritten > 0 }
🤖 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-do.ts` around lines 1400 - 1418,
Update shadowMarkDeleting to derive created from the INSERT statement’s
rowsWritten result rather than comparing stored and input timestamps. Preserve
the existing first-write-wins behavior and return the stored timestamp, while
ensuring created is false when the conflict leaves an existing row unchanged.
packages/worker/src/jobs/job-schedule-watchdog.ts (1)

191-204: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Pass waitUntil into watchdog lease repair instead of awaiting it per stuck row.

withAccountWriteLease awaits the shadow write/release promises when waitUntil is omitted. This loop can include many stuck jobs, so each repair adds two inline lease round trips before proceeding. Thread ctx.waitUntil from the scheduled path through runJobScheduleWatchdogTick and include it in the lease write() call so shadow work detaches from the loop.

🤖 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/jobs/job-schedule-watchdog.ts` around lines 191 - 204,
Thread ctx.waitUntil from the scheduled entry point through
runJobScheduleWatchdogTick, then pass it to withAccountWriteLease in the
stuck-job repair block around advanceStuckSkippedJobNextRunAt. Ensure lease
shadow write and release work is scheduled through waitUntil rather than awaited
inline for each repaired row.
🤖 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 `@docs/contributing/architecture/data-storage.md`:
- Around line 569-585: The documentation must no longer describe
account_write_leases as unbounded. Update the lease-shadow lifecycle around
shadowReleaseWriteLease and the documented release/repair paths to add an
automated cleanup boundary for stale unreleased or orphaned rows, using
D1-backed cleanup or bounded retention, while preserving active lease rows and
existing fencing behavior.

In `@docs/contributing/architecture/entitlements.md`:
- Around line 316-320: Update UserMeter.exportCounters and the account export
path so UserMeterDeletionShadow.writeLeases excludes the raw
account_write_leases token and holder fields; retain only non-sensitive lease
information such as acquired_at, or introduce a separate sanitized export shape
when identifiers are required.

---

Nitpick comments:
In `@packages/worker/src/entitlements/user-meter-do.ts`:
- Around line 1400-1418: Update shadowMarkDeleting to derive created from the
INSERT statement’s rowsWritten result rather than comparing stored and input
timestamps. Preserve the existing first-write-wins behavior and return the
stored timestamp, while ensuring created is false when the conflict leaves an
existing row unchanged.

In `@packages/worker/src/jobs/job-schedule-watchdog.ts`:
- Around line 191-204: Thread ctx.waitUntil from the scheduled entry point
through runJobScheduleWatchdogTick, then pass it to withAccountWriteLease in the
stuck-job repair block around advanceStuckSkippedJobNextRunAt. Ensure lease
shadow write and release work is scheduled through waitUntil rather than awaited
inline for each repaired row.
🪄 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: c94c5c13-be67-4dd7-b97e-331a98f13a58

📥 Commits

Reviewing files that changed from the base of the PR and between e0325f5 and c566c54.

📒 Files selected for processing (24)
  • docs/contributing/architecture/data-storage.md
  • docs/contributing/architecture/entitlements.md
  • docs/contributing/architecture/primitives.yaml
  • packages/worker/src/account/deletion-state.node.test.ts
  • packages/worker/src/account/deletion-state.ts
  • 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-deletion.node.test.ts
  • packages/worker/src/app/account-deletion.ts
  • packages/worker/src/app/account-write-lease-middleware.ts
  • packages/worker/src/billing/stripe-plan-refresh-client.ts
  • packages/worker/src/billing/stripe-plan-refresh-do.ts
  • packages/worker/src/community/avatar.ts
  • packages/worker/src/community/community-icon.ts
  • packages/worker/src/entitlements/user-meter-do.ts
  • packages/worker/src/entitlements/user-meter.workers.test.ts
  • packages/worker/src/jobs/job-schedule-watchdog.ts
  • packages/worker/src/jobs/service.ts
  • packages/worker/src/mcp-auth.ts
  • packages/worker/src/mcp/capabilities/admin/admin-account-write-lease-repair.ts
  • packages/worker/src/mcp/memory/service.ts
  • packages/worker/src/package-registry/service.ts
  • packages/worker/src/test-support/user-meter.ts

Comment thread docs/contributing/architecture/data-storage.md Outdated
Comment thread docs/contributing/architecture/entitlements.md Outdated
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 62f6078. Configure here.

})
if (!input.waitUntil) await markShadowPromise
}
return activeLeases.length

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale lease snapshot before shadow

Medium Severity

markAccountDeleting reads active D1 write leases once, then may await the UserMeter shadowReplaceDeletionState call (for example when deleteUserAccount passes env without waitUntil). Releases can finish in that gap while the function still returns the old lease count and pushes the same stale list into the shadow. That can trigger a false AccountDeletionWritersActiveError after deleting_at is already set, and leave ghost shadow leases until a later mark retry.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 62f6078. Configure here.

@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.

🧹 Nitpick comments (1)
packages/worker/src/entitlements/user-meter-do.ts (1)

1446-1504: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Prefer transactionSync over blockConcurrencyWhile for this read-modify-write.

shadowReplaceDeletionState wraps a read-modify-write sequence (read deletingAt, conditional insert, DELETE FROM account_write_leases, then re-insert) in this.ctx.blockConcurrencyWhile(async () => {...}). This same file already uses this.ctx.storage.transactionSync(() => {...}) for the related UPDATE+INSERT in consumeInboundDelivery, so an established in-file convention for atomic multi-statement writes already exists.

blockConcurrencyWhile blocks every other request against this per-user UserMeter Durable Object instance — including unrelated counter, storage-bytes, and package-service-state operations — for the duration of the callback. The blockConcurrencyWhile() method guarantees that no other events are processed until the provided callback completes, even if the callback performs asynchronous I/O... Because blockConcurrencyWhile() blocks all concurrency unconditionally, it significantly reduces throughput. Reserve it for initialization and migrations, not regular request handling... For atomic read-modify-write operations during request handling, prefer transaction() over blockConcurrencyWhile().

Switch to this.ctx.storage.transactionSync(() => {...}), which wraps the same statements in an all-or-nothing SQL transaction without blocking unrelated requests to this DO instance.

♻️ Proposed refactor to use `transactionSync`
-		return await this.ctx.blockConcurrencyWhile(async () => {
+		return this.ctx.storage.transactionSync(() => {
 			const existing = this.readDeletingAt()
 			let created = false
 			if (existing == null) {
 				const cursor = this.ctx.storage.sql.exec(
 					`INSERT INTO deletion_state (id, deleting_at)
 					VALUES (?, ?)
 					ON CONFLICT(id) DO NOTHING`,
 					deletionStateRowId,
 					deletingAt,
 				)
 				created = cursor.rowsWritten > 0
 			}
 			const stored = this.readDeletingAt() ?? deletingAt
 			this.ctx.storage.sql.exec(`DELETE FROM account_write_leases`)
 			for (const lease of leases) {
 				this.ctx.storage.sql.exec(
 					`INSERT INTO account_write_leases (token, holder, acquired_at)
 					VALUES (?, ?, ?)`,
 					lease.token,
 					lease.holder,
 					lease.acquiredAt,
 				)
 			}
 			return {
 				deletingAt: stored,
 				created,
 				leaseCount: leases.length,
 			}
 		})

Please verify with the Cloudflare Durable Objects SQLite storage documentation that transactionSync fully covers this use case (synchronous callback, no await inside), since it relates to an external platform API.

🤖 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-do.ts` around lines 1446 - 1504,
Replace the blockConcurrencyWhile wrapper in shadowReplaceDeletionState with
this.ctx.storage.transactionSync, keeping the existing synchronous read,
conditional insert, lease deletion, lease reinsertion, and result construction
inside the transaction callback. Preserve the current validation and returned
deletingAt, created, and leaseCount behavior, and do not introduce await usage
in the callback.
🤖 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.

Nitpick comments:
In `@packages/worker/src/entitlements/user-meter-do.ts`:
- Around line 1446-1504: Replace the blockConcurrencyWhile wrapper in
shadowReplaceDeletionState with this.ctx.storage.transactionSync, keeping the
existing synchronous read, conditional insert, lease deletion, lease
reinsertion, and result construction inside the transaction callback. Preserve
the current validation and returned deletingAt, created, and leaseCount
behavior, and do not introduce await usage in the callback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d160d053-5ac6-4ee0-98df-1a71e94805c4

📥 Commits

Reviewing files that changed from the base of the PR and between c566c54 and 62f6078.

📒 Files selected for processing (11)
  • docs/contributing/architecture/data-storage.md
  • docs/contributing/architecture/entitlements.md
  • packages/worker/src/account/deletion-state.node.test.ts
  • packages/worker/src/account/deletion-state.ts
  • 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-deletion.node.test.ts
  • packages/worker/src/entitlements/user-meter-do.ts
  • packages/worker/src/entitlements/user-meter.workers.test.ts
  • packages/worker/src/test-support/user-meter.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • packages/worker/src/account/user-owned-surfaces.ts
  • packages/worker/src/account/export.node.test.ts
  • docs/contributing/architecture/entitlements.md
  • packages/worker/src/entitlements/user-meter.workers.test.ts
  • docs/contributing/architecture/data-storage.md
  • packages/worker/src/test-support/user-meter.ts
  • packages/worker/src/account/deletion-state.ts
  • packages/worker/src/account/deletion-state.node.test.ts
  • packages/worker/src/account/export.ts

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