feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1

Merged
GKaszewski merged 334 commits from v2 into master 2026-05-16 09:42:43 +00:00
Showing only changes of commit 9af1d33e71 - Show all commits

View File

@@ -0,0 +1,779 @@
# ActivityPub Likes & Boost Notifications Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Wire local likes/unlikes to outbound Like/Undo(Like) AP activities, and handle inbound Like and Announce activities so Mastodon interactions create notifications.
**Architecture:** Four layers of change — domain port extension, ActivityPubService implementation, application-layer federation event routing, and inbox activity handler registration. Inbound likes/boosts publish domain events (LikeAdded/BoostAdded) so the existing notification service picks them up without duplication. A locality guard in `federation_event.rs` prevents re-broadcasting remote boosts.
**Tech Stack:** Rust, activitypub_federation crate, async-trait, serde, domain ports.
---
## Files
| Action | File | Purpose |
|--------|------|---------|
| Modify | `crates/domain/src/ports.rs` | Add `broadcast_like`, `broadcast_undo_like` to `OutboundFederationPort` |
| Modify | `crates/application/src/services/federation_event.rs` | Add `liked`/`undo_liked` to SpyPort; add `LikeAdded`/`LikeRemoved` arms; add locality guard to `BoostAdded` |
| Modify | `crates/adapters/activitypub-base/src/activities.rs` | Add `LikeActivity` struct; `LikeActivity::receive`; update `AnnounceActivity::receive`; register in `InboxActivities` |
| Modify | `crates/adapters/activitypub-base/src/content.rs` | Add `on_like`, `on_announce_received` to `ApObjectHandler` trait |
| Modify | `crates/adapters/activitypub-base/src/service.rs` | Add `broadcast_like_to_inbox`, `broadcast_undo_like_to_inbox`; implement port methods |
| Modify | `crates/adapters/activitypub/src/handler.rs` | Implement `on_like`, `on_announce_received` in `ThoughtsObjectHandler` |
---
## Task 1: Extend OutboundFederationPort + SpyPort
**Files:**
- Modify: `crates/domain/src/ports.rs`
- Modify: `crates/application/src/services/federation_event.rs`
- [ ] **Step 1: Add two methods to `OutboundFederationPort` in `crates/domain/src/ports.rs`**
Find `OutboundFederationPort` (around line 417). Add after `broadcast_undo_announce`:
```rust
/// Send a Like activity to a remote thought author's inbox.
/// Only called when a LOCAL user likes a REMOTE thought (one with an ap_id).
async fn broadcast_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Send Undo(Like) to a remote thought author's inbox.
async fn broadcast_undo_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
```
- [ ] **Step 2: Add stubs to `SpyPort` in `crates/application/src/services/federation_event.rs`**
Find `SpyPort` struct (around line 245). Add two fields:
```rust
liked: Mutex<Vec<String>>,
undo_liked: Mutex<Vec<String>>,
```
Find `impl OutboundFederationPort for SpyPort`. Add after `broadcast_undo_announce`:
```rust
async fn broadcast_like(
&self,
_: &UserId,
ap_id: &str,
_: &str,
) -> Result<(), DomainError> {
self.liked.lock().unwrap().push(ap_id.to_string());
Ok(())
}
async fn broadcast_undo_like(
&self,
_: &UserId,
ap_id: &str,
_: &str,
) -> Result<(), DomainError> {
self.undo_liked.lock().unwrap().push(ap_id.to_string());
Ok(())
}
```
- [ ] **Step 3: Verify compilation**
```bash
cd /mnt/drive/dev/thoughts && cargo build -p domain -p application 2>&1 | grep "^error" | head -10
```
Expected: no errors (activitypub-base will fail until Task 3 — that's fine, build only those two crates).
- [ ] **Step 4: Commit**
```bash
git add crates/domain/src/ports.rs crates/application/src/services/federation_event.rs
git commit -m "feat(domain): add broadcast_like/broadcast_undo_like to OutboundFederationPort"
```
---
## Task 2: LikeActivity struct + ApObjectHandler trait methods
**Files:**
- Modify: `crates/adapters/activitypub-base/src/activities.rs`
- Modify: `crates/adapters/activitypub-base/src/content.rs`
### Part A — LikeActivity struct (activities.rs)
- [ ] **Step 1: Add `LikeType` and `LikeActivity` to `crates/adapters/activitypub-base/src/activities.rs`**
Find where `AnnounceType` is defined (around line 13). Add right after:
```rust
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename = "Like")]
pub struct LikeType;
impl Default for LikeType {
fn default() -> Self {
Self
}
}
```
Find where `AnnounceActivity` struct is defined (around line 461). Add a `LikeActivity` struct after it:
```rust
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LikeActivity {
pub id: Url,
#[serde(rename = "type")]
pub kind: LikeType,
pub actor: ObjectId<DbActor>,
pub object: Url,
}
```
### Part B — ApObjectHandler trait (content.rs)
- [ ] **Step 2: Add `on_like` and `on_announce_received` to `ApObjectHandler` in `crates/adapters/activitypub-base/src/content.rs`**
Find the `ApObjectHandler` trait. Add after `on_actor_removed`:
```rust
/// Called when a remote actor likes a local thought.
/// `object_url` is the AP URL of the liked note (e.g. `{base}/thoughts/{uuid}`).
/// `actor_url` is the AP URL of the remote actor who sent the Like.
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Called when a remote actor boosts (Announce) a local thought.
/// `object_url` is the AP URL of the announced note.
/// `actor_url` is the AP URL of the remote actor who sent the Announce.
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
```
- [ ] **Step 3: Verify compilation of activitypub-base**
```bash
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error" | head -10
```
Expected: errors that `ThoughtsObjectHandler` in `activitypub` doesn't implement the new methods — that's fine. `activitypub-base` itself should compile.
- [ ] **Step 4: Commit**
```bash
git add crates/adapters/activitypub-base/src/activities.rs \
crates/adapters/activitypub-base/src/content.rs
git commit -m "feat(activitypub-base): LikeActivity struct + on_like/on_announce_received trait methods"
```
---
## Task 3: Implement broadcast_like + LikeActivity::receive + AnnounceActivity update
**Files:**
- Modify: `crates/adapters/activitypub-base/src/service.rs`
- Modify: `crates/adapters/activitypub-base/src/activities.rs`
### Part A — ActivityPubService implementation (service.rs)
- [ ] **Step 1: Add `broadcast_like_to_inbox` private method to `impl ActivityPubService`**
Add this private method inside `impl ActivityPubService` (not inside the port impl block):
```rust
pub async fn broadcast_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: url::Url,
author_inbox_url: url::Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
// Deterministic ID so Undo(Like) can reference the same activity.
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes(),
)
))?;
let like = crate::activities::LikeActivity {
id: like_id,
kind: Default::default(),
actor: activitypub_federation::fetch::object_id::ObjectId::from(
local_actor.ap_id.clone(),
),
object: object_ap_id,
};
let sends = activitypub_federation::activity_sending::SendActivityTask::prepare(
&activitypub_federation::protocol::context::WithContext::new_default(like),
&local_actor,
vec![author_inbox_url],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some Like deliveries failed permanently");
}
Ok(())
}
```
- [ ] **Step 2: Add `broadcast_undo_like_to_inbox` private method**
Add directly after `broadcast_like_to_inbox`:
```rust
pub async fn broadcast_undo_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: url::Url,
author_inbox_url: url::Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
// Reconstruct the same deterministic like ID used when the like was sent.
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes(),
)
))?;
let undo_id =
crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let undo = crate::activities::UndoActivity {
id: undo_id,
kind: Default::default(),
actor: activitypub_federation::fetch::object_id::ObjectId::from(
local_actor.ap_id.clone(),
),
object: serde_json::json!({
"type": "Like",
"id": like_id.to_string(),
"actor": local_actor.ap_id.to_string(),
"object": object_ap_id.to_string(),
}),
};
let sends = activitypub_federation::activity_sending::SendActivityTask::prepare(
&activitypub_federation::protocol::context::WithContext::new_default(undo),
&local_actor,
vec![author_inbox_url],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some Undo(Like) deliveries failed permanently");
}
Ok(())
}
```
- [ ] **Step 3: Implement `broadcast_like` and `broadcast_undo_like` in `impl domain::ports::OutboundFederationPort for ActivityPubService`**
Find the existing `broadcast_undo_announce` impl. Add directly after it:
```rust
async fn broadcast_like(
&self,
liker_user_id: &domain::value_objects::UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), domain::errors::DomainError> {
let object = url::Url::parse(object_ap_id)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
let inbox = url::Url::parse(author_inbox_url)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
self.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))
}
async fn broadcast_undo_like(
&self,
liker_user_id: &domain::value_objects::UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), domain::errors::DomainError> {
let object = url::Url::parse(object_ap_id)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
let inbox = url::Url::parse(author_inbox_url)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
self.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))
}
```
### Part B — LikeActivity::receive + AnnounceActivity update (activities.rs)
- [ ] **Step 4: Implement `Activity` for `LikeActivity` in `crates/adapters/activitypub-base/src/activities.rs`**
Add after the `LikeActivity` struct definition:
```rust
#[async_trait]
impl Activity for LikeActivity {
type DataType = FederationData;
type Error = crate::error::Error;
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Like from blocked domain");
return Ok(());
}
// Only process if the liked object is on our instance.
if self.object.host_str().unwrap_or("") != data.domain {
return Ok(());
}
data.object_handler
.on_like(&self.object, self.actor.inner())
.await
.map_err(|e| crate::error::Error::Other(e.to_string()))?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like");
Ok(())
}
}
```
- [ ] **Step 5: Update `AnnounceActivity::receive` to call `on_announce_received`**
Find `AnnounceActivity::receive`. After the `add_announce` call and before the `tracing::info!`, add:
```rust
data.object_handler
.on_announce_received(&self.object, self.actor.inner())
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process announce notification");
});
```
- [ ] **Step 6: Register `LikeActivity` in `InboxActivities` enum**
Find the `InboxActivities` enum. Add:
```rust
#[serde(rename = "Like")]
Like(LikeActivity),
```
- [ ] **Step 7: Verify activitypub-base compiles**
```bash
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error" | head -10
```
Expected: no errors from activitypub-base. (`activitypub` crate will fail until Task 4.)
- [ ] **Step 8: Commit**
```bash
git add crates/adapters/activitypub-base/src/service.rs \
crates/adapters/activitypub-base/src/activities.rs
git commit -m "feat(activitypub-base): broadcast_like/undo_like + LikeActivity inbox handler"
```
---
## Task 4: Implement on_like and on_announce_received in ThoughtsObjectHandler
**Files:**
- Modify: `crates/adapters/activitypub/src/handler.rs`
`ThoughtsObjectHandler` has `ap_repo: Arc<dyn ActivityPubRepository>` and `event_publisher: Option<Arc<dyn EventPublisher>>`. These are all we need.
Pattern for both methods:
1. Parse the thought UUID out of the object URL path (`/thoughts/{uuid}`)
2. Find the remote actor's local user ID via `ap_repo.find_remote_actor_id(actor_url)`
3. Publish the appropriate domain event — the notification service already handles `LikeAdded` and `BoostAdded`
- [ ] **Step 1: Read `crates/adapters/activitypub/src/handler.rs` to understand the struct and existing impls**
Look for `struct ThoughtsObjectHandler` and `impl ApObjectHandler for ThoughtsObjectHandler`.
- [ ] **Step 2: Implement `on_like` in `impl ApObjectHandler for ThoughtsObjectHandler`**
Add:
```rust
async fn on_like(&self, object_url: &url::Url, actor_url: &url::Url) -> anyhow::Result<()> {
// Parse thought UUID from path like /thoughts/{uuid}
let thought_uuid = object_url
.path()
.strip_prefix("/thoughts/")
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => {
tracing::debug!(object = %object_url, "on_like: not a local thought URL, skipping");
return Ok(());
}
};
// Resolve the remote actor to a local user ID.
let actor_user_id = self
.ap_repo
.find_remote_actor_id(actor_url)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => {
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping notification");
return Ok(());
}
};
if let Some(ep) = &self.event_publisher {
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let like_id = domain::value_objects::LikeId::new();
ep.publish(&domain::events::DomainEvent::LikeAdded {
like_id,
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok(())
}
```
- [ ] **Step 3: Implement `on_announce_received`**
Add directly after `on_like`:
```rust
async fn on_announce_received(
&self,
object_url: &url::Url,
actor_url: &url::Url,
) -> anyhow::Result<()> {
// Parse thought UUID from path like /thoughts/{uuid}
let thought_uuid = object_url
.path()
.strip_prefix("/thoughts/")
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => return Ok(()),
};
let actor_user_id = self
.ap_repo
.find_remote_actor_id(actor_url)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => return Ok(()),
};
if let Some(ep) = &self.event_publisher {
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let boost_id = domain::value_objects::BoostId::new();
ep.publish(&domain::events::DomainEvent::BoostAdded {
boost_id,
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok(())
}
```
- [ ] **Step 4: Verify full workspace build**
```bash
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -10
```
Expected: no errors.
- [ ] **Step 5: Run tests**
```bash
cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5
```
- [ ] **Step 6: Commit**
```bash
git add crates/adapters/activitypub/src/handler.rs
git commit -m "feat(activitypub): implement on_like and on_announce_received in ThoughtsObjectHandler"
```
---
## Task 5: federation_event.rs — LikeAdded/LikeRemoved arms + BoostAdded locality guard
**Files:**
- Modify: `crates/application/src/services/federation_event.rs`
The federation service must:
- **BoostAdded**: add a locality check so remote boosts (published by Task 4) don't get re-broadcast
- **LikeAdded**: fan-out only when a LOCAL user likes a REMOTE thought (has ap_id)
- **LikeRemoved**: Undo(Like) when a LOCAL user unlikes a REMOTE thought
- [ ] **Step 1: Write tests for the new arms**
Find the `#[cfg(test)]` block in `crates/application/src/services/federation_event.rs`. Add:
```rust
#[tokio::test]
async fn like_added_local_user_remote_thought_broadcasts_like() {
let store = TestStore::default();
let spy = Arc::new(SpyPort::default());
// Set up a remote thought with ap_id
let author = {
let mut u = test_user("remote_author");
u.local = false;
u.inbox_url = Some("https://mastodon.social/users/author/inbox".into());
u
};
let thought = {
let mut t = test_thought(author.id.clone());
t.ap_id = Some("https://mastodon.social/posts/123".into());
t.in_reply_to_url = None;
t
};
let liker = test_user("alice"); // local user
store.users.lock().unwrap().push(author);
store.users.lock().unwrap().push(liker.clone());
store.thoughts.lock().unwrap().push(thought.clone());
let svc = test_service(store, spy.clone());
svc.process(&DomainEvent::LikeAdded {
like_id: LikeId::new(),
user_id: liker.id,
thought_id: thought.id,
})
.await
.unwrap();
assert_eq!(spy.liked.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn like_added_remote_user_skips_broadcast() {
let store = TestStore::default();
let spy = Arc::new(SpyPort::default());
let author = test_user("alice");
let thought = test_thought(author.id.clone()); // local thought, no ap_id
let remote_liker = {
let mut u = test_user("bob");
u.local = false;
u
};
store.users.lock().unwrap().push(author);
store.users.lock().unwrap().push(remote_liker.clone());
store.thoughts.lock().unwrap().push(thought.clone());
let svc = test_service(store, spy.clone());
svc.process(&DomainEvent::LikeAdded {
like_id: LikeId::new(),
user_id: remote_liker.id,
thought_id: thought.id,
})
.await
.unwrap();
assert!(spy.liked.lock().unwrap().is_empty());
}
#[tokio::test]
async fn boost_added_remote_user_skips_broadcast() {
let store = TestStore::default();
let spy = Arc::new(SpyPort::default());
let author = test_user("alice");
let thought = test_thought(author.id.clone());
let remote_booster = {
let mut u = test_user("bob");
u.local = false;
u
};
store.users.lock().unwrap().push(author);
store.users.lock().unwrap().push(remote_booster.clone());
store.thoughts.lock().unwrap().push(thought.clone());
let svc = test_service(store, spy.clone());
svc.process(&DomainEvent::BoostAdded {
boost_id: BoostId::new(),
user_id: remote_booster.id,
thought_id: thought.id,
})
.await
.unwrap();
assert!(spy.announced.lock().unwrap().is_empty());
}
```
Note: these tests use `test_user`, `test_thought`, `test_service` helpers — read the existing tests in the same file to find these helpers and use the same pattern. If `User.local` field setters don't exist, set the field directly (it's `pub`).
- [ ] **Step 2: Run tests to confirm they fail**
```bash
cd /mnt/drive/dev/thoughts && cargo test -p application federation_event 2>&1 | grep "FAILED\|error" | head -10
```
Expected: tests fail (LikeAdded arm not handled, BoostAdded has no locality guard).
- [ ] **Step 3: Add locality guard to existing `BoostAdded` arm**
Find the `DomainEvent::BoostAdded` match arm. Add a locality check at the top:
```rust
DomainEvent::BoostAdded {
boost_id: _,
user_id,
thought_id,
} => {
// Only fan-out if the booster is a local user. Remote boosts (from inbound
// Announce activities) must not be re-broadcast to avoid loops.
let booster = match self.users.find_by_id(user_id).await? {
Some(u) if u.local => u,
_ => return Ok(()),
};
let _ = booster; // suppress unused warning — kept for the locality check
let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) => t,
None => return Ok(()),
};
let object_ap_id = self.object_ap_id(&thought, thought_id);
self.ap.broadcast_announce(user_id, &object_ap_id).await
}
```
- [ ] **Step 4: Add `LikeAdded` arm**
Find the `_ => Ok(())` catch-all at the end of the `match event` block. Add before it:
```rust
DomainEvent::LikeAdded {
like_id: _,
user_id,
thought_id,
} => {
// Only federate: local liker + remote thought (has ap_id + author has inbox).
let liker = match self.users.find_by_id(user_id).await? {
Some(u) if u.local => u,
_ => return Ok(()),
};
let _ = liker;
let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) if t.ap_id.is_some() => t,
_ => return Ok(()),
};
let author = match self.users.find_by_id(&thought.user_id).await? {
Some(u) if u.inbox_url.is_some() => u,
_ => return Ok(()),
};
let object_ap_id = thought.ap_id.unwrap();
let inbox_url = author.inbox_url.unwrap();
self.ap
.broadcast_like(user_id, &object_ap_id, &inbox_url)
.await
}
```
- [ ] **Step 5: Add `LikeRemoved` arm**
Add directly after `LikeAdded`:
```rust
DomainEvent::LikeRemoved {
user_id,
thought_id,
} => {
let liker = match self.users.find_by_id(user_id).await? {
Some(u) if u.local => u,
_ => return Ok(()),
};
let _ = liker;
let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) if t.ap_id.is_some() => t,
_ => return Ok(()),
};
let author = match self.users.find_by_id(&thought.user_id).await? {
Some(u) if u.inbox_url.is_some() => u,
_ => return Ok(()),
};
let object_ap_id = thought.ap_id.unwrap();
let inbox_url = author.inbox_url.unwrap();
self.ap
.broadcast_undo_like(user_id, &object_ap_id, &inbox_url)
.await
}
```
- [ ] **Step 6: Run tests — all should pass**
```bash
cd /mnt/drive/dev/thoughts && cargo test -p application federation_event 2>&1 | tail -5
```
Expected: all pass.
- [ ] **Step 7: Full build + all unit tests**
```bash
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -5
cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5
```
- [ ] **Step 8: Commit**
```bash
git add crates/application/src/services/federation_event.rs
git commit -m "feat(application): federate local likes + locality guard prevents remote boost re-broadcast"
```
---
## Notes
- **No loop risk**: The `BoostAdded` locality guard (`u.local`) ensures remote boosts published by `on_announce_received` skip federation fan-out. Same guard applies to `LikeAdded`.
- **Existing notification service**: `LikeAdded` and `BoostAdded` events published from inbound activity handlers are picked up by `NotificationEventService` unchanged — it already creates notifications for these events.
- **Deterministic activity IDs**: Like and Undo(Like) use `Uuid::new_v5(NAMESPACE_URL, "{user}/{object}")` so the Undo can reference the original Like ID without DB storage.
- **Only remote thoughts get likes federated**: Local thoughts liked by local users generate no outbound activity (the like is already recorded locally).