Files
thoughts/docs/superpowers/plans/2026-05-15-ap-likes-boosts.md

25 KiB

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:

/// 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:

liked: Mutex<Vec<String>>,
undo_liked: Mutex<Vec<String>>,

Find impl OutboundFederationPort for SpyPort. Add after broadcast_undo_announce:

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
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
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:

#[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:

#[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:

/// 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
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
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):

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:

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:

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:

#[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:

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:

#[serde(rename = "Like")]
Like(LikeActivity),
  • Step 7: Verify activitypub-base compiles
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
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:

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:

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
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -10

Expected: no errors.

  • Step 5: Run tests
cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5
  • Step 6: Commit
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:

#[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
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:

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:

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:

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
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
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
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).