Files
thoughts/docs/superpowers/plans/2026-05-15-domain-application-refactor.md

44 KiB

Domain & Application Refactor 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: Clean up 11 architectural problems in crates/domain/ and crates/application/ — from most critical to least — applying CQRS port splits, removing AP infrastructure leakage from the domain, making invalid states unrepresentable, and eliminating pass-through noise.

Architecture: Hexagonal (ports & adapters). Domain knows nothing about HTTP, SQL, or ActivityPub. Application orchestrates domain via ports. Adapters implement ports. CQRS: every repository is split into a read-side trait and a write-side trait; the concrete adapter implements both; a combined supertrait keeps AppState unchanged.

Tech Stack: Rust, Tokio, SQLx, Axum. All changes must pass cargo check --workspace and the pre-commit hook (cargo fmt && cargo clippy).


Phase 1 — Remove AP Infrastructure from the Domain (most critical)

These four tasks are the most invasive. They must land together in one commit because they form a closed loop of changes.

Task 1: Add AP lookup accessors to ActivityPubRepository

The domain models User and Thought currently carry AP-specific fields (ap_id, inbox_url, in_reply_to_url). Before we can remove those fields, the services that currently read them need another way to get the data. This task adds that other way.

Files:

  • Modify: crates/domain/src/ports.rs

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

  • Modify: crates/domain/src/testing.rs

  • Step 1: Add ActorApUrls struct and two new methods to ActivityPubRepository in ports.rs

In crates/domain/src/ports.rs, after the OutboxEntry struct (~line 336), add:

/// AP-protocol endpoints for a locally-stored user (local or interned remote).
#[derive(Debug, Clone)]
pub struct ActorApUrls {
    pub ap_id: String,
    pub inbox_url: String,
}

Then inside pub trait ActivityPubRepository, add after count_local_notes:

    /// Return the ActivityPub object URL for a thought, if one is stored.
    /// Returns None for local thoughts (caller constructs the URL from base_url + thought_id).
    async fn get_thought_ap_id(
        &self,
        thought_id: &ThoughtId,
    ) -> Result<Option<String>, DomainError>;

    /// Return the AP actor URL and inbox URL for a user, if stored.
    /// Returns None for users that have not been federated.
    async fn get_actor_ap_urls(
        &self,
        user_id: &UserId,
    ) -> Result<Option<ActorApUrls>, DomainError>;
  • Step 2: Implement both methods in PgActivityPubRepository

In crates/adapters/postgres/src/activitypub.rs, add the two implementations:

async fn get_thought_ap_id(
    &self,
    thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError> {
    sqlx::query_scalar::<_, String>(
        "SELECT ap_id FROM thoughts WHERE id = $1 AND ap_id IS NOT NULL",
    )
    .bind(thought_id.as_uuid())
    .fetch_optional(&self.pool)
    .await
    .into_domain()
}

async fn get_actor_ap_urls(
    &self,
    user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> {
    sqlx::query_as::<_, (String, String)>(
        "SELECT ap_id, inbox_url FROM users \
         WHERE id = $1 AND ap_id IS NOT NULL AND inbox_url IS NOT NULL",
    )
    .bind(user_id.as_uuid())
    .fetch_optional(&self.pool)
    .await
    .into_domain()
    .map(|opt| {
        opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url })
    })
}
  • Step 3: Add stub implementations in TestStore

In crates/domain/src/testing.rs, add to the ActivityPubRepository impl for TestStore:

async fn get_thought_ap_id(
    &self,
    _thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError> {
    Ok(None)
}

async fn get_actor_ap_urls(
    &self,
    _user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> {
    Ok(None)
}
  • Step 4: Compile check
cargo check --workspace 2>&1 | head -30

Expected: 0 errors.


Task 2: Remove AP fields from User and Thought domain models

Now that the AP lookup methods exist, remove the AP-specific fields from the domain models and update all consumers.

Files:

  • Modify: crates/domain/src/models/user.rs

  • Modify: crates/domain/src/models/thought.rs

  • Modify: crates/adapters/postgres/src/user.rs (UserRow → User mapping)

  • Modify: crates/adapters/postgres/src/thought.rs (ThoughtRow → Thought mapping)

  • Modify: crates/adapters/postgres/src/feed.rs (FeedRow → FeedEntry mapping)

  • Modify: crates/adapters/postgres-search/src/lib.rs (FeedRow → FeedEntry mapping)

  • Modify: crates/application/src/services/federation_event.rs

  • Modify: crates/domain/src/testing.rs

  • Step 1: Remove from User model

In crates/domain/src/models/user.rs, remove ap_id and inbox_url fields:

#[derive(Debug, Clone)]
pub struct User {
    pub id: UserId,
    pub username: Username,
    pub email: Email,
    pub password_hash: PasswordHash,
    pub display_name: Option<String>,
    pub bio: Option<String>,
    pub avatar_url: Option<String>,
    pub header_url: Option<String>,
    pub custom_css: Option<String>,
    pub local: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl User {
    pub fn new_local(
        id: UserId,
        username: Username,
        email: Email,
        password_hash: PasswordHash,
    ) -> Self {
        let now = Utc::now();
        Self {
            id,
            username,
            email,
            password_hash,
            display_name: None,
            bio: None,
            avatar_url: None,
            header_url: None,
            custom_css: None,
            local: true,
            created_at: now,
            updated_at: now,
        }
    }
}
  • Step 2: Remove from Thought model

In crates/domain/src/models/thought.rs, remove ap_id and in_reply_to_url fields:

#[derive(Debug, Clone)]
pub struct Thought {
    pub id: ThoughtId,
    pub user_id: UserId,
    pub content: Content,
    pub in_reply_to_id: Option<ThoughtId>,
    pub visibility: Visibility,
    pub content_warning: Option<String>,
    pub sensitive: bool,
    pub local: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: Option<DateTime<Utc>>,
}

impl Thought {
    pub fn new_local(
        id: ThoughtId,
        user_id: UserId,
        content: Content,
        in_reply_to_id: Option<ThoughtId>,
        visibility: Visibility,
        content_warning: Option<String>,
        sensitive: bool,
    ) -> Self {
        Self {
            id,
            user_id,
            content,
            in_reply_to_id,
            visibility,
            content_warning,
            sensitive,
            local: true,
            created_at: Utc::now(),
            updated_at: None,
        }
    }
}
  • Step 3: Update UserRow → User mapping in postgres/src/user.rs

Remove the ap_id and inbox_url fields from UserRow and its From<UserRow> for User impl:

#[derive(sqlx::FromRow)]
pub struct UserRow {
    pub id: uuid::Uuid,
    pub username: String,
    pub email: String,
    pub password_hash: String,
    pub display_name: Option<String>,
    pub bio: Option<String>,
    pub avatar_url: Option<String>,
    pub header_url: Option<String>,
    pub custom_css: Option<String>,
    pub local: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl From<UserRow> for User {
    fn from(r: UserRow) -> Self {
        User {
            id: UserId::from_uuid(r.id),
            username: Username::from_trusted(r.username),
            email: Email::from_trusted(r.email),
            password_hash: PasswordHash(r.password_hash),
            display_name: r.display_name,
            bio: r.bio,
            avatar_url: r.avatar_url,
            header_url: r.header_url,
            custom_css: r.custom_css,
            local: r.local,
            created_at: r.created_at,
            updated_at: r.updated_at,
        }
    }
}

Update USER_SELECT to remove the columns:

pub const USER_SELECT: &str =
    "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
     custom_css,local,created_at,updated_at FROM users";
  • Step 4: Update ThoughtRow → Thought mapping in postgres/src/thought.rs

Remove ap_id and in_reply_to_url from ThoughtRow and its From impl. The ap_id column stays in the DB — we just don't pull it into the domain model via this path. The SELECT query changes too:

#[derive(sqlx::FromRow)]
struct ThoughtRow {
    pub id: uuid::Uuid,
    pub user_id: uuid::Uuid,
    pub content: String,
    pub in_reply_to_id: Option<uuid::Uuid>,
    pub visibility: String,
    pub content_warning: Option<String>,
    pub sensitive: bool,
    pub local: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: Option<DateTime<Utc>>,
}

Update the SELECT constant and From impl to match (remove ap_id, in_reply_to_url from both). Also update the save method — remove the ap_id binding from INSERT. The ap_id column in the DB gets set to NULL for local thoughts; remote thoughts' ap_id is set by accept_note in the AP adapter.

  • Step 5: Update FeedRow mappings in feed.rs and postgres-search/lib.rs

In postgres/src/feed.rs, remove t_ap_id and in_reply_to_url from FeedRow and from the SELECT query. In postgres-search/src/lib.rs, same removal.

The FeedEntry.thought field is now a Thought without ap_id. Adapters that need AP context use ActivityPubRepository.

  • Step 6: Update FederationEventService to use AP lookups

In crates/application/src/services/federation_event.rs, replace all reads of thought.ap_id and user.inbox_url with calls to self.ap_repo:

Replace the object_ap_id helper:

async fn object_ap_id(&self, thought: &Thought, thought_id: &ThoughtId) -> Result<String, DomainError> {
    if !thought.local {
        if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? {
            return Ok(ap_id);
        }
    }
    Ok(format!("{}/thoughts/{}", self.base_url, thought_id))
}

Note: this method is now async. Update all callers to .await.

For ThoughtCreated — the in_reply_to_url resolution changes. Instead of reading thought.ap_id of the parent, call self.ap_repo.get_thought_ap_id(reply_id).await? and fall back to constructing the URL.

For LikeAdded / LikeRemoved — replace thought.ap_id.is_some() check and thought.ap_id.unwrap() / user.inbox_url.unwrap() with:

let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
    Some(id) => id,
    None => return Ok(()), // local thought — no federation needed
};
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
    Some(u) => u,
    None => return Ok(()),
};
self.ap.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url).await

For BoostAdded / BoostRemoved — same pattern, use object_ap_id (now async).

  • Step 7: Update TestStore for fields removed from models

In crates/domain/src/testing.rs, update any place that constructs User or Thought with ap_id/inbox_url/in_reply_to_url fields. These fields no longer exist on the structs.

  • Step 8: Compile check
cargo check --workspace 2>&1 | head -40

Fix all errors before moving on.

  • Step 9: Commit
git add -p
git commit -m "refactor(domain): remove AP fields from User and Thought; use ActivityPubRepository lookups"

Task 3: Remove AP infrastructure events from DomainEvent; add FederationSchedulerPort

FetchRemoteActorPosts and FetchActorConnections in DomainEvent carry raw AP URLs — infrastructure, not domain. Replace them with a narrow port that the application layer calls directly.

Files:

  • Modify: crates/domain/src/events.rs

  • Modify: crates/domain/src/ports.rs

  • Modify: crates/domain/src/testing.rs

  • Modify: crates/application/src/use_cases/federation_management.rs

  • Modify: crates/application/src/services/federation_event.rs

  • Modify: crates/adapters/activitypub-base/src/service.rs (implements the new port)

  • Modify: crates/presentation/src/state.rs

  • Modify: crates/bootstrap/src/factory.rs

  • Step 1: Add FederationSchedulerPort to ports.rs

#[async_trait]
pub trait FederationSchedulerPort: Send + Sync {
    async fn schedule_actor_posts_fetch(
        &self,
        actor_ap_url: &str,
        outbox_url: &str,
    ) -> Result<(), DomainError>;

    async fn schedule_connections_fetch(
        &self,
        actor_ap_url: &str,
        collection_url: &str,
        connection_type: &str,
        page: u32,
    ) -> Result<(), DomainError>;
}
  • Step 2: Remove the two AP events from events.rs

In crates/domain/src/events.rs, delete the FetchRemoteActorPosts and FetchActorConnections variants entirely. The enum becomes:

#[derive(Debug, Clone)]
pub enum DomainEvent {
    ThoughtCreated { thought_id: ThoughtId, user_id: UserId, in_reply_to_id: Option<ThoughtId> },
    ThoughtDeleted { thought_id: ThoughtId, user_id: UserId },
    ThoughtUpdated { thought_id: ThoughtId, user_id: UserId },
    LikeAdded { like_id: LikeId, user_id: UserId, thought_id: ThoughtId },
    LikeRemoved { user_id: UserId, thought_id: ThoughtId },
    BoostAdded { boost_id: BoostId, user_id: UserId, thought_id: ThoughtId },
    BoostRemoved { user_id: UserId, thought_id: ThoughtId },
    FollowRequested { follower_id: UserId, following_id: UserId },
    FollowAccepted { follower_id: UserId, following_id: UserId },
    FollowRejected { follower_id: UserId, following_id: UserId },
    Unfollowed { follower_id: UserId, following_id: UserId },
    UserBlocked { blocker_id: UserId, blocked_id: UserId },
    UserUnblocked { blocker_id: UserId, blocked_id: UserId },
    UserRegistered { user_id: UserId },
    ProfileUpdated { user_id: UserId },
    MentionReceived { thought_id: ThoughtId, mentioned_user_id: UserId, author_user_id: UserId },
}
  • Step 3: Update federation_management.rs to call the scheduler port directly

In crates/application/src/use_cases/federation_management.rs, add a scheduler: &dyn FederationSchedulerPort parameter to get_remote_actor_posts and get_actor_connections_page, and call it instead of publishing an event:

pub async fn get_remote_actor_posts(
    federation: &dyn FederationActionPort,
    ap_repo: &dyn ActivityPubRepository,
    feed: &dyn FeedRepository,
    scheduler: &dyn FederationSchedulerPort,   // NEW
    handle: &str,
    page: PageParams,
    viewer_id: Option<&UserId>,
) -> Result<(Paginated<FeedEntry>, bool), DomainError> {
    let actor = federation.lookup_actor(handle).await?;
    let actor_url = url::Url::parse(&actor.url)
        .map_err(|e| DomainError::ExternalService(e.to_string()))?;
    let author_id = match ap_repo.find_remote_actor_id(&actor_url).await? {
        Some(id) => id,
        None => ap_repo.intern_remote_actor(&actor_url).await?,
    };
    let result = get_user_feed(feed, &author_id, page, viewer_id).await?;
    // Schedule background fetch if actor has outbox
    if let Some(ref outbox_url) = actor.outbox_url {
        let _ = scheduler
            .schedule_actor_posts_fetch(&actor.url, outbox_url)
            .await;
    }
    let has_more = !result.items.is_empty();
    Ok((result, has_more))
}

pub async fn get_actor_connections_page(
    federation: &dyn FederationActionPort,
    connections: &dyn RemoteActorConnectionRepository,
    scheduler: &dyn FederationSchedulerPort,   // NEW
    handle: &str,
    connection_type: &str,
    page: u32,
) -> Result<(Vec<ActorConnectionSummary>, bool), DomainError> {
    // ... existing lookup logic ...
    // Replace event publish with:
    if stale {
        let _ = scheduler
            .schedule_connections_fetch(&actor.url, &collection_url, connection_type, page)
            .await;
    }
    // ...
}
  • Step 4: Update FederationEventService — remove the two AP event arms

In federation_event.rs, delete the DomainEvent::FetchRemoteActorPosts and DomainEvent::FetchActorConnections match arms. The service no longer handles infrastructure fetch commands.

Also remove the federation_action and remote_actor_connections fields from FederationEventService if they're only used by those two removed arms. Check: they're also used nowhere else. Remove them from the struct definition.

  • Step 5: Implement FederationSchedulerPort in ActivityPubService

In crates/adapters/activitypub-base/src/service.rs, add an impl:

#[async_trait::async_trait]
impl domain::ports::FederationSchedulerPort for ActivityPubService {
    async fn schedule_actor_posts_fetch(
        &self,
        actor_ap_url: &str,
        outbox_url: &str,
    ) -> Result<(), domain::errors::DomainError> {
        // Re-use the existing event publisher to publish the background task,
        // but now this is an adapter concern, not a domain event.
        self.events
            .publish(&domain::events::DomainEvent::ThoughtCreated {
                // ... actually: use a separate internal channel or directly spawn
            })
            .await
    }
}

Note: The exact implementation depends on how the worker picks up background tasks. If the worker currently subscribes to DomainEvent, and we've removed these variants, the worker needs a new subscription path. The simplest compatible approach: have ActivityPubService keep an internal sender for these tasks (the existing NATS/channel mechanism), and the worker subscribes to that same mechanism. Read crates/adapters/nats/src/lib.rs to understand the existing plumbing, then implement accordingly.

  • Step 6: Add FederationSchedulerPort to AppState and TestStore

AppState (crates/presentation/src/state.rs):

pub federation_scheduler: Arc<dyn FederationSchedulerPort>,

TestStore (crates/domain/src/testing.rs) — add a no-op impl:

#[async_trait]
impl FederationSchedulerPort for TestStore {
    async fn schedule_actor_posts_fetch(&self, _: &str, _: &str) -> Result<(), DomainError> {
        Ok(())
    }
    async fn schedule_connections_fetch(&self, _: &str, _: &str, _: &str, _: u32) -> Result<(), DomainError> {
        Ok(())
    }
}
  • Step 7: Wire up in bootstrap/src/factory.rs

Add federation_scheduler: Arc::new(infra.ap_service.clone()) to the AppState construction (assuming ActivityPubService implements the port).

  • Step 8: Update handlers that call get_remote_actor_posts / get_actor_connections_page

In crates/presentation/src/handlers/federation_actors.rs, pass &*s.federation_scheduler to the new parameter.

  • Step 9: Compile check
cargo check --workspace 2>&1 | head -40
  • Step 10: Commit
git commit -m "refactor(domain): remove FetchRemoteActorPosts/FetchActorConnections from DomainEvent; add FederationSchedulerPort"

Phase 2 — CQRS Port Split

Task 4: Split UserRepository into UserReader + UserWriter

Files:

  • Modify: crates/domain/src/ports.rs

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

  • Modify: crates/domain/src/testing.rs

  • Modify: crates/application/src/use_cases/*.rs (update function signatures)

  • Step 1: Define UserReader and UserWriter in ports.rs

Replace the existing UserRepository trait with three traits:

#[async_trait]
pub trait UserReader: Send + Sync {
    async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
    async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
    async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
    async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
    async fn count(&self) -> Result<i64, DomainError>;
}

#[async_trait]
pub trait UserWriter: Send + Sync {
    async fn save(&self, user: &User) -> Result<(), DomainError>;
    async fn update_profile(
        &self,
        user_id: &UserId,
        display_name: Option<String>,
        bio: Option<String>,
        avatar_url: Option<String>,
        header_url: Option<String>,
        custom_css: Option<String>,
    ) -> Result<(), DomainError>;
}

/// Combined supertrait — kept so `AppState` needs no change.
/// Postgres adapter implements all three; use cases declare the narrower bound they need.
pub trait UserRepository: UserReader + UserWriter {}
impl<T: UserReader + UserWriter> UserRepository for T {}
  • Step 2: PgUserRepository implements UserReader and UserWriter separately

In crates/adapters/postgres/src/user.rs, split the single impl UserRepository into:

#[async_trait]
impl UserReader for PgUserRepository {
    // all read methods
}

#[async_trait]
impl UserWriter for PgUserRepository {
    // save, update_profile
}

The blanket impl in ports.rs makes PgUserRepository: UserRepository automatically.

  • Step 3: Update use case function signatures to declare the narrowest bound they need

In each use case file, change users: &dyn UserRepository to the narrowest applicable bound:

Use case New bound
get_user, get_user_by_username, get_user_by_id_or_username, get_top_friends &dyn UserReader
register_user &dyn UserWriter
update_profile &dyn UserWriter (for the write) + &dyn UserReader (to fetch back)
follow_actor, unfollow_actor, block_by_username &dyn UserReader (lookup only)

Handlers still pass &*s.users (an Arc<dyn UserRepository>), which satisfies any narrower bound via Rust's trait coercion.

  • Step 4: Update TestStore — split its user impl to satisfy both traits
#[async_trait]
impl UserReader for TestStore { /* existing read methods */ }

#[async_trait]
impl UserWriter for TestStore { /* existing write methods */ }
  • Step 5: Compile check
cargo check --workspace 2>&1 | head -30
  • Step 6: Commit
git commit -m "refactor(ports): CQRS split — UserReader + UserWriter supertrait"

Task 5: Split FederationActionPort into four focused sub-ports

Files:

  • Modify: crates/domain/src/ports.rs

  • Modify: crates/domain/src/testing.rs

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

  • Modify: crates/application/src/use_cases/federation_management.rs

  • Modify: crates/application/src/use_cases/social.rs

  • Modify: crates/application/src/use_cases/profile.rs

  • Modify: crates/presentation/src/state.rs

  • Step 1: Define the four sub-ports in ports.rs

Replace FederationActionPort with:

#[async_trait]
pub trait FederationLookupPort: Send + Sync {
    async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
    async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
    async fn followers_collection_json(&self, user_id: &UserId, page: Option<u32>) -> Result<String, DomainError>;
    async fn following_collection_json(&self, user_id: &UserId, page: Option<u32>) -> Result<String, DomainError>;
}

#[async_trait]
pub trait FederationFollowPort: Send + Sync {
    async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
    async fn unfollow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
    async fn get_remote_following(&self, user_id: &UserId) -> Result<Vec<RemoteActor>, DomainError>;
}

#[async_trait]
pub trait FederationFollowRequestPort: Send + Sync {
    async fn get_pending_followers(&self, user_id: &UserId) -> Result<Vec<RemoteActor>, DomainError>;
    async fn accept_follow_request(&self, user_id: &UserId, actor_url: &str) -> Result<(), DomainError>;
    async fn reject_follow_request(&self, user_id: &UserId, actor_url: &str) -> Result<(), DomainError>;
    async fn get_remote_followers(&self, user_id: &UserId) -> Result<Vec<RemoteActor>, DomainError>;
    async fn remove_remote_follower(&self, user_id: &UserId, actor_url: &str) -> Result<(), DomainError>;
}

#[async_trait]
pub trait FederationFetchPort: Send + Sync {
    async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError>;
    async fn fetch_actor_urls_from_collection(&self, collection_url: &str) -> Result<Vec<String>, DomainError>;
    async fn resolve_actor_profiles(&self, urls: Vec<String>) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
}

/// Combined supertrait — `AppState.federation` stays as a single field.
pub trait FederationActionPort: FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort {}
impl<T: FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort> FederationActionPort for T {}
  • Step 2: Split ActivityPubService impl into four sub-impls

In crates/adapters/activitypub-base/src/service.rs, replace impl FederationActionPort with four separate impls, each containing only the methods for that sub-port.

  • Step 3: Update use case signatures to declare the narrowest port they need
Use case / handler New bound
get_user (AP actor JSON path) &dyn FederationLookupPort
follow_actor, unfollow_actor &dyn FederationFollowPort
get_top_friends (no federation) remove federation param if unused
list_pending_requests, accept/reject, list_remote_followers/following &dyn FederationFollowRequestPort
get_remote_actor_posts, get_actor_connections_page &dyn FederationLookupPort + &dyn FederationFetchPort
remove_remote_following (federation_management) &dyn FederationFollowPort

Handlers pass &*s.federation which satisfies all bounds.

  • Step 4: Update TestStore — split impl into four
#[async_trait]
impl FederationLookupPort for TestStore { /* ... */ }
#[async_trait]
impl FederationFollowPort for TestStore { /* ... */ }
#[async_trait]
impl FederationFollowRequestPort for TestStore { /* ... */ }
#[async_trait]
impl FederationFetchPort for TestStore { /* ... */ }
  • Step 5: Compile check
cargo check --workspace 2>&1 | head -40
  • Step 6: Commit
git commit -m "refactor(ports): CQRS split — FederationActionPort into four focused sub-ports"

Phase 3 — Domain Model Quality

Task 6: Algebraic Notification type (make invalid states unrepresentable)

Files:

  • Modify: crates/domain/src/models/notification.rs

  • Modify: crates/domain/src/ports.rs (NotificationRepository::save param type stays &Notification)

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

  • Modify: crates/application/src/services/notification_event.rs

  • Modify: crates/domain/src/testing.rs

  • Step 1: Redefine Notification as an algebraic type

Replace crates/domain/src/models/notification.rs entirely:

use crate::value_objects::{NotificationId, ThoughtId, UserId};
use chrono::{DateTime, Utc};

#[derive(Debug, Clone, PartialEq)]
pub enum NotificationKind {
    Like    { thought_id: ThoughtId, from_user_id: UserId },
    Boost   { thought_id: ThoughtId, from_user_id: UserId },
    Reply   { thought_id: ThoughtId, from_user_id: UserId },
    Mention { thought_id: ThoughtId, from_user_id: UserId },
    Follow  { from_user_id: UserId },
}

impl NotificationKind {
    pub fn from_user_id(&self) -> &UserId {
        match self {
            Self::Like    { from_user_id, .. } => from_user_id,
            Self::Boost   { from_user_id, .. } => from_user_id,
            Self::Reply   { from_user_id, .. } => from_user_id,
            Self::Mention { from_user_id, .. } => from_user_id,
            Self::Follow  { from_user_id }     => from_user_id,
        }
    }

    pub fn thought_id(&self) -> Option<&ThoughtId> {
        match self {
            Self::Like    { thought_id, .. } => Some(thought_id),
            Self::Boost   { thought_id, .. } => Some(thought_id),
            Self::Reply   { thought_id, .. } => Some(thought_id),
            Self::Mention { thought_id, .. } => Some(thought_id),
            Self::Follow  { .. }             => None,
        }
    }

    pub fn kind_str(&self) -> &'static str {
        match self {
            Self::Like { .. }    => "like",
            Self::Boost { .. }   => "boost",
            Self::Reply { .. }   => "reply",
            Self::Mention { .. } => "mention",
            Self::Follow { .. }  => "follow",
        }
    }
}

#[derive(Debug, Clone)]
pub struct Notification {
    pub id: NotificationId,
    pub user_id: UserId,
    pub kind: NotificationKind,
    pub read: bool,
    pub created_at: DateTime<Utc>,
}
  • Step 2: Update postgres/src/notification.rs

The DB row still has notification_type, from_user_id, thought_id. Add a NotificationRow → Notification conversion that constructs the correct NotificationKind variant:

#[derive(sqlx::FromRow)]
struct NotificationRow {
    id: uuid::Uuid,
    user_id: uuid::Uuid,
    notification_type: String,
    from_user_id: Option<uuid::Uuid>,
    thought_id: Option<uuid::Uuid>,
    read: bool,
    created_at: DateTime<Utc>,
}

fn row_to_notification(r: NotificationRow) -> Result<Notification, DomainError> {
    let from_user_id = r.from_user_id
        .map(UserId::from_uuid)
        .ok_or_else(|| DomainError::Internal("notification missing from_user_id".into()))?;

    let kind = match r.notification_type.as_str() {
        "follow" => NotificationKind::Follow { from_user_id },
        other => {
            let thought_id = r.thought_id
                .map(ThoughtId::from_uuid)
                .ok_or_else(|| DomainError::Internal(
                    format!("notification type '{other}' missing thought_id")
                ))?;
            match other {
                "like"    => NotificationKind::Like    { thought_id, from_user_id },
                "boost"   => NotificationKind::Boost   { thought_id, from_user_id },
                "reply"   => NotificationKind::Reply   { thought_id, from_user_id },
                "mention" => NotificationKind::Mention { thought_id, from_user_id },
                _ => return Err(DomainError::Internal(
                    format!("unknown notification type: {other}")
                )),
            }
        }
    };

    Ok(Notification {
        id: NotificationId::from_uuid(r.id),
        user_id: UserId::from_uuid(r.user_id),
        kind,
        read: r.read,
        created_at: r.created_at,
    })
}

Update save to write the new fields from n.kind:

async fn save(&self, n: &Notification) -> Result<(), DomainError> {
    sqlx::query(
        "INSERT INTO notifications(id,user_id,notification_type,from_user_id,thought_id,read,created_at)
         VALUES($1,$2,$3,$4,$5,$6,$7)
         ON CONFLICT(id) DO NOTHING"
    )
    .bind(n.id.as_uuid())
    .bind(n.user_id.as_uuid())
    .bind(n.kind.kind_str())
    .bind(n.kind.from_user_id().as_uuid())
    .bind(n.kind.thought_id().map(|t| t.as_uuid()))
    .bind(n.read)
    .bind(n.created_at)
    .execute(&self.pool)
    .await
    .into_domain()
    .map(|_| ())
}
  • Step 3: Update notification_event.rs (the service that creates notifications)

Read crates/application/src/services/notification_event.rs and update all Notification { notification_type, from_user_id: Some(...), thought_id: Some(...), ... } constructions to use the new NotificationKind enum:

// Before
Notification {
    id: NotificationId::new(),
    user_id: owner_id.clone(),
    notification_type: NotificationType::Like,
    from_user_id: Some(liker_id.clone()),
    thought_id: Some(thought_id.clone()),
    read: false,
    created_at: Utc::now(),
}

// After
Notification {
    id: NotificationId::new(),
    user_id: owner_id.clone(),
    kind: NotificationKind::Like { thought_id: thought_id.clone(), from_user_id: liker_id.clone() },
    read: false,
    created_at: Utc::now(),
}
  • Step 4: Update handlers that read Notification fields

In crates/presentation/src/handlers/notifications.rs and any response mapping — change .notification_type / .from_user_id / .thought_id reads to pattern match on .kind.

  • Step 5: Remove NotificationType enum (the old one in notification.rs) — it is fully replaced by NotificationKind.

  • Step 6: Compile check

cargo check --workspace 2>&1 | head -30
  • Step 7: Commit
git commit -m "refactor(domain): algebraic Notification type — invalid states now unrepresentable"

Task 7: Make from_db_str return Result instead of silently defaulting

Files:

  • Modify: crates/domain/src/models/thought.rs

  • Modify: crates/domain/src/models/social.rs

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

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

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

  • Modify: crates/adapters/postgres-search/src/lib.rs

  • Step 1: Change Visibility::from_db_str to return Result

In crates/domain/src/models/thought.rs:

impl Visibility {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Public    => "public",
            Self::Followers => "followers",
            Self::Unlisted  => "unlisted",
            Self::Direct    => "direct",
        }
    }

    pub fn from_db_str(s: &str) -> Result<Self, crate::errors::DomainError> {
        match s {
            "public"    => Ok(Self::Public),
            "followers" => Ok(Self::Followers),
            "unlisted"  => Ok(Self::Unlisted),
            "direct"    => Ok(Self::Direct),
            other => Err(crate::errors::DomainError::Internal(
                format!("unknown visibility value in DB: '{other}'")
            )),
        }
    }
}
  • Step 2: Change FollowState::from_db_str to return Result

In crates/domain/src/models/social.rs:

impl FollowState {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Pending  => "pending",
            Self::Accepted => "accepted",
            Self::Rejected => "rejected",
        }
    }

    pub fn from_db_str(s: &str) -> Result<Self, crate::errors::DomainError> {
        match s {
            "pending"  => Ok(Self::Pending),
            "accepted" => Ok(Self::Accepted),
            "rejected" => Ok(Self::Rejected),
            other => Err(crate::errors::DomainError::Internal(
                format!("unknown follow_state value in DB: '{other}'")
            )),
        }
    }
}
  • Step 3: Update all callers

In each postgres adapter file, every call to Visibility::from_db_str(s) or FollowState::from_db_str(s) now returns a Result. Callers use ? to propagate:

// Before
visibility: Visibility::from_db_str(&r.visibility),
// After
visibility: Visibility::from_db_str(&r.visibility)?,

Do this in: thought.rs, feed.rs, follow.rs, postgres-search/lib.rs.

  • Step 4: Compile check
cargo check --workspace 2>&1 | head -20
  • Step 5: Commit
git commit -m "fix(domain): from_db_str returns Result — unknown DB values are now errors, not silent defaults"

Phase 4 — Architecture Cleanup

Task 8: Remove pass-through use cases

Use cases search_thoughts, search_users are single-line delegations with no business logic. Remove them and call the port directly from handlers.

Files:

  • Delete: crates/application/src/use_cases/search.rs

  • Modify: crates/application/src/use_cases/mod.rs

  • Modify: crates/presentation/src/handlers/feed.rs

  • Step 1: Delete search.rs and remove from mod.rs

rm crates/application/src/use_cases/search.rs

In crates/application/src/use_cases/mod.rs, remove pub mod search;.

  • Step 2: Update feed.rs handler to call the port directly

In crates/presentation/src/handlers/feed.rs, replace:

use application::use_cases::search::{search_thoughts, search_users};
// ...
search_thoughts(&*s.search, &query, ...).await?

with:

s.search.search_thoughts(&query, &page, viewer.as_ref()).await?

Same for search_users:

s.search.search_users(&query, &page).await?
  • Step 3: Compile check and commit
cargo check --workspace 2>&1 | head -20
git commit -m "refactor(application): remove pass-through search use cases — handlers call port directly"

Task 9: Deduplicate actor resolution logic

profile::get_user_by_id_or_username and social::follow_actor/unfollow_actor both do "UUID or username?" routing independently. The social use cases' version (@handle detection) is already correct and distinct, but the presentation layer has a duplicate resolve_user_id that was fixed earlier. Verify no remaining duplication and consolidate if any remains.

Files:

  • Modify: crates/application/src/use_cases/profile.rs (if needed)

  • Modify: crates/presentation/src/handlers/feed.rs (verify)

  • Step 1: Audit current state

Read profile.rs::get_user_by_id_or_username and feed.rs::get_following_handler / get_followers_handler. Confirm feed.rs already calls get_user_by_id_or_username (this was fixed in a prior session).

  • Step 2: Document the two distinct resolution patterns

  • get_user_by_id_or_username — UUID or local username (AP actor URL routing)

  • follow_actor@handle@domain vs local username (AP follow routing)

These are intentionally different. No merge needed. Verify there are no other copies.

grep -rn "parse_str\|parse::<uuid" crates/application/src/ crates/presentation/src/

If no other UUID-parse-then-username-fallback patterns appear outside these two, the task is complete.

  • Step 3: Commit if any changes made, otherwise skip

Task 10: Clean up RemoteActor — strip AP-specific fields

RemoteActor in domain/src/models/remote_actor.rs carries AP infrastructure fields (inbox_url, shared_inbox_url, public_key, etc.). The domain layer only cares about the identity of a remote user, not AP delivery details.

Files:

  • Modify: crates/domain/src/models/remote_actor.rs

  • Modify: crates/domain/src/ports.rs (RemoteActorRepository)

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

  • Modify: crates/adapters/activitypub-base/src/repository.rs (has its own RemoteActor)

  • Modify: crates/presentation/src/handlers/federation_management.rs (response mapping)

  • Step 1: Slim down domain RemoteActor

In crates/domain/src/models/remote_actor.rs, keep only the fields meaningful at the domain level:

use chrono::{DateTime, Utc};

#[derive(Debug, Clone)]
pub struct RemoteActor {
    pub url: String,
    pub handle: String,
    pub display_name: Option<String>,
    pub avatar_url: Option<String>,
    pub bio: Option<String>,
    pub banner_url: Option<String>,
    pub also_known_as: Option<String>,
    pub outbox_url: Option<String>,
    pub followers_url: Option<String>,
    pub following_url: Option<String>,
    pub attachment: Vec<(String, String)>,
    pub last_fetched_at: DateTime<Utc>,
}

Removed: inbox_url, shared_inbox_url, public_key.

  • Step 2: Update RemoteActorRepository port if it uses the removed fields

The upsert and find_by_url methods accept/return the slimmed model. Verify the postgres remote_actor.rs adapter maps correctly (the DB still has inbox_url column; just don't pull it into the domain model).

  • Step 3: Fix compilation errors from removed fields

The main consumer is FederationActionPort.lookup_actor() which returns RemoteActor. The AP adapter (ActivityPubService) constructs RemoteActor from fetched data — update it to not set the removed fields.

InboxUrl and shared_inbox_url are needed by the AP delivery layer (OutboundFederationPort). Those should use the activitypub-base's own RemoteActor struct (in repository.rs) instead of the domain one.

  • Step 4: Compile check
cargo check --workspace 2>&1 | head -30
  • Step 5: Commit
git commit -m "refactor(domain): remove AP delivery fields from RemoteActor domain model"

Task 11: Change ActivityPubRepository port params from url::Url to &str

The domain port currently requires callers to construct url::Url — an external library type — before calling methods. Use &str at the port boundary; let the adapter parse.

Files:

  • Modify: crates/domain/src/ports.rs (ActivityPubRepository trait)

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

  • Modify: crates/application/src/services/federation_event.rs

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

  • Step 1: Change method signatures in ActivityPubRepository

In ports.rs, change:

async fn find_remote_actor_id(&self, actor_ap_url: &url::Url) -> Result<Option<UserId>, DomainError>;
async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result<UserId, DomainError>;
async fn accept_note(&self, ap_id: &url::Url, ..., in_reply_to: Option<&url::Url>) -> Result<(), DomainError>;
async fn apply_note_update(&self, ap_id: &url::Url, ...) -> Result<(), DomainError>;
async fn retract_note(&self, ap_id: &url::Url) -> Result<(), DomainError>;
async fn retract_actor_notes(&self, actor_ap_url: &url::Url) -> Result<(), DomainError>;
async fn get_thought_ap_id(&self, thought_id: &ThoughtId) -> Result<Option<String>, DomainError>;
async fn get_actor_ap_urls(&self, user_id: &UserId) -> Result<Option<ActorApUrls>, DomainError>;

To:

async fn find_remote_actor_id(&self, actor_ap_url: &str) -> Result<Option<UserId>, DomainError>;
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
async fn accept_note(&self, ap_id: &str, ..., in_reply_to: Option<&str>) -> Result<(), DomainError>;
async fn apply_note_update(&self, ap_id: &str, ...) -> Result<(), DomainError>;
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
// get_thought_ap_id and get_actor_ap_urls already use &ThoughtId/&UserId — no change needed
  • Step 2: Update PgActivityPubRepository — parse URL internally

In postgres/src/activitypub.rs, each method now receives &str and uses it directly in the SQL bind (URLs are stored as TEXT). Remove the .as_str() calls.

  • Step 3: Update callers — remove URL construction

In federation_event.rs and handler.rs, remove url::Url::parse(...) before calling repo methods; pass the &str directly:

// Before
let actor_url = url::Url::parse(actor_ap_url)?;
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;

// After
let author_id = self.ap_repo.intern_remote_actor(actor_ap_url).await?;
  • Step 4: Compile check
cargo check --workspace 2>&1 | head -20
  • Step 5: Commit
git commit -m "refactor(ports): ActivityPubRepository takes &str instead of &url::Url — infra type stays in adapter"

Self-Review

Spec coverage check:

Issue Task
AP fields in domain models (User, Thought) Task 1 (new lookup methods) + Task 2 (field removal)
DomainEvent contains AP events Task 3
FederationEventService mixes concerns Task 3 (removes FetchX arms), Task 1 (removes ap_id reads)
Notification algebraic types Task 6
from_db_str silently defaults Task 7
CQRS UserRepository Task 4
CQRS FederationActionPort Task 5
Pass-through use cases Task 8
Duplicate actor resolution Task 9
RemoteActor AP field leakage Task 10
Port params url::Url → &str Task 11

Gaps found:

  • FeedEntry coupling (issue #10 from audit) — deferred. Requires FeedRepository changes and affects all feed handlers. Low risk to leave as-is; worth a separate plan.

No placeholders found. Each task has exact file paths and concrete code.

Type consistency: ActorApUrls introduced in Task 1 is used in Task 2. NotificationKind introduced in Task 6 is used throughout Task 6. All type names consistent.