feat(messaging): ADR-0030 P1 — reliable delivery + RecipientResolver#1441
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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 was referenced Jun 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements ADR-0030 P1 — Reliable delivery + RecipientResolver, continuing from the merged P0 (#1434). Mirrors the proven
plugin-webhooksoutbox pattern.1. RecipientResolver (audience → user ids)
The single home for recipient resolution, wired into
MessagingService.emit(). Queries the same identity/membership modelplugin-sharinguses (directly via the data engine — no backward plugin dependency):role:<name>→sys_memberrows (tenant-scoped)team:<id>→sys_team_memberrowsowner_of:<obj>:<id>/{ ownerOf }→ the record's owner/assignee field<email>→sys_userlookup (verbatim fallback on miss);user:<id>/ bare id → idBest-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_deliveryoutbox object (L4) — one row per(event × recipient × channel);pending|in_flight|success|failed|dead|suppressedstate machine; unique(notification_id, recipient_id, channel)enqueue dedup.INotificationOutboxwithSqlNotificationOutbox+MemoryNotificationOutbox; atomic claim (pending → in_flight) + stale-in_flightreaping (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 noclusterservice); sends via the channel, acks with exponential backoff + jitter, dead-letters once the budget is exhausted; unknown channel →dead.emit()enqueuespendingdeliveries when an outbox is attached; otherwise inline fan-out (P0). The plugin wires the outbox + dispatcher atkernel:readyand registers the new object.Acceptance (per build spec)
status,attempts,next_attempt_at,error).dedupKeyidempotency from P0 still applies.Testing
service-messaging: 51 passing — adds
recipient-resolver.test.ts(resolution matrix) anddispatcher.test.ts(deliver / retry / dead-letter / unknown-channel / dedup, with injectable clock + backoff). service-automation 105 ✓ (notify-node surface unchanged).Notes / follow-ups
emit()'sdeliveredcount means accepted/enqueued (the dispatcher does the async send); progress is observable on the delivery row.DispatchClusterinterface; when noclusterservice is registered the dispatcher runs single-node correctly.🤖 Generated with Claude Code
Generated by Claude Code