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

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

vercel Bot commented Jun 1, 2026 •

Copy link
Copy Markdown

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

This branch was successfully deployed

1 active deployment
Preview — 264e85a0 Deployed Jun 1, 2026 by vercel[bot]
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