|
| 1 | +# Event-Driven Federation |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Proposed |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +Outbound ActivityPub federation (`sendActivity` calls) is scattered across controllers, activity handlers, and services with inconsistent error handling: |
| 10 | + |
| 11 | +- **Bare `await` (no try/catch):** Delivery failures crash the request with a 500, even though the local operation succeeded. This is the class of bug reported in BER-2715 - a note is posted successfully but `sendActivityToFollowers` throws when a follower's inbox is unreachable, and the error propagates back through `emitAsync` to the controller, producing a 500. The same pattern exists in the repost path (`post.controller.ts`) where a bare `await` on `sendActivity` means a dead inbox crashes the request. |
| 12 | +- **Fire-and-forget (no `await`):** Delivery failures produce unhandled promise rejections. |
| 13 | +- **Try/catch with logging:** The correct approach, but only used in one place. |
| 14 | + |
| 15 | +There are 18 `sendActivity` calls outside the `FediverseBridge`, spread across `like.controller.ts`, `post.controller.ts`, `follow.controller.ts`, `follow.handler.ts`, and `bluesky.service.ts`. The `FediverseBridge` already handles 5 domain events correctly, demonstrating the target pattern. |
| 16 | + |
| 17 | +Federation success should not determine whether a local operation succeeded. Controllers should not be coupled to federation delivery concerns. |
| 18 | + |
| 19 | +This decision builds on [ADR-0011 (Serializable Domain Events)](0011-serializable-domain-events.md), which was a prerequisite for this work. |
| 20 | + |
| 21 | +## Decision |
| 22 | + |
| 23 | +All outbound federation must go through the `FediverseBridge`, triggered by domain events. Controllers and services must not call `sendActivity` directly. |
| 24 | + |
| 25 | +### Scope |
| 26 | + |
| 27 | +This applies to federation that is a **side effect of a domain operation** (e.g. liking a post federates a `Like` activity). It does not apply to: |
| 28 | + |
| 29 | +- **Inbound activity responses** (e.g. `Accept` in `follow.handler.ts` in response to a received `Follow`) - these are responses to inbound activities and belong in the activity handler that processes them. Note: the `FediverseBridge` already handles `AccountBlockedEvent` -> `Reject(Follow)`, which is similar in shape but different in trigger - it is a domain event (blocking an account) that has a federation side effect, not a response to an inbound activity. |
| 30 | +- **Integration-specific federation** (e.g. Bluesky/Bridgy bridge in `bluesky.service.ts`) - these use ActivityPub as a transport mechanism for integration protocols and should be handled by their own dedicated service. |
| 31 | + |
| 32 | +### Consolidation |
| 33 | + |
| 34 | +The `FedifyActivitySender` class in `src/activitypub/activity.ts` is a separate abstraction that also wraps `sendActivity`. Once all federation is routed through the `FediverseBridge`, `FedifyActivitySender` should be removed to avoid having two places that send activities. |
| 35 | + |
| 36 | +## Implementation |
| 37 | + |
| 38 | +### Pattern |
| 39 | + |
| 40 | +The existing `FediverseBridge` handlers demonstrate the pattern. The controller does only the domain operation, the bridge handles federation: |
| 41 | + |
| 42 | +```typescript |
| 43 | +// 1. Controller does the domain operation only |
| 44 | +async handleRepost(ctx: AppContext) { |
| 45 | + // postService calls postRepository.save(), which emits PostRepostedEvent |
| 46 | + await this.postService.repostByApId(account, postApId); |
| 47 | + return ok(); |
| 48 | +} |
| 49 | + |
| 50 | +// 2. FediverseBridge listens for the event and federates |
| 51 | +private async handlePostReposted(event: PostRepostedEvent) { |
| 52 | + const account = await this.accountService.getAccountById( |
| 53 | + event.getAccountId(), |
| 54 | + ); |
| 55 | + const post = await this.postRepository.getById(event.getPostId()); |
| 56 | + |
| 57 | + if (!account || !post || !post.author.isInternal) { |
| 58 | + return; |
| 59 | + } |
| 60 | + |
| 61 | + const ctx = this.fedifyContextFactory.getFedifyContext(); |
| 62 | + const announce = await buildAnnounceActivityForPost(account, post, ctx); |
| 63 | + |
| 64 | + await this.sendActivityToInbox(account, post.author, announce); |
| 65 | + await this.sendActivityToFollowers(account, announce); |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +Some handlers need to send to both a specific inbox and followers (e.g. likes send to the attribution actor's inbox and to all followers, reposts send to the original author and followers). Handlers call both `sendActivityToInbox` and `sendActivityToFollowers` as needed. |
| 70 | + |
| 71 | +### Error handling |
| 72 | + |
| 73 | +The `FediverseBridge` helper methods (`sendActivityToFollowers`, `sendActivityToInbox`) should catch and log errors rather than throwing. Federation is best-effort - a failed delivery should not crash the event handler: |
| 74 | + |
| 75 | +```typescript |
| 76 | +private async sendActivityToFollowers(account: Account, activity: Activity) { |
| 77 | + const ctx = this.fedifyContextFactory.getFedifyContext(); |
| 78 | + |
| 79 | + try { |
| 80 | + await ctx.sendActivity( |
| 81 | + { username: account.username }, |
| 82 | + 'followers', |
| 83 | + activity, |
| 84 | + { preferSharedInbox: true }, |
| 85 | + ); |
| 86 | + } catch (err) { |
| 87 | + this.logger.error('Failed to send activity {activityId} to followers of {account}', { |
| 88 | + activityId: activity.id?.href, |
| 89 | + account: account.username, |
| 90 | + error: err, |
| 91 | + }); |
| 92 | + } |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +### New events required |
| 97 | + |
| 98 | +Some domain operations do not currently emit events. These need to be created (following [ADR-0011](0011-serializable-domain-events.md)) before their federation can be moved to the bridge: |
| 99 | + |
| 100 | +- `PostUnlikedEvent` - for federating `Undo(Like)` |
| 101 | +- `FollowRequestedEvent` - for federating outbound `Follow` requests to external accounts |
| 102 | + |
| 103 | +### Migration strategy |
| 104 | + |
| 105 | +Each `sendActivity` call site is migrated independently in its own PR: |
| 106 | + |
| 107 | +1. Create the bridge handler (and new event if needed) |
| 108 | +2. Remove the `sendActivity` call from the controller/service |
| 109 | +3. Verify with tests |
| 110 | + |
| 111 | +This can be done incrementally - the codebase can have a mix of migrated and unmigrated call sites during the transition. |
| 112 | + |
| 113 | +## Consequences |
| 114 | + |
| 115 | +### Positive |
| 116 | + |
| 117 | +- Federation happens in a single place, not scattered across the codebase |
| 118 | +- Controllers and services are decoupled from Fedify |
| 119 | +- Consistent error handling for all outbound federation |
| 120 | +- Fixes the class of bugs where federation failure crashes the HTTP request (BER-2715) |
| 121 | +- Federation is easier to reason about: "when domain event X occurs, federate activity Y" |
| 122 | +- Enables future improvements like retry logic, dead letter queues, or federation metrics in one place |
| 123 | + |
| 124 | +### Negative |
| 125 | + |
| 126 | +- Bridge handlers need repository lookups to reconstruct context (slight latency, handled gracefully if entity is missing) |
| 127 | +- `FediverseBridge` grows as more handlers are added - may need to be split into focused modules later |
| 128 | +- Some federation requires data that isn't in the current event payloads, requiring either richer events or additional lookups |
| 129 | + |
| 130 | +## Risks |
| 131 | + |
| 132 | +- **Response latency:** `emitAsync` waits for all handlers, so federation delivery blocks the HTTP response (same as today). The try/catch fix prevents 500 errors but does not improve response times. Moving events to a message queue (which ADR-0011 enables) would make federation truly async and is the long-term solution. |
| 133 | +- **globaldb coupling:** Many current `sendActivity` call sites also write activity JSON-LD to `globaldb`. This storage concern moves into the bridge handlers, coupling the bridge to `globaldb`. This is acceptable as `globaldb` is an implementation detail of ActivityPub federation. |
| 134 | +- **Entity deletion race:** If an entity is deleted between event emission and handler execution, the repository lookup returns nothing. Handlers must handle this gracefully (early return). `PostDeletedEvent` already solves this by carrying all needed data inline. |
0 commit comments