Repository navigation
Make inbound email delivery durable and idempotent - #891
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (11)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughChangesThe PR adds a durable inbound email pipeline with deterministic delivery IDs, D1/R2 deduplication, quota charging, storage fencing, idempotent retries, effect dispatch, stale-work reconciliation, scheduled cleanup, package invocation recovery, and inbound usage rollups. Inbound delivery lifecycle
Package invocation recovery
Inbound usage rollups
Migration index
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant EmailHandler
participant InboundDelivery
participant StorageService
participant R2
participant D1
participant Effects
EmailHandler->>InboundDelivery: build, deduplicate, and charge delivery
EmailHandler->>InboundDelivery: claim storage lease
EmailHandler->>StorageService: store idempotent inbound email
StorageService->>R2: persist raw MIME
StorageService->>D1: persist fenced message and attachments
StorageService->>InboundDelivery: finalize received state
EmailHandler->>Effects: schedule delivery effects
Effects->>D1: record usage and effect state
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
6469c7a to
f2c2407
Compare
|
@coderabbitai review |
1 similar comment
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
packages/worker/src/email/reconcile-inbound-deliveries.ts (1)
44-119: 🚀 Performance & Scalability | 🔵 TrivialConsider an index to keep the 5-minute discovery scan cheap.
The
due_usersCTE filtersemail_delivery_eventsprimarily byprovider/event_typeand then evaluates severaljson_extract(detail_json, ...)predicates. As delivery-event volume grows, this scheduled scan can become a hot, mostly-full scan every cron tick. A composite index aligned with the three union branches (e.g.(provider, event_type, created_at)) would let the planner seed each branch by its selective columns before the JSON predicates run.Since
reconcileAfter/state/dedupeExpiresAt/effect fields live insidedetail_json, they can't be indexed directly without generated/expression columns; the covering win here is the provider/event_type/created_at prefix plus keepingLIMIT-bounded ordering oncreated_at.🤖 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/email/reconcile-inbound-deliveries.ts` around lines 44 - 119, The reconciliation query’s three due-user branches lack an index aligned with their provider, event type, and timestamp filters. Add or reuse a composite index on email_delivery_events covering provider, event_type, and created_at, and verify the due_users query can use it while retaining the existing JSON predicates and ordering.packages/worker/src/email/package-subscriptions.ts (1)
223-230: 🩺 Stability & Availability | 🔵 TrivialConsider a bounded-retry / dead-letter path for permanent discovery failures.
Throwing whenever
discoveryErrors.length > 0couples one user's broken package manifest to the whole delivery's subscription effect. SinceprocessInboundDeliveryEffectsWithLeaseHelddefers withsubscriptionEffectRetryAt(+15m) and reconciliation re-runs, a permanently unresolvable manifest will retry indefinitely with no attempt cap or dead-letter. Transient failures benefit; permanent ones become perpetual reconciliation churn per delivery.Consider distinguishing permanent vs transient discovery failures, or capping retries after N attempts and marking the effect terminally failed.
🤖 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/email/package-subscriptions.ts` around lines 223 - 230, Update processInboundDeliveryEffectsWithLeaseHeld and the subscription-effect reconciliation flow so permanent discovery failures do not retry indefinitely: distinguish permanent from transient errors or enforce a bounded attempt count, then mark exhausted/permanent effects terminally failed or dead-lettered while preserving subscriptionEffectRetryAt retries for transient failures.
🤖 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/package-invocations/idempotent-module-invocation.ts`:
- Around line 110-174: Move the in_progress polling logic surrounding
lookupInvocation and the pollDeadline outside the
withAccountWriteLease/invokeSavedPackageModule write-lease scope, so duplicate
requests can poll while the original write is active. Keep stale-claim handling
and resolveExistingInvocation behavior unchanged, and ensure the lease only
covers the necessary write operations rather than the polling wait.
---
Nitpick comments:
In `@packages/worker/src/email/package-subscriptions.ts`:
- Around line 223-230: Update processInboundDeliveryEffectsWithLeaseHeld and the
subscription-effect reconciliation flow so permanent discovery failures do not
retry indefinitely: distinguish permanent from transient errors or enforce a
bounded attempt count, then mark exhausted/permanent effects terminally failed
or dead-lettered while preserving subscriptionEffectRetryAt retries for
transient failures.
In `@packages/worker/src/email/reconcile-inbound-deliveries.ts`:
- Around line 44-119: The reconciliation query’s three due-user branches lack an
index aligned with their provider, event type, and timestamp filters. Add or
reuse a composite index on email_delivery_events covering provider, event_type,
and created_at, and verify the due_users query can use it while retaining the
existing JSON predicates and ordering.
🪄 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: f934f109-e93f-4c2e-8e7c-093f1f2b1a66
📒 Files selected for processing (19)
packages/worker/src/email/inbound-delivery.tspackages/worker/src/email/inbound-effects.tspackages/worker/src/email/inbound-entitlements.workers.test.tspackages/worker/src/email/inbound.tspackages/worker/src/email/inbound.workers.test.tspackages/worker/src/email/package-subscriptions.tspackages/worker/src/email/parser.tspackages/worker/src/email/reconcile-inbound-deliveries.tspackages/worker/src/email/repo.tspackages/worker/src/email/service.tspackages/worker/src/email/system-email.workers.test.tspackages/worker/src/index.tspackages/worker/src/index.workers.test.tspackages/worker/src/package-invocations/admin-package-subscriptions.tspackages/worker/src/package-invocations/idempotent-module-invocation.tspackages/worker/src/package-invocations/repo.tspackages/worker/src/package-invocations/service.node.test.tspackages/worker/src/usage/aggregate-rollups.node.test.tspackages/worker/src/usage/aggregate-rollups.ts
| const pollDeadline = Date.now() + packageInvocationPollBudgetMs | ||
| while ( | ||
| existing.status === 'in_progress' && | ||
| !isStaleInvocation(existing.updated_at, new Date()) && | ||
| Date.now() < pollDeadline | ||
| ) { | ||
| await new Promise((resolve) => | ||
| setTimeout(resolve, packageInvocationPollIntervalMs), | ||
| ) | ||
| existing = await lookupInvocation() | ||
| if (!existing) break | ||
| } | ||
| if (!existing) { | ||
| return buildJsonErrorResponse({ | ||
| status: 500, | ||
| code: 'idempotency_conflict_unresolved', | ||
| message: 'Package invocation disappeared while polling.', | ||
| idempotencyKey: input.idempotencyKey, | ||
| }) | ||
| } | ||
| if (existing.status !== 'in_progress') { | ||
| return resolveExistingInvocation({ | ||
| record: existing, | ||
| requestHash, | ||
| source: input.source, | ||
| topic: input.topic, | ||
| status: 'in_progress', | ||
| }, | ||
| }) | ||
| } catch (error) { | ||
| console.error( | ||
| 'package invocation idempotency persistence failed', | ||
| error, | ||
| ) | ||
| return buildJsonErrorResponse({ | ||
| status: 500, | ||
| code: 'idempotency_persistence_failed', | ||
| message: | ||
| 'Unable to persist the package invocation idempotency record. Please retry.', | ||
| idempotencyKey: input.idempotencyKey, | ||
| idempotencyKey: input.idempotencyKey, | ||
| }) | ||
| } | ||
| const now = new Date() | ||
| if (!isStaleInvocation(existing.updated_at, now)) { | ||
| return resolveExistingInvocation({ | ||
| record: existing, | ||
| requestHash, | ||
| idempotencyKey: input.idempotencyKey, | ||
| }) | ||
| } | ||
| const reclaimedAt = now.toISOString() | ||
| const reclaimed = await tryClaimStalePackageInvocation({ | ||
| db: input.env.APP_DB, | ||
| id: existing.id, | ||
| userId: input.actor.userId, | ||
| expectedUpdatedAt: existing.updated_at, | ||
| staleBefore: new Date( | ||
| now.getTime() - packageInvocationStaleAfterMs, | ||
| ).toISOString(), | ||
| now: reclaimedAt, | ||
| }) | ||
| if (!reclaimed) { | ||
| const current = await lookupInvocation() | ||
| if (!current) { | ||
| return buildJsonErrorResponse({ | ||
| status: 500, | ||
| code: 'idempotency_conflict_unresolved', | ||
| message: 'Stale package invocation reclaim conflicted.', | ||
| idempotencyKey: input.idempotencyKey, | ||
| }) | ||
| } | ||
| return resolveExistingInvocation({ | ||
| record: current, | ||
| requestHash, | ||
| idempotencyKey: input.idempotencyKey, | ||
| }) | ||
| } | ||
| invocationId = existing.id | ||
| claimUpdatedAt = reclaimedAt | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect withAccountWriteLease semantics: is it a per-user mutex or advisory?
fd -e ts account-deletion-state
ast-grep outline "$(fd -e ts account-deletion-state | head -n1)" --items all
rg -nP -C4 'function withAccountWriteLease' "$(fd -e ts account-deletion-state | head -n1)"Repository: kentcdodds/kody
Length of output: 1089
🏁 Script executed:
#!/bin/bash
set -u
printf '--- account-deletion-state outline and implementation ---\n'
ast-grep outline packages/worker/src/app/account-deletion-state.ts --view expanded || true
cat -n packages/worker/src/app/account-deletion-state.ts | sed -n '1,180p'
printf '\n--- idempotent-module-invocation relevant context ---\n'
ast-grep outline packages/worker/src/package-invocations/idempotent-module-invocation.ts --view expanded || true
cat -n packages/worker/src/package-invocations/idempotent-module-invocation.ts | sed -n '1,230p'Repository: kentcdodds/kody
Length of output: 14043
🌐 Web query:
D1 database transaction read committed concurrent writes read locks release after commit SQLite docs
💡 Result:
Cloudflare D1 is a serverless SQL database built on SQLite [1]. Because D1 operates as a managed service on the Cloudflare Workers platform, it abstracts away the low-level locking and concurrency mechanisms typically found in a standard SQLite environment [2][1]. 1. Isolation and Consistency: While standard SQLite in WAL (Write-Ahead Logging) mode provides snapshot isolation [2][3], Cloudflare D1's consistency model is distinct due to its global nature and read replication [4][2]. In the presence of read replicas, D1 provides sequential consistency for applications using the Sessions API [4][5]. Without the Sessions API—or in configurations where requests are routed arbitrarily across replicas—D1 offers read-committed consistency, which guarantees only that queries read committed data [5][2]. 2. Locks and Concurrent Writes: In standard SQLite, write transactions use locks to serialize access; a writer must acquire an exclusive lock, which forces readers to wait or causes other writers to fail/wait depending on the configuration [6][3]. However, D1 manages these complexities transparently [5]. Application developers do not interact with raw SQLite read/write locks, nor do they manage manual lock releases [5][7]. 3. Transactional Behavior: D1 handles write concurrency and transactional integrity at the service level [5]. If your application requires atomic operations or data integrity guarantees during concurrent writes (such as the classic "read-modify-write" race condition), you should utilize D1's Batch API or the Sessions API to ensure that related operations are processed in a consistent, sequential order [4][5][2]. You cannot explicitly manage read locks to prevent concurrent modifications (e.g., SELECT... FOR UPDATE) as you might in other relational databases, because D1 abstracts the underlying locking mechanism [5]. In summary, D1 does not expose the SQLite locking interface to users. You should rely on its high-level abstractions—specifically Sessions and Batches—to ensure data consistency rather than attempting to manage locks manually [4][5][2].
Citations:
- 1: https://workers.cloudflare.com/products/d1/
- 2: https://blog.cloudflare.com/building-d1-a-global-database/
- 3: https://www.sqlite.org/isolation.html
- 4: https://010dd5f9.preview.developers.cloudflare.com/d1/best-practices/read-replication/
- 5: https://blog.cloudflare.com/d1-read-replication-beta/
- 6: https://www.sqlite.org/lockingv3.html
- 7: https://developers.cloudflare.com/d1/sql-api/sql-statements/
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- idempotency and repo implementations ---'
for f in $(git ls-files | grep -E 'packages/worker/src/package-invocations/(idempotency|repo)\.ts$'); do
echo "FILE: $f"
wc -l "$f"
ast-grep outline "$f" --view expanded || true
sed -n '1,260p' "$f"
done
printf '%s\n' '--- usages of invokeSavedPackageModule and withAccountWriteLease ---'
rg -n "invokeSavedPackageModule|withAccountWriteLease" packages/worker/src packages/worker -g '*.ts' -g '*.tsx'Repository: kentcdodds/kody
Length of output: 17550
Move the stale polling window outside the per-user write lease.
withAccountWriteLease increments users.active_write_count for the entire invokeSavedPackageModule body, so a concurrent duplicate request enters withAccountWriteLease first and blocks until the first write path returns. The in_progress poll is only reachable after that other request has completed and released the lease; under contention it effectively serializes duplicate invocations behind this + up to 1s polling timeout. Poll after the lease completes, or return with a retryable error from this path while another same-user write is active.
🤖 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/package-invocations/idempotent-module-invocation.ts`
around lines 110 - 174, Move the in_progress polling logic surrounding
lookupInvocation and the pollDeadline outside the
withAccountWriteLease/invokeSavedPackageModule write-lease scope, so duplicate
requests can poll while the original write is active. Keep stale-claim handling
and resolveExistingInvocation behavior unchanged, and ensure the lease only
covers the necessary write operations rather than the polling wait.
There was a problem hiding this comment.
Triaged against #890's implementation: withAccountWriteLease is not a per-user mutex. It atomically increments active_write_count so deletion waits, but concurrent writers continue normally. Polling inside the lease therefore does not block the original invocation; moving it outside would instead open an account-deletion race during the invocation lifecycle. No change needed.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
48f663e to
c720892
Compare
40e22ca to
e78e4cf
Compare
3d38a9d to
fcbb20d
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b6c2c8d. Configure here.
6d378b6 to
b4c8bfc
Compare
|
🔎 Preview deployed: https://kody-pr-891.kody-a99.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/worker/src/email/package-subscriptions.ts (1)
242-247: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
dispatchEmailDeliverySubscriptionEventsdropsdiscoveryErrors, unlike its sibling.
loadMatchingEmailSubscriptionsnow surfacesdiscoveryErrorsfor manifest-load failures, anddispatchInboundEmailSubscriptionEvents(Lines 183-232) correctly checks them and throws "dispatch was incomplete" so the caller can retry. Here the same discovery-error information is discarded — onlysubscriptionsis destructured — so a package whose manifest failed to load during a delivery-status update silently never receives that webhook, and nothing signals the dispatch as incomplete for reconciliation/retry.🐛 Proposed fix: surface discovery errors the same way the inbound path does
- const { subscriptions } = await loadMatchingEmailSubscriptions({ + const { subscriptions, discoveryErrors } = await loadMatchingEmailSubscriptions({ env: input.env, baseUrl, userId: input.message.userId, topic: emailDeliveryUpdatedTopic, })Then, after the invocation loop, throw when
discoveryErrors.length > 0, mirroring the "dispatch was incomplete" pattern used indispatchInboundEmailSubscriptionEvents.🤖 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/email/package-subscriptions.ts` around lines 242 - 247, Update dispatchEmailDeliverySubscriptionEvents to retain discoveryErrors from loadMatchingEmailSubscriptions, process all successfully loaded subscriptions as before, then after the invocation loop throw the same “dispatch was incomplete” error used by dispatchInboundEmailSubscriptionEvents when discoveryErrors.length is greater than zero.packages/worker/src/email/parser.ts (1)
111-130: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winKeep the 512 KB raw MIME cap on the direct parsing path.
inbound.tsnow callsreadForwardableEmailRawMime/parseForwardableEmailRawMimedirectly, and the only pre-parse rejection is theemail_message_bytesentitlement check. With Cloudflare inbound messages potentially much larger, add themaxRawMimeBytesguard before returning fromreadForwardableEmailRawMime/parseForwardableEmailRawMimeor switch these direct call sites back throughparseForwardableEmailMessage.🤖 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/email/parser.ts` around lines 111 - 130, Preserve the 512 KB raw MIME limit for direct callers of readForwardableEmailRawMime and parseForwardableEmailRawMime. Add the maxRawMimeBytes validation to the shared direct parsing flow before parsing or returning the raw MIME, or route inbound.ts through parseForwardableEmailMessage so the existing guard is always applied.
🧹 Nitpick comments (1)
packages/worker/src/email/package-subscriptions.ts (1)
138-152: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueUnbounded concurrency for manifest loads, unlike the chunked admin dispatcher.
admin-package-subscriptions.ts's discovery loop usesmapSettledInChunksfor bounded concurrency, but this loads allsavedPackagesmanifests concurrently via a flatPromise.allSettled. Likely low risk since this is scoped to one user's saved packages, but worth aligning with the chunked pattern if a user can accumulate many saved packages.🤖 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/email/package-subscriptions.ts` around lines 138 - 152, The manifest discovery in the savedPackages flow launches every loadPackageManifestBySourceId call concurrently through Promise.allSettled. Replace the flat mapping with the existing mapSettledInChunks pattern used by the admin subscription discovery, preserving the current callback behavior and settled-result handling while applying bounded concurrency.
🤖 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/usage/aggregate-rollups.workers.test.ts`:
- Around line 58-65: Scope both fixture mutations in the test cleanup by userId:
add the user_id predicate to the DELETE on email_messages and the UPDATE on
email_delivery_events, binding userId alongside messageId in each statement.
Preserve the existing cleanup behavior while ensuring both writes target only
the current user’s rows.
---
Outside diff comments:
In `@packages/worker/src/email/package-subscriptions.ts`:
- Around line 242-247: Update dispatchEmailDeliverySubscriptionEvents to retain
discoveryErrors from loadMatchingEmailSubscriptions, process all successfully
loaded subscriptions as before, then after the invocation loop throw the same
“dispatch was incomplete” error used by dispatchInboundEmailSubscriptionEvents
when discoveryErrors.length is greater than zero.
In `@packages/worker/src/email/parser.ts`:
- Around line 111-130: Preserve the 512 KB raw MIME limit for direct callers of
readForwardableEmailRawMime and parseForwardableEmailRawMime. Add the
maxRawMimeBytes validation to the shared direct parsing flow before parsing or
returning the raw MIME, or route inbound.ts through parseForwardableEmailMessage
so the existing guard is always applied.
---
Nitpick comments:
In `@packages/worker/src/email/package-subscriptions.ts`:
- Around line 138-152: The manifest discovery in the savedPackages flow launches
every loadPackageManifestBySourceId call concurrently through
Promise.allSettled. Replace the flat mapping with the existing
mapSettledInChunks pattern used by the admin subscription discovery, preserving
the current callback behavior and settled-result handling while applying bounded
concurrency.
🪄 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: 8ac0619e-17c9-4bd0-a71d-914581d146f4
📒 Files selected for processing (25)
packages/worker/migrations/0089-email-delivery-reconciliation-index.sqlpackages/worker/src/email/inbound-delivery.tspackages/worker/src/email/inbound-effects.tspackages/worker/src/email/inbound-entitlements.workers.test.tspackages/worker/src/email/inbound-reconciliation-index-migration.node.test.tspackages/worker/src/email/inbound.tspackages/worker/src/email/inbound.workers.test.tspackages/worker/src/email/package-subscriptions.tspackages/worker/src/email/parser.tspackages/worker/src/email/reconcile-inbound-deliveries.tspackages/worker/src/email/repo.tspackages/worker/src/email/service.tspackages/worker/src/email/system-email.workers.test.tspackages/worker/src/index.tspackages/worker/src/index.workers.test.tspackages/worker/src/package-invocations/admin-package-subscriptions.tspackages/worker/src/package-invocations/idempotent-module-invocation.tspackages/worker/src/package-invocations/module-artifacts.tspackages/worker/src/package-invocations/repo.tspackages/worker/src/package-invocations/service.node.test.tspackages/worker/src/usage/aggregate-rollups.node.test.tspackages/worker/src/usage/aggregate-rollups.tspackages/worker/src/usage/aggregate-rollups.workers.test.tstools/check-migrations.node.test.tstools/migration-ledger.json
🚧 Files skipped from review as they are similar to previous changes (13)
- packages/worker/src/index.ts
- packages/worker/src/package-invocations/admin-package-subscriptions.ts
- packages/worker/src/email/system-email.workers.test.ts
- packages/worker/src/index.workers.test.ts
- packages/worker/src/email/service.ts
- packages/worker/src/package-invocations/repo.ts
- packages/worker/src/email/inbound-effects.ts
- packages/worker/src/usage/aggregate-rollups.ts
- packages/worker/src/email/repo.ts
- packages/worker/src/email/inbound.ts
- packages/worker/src/email/reconcile-inbound-deliveries.ts
- packages/worker/src/email/inbound.workers.test.ts
- packages/worker/src/email/inbound-delivery.ts

Summary
system:emailremains exempt0089-email-delivery-reconciliation-index.sqlwith index/query-plan coverage for global scheduled reconciliationValidation
npm run validate: passed on latestmainRemaining risks
System recap — extends existing primitives (medium risk)
Mode: recap · Base:
main@d38cac51· Head:6c464fc0Classification: extends — changes inbound email durability/idempotency, retention-safe usage, subscription invocation recovery, and scheduled reconciliation while composing merged account write leases.
Primitives touched
emailemail-blobs-r2usage-meteringscheduled-cronSystem map
Inbound delivery and its effects run under the merged account write lease; scheduled reconciliation uses indexed delivery-event filters and preserves usage after message retention without resurrecting deleted accounts.
Legend: green = composes (wiring only) · amber = extended by this PR · red = new primitive · gray = context (unchanged, included only when an edge crosses it).
Invariants
Summary by CodeRabbit