From 9af1d33e71ba906c5941b776f2b594c20939c5fd Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 04:45:39 +0200 Subject: [PATCH] docs: AP likes and boost notifications implementation plan --- .../plans/2026-05-15-ap-likes-boosts.md | 779 ++++++++++++++++++ 1 file changed, 779 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-15-ap-likes-boosts.md diff --git a/docs/superpowers/plans/2026-05-15-ap-likes-boosts.md b/docs/superpowers/plans/2026-05-15-ap-likes-boosts.md new file mode 100644 index 0000000..e0e5a73 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-ap-likes-boosts.md @@ -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>, +undo_liked: Mutex>, +``` + +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, + 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) -> 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` and `event_publisher: Option>`. 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).