Skip to content

Commit 0e519ae

Browse files
committed
Added ADR-0013 to document event driven federation
ref https://linear.app/ghost/issue/BER-2715 Added ADR-0013 to document event driven federation which is a prerequisite to resolving https://linear.app/ghost/issue/BER-2715
1 parent b4c773b commit 0e519ae

File tree

1 file changed

+134
-0
lines changed

1 file changed

+134
-0
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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

Comments
 (0)