Skip to content

feat(messaging): ADR-0030 P1 — reliable delivery + RecipientResolver#1441

Merged
os-zhuang merged 4 commits into
mainfrom
claude/adr-0030-p1
Jun 1, 2026
Merged

feat(messaging): ADR-0030 P1 — reliable delivery + RecipientResolver#1441
os-zhuang merged 4 commits into
mainfrom
claude/adr-0030-p1

Conversation

@os-zhuang
Copy link
Copy Markdown
Contributor

Summary

Implements ADR-0030 P1 — Reliable delivery + RecipientResolver, continuing from the merged P0 (#1434). Mirrors the proven plugin-webhooks outbox pattern.

1. RecipientResolver (audience → user ids)

The single home for recipient resolution, wired into MessagingService.emit(). Queries the same identity/membership model plugin-sharing uses (directly via the data engine — no backward plugin dependency):

  • role:<name>sys_member rows (tenant-scoped)
  • team:<id>sys_team_member rows
  • owner_of:<obj>:<id> / { ownerOf } → the record's owner/assignee field
  • <email>sys_user lookup (verbatim fallback on miss); user:<id> / bare id → id

Best-effort (a failed lookup → 0 recipients for that spec, never throws). The inbox channel's email→id fallback moved here — the channel now keys rows by the already-resolved recipient.

2. Reliable delivery outbox + dispatcher

  • sys_notification_delivery outbox object (L4) — one row per (event × recipient × channel); pending|in_flight|success|failed|dead|suppressed state machine; unique (notification_id, recipient_id, channel) enqueue dedup.
  • INotificationOutbox with SqlNotificationOutbox + MemoryNotificationOutbox; atomic claim (pending → in_flight) + stale-in_flight reaping (visibility timeout); ack records outcome + retry schedule.
  • NotificationDispatcher — interval loop over partitions, each guarded by a per-partition cluster lock (single-node always-grant fallback when no cluster service); sends via the channel, acks with exponential backoff + jitter, dead-letters once the budget is exhausted; unknown channel → dead.
  • emit() enqueues pending deliveries when an outbox is attached; otherwise inline fan-out (P0). The plugin wires the outbox + dispatcher at kernel:ready and registers the new object.

Acceptance (per build spec)

  • ✅ A failed channel send retries and is observable on the delivery row (status, attempts, next_attempt_at, error).
  • ✅ Duplicate enqueue is idempotent (unique index + dedup pre-check); event-level dedupKey idempotency from P0 still applies.

Testing

service-messaging: 51 passing — adds recipient-resolver.test.ts (resolution matrix) and dispatcher.test.ts (deliver / retry / dead-letter / unknown-channel / dedup, with injectable clock + backoff). service-automation 105 ✓ (notify-node surface unchanged).

Notes / follow-ups

  • With an outbox attached, emit()'s delivered count means accepted/enqueued (the dispatcher does the async send); progress is observable on the delivery row.
  • The cluster lock uses a structural DispatchCluster interface; when no cluster service is registered the dispatcher runs single-node correctly.
  • Still ahead: P2 (subscription + preference), P3 (channels + templates + digest), and the objectui bell cut-over + mark-read receipt write from P0's handoff.

🤖 Generated with Claude Code


Generated by Claude Code

claude added 3 commits June 1, 2026 04:42
Add the single recipient-resolution home: expand an audience into user ids,
querying the same identity/membership model plugin-sharing uses, directly via
the data engine (no backward plugin dependency).

- role:<name>  → sys_member rows with that role (tenant-scoped)
- team:<id>    → sys_team_member rows for the team
- owner_of:<obj>:<id> / { ownerOf } → the record's owner/assignee field
- <email>      → sys_user lookup (verbatim fallback on miss)
- user:<id> / bare id → the id
All lookups best-effort (a failed query → 0 recipients for that spec, never throws).

Wire it into MessagingService.emit() (replaces the P0 inline deferred-selector
stub) and move the email→id fallback up out of the inbox channel — the channel
now keys rows by the already-resolved recipient.

https://claude.ai/code/session_015pRGvrm3zrk5m8YvZhkAmF
Add the durable delivery spine (mirrors plugin-webhooks' proven outbox):

- sys_notification_delivery outbox object: one row per (event × recipient ×
  channel); status pending|in_flight|success|failed|dead|suppressed; attempts,
  partition_key, claim/next_attempt timestamps; unique (notification_id,
  recipient_id, channel) for enqueue dedup.
- INotificationOutbox with SQL + in-memory backends. Atomic claim via
  pending→in_flight + stale-in_flight reaping (visibility timeout); ack records
  outcome and the retry schedule.
- NotificationDispatcher: interval loop over partitions, each guarded by a
  per-partition cluster lock (single-node always-grant fallback when no cluster
  service). processRow sends via the channel and acks with exponential backoff
  + jitter; dead-letters once the budget is exhausted; unknown channel → dead.
- emit() enqueues pending deliveries when an outbox is attached (else inline
  fan-out, P0). The plugin wires SqlNotificationOutbox + dispatcher at
  kernel:ready and registers sys_notification_delivery.

Outcome: a failed channel send retries and is observable on the delivery row;
duplicate enqueue is idempotent. Backoff/classify and clocks are injectable for
deterministic tests.

https://claude.ai/code/session_015pRGvrm3zrk5m8YvZhkAmF
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jun 1, 2026 4:59am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl labels Jun 1, 2026
@os-zhuang os-zhuang marked this pull request as ready for review June 1, 2026 04:54
Comment thread packages/services/service-messaging/src/recipient-resolver.ts Fixed
CodeQL flagged the email heuristic /^[^\s@]+@[^\s@]+\.[^\s@]+$/ as a polynomial
regex on uncontrolled data — the '.' is also matched by [^\s@], so it backtracks
on inputs like 'a@' + '!.'×N. Replace with a hand-rolled O(n) looksLikeEmail()
(indexOf/lastIndexOf + a single \s test). Behavior unchanged for valid emails.

https://claude.ai/code/session_015pRGvrm3zrk5m8YvZhkAmF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants