Files
thoughts/docs/superpowers/plans/2026-05-15-federation-gaps-2.md

27 KiB

Federation Gaps — Round 2 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: Fix seven federation gaps: HTML content format, hashtag federation, Undo(Like) inbound, Update(Actor) on profile change, @mention notifications, remote posts in home feed, and orphaned reply parent display.

Architecture: Backend changes span the AP adapter layer (activities.rs, service.rs, handler.rs), application layer (use cases, event service), and postgres adapter (feed.rs). Frontend changes are limited to api.ts and thought-card.tsx. All changes follow the existing hexagonal pattern — no business logic in presentation, domain events for cross-cutting concerns.

Tech Stack: Rust / axum / sqlx / activitypub_federation crate; Next.js 15 / TypeScript / Zod.


Files Modified

Task File Change
1 crates/adapters/activitypub-base/src/service.rs Wrap content in <p> tags with HTML escaping
2 crates/adapters/activitypub/src/note.rs Add tag field to ThoughtNote
2 crates/adapters/activitypub-base/src/service.rs Extract hashtags and add to Note JSON
3 crates/adapters/activitypub-base/src/activities.rs Add "Like" arm to UndoActivity::receive
4 crates/domain/src/ports.rs Add broadcast_actor_update to OutboundFederationPort
4 crates/domain/src/events.rs Add ProfileUpdated variant
4 crates/domain/src/testing.rs Add SpyPort stub for broadcast_actor_update
4 crates/application/src/use_cases/profile.rs Publish ProfileUpdated from update_profile
4 crates/application/src/services/federation_event.rs Handle ProfileUpdated → broadcast_actor_update
4 crates/adapters/activitypub-base/src/service.rs Implement broadcast_actor_update port method
5 crates/adapters/activitypub/src/note.rs Add tag deserialization field
5 crates/adapters/activitypub-base/src/content.rs Add on_mention to ApObjectHandler
5 crates/adapters/activitypub/src/handler.rs Parse Mention tags, implement on_mention
5 crates/domain/src/events.rs Add MentionReceived variant
5 crates/domain/src/testing.rs No-op on_mention in TestStore impl
5 crates/application/src/services/notification_event.rs Handle MentionReceived
6 crates/adapters/postgres/src/feed.rs Extend home_feed SQL to include federation_following
7 crates/api-types/src/responses.rs Add in_reply_to_url to ThoughtResponse
7 crates/presentation/src/handlers/feed.rs Map in_reply_to_url into response
7 thoughts-frontend/lib/api.ts Add replyToUrl to ThoughtSchema
7 thoughts-frontend/components/thought-card.tsx Show external reply link when replyToUrl set

Task 1: HTML content in outbound Notes

Mastodon and other AP servers expect HTML, not plain text. Wrap content in <p> tags and escape HTML entities. Multi-paragraph posts (newlines) get multiple <p> elements.

Files:

  • Modify: crates/adapters/activitypub-base/src/service.rs (function thought_note_json)

  • Step 1: Add a private HTML-escaping helper near the top of service.rs

Read crates/adapters/activitypub-base/src/service.rs. Find fn thought_note_json. Add this private function just before it:

fn content_to_html(text: &str) -> String {
    let escaped = text
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;");
    let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
    if paragraphs.is_empty() {
        format!("<p>{}</p>", escaped)
    } else {
        paragraphs
            .iter()
            .map(|p| format!("<p>{}</p>", p))
            .collect::<Vec<_>>()
            .join("")
    }
}
  • Step 2: Use content_to_html in thought_note_json

In thought_note_json, find:

"content": thought.content.as_str(),

Replace with:

"content": content_to_html(thought.content.as_str()),
  • Step 3: Verify compilation
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error"

Expected: no errors.

  • Step 4: Commit
git add crates/adapters/activitypub-base/src/service.rs
git commit -m "fix(ap): wrap outbound Note content in HTML paragraph tags"

Task 2: Hashtag federation

Outbound Notes must include a tag array with Hashtag objects so Mastodon can index posts by hashtag. Extract #word patterns from content and add to the Note JSON.

Files:

  • Modify: crates/adapters/activitypub-base/src/service.rs (thought_note_json)

  • Step 1: Add a hashtag-extraction helper in service.rs

Add this function near content_to_html (already added in Task 1):

fn extract_hashtag_tags(content: &str, base_url: &str) -> Vec<serde_json::Value> {
    let mut seen = std::collections::HashSet::new();
    let mut tags = Vec::new();
    for word in content.split_whitespace() {
        let tag = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '#');
        if let Some(name) = tag.strip_prefix('#') {
            if !name.is_empty() && seen.insert(name.to_lowercase()) {
                let lower = name.to_lowercase();
                tags.push(serde_json::json!({
                    "type": "Hashtag",
                    "name": format!("#{}", lower),
                    "href": format!("{}/tags/{}", base_url, lower),
                }));
            }
        }
    }
    tags
}
  • Step 2: Add hashtag tags to the Note JSON in thought_note_json

In thought_note_json, after the closing } of let mut note = serde_json::json!({...}), add:

let hashtag_tags = extract_hashtag_tags(thought.content.as_str(), base_url);
if !hashtag_tags.is_empty() {
    note["tag"] = serde_json::json!(hashtag_tags);
}

Note: base_url is already a parameter of thought_note_json(&self, thought, local_actor, base_url) — use it directly.

  • Step 3: Verify compilation
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error"
  • Step 4: Commit
git add crates/adapters/activitypub-base/src/service.rs
git commit -m "feat(ap): add hashtag tag array to outbound Notes"

Task 3: Undo(Like) inbound handler

When a remote user unlikes a local post, we should acknowledge it. Add a "Like" arm to UndoActivity::receive that calls on_unlike on the object handler. The on_unlike impl will be a no-op (we don't store remote likes in the likes table, only notifications — removing them requires more infrastructure). This prevents the "ignoring Undo of unknown activity type" log spam.

Files:

  • Modify: crates/adapters/activitypub-base/src/activities.rs

  • Modify: crates/adapters/activitypub-base/src/content.rs

  • Modify: crates/adapters/activitypub/src/handler.rs

  • Step 1: Add on_unlike to ApObjectHandler trait in content.rs

Read crates/adapters/activitypub-base/src/content.rs. Find ApObjectHandler. Add after on_announce_received:

/// Called when a remote actor removes a Like from a local thought.
async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
  • Step 2: Add no-op on_unlike to ThoughtsObjectHandler in handler.rs

Read crates/adapters/activitypub/src/handler.rs. Add after on_announce_received:

async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
    Ok(())
}
  • Step 3: Add "Like" arm to UndoActivity::receive in activities.rs

Read crates/adapters/activitypub-base/src/activities.rs. Find UndoActivity::receive. Find the match obj_type block. Add before the other => catch-all:

"Like" => {
    if let Some(obj_url_str) = self.object.get("object").and_then(|o| o.as_str())
        && let Ok(obj_url) = Url::parse(obj_url_str)
        && obj_url.host_str().unwrap_or("") == data.domain
    {
        data.object_handler
            .on_unlike(&obj_url, self.actor.inner())
            .await
            .unwrap_or_else(|e| {
                tracing::warn!(error = %e, "failed to process unlike");
            });
    }
    tracing::info!(actor = %self.actor.inner(), "received Undo(Like)");
}
  • Step 4: Verify compilation
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error"
  • Step 5: Commit
git add crates/adapters/activitypub-base/src/activities.rs \
        crates/adapters/activitypub-base/src/content.rs \
        crates/adapters/activitypub/src/handler.rs
git commit -m "feat(ap): handle Undo(Like) inbound activity"

Task 4: Update(Actor) outbound on profile change

When a user updates their profile (display name, bio, avatar), broadcast an Update(Actor) activity to their AP followers. The broadcast_actor_update method already exists on ActivityPubService — it just needs to be exposed as a port method and wired through the event system.

Files:

  • Modify: crates/domain/src/ports.rs — add to OutboundFederationPort

  • Modify: crates/domain/src/events.rs — add ProfileUpdated variant

  • Modify: crates/domain/src/testing.rs — SpyPort stub

  • Modify: crates/application/src/use_cases/profile.rs — publish event

  • Modify: crates/application/src/services/federation_event.rs — handle event

  • Modify: crates/adapters/activitypub-base/src/service.rs — implement port method

  • Step 1: Add ProfileUpdated to DomainEvent in crates/domain/src/events.rs

Read the file. Add to the enum:

ProfileUpdated { user_id: UserId },
  • Step 2: Add broadcast_actor_update to OutboundFederationPort in crates/domain/src/ports.rs

Find OutboundFederationPort. Add after broadcast_undo_like:

/// Broadcast Update(Actor) to all accepted followers when a user updates their profile.
async fn broadcast_actor_update(
    &self,
    user_id: &UserId,
) -> Result<(), DomainError>;
  • Step 3: Add stub to SpyPort in crates/application/src/services/federation_event.rs

Find SpyPort struct. Add field:

actor_updated: Mutex<Vec<UserId>>,

Find impl OutboundFederationPort for SpyPort. Add:

async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> {
    self.actor_updated.lock().unwrap().push(user_id.clone());
    Ok(())
}
  • Step 4: Add EventPublisher to update_profile use case in crates/application/src/use_cases/profile.rs

Read the file. Find pub async fn update_profile(...). Add events: &dyn EventPublisher as a parameter and import it. Publish ProfileUpdated after the update:

pub async fn update_profile(
    users: &dyn UserRepository,
    events: &dyn EventPublisher,
    user_id: &UserId,
    display_name: Option<String>,
    bio: Option<String>,
    avatar_url: Option<String>,
    header_url: Option<String>,
    custom_css: Option<String>,
) -> Result<(), DomainError> {
    users
        .update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
        .await?;
    events
        .publish(&DomainEvent::ProfileUpdated {
            user_id: user_id.clone(),
        })
        .await?;
    Ok(())
}

Make sure DomainEvent and EventPublisher are imported at the top of profile.rs. Check the existing imports and add what's missing.

  • Step 5: Update all callers of update_profile to pass &*s.events

update_profile is called from crates/presentation/src/handlers/users.rs. Read that file. Find the patch_profile handler call to update_profile. Add &*s.events as the second argument:

update_profile(
    &*s.users,
    &*s.events,
    &uid,
    body.display_name,
    body.bio,
    body.avatar_url,
    body.header_url,
    body.custom_css,
)
.await?;
  • Step 6: Implement broadcast_actor_update port method in ActivityPubService in crates/adapters/activitypub-base/src/service.rs

Find impl domain::ports::OutboundFederationPort for ActivityPubService. Add after broadcast_undo_like:

async fn broadcast_actor_update(
    &self,
    user_id: &domain::value_objects::UserId,
) -> Result<(), domain::errors::DomainError> {
    self.broadcast_actor_update(user_id.as_uuid())
        .await
        .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))
}

Note: this calls the existing private broadcast_actor_update(uuid) method on ActivityPubService.

  • Step 7: Handle ProfileUpdated in federation_event.rs

Find the match event block. Add before the catch-all _ => Ok(()):

DomainEvent::ProfileUpdated { user_id } => {
    self.ap.broadcast_actor_update(user_id).await
}
  • Step 8: Verify build and tests
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -5
cargo test -p domain -p application 2>&1 | tail -5
  • Step 9: Commit
git add crates/domain/src/events.rs \
        crates/domain/src/ports.rs \
        crates/domain/src/testing.rs \
        crates/application/src/use_cases/profile.rs \
        crates/application/src/services/federation_event.rs \
        crates/presentation/src/handlers/users.rs \
        crates/adapters/activitypub-base/src/service.rs
git commit -m "feat(ap): broadcast Update(Actor) when user updates their profile"

Task 5: @mention notification

When a remote Note arrives with a Mention tag pointing to a local user, create a notification. The Note's tag array contains objects like {"type":"Mention","href":"https://our.instance/users/{uuid}","name":"@user@domain"}.

Files:

  • Modify: crates/adapters/activitypub/src/note.rs — add tag field

  • Modify: crates/adapters/activitypub-base/src/content.rs — add on_mention to trait

  • Modify: crates/adapters/activitypub/src/handler.rs — parse tags, implement on_mention

  • Modify: crates/domain/src/events.rs — add MentionReceived

  • Modify: crates/domain/src/testing.rs — no-op on_mention

  • Modify: crates/application/src/services/notification_event.rs — handle MentionReceived

  • Step 1: Add tag field to ThoughtNote in crates/adapters/activitypub/src/note.rs

Read the file. Add to the ThoughtNote struct:

#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tag: Vec<serde_json::Value>,
  • Step 2: Add MentionReceived to DomainEvent in crates/domain/src/events.rs

Add to the enum:

MentionReceived {
    thought_id: ThoughtId,
    mentioned_user_id: UserId,
    author_user_id: UserId,
},
  • Step 3: Add on_mention to ApObjectHandler in crates/adapters/activitypub-base/src/content.rs

Add after on_unlike:

/// Called once per @mention of a local user in a remote Note.
/// `thought_ap_id` is the AP URL of the Note, `mentioned_user_id` is the UUID
/// of the local user being mentioned, `actor_url` is the remote author's AP URL.
async fn on_mention(
    &self,
    thought_ap_id: &Url,
    mentioned_user_uuid: uuid::Uuid,
    actor_url: &Url,
) -> anyhow::Result<()>;
  • Step 4: Add no-op on_mention to TestStore's ApObjectHandler impl in crates/domain/src/testing.rs

Note: TestStore does NOT implement ApObjectHandler — that's ThoughtsObjectHandler in the activitypub adapter. Instead, find if there is a test double or just implement in handler.rs directly (step 5 below covers it).

  • Step 5: Implement on_mention in ThoughtsObjectHandler in crates/adapters/activitypub/src/handler.rs

Add after on_unlike:

async fn on_mention(
    &self,
    thought_ap_id: &url::Url,
    mentioned_user_uuid: uuid::Uuid,
    actor_url: &url::Url,
) -> anyhow::Result<()> {
    // Resolve remote author to a local user ID.
    let author_user_id = match self
        .repo
        .find_remote_actor_id(actor_url)
        .await
        .map_err(|e| anyhow::anyhow!("{e}"))?
    {
        Some(id) => id,
        None => return Ok(()),
    };

    // Extract thought UUID from /thoughts/{uuid} path.
    let thought_uuid = thought_ap_id
        .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(()),
    };

    if let Some(ep) = &self.event_publisher {
        ep.publish(&domain::events::DomainEvent::MentionReceived {
            thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
            mentioned_user_id: domain::value_objects::UserId::from_uuid(mentioned_user_uuid),
            author_user_id,
        })
        .await
        .map_err(|e| anyhow::anyhow!("{e}"))?;
    }

    Ok(())
}
  • Step 6: Parse Mention tags and call on_mention in ThoughtsObjectHandler::on_create

Find on_create. After the accept_note(...) call, add:

// Fire mention notifications for any local @mentions in the note's tag array.
let local_domain = self.urls.base_url().host_str().unwrap_or("");
for tag in &note.tag {
    if tag.get("type").and_then(|t| t.as_str()) != Some("Mention") {
        continue;
    }
    let href = match tag.get("href").and_then(|h| h.as_str()) {
        Some(h) => h,
        None => continue,
    };
    let href_url = match url::Url::parse(href) {
        Ok(u) => u,
        Err(_) => continue,
    };
    // Only process mentions of local users (UUID-based /users/{uuid} paths).
    if href_url.host_str().unwrap_or("") != local_domain {
        continue;
    }
    let user_uuid = href_url
        .path()
        .strip_prefix("/users/")
        .and_then(|s| s.split('/').next())
        .and_then(|s| uuid::Uuid::parse_str(s).ok());
    if let Some(uuid) = user_uuid {
        self.on_mention(ap_id, uuid, actor_url)
            .await
            .unwrap_or_else(|e| {
                tracing::warn!(error = %e, "failed to process mention notification");
            });
    }
}

Note: self.urls.base_url() — check ThoughtsUrls for how to get the base URL Url. If not available, parse self.urls fields or add a helper. Check the ThoughtsUrls struct in crates/adapters/activitypub/src/urls.rs.

  • Step 7: Handle MentionReceived in crates/application/src/services/notification_event.rs

Find the match event block. Add before the _ => Ok(()) catch-all:

DomainEvent::MentionReceived {
    thought_id,
    mentioned_user_id,
    author_user_id,
} => {
    self.notifications
        .save(&Notification {
            id: NotificationId::new(),
            user_id: mentioned_user_id.clone(),
            notification_type: NotificationType::Mention,
            from_user_id: Some(author_user_id.clone()),
            thought_id: Some(thought_id.clone()),
            read: false,
            created_at: Utc::now(),
        })
        .await
}

Make sure NotificationType::Mention is a variant — check crates/domain/src/models/notification.rs. It already has Mention variant.

  • Step 8: Verify build and tests
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -10
cargo test -p domain -p application 2>&1 | tail -5
  • Step 9: Commit
git add crates/adapters/activitypub/src/note.rs \
        crates/adapters/activitypub-base/src/content.rs \
        crates/adapters/activitypub/src/handler.rs \
        crates/domain/src/events.rs \
        crates/application/src/services/notification_event.rs
git commit -m "feat(ap): @mention notification from inbound remote Notes"

Task 6: Remote posts in home feed

The home_feed SQL currently only includes thoughts from users in the follows table (local follows). Remote follows are in federation_following, so remote users' posts never appear. Extend the SQL to also include thoughts from users whose AP URL is in federation_following for the viewer.

Files:

  • Modify: crates/adapters/postgres/src/feed.rs

  • Step 1: Read crates/adapters/postgres/src/feed.rs in full

Focus on fn feed_select(viewer: Option<uuid::Uuid>) -> String and async fn home_feed(...).

Key insight: feed_select embeds viewer UUID directly into the SQL string (not as a bind parameter). The home_feed SQL uses $1 (following_ids), $2 (limit), $3 (offset) as bind params.

  • Step 2: Add follower parameter to feed_select

Change the signature to:

fn feed_select(viewer: Option<uuid::Uuid>, follower: Option<uuid::Uuid>) -> String

At the top of the function body, generate a federation following subquery:

let federation_clause = match follower {
    Some(fid) => format!(
        "OR t.user_id IN (
            SELECT u2.id FROM users u2
            JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
            WHERE ff.local_user_id = '{fid}'
        )"
    ),
    None => String::new(),
};

This string is used in step 3's WHERE clause modification.

Since feed_select generates only the SELECT part (not WHERE), the federation_clause needs to be returned somehow. Options:

  • Return a tuple (select_str, federation_clause) from feed_select
  • Or add a separate helper fn federation_following_clause(follower: Option<uuid::Uuid>) -> String

Use option B — separate helper — to avoid changing feed_select's return type:

fn federation_following_clause(follower: Option<uuid::Uuid>) -> String {
    match follower {
        Some(fid) => format!(
            " OR t.user_id IN (
                SELECT u2.id FROM users u2
                JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
                WHERE ff.local_user_id = '{fid}'
            )"
        ),
        None => String::new(),
    }
}

Leave feed_select signature unchanged.

  • Step 3: Modify home_feed to use federation_following_clause

Find the home_feed method. The viewer_id is the feed owner (the logged-in user), which is also the person whose federation_following we want.

Replace:

let viewer = viewer_id.map(|v| v.as_uuid());
// ...
let total: i64 = sqlx::query_scalar(
    "SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility != 'direct'",
)

With:

let viewer = viewer_id.map(|v| v.as_uuid());
let fed_clause = federation_following_clause(viewer);
let total: i64 = sqlx::query_scalar(&format!(
    "SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
    fed_clause
))

And replace:

let sql = format!("{sel} WHERE t.user_id=ANY($1) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");

With:

let sql = format!("{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3", fed_clause);

The rest of the bindings ($1, $2, $3) stay unchanged.

  • Step 4: Verify compilation
cd /mnt/drive/dev/thoughts && cargo build -p postgres 2>&1 | grep "^error" | head -5
  • Step 5: Run all tests
cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5
  • Step 6: Commit
git add crates/adapters/postgres/src/feed.rs
git commit -m "feat(feed): include remote following posts in home feed"

Task 7: Reply parent display + API field

Remote posts that are replies show without context because:

  1. ThoughtResponse doesn't expose in_reply_to_url (the external URL of the parent)
  2. The frontend doesn't link to the parent when it's external

Files:

  • Modify: crates/api-types/src/responses.rs

  • Modify: crates/presentation/src/handlers/feed.rs (or wherever to_thought_response is defined)

  • Modify: thoughts-frontend/lib/api.ts

  • Modify: thoughts-frontend/components/thought-card.tsx

  • Step 1: Add in_reply_to_url to ThoughtResponse in crates/api-types/src/responses.rs

Read the file. Find ThoughtResponse struct. Add after reply_to_id:

#[serde(rename = "replyToUrl", skip_serializing_if = "Option::is_none")]
pub in_reply_to_url: Option<String>,
  • Step 2: Map in_reply_to_url in the response builder

Find where ThoughtResponse is constructed from a Thought (search for ThoughtResponse { or to_thought_response). Add the mapping:

in_reply_to_url: thought.in_reply_to_url.clone(),
  • Step 3: Verify backend compilation
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -5
  • Step 4: Add replyToUrl to ThoughtSchema in thoughts-frontend/lib/api.ts

Find ThoughtSchema. Add:

replyToUrl: z.string().url().nullable().optional(),
  • Step 5: Update thought-card.tsx to show external reply link

Read thoughts-frontend/components/thought-card.tsx. Find the section that renders thought.replyToId. It currently shows "Replying to parent thought" with a hash link only when isReply is true.

Add an external reply link for when the thought has a replyToUrl but no local replyToId:

{thought.replyToId && isReply && (
  <div className="text-sm text-muted-foreground flex items-center gap-2">
    <CornerUpLeft className="h-4 w-4 text-primary/70" />
    <span>
      Replying to{" "}
      <Link
        href={`#${thought.replyToId}`}
        className="hover:underline text-primary text-shadow-sm"
      >
        parent thought
      </Link>
    </span>
  </div>
)}
{!thought.replyToId && thought.replyToUrl && (
  <div className="text-sm text-muted-foreground flex items-center gap-2">
    <CornerUpLeft className="h-4 w-4 text-primary/70" />
    <span>
      Replying to{" "}
      <a
        href={thought.replyToUrl}
        target="_blank"
        rel="noopener noreferrer"
        className="hover:underline text-primary text-shadow-sm"
      >
        original post 
      </a>
    </span>
  </div>
)}
  • Step 6: Type check frontend
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -5
  • Step 7: Final build + tests
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error"
cargo test -p domain -p application 2>&1 | tail -5
  • Step 8: Commit
git add crates/api-types/src/responses.rs \
        thoughts-frontend/lib/api.ts \
        thoughts-frontend/components/thought-card.tsx
git commit -m "feat: expose replyToUrl in API + show external parent link on remote reply posts"

Notes

  • Task 5 (mentions): self.urls.base_url() — if ThoughtsUrls doesn't expose the base URL as a Url, parse it from self.urls.base_url string. Check crates/adapters/activitypub/src/urls.rs for the exact field.
  • Task 6 (feed): The embedded UUID in the SQL is a UUID type (hex + hyphens only), safe to format-string without SQL injection risk.
  • Task 7 (reply): The to_thought_response builder might be in handlers/feed.rs, handlers/thoughts.rs, or a shared module — search the codebase for where ThoughtResponse is constructed.
  • Profile update (Task 4): If tests in application call update_profile directly, they'll need to pass a TestStore as the events parameter (TestStore implements EventPublisher).