Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
3c21148
perf(entitlements): cache enforcement plan lookups
cursoragent Jul 31, 2026
7d5d777
style(docs): format entitlement cache notes
cursoragent Jul 31, 2026
0cd468d
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Jul 31, 2026
59a8238
feat(entitlements): add per-user quota meter
cursoragent Aug 1, 2026
8ebf663
feat(account): inventory UserMeter lifecycle
cursoragent Aug 1, 2026
4a60dd9
test(entitlements): provide UserMeter fixtures
cursoragent Aug 1, 2026
73a8e37
test(entitlements): clean meter test imports
cursoragent Aug 1, 2026
8b001bf
style(mcp): format meter gateway tests
cursoragent Aug 1, 2026
df32168
fix(email): read daily usage from UserMeter
cursoragent Aug 1, 2026
ae07b85
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
e6622a4
docs(entitlements): clarify meter cutover semantics
cursoragent Aug 1, 2026
7628aaf
fix(entitlements): address UserMeter review feedback
cursoragent Aug 1, 2026
468ee51
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
a8cba2b
test(entitlements): add RunLog admin fixture
cursoragent Aug 1, 2026
edfd973
test(run-log): isolate usage counts per user
cursoragent Aug 1, 2026
2307d37
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
b18246f
feat(entitlements): shadow storage bytes in UserMeter
cursoragent Aug 1, 2026
eaaa509
docs(entitlements): inventory storage shadow
cursoragent Aug 1, 2026
3c13b53
fix(entitlements): make storage reserves fail closed
cursoragent Aug 1, 2026
68256ea
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
e454e76
feat(services): shadow liveness in UserMeter
cursoragent Aug 1, 2026
9f7be03
docs(services): inventory liveness shadow
cursoragent Aug 1, 2026
581b896
perf(services): defer liveness shadow writes
cursoragent Aug 1, 2026
393b2a4
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
12e7c02
fix(services): serialize liveness shadows
cursoragent Aug 1, 2026
3a0d41d
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
194d5b5
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
5e8d1b9
feat(account): shadow deletion leases in UserMeter
cursoragent Aug 1, 2026
2e92f03
docs(account): inventory deletion shadow
cursoragent Aug 1, 2026
c566c54
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
62f6078
fix(account): reconcile and sanitize deletion shadows
cursoragent Aug 1, 2026
1116e07
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
d20696d
feat(account): move write leases into UserMeter
cursoragent Aug 1, 2026
6c2effd
docs(account): describe UserMeter lease authority
cursoragent Aug 1, 2026
afe0bb2
test(account): provide authoritative meter fixtures
cursoragent Aug 1, 2026
29dc257
test(account): wire meter into workers fixtures
cursoragent Aug 1, 2026
11814af
fix(account): clean partial lease mirrors
cursoragent Aug 1, 2026
7e88800
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
8d368eb
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
4c0aa23
feat(admin): add UserMeter parity report
cursoragent Aug 1, 2026
c44a518
docs(entitlements): define production parity gates
cursoragent Aug 1, 2026
a6180fc
fix(admin): bound parity page walks
cursoragent Aug 1, 2026
5af8263
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
177a471
refactor(entitlements): stop D1 daily counter mirror
cursoragent Aug 1, 2026
b955401
docs(entitlements): stage daily mirror retirement
cursoragent Aug 1, 2026
164b275
fix(entitlements): preserve pre-drop schema guards
cursoragent Aug 1, 2026
275d23b
fix(entitlements): clarify mirror-stop fallbacks
cursoragent Aug 1, 2026
436c272
docs(entitlements): clarify pre-drop inventory reads
cursoragent Aug 1, 2026
a279b9e
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
fc3bf7a
refactor(account): detach pending daily counter table
cursoragent Aug 1, 2026
fe1766b
docs(storage): point pending-drop guardrail correctly
cursoragent Aug 1, 2026
05bc952
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
659033c
refactor(entitlements): drop retired daily counter table
cursoragent Aug 1, 2026
65b2828
docs(entitlements): format retirement references
cursoragent Aug 1, 2026
3f1f3da
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
698258e
feat(admin): add storage reconciliation maintenance
cursoragent Aug 1, 2026
55b61bb
test(admin): expose reconciliation batch constant
cursoragent Aug 1, 2026
1218bfb
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
3419840
feat(entitlements): make UserMeter storage authority
cursoragent Aug 1, 2026
b1a62a2
fix(entitlements): preserve missing-user storage semantics
cursoragent Aug 1, 2026
70a4b07
style(entitlements): format missing-user test
cursoragent Aug 1, 2026
69dc243
Merge remote-tracking branch 'origin/main' into cursor/meter-do-38c8
cursoragent Aug 1, 2026
1909a01
feat(entitlements): make UserMeter service authority
cursoragent Aug 2, 2026
9dff125
style(entitlements): format service authority tests
cursoragent Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 98 additions & 60 deletions docs/contributing/architecture/entitlements.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ Module: `packages/worker/src/entitlements/`
helpers for rate-style limits, `assertWithinStorageBytesEntitlement`
(UserMeter DO reserve with cold bootstrap), and
`readCurrentEntitlementResourceUsage` (UserMeter-authoritative for
`storage_bytes` and daily resources).
`storage_bytes`, `package_services`, and daily resources).
`countRunningPackageServices` is UserMeter-authoritative.
`countRunningPackageServicesFromD1` is reserved for parity only.

## Plan model

Expand Down Expand Up @@ -130,11 +132,11 @@ live 2026-08-01). `assertWithinStorageBytesEntitlement` uses atomic DO
async mirror** — it is never read for enforcement or usage after the flip; only
parity checks, reconcile tooling, and cold bootstrap read it.

**D1 package service liveness** (`package_service_states`) stays authoritative
for running counts, discovery, and `service_start` enforcement. UserMeter schema
v5 adds an optional per-service shadow table as **best-effort future-cutover
support only** — see
[Package service liveness — UserMeter shadow](#package-service-liveness--usermeter-shadow-expand-phase-slice-4-phase-a).
**D1 package service liveness** (`package_service_states`): UserMeter is now the
**authoritative running-count source** for `package_services` enforcement and
`service_start` after the cutover (2026-08-01). D1 remains the enumeration index
for discovery, account export, and deletion, and is the parity mirror — see
[Package service liveness — UserMeter authority](#package-service-liveness--usermeter-authority-cutover-2026-08-01).

**Account-deletion write fencing** uses a split authority model after slice 5
Phase B: D1 `users.deleting_at` remains the permanent point gate; callers that
Expand Down Expand Up @@ -246,22 +248,71 @@ return `null` for each shadow so paged consumers never double-count them
`UserMeter.purge()` clears counters, inbound delivery claims, and all shadow
state (storage bytes and package-service liveness) via `deleteAll`.

### Package service liveness — UserMeter shadow (expand phase slice 4, Phase A)

D1 `package_service_states` remains the **sole authority** for running-service
**count**, **discovery**, and **`service_start` enforcement** in this PR.
Nothing in Phase A switches those reads or the `assertWithinEntitlement` path
for `package_services` / `persistent_package_services`.

UserMeter schema **v5** adds a per-service `package_service_states` table inside
the DO as **best-effort shadow / future-cutover support only** (`status`,
`started_at`, monotonic `source_updated_at` from the D1 projection timestamp,
`revision`, `updated_at`). User scope is the DO identity — there is no `user_id`
column. Shadow rows are never read for usage display, entitlement enforcement,
or account-deletion inventory in this slice.

**Dual-write from `PackageServiceInstance`:** every D1 projection also attempts
a best-effort UserMeter shadow on the same lifecycle surface:
### Package service liveness — UserMeter authority (cutover 2026-08-01)

**Production evidence (authority gate):** 38-user parity sweep at 2026-08-01
~21:27Z showed running-count parity clean for every user. Exactly one user had 2
D1-only stopped inventory rows with `d1-fresh-running=0` and `meter-running=0`.
No declared package services exist in production (Kent's 59 personal or 10
platform packages), so deliberate lifecycle exercise is unavailable; the parity
sweep is the production gate.

**Authority after the flip:** `countRunningPackageServices` reads directly from
the UserMeter DO — no D1 access on the enforcement path. New lifecycle
dual-writes (`upsertPackageServiceState`) populate the meter so enforcement is
immediately consistent. D1 `package_service_states` remains:
Comment on lines +260 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make running-state projection required before a service becomes runnable.

countRunningPackageServices reads UserMeter only. If upsertPackageServiceState fails, D1 can contain a fresh running row while UserMeter remains missing or stale. The next service_start can undercount usage and admit a service beyond the entitlement limit.

Parity detection and later rollback do not protect the failed enforcement decision. Retry the running-state projection synchronously and fail the start if it cannot succeed. Keep best-effort projection only for transitions that cannot increase usage.

Also applies to: 325-328

🤖 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 `@docs/contributing/architecture/entitlements.md` around lines 260 - 263,
Update the lifecycle start path and its upsertPackageServiceState handling so
running-state projection is retried synchronously and the service start fails if
projection cannot succeed. Keep projection best-effort only for transitions that
cannot increase usage, ensuring countRunningPackageServices never evaluates a
newly runnable service without a successful UserMeter update.


- the **enumeration index** for account export, deletion, and admin discovery
- the **parity mirror**: `admin_user_meter_parity` compares
`countRunningPackageServicesFromD1` (D1 side) against
`meter.countRunningPackageServices` (DO side)
- the **repair source**: `bootstrapPackageServiceStates` is an explicit
migration/repair primitive only — it is not on the enforcement path

**Stopped inventory policy:** D1 stopped/error rows are historical inventory and
do not gate liveness authority. They are never loaded into the meter at
enforcement time. The meter count is 0 for a new user or a user with no live
dual-writes; this is correct.

**D1-only fresh-running row:** a D1 fresh-running row that has no corresponding
meter row means a lifecycle dual-write shadow missed the meter. This is a
migration anomaly and parity blocker; `admin_user_meter_parity` will expose it.
The authority count returns 0 (from the empty meter). Roll back D1 authority if
this is observed in production.

**`excludeService` and 24h staleness:** both semantics are preserved exactly.
`excludeService` is forwarded to `meter.countRunningPackageServices`. The 24h
`staleAfterMs` window applies to DO `source_updated_at` (kept fresh by heartbeat
dual-writes).

**`readEntitlementResourceUsage` throws:** calling
`readEntitlementResourceUsage(package_services)` throws with guidance directing
callers to `readCurrentEntitlementResourceUsage` or `assertWithinEntitlement`
with an explicit `getCurrent`. No production D1 count path for enforcement
remains.

**`service_start` caller:** passes `env: ctx.env` into
`countRunningPackageServices` so `USER_METER` binding is available. Missing
binding fails closed.

**Rollback window:** D1 `package_service_states` continues to receive D1-first
lifecycle dual-writes (no table/column retirements). To roll back: remove the
`readCurrentEntitlementResourceUsage` / `countRunningPackageServices` changes,
revert `service-start.ts` to call `countRunningPackageServicesFromD1`, and
restore the `readEntitlementResourceUsage` D1 path. Verify via
`admin_user_meter_parity` before rolling back.

**No declared service lifecycle:** because no package services are currently
declared in production packages, the only ongoing exercise of this path is the
heartbeat alarm and any future `service_start` calls. Parity sweeps remain the
primary verification surface.

UserMeter schema **v5** adds the per-service `package_service_states` table
inside the DO (`status`, `started_at`, monotonic `source_updated_at`,
`revision`, `updated_at`).

**Dual-write from `PackageServiceInstance`:** D1 upsert/delete runs first; a
best-effort UserMeter shadow follows on the same lifecycle surfaces:

- lifecycle transitions and warm-start restore after upgrades
(`projectServiceStateToD1`)
Expand All @@ -271,27 +322,17 @@ a best-effort UserMeter shadow on the same lifecycle surface:
- purge (`deleteProjectedServiceState` deletes D1 then shadow before
`deleteAll`)

D1 upsert/delete runs first; shadow RPCs are optional when `USER_METER` is
unbound and failures log `package-service-user-meter-shadow-failed` without
affecting the service path. Shadow upserts reject stale/out-of-order writes when
`sourceUpdatedAt` is older than the existing shadow row so cold bootstrap cannot
clobber fresher state.

**Timing unchanged:** live services heartbeat D1 `updated_at` every **1 hour**
(`packageServiceStateHeartbeatMs`). Running counts still treat rows as stale
after **24 hours** without a fresh heartbeat (`packageServiceStateStaleMs` in
`entitlements/service.ts`). The UserMeter cutover-support RPC
`countRunningPackageServices` uses the same 24h window on shadow
`source_updated_at` but is **not** wired to enforcement in Phase A.
Shadow RPCs are optional when `USER_METER` is unbound and failures log
`package-service-user-meter-shadow-failed` without affecting the service path.
Shadow upserts reject stale/out-of-order writes when `sourceUpdatedAt` is older
than the existing shadow row.

**Account export:** `UserMeter.exportCounters` returns additive
`packageServiceStatesShadow` on the first page only (`startAfter` absent); later
pages return `null`. Section totals count the shadow inventory once when
present; the field is explicitly non-authoritative — authoritative liveness
remains on D1.
pages return `null`. The field reflects the authoritative DO inventory.

**Account purge:** `UserMeter.purge()` clears package-service shadow rows with
the rest of DO state via `deleteAll`.
**Account purge:** `UserMeter.purge()` clears package-service shadow rows via
`deleteAll` then schema re-init.

### Account-deletion write fencing — UserMeter authority (expand phase slice 5, Phase B)

Expand Down Expand Up @@ -351,19 +392,14 @@ restores any deleting tombstone; D1 `deleting_at` remains the gate.
Public errors, ALS nested-lease reuse, holder strings, and `waitUntil` release
detachment stay parity-compatible with the pre-cutover surface.

### Future package-service authority flip (contract follow-up)

A separate **high-risk contract PR** — not a merge blocker for Phase A — will
flip running-service count/discovery/enforcement into UserMeter only after:

1. at least one full **24h stale-window soak** with shadow/D1 parity review, and
2. a **cold-bootstrap design** for accounts whose DO shadow is empty while D1
still holds rows (`bootstrapPackageServiceStates` / equivalent).
### Package-service authority flip (complete, 2026-08-01)

Until that flip, shadow divergence is acceptable; D1 remains the contract. **D1
likely stays the enumeration index** for account export, deletion, and admin
discovery until an alternate inventory exists — UserMeter would become the
running-count authority first, not a wholesale replacement for every D1 reader.
The running-count authority for `package_services` moved from D1 to UserMeter as
of the cutover (see
[Package service liveness — UserMeter authority](#package-service-liveness--usermeter-authority-cutover-2026-08-01)).
D1 remains the enumeration index for account export, deletion, and admin
discovery. `countRunningPackageServicesFromD1` is retained for parity
comparisons; all enforcement uses UserMeter via `countRunningPackageServices`.

### Storage authority flip (complete, 2026-08-01)

Expand Down Expand Up @@ -622,14 +658,16 @@ Rules:
## Counting strategy

- **Row-count limits** (saved packages, scheduled jobs, repo sessions, secrets,
running package services) are counted **directly from their source D1 tables
at the enforcement point** via built-in counters in `service.ts`. They do not
depend on any metering or rollup tables. Running package services are counted
from D1 `package_service_states` (status `running` and freshly heartbeaten; 1h
heartbeat, 24h staleness), not from run-history rows — see
[Run records](./run-records.md) (`state-vs-history`). Expand-phase slice 4
Phase A dual-writes the same projection into UserMeter as a non-authoritative
shadow; enforcement and `service_start` still read D1 only. **Concurrent
running package services) are counted via built-in counters in `service.ts`.
Most are counted directly from their source D1 tables. **Running package
services** are now counted from the **per-user UserMeter DO** (authoritative
as of 2026-08-01): `countRunningPackageServices` counts `status = 'running'`
rows with the 24h staleness window from DO `source_updated_at`. D1
`package_service_states` remains the enumeration index (discovery, export,
deletion) and parity mirror; `countRunningPackageServicesFromD1` is retained
for parity only — see
[Package service liveness — UserMeter authority](#package-service-liveness--usermeter-authority-cutover-2026-08-01)
and [Run records](./run-records.md) (`state-vs-history`). **Concurrent
workflows** are authoritative in per-user RunLog `workflow_projections`:
create reserves atomically via `reserveWorkflowProjectionSlot`, and usage
readers call `countActiveWorkflowProjections` through
Expand Down Expand Up @@ -782,7 +820,7 @@ Use `getCurrent` only when the built-in D1 counter cannot express the resource.
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `scheduled_jobs` | `createJob` in `packages/worker/src/jobs/service.ts` (exemplar) |
| `saved_packages` | new-package branch of `package_save` and projection insert |
| `package_services` | `service_start` capability path (count from `package_service_states`) |
| `package_services` | `service_start` capability path (`getCurrent` with `countRunningPackageServices(env)`; UserMeter authority since 2026-08-01; D1-only fresh-running rows are parity blockers rather than runtime bootstrap input) |
| `persistent_package_services` | `service_start` for services declared `mode: 'persistent'` |
| `repo_sessions` | `repo_open_session` before creating a new session |
| `email_sends_per_day` | `sendOutboundEmail` (`consumeDailyEntitlement`; plan limit from `resolvePlanLimit`) |
Expand Down
4 changes: 2 additions & 2 deletions packages/worker/src/admin/user-meter-parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type UserMeterWriteLeaseShadow,
} from '#worker/entitlements/user-meter-do.ts'
import {
countRunningPackageServices,
countRunningPackageServicesFromD1,
packageServiceStateStaleMs,
} from '#worker/entitlements/service.ts'
import { planLimits } from '#worker/entitlements/plans.ts'
Expand Down Expand Up @@ -462,7 +462,7 @@ async function readPackageServicesParity(input: {
d1Rows,
meterStates,
})
const d1FreshRunningCount = await countRunningPackageServices({
const d1FreshRunningCount = await countRunningPackageServicesFromD1({
db: input.db,
userId: input.stableUserId,
now: input.now,
Expand Down
8 changes: 4 additions & 4 deletions packages/worker/src/entitlements/entitlements.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
assertWithinEntitlement,
assertWithinStorageBytesEntitlement,
consumeDailyEntitlement,
countRunningPackageServices,
countRunningPackageServicesFromD1,
estimateEntitlementStorageEntryByteDelta,
estimateEntitlementStorageEntryBytes,
findCachedUserAccountByStableUserId,
Expand Down Expand Up @@ -1613,7 +1613,7 @@ test('getUserPlan resolves effective plan from manual plan and stripe_plan', asy
).toBe('max')
})

test('countRunningPackageServices queries package_service_states with staleness and excludeService', async () => {
test('countRunningPackageServicesFromD1 queries package_service_states with staleness and excludeService', async () => {
const now = new Date('2026-07-26T12:00:00.000Z')
const queries: Array<{ sql: string; params: Array<unknown> }> = []
const db = {
Expand All @@ -1632,7 +1632,7 @@ test('countRunningPackageServices queries package_service_states with staleness
} as unknown as D1Database

expect(
await countRunningPackageServices({
await countRunningPackageServicesFromD1({
db,
userId: 'user-1',
now,
Expand All @@ -1645,7 +1645,7 @@ test('countRunningPackageServices queries package_service_states with staleness
])

queries.length = 0
await countRunningPackageServices({
await countRunningPackageServicesFromD1({
db,
userId: 'user-1',
now,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { env } from 'cloudflare:workers'
import { expect, test } from 'vitest'
import {
assertWithinEntitlement,
countRunningPackageServices,
countRunningPackageServicesFromD1,
packageServiceStateStaleMs,
} from '#worker/entitlements/service.ts'
import { EntitlementLimitError } from '#worker/entitlements/errors.ts'
Expand Down Expand Up @@ -100,14 +100,14 @@ test('package_service_states counts fresh running rows and enforces the plan bou
})

expect(
await countRunningPackageServices({
await countRunningPackageServicesFromD1({
db: env.APP_DB,
userId,
now,
}),
).toBe(1)
expect(
await countRunningPackageServices({
await countRunningPackageServicesFromD1({
db: env.APP_DB,
userId,
now,
Expand All @@ -124,7 +124,7 @@ test('package_service_states counts fresh running rows and enforces the plan bou
updatedAt: fresh,
})
expect(
await countRunningPackageServices({
await countRunningPackageServicesFromD1({
db: env.APP_DB,
userId,
now,
Expand Down Expand Up @@ -153,12 +153,20 @@ test('package_service_states counts fresh running rows and enforces the plan bou
})
}

// assertWithinEntitlement for package_services requires getCurrent with the
// authoritative counter (enforcement uses UserMeter; parity/bootstrap uses D1).
const denied = await assertWithinEntitlement({
db: env.APP_DB,
userId: limitUserId,
email,
resource: 'package_services',
now,
getCurrent: async () =>
await countRunningPackageServicesFromD1({
db: env.APP_DB,
userId: limitUserId,
now,
}),
}).then(
() => null,
(thrown: unknown) => thrown,
Expand All @@ -182,7 +190,7 @@ test('package_service_states counts fresh running rows and enforces the plan bou
resource: 'package_services',
now,
getCurrent: async () =>
await countRunningPackageServices({
await countRunningPackageServicesFromD1({
db: env.APP_DB,
userId: limitUserId,
now,
Expand Down
Loading
Loading