# 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: ```rust /// 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`: ```rust /// 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, 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, DomainError>; ``` - [ ] **Step 2: Implement both methods in `PgActivityPubRepository`** In `crates/adapters/postgres/src/activitypub.rs`, add the two implementations: ```rust async fn get_thought_ap_id( &self, thought_id: &ThoughtId, ) -> Result, 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, 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`: ```rust async fn get_thought_ap_id( &self, _thought_id: &ThoughtId, ) -> Result, DomainError> { Ok(None) } async fn get_actor_ap_urls( &self, _user_id: &UserId, ) -> Result, DomainError> { Ok(None) } ``` - [ ] **Step 4: Compile check** ```bash 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: ```rust #[derive(Debug, Clone)] pub struct User { pub id: UserId, pub username: Username, pub email: Email, pub password_hash: PasswordHash, pub display_name: Option, pub bio: Option, pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, pub local: bool, pub created_at: DateTime, pub updated_at: DateTime, } 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: ```rust #[derive(Debug, Clone)] pub struct Thought { pub id: ThoughtId, pub user_id: UserId, pub content: Content, pub in_reply_to_id: Option, pub visibility: Visibility, pub content_warning: Option, pub sensitive: bool, pub local: bool, pub created_at: DateTime, pub updated_at: Option>, } impl Thought { pub fn new_local( id: ThoughtId, user_id: UserId, content: Content, in_reply_to_id: Option, visibility: Visibility, content_warning: Option, 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 for User` impl: ```rust #[derive(sqlx::FromRow)] pub struct UserRow { pub id: uuid::Uuid, pub username: String, pub email: String, pub password_hash: String, pub display_name: Option, pub bio: Option, pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, pub local: bool, pub created_at: DateTime, pub updated_at: DateTime, } impl From 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: ```rust 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: ```rust #[derive(sqlx::FromRow)] struct ThoughtRow { pub id: uuid::Uuid, pub user_id: uuid::Uuid, pub content: String, pub in_reply_to_id: Option, pub visibility: String, pub content_warning: Option, pub sensitive: bool, pub local: bool, pub created_at: DateTime, pub updated_at: Option>, } ``` 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: ```rust async fn object_ap_id(&self, thought: &Thought, thought_id: &ThoughtId) -> Result { 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: ```rust 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** ```bash cargo check --workspace 2>&1 | head -40 ``` Fix all errors before moving on. - [ ] **Step 9: Commit** ```bash 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`** ```rust #[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: ```rust #[derive(Debug, Clone)] pub enum DomainEvent { ThoughtCreated { thought_id: ThoughtId, user_id: UserId, in_reply_to_id: Option }, 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: ```rust 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, 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, 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: ```rust #[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`): ```rust pub federation_scheduler: Arc, ``` `TestStore` (`crates/domain/src/testing.rs`) — add a no-op impl: ```rust #[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** ```bash cargo check --workspace 2>&1 | head -40 ``` - [ ] **Step 10: Commit** ```bash 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: ```rust #[async_trait] pub trait UserReader: Send + Sync { async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; async fn find_by_username(&self, username: &Username) -> Result, DomainError>; async fn find_by_email(&self, email: &Email) -> Result, DomainError>; async fn list_with_stats(&self) -> Result, DomainError>; async fn count(&self) -> Result; } #[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, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option, ) -> 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 UserRepository for T {} ``` - [ ] **Step 2: `PgUserRepository` implements `UserReader` and `UserWriter` separately** In `crates/adapters/postgres/src/user.rs`, split the single `impl UserRepository` into: ```rust #[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`), which satisfies any narrower bound via Rust's trait coercion. - [ ] **Step 4: Update `TestStore` — split its user impl to satisfy both traits** ```rust #[async_trait] impl UserReader for TestStore { /* existing read methods */ } #[async_trait] impl UserWriter for TestStore { /* existing write methods */ } ``` - [ ] **Step 5: Compile check** ```bash cargo check --workspace 2>&1 | head -30 ``` - [ ] **Step 6: Commit** ```bash 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: ```rust #[async_trait] pub trait FederationLookupPort: Send + Sync { async fn lookup_actor(&self, handle: &str) -> Result; async fn actor_json(&self, user_id: &UserId) -> Result; async fn followers_collection_json(&self, user_id: &UserId, page: Option) -> Result; async fn following_collection_json(&self, user_id: &UserId, page: Option) -> Result; } #[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, DomainError>; } #[async_trait] pub trait FederationFollowRequestPort: Send + Sync { async fn get_pending_followers(&self, user_id: &UserId) -> Result, 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, 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, DomainError>; async fn fetch_actor_urls_from_collection(&self, collection_url: &str) -> Result, DomainError>; async fn resolve_actor_profiles(&self, urls: Vec) -> Vec; } /// Combined supertrait — `AppState.federation` stays as a single field. pub trait FederationActionPort: FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort {} impl 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** ```rust #[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** ```bash cargo check --workspace 2>&1 | head -40 ``` - [ ] **Step 6: Commit** ```bash 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: ```rust 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, } ``` - [ ] **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: ```rust #[derive(sqlx::FromRow)] struct NotificationRow { id: uuid::Uuid, user_id: uuid::Uuid, notification_type: String, from_user_id: Option, thought_id: Option, read: bool, created_at: DateTime, } fn row_to_notification(r: NotificationRow) -> Result { 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`: ```rust 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: ```rust // 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** ```bash cargo check --workspace 2>&1 | head -30 ``` - [ ] **Step 7: Commit** ```bash 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`: ```rust 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 { 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`: ```rust 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 { 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: ```rust // 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** ```bash cargo check --workspace 2>&1 | head -20 ``` - [ ] **Step 5: Commit** ```bash 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`** ```bash 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: ```rust use application::use_cases::search::{search_thoughts, search_users}; // ... search_thoughts(&*s.search, &query, ...).await? ``` with: ```rust s.search.search_thoughts(&query, &page, viewer.as_ref()).await? ``` Same for `search_users`: ```rust s.search.search_users(&query, &page).await? ``` - [ ] **Step 3: Compile check and commit** ```bash 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. ```bash grep -rn "parse_str\|parse::, pub avatar_url: Option, pub bio: Option, pub banner_url: Option, pub also_known_as: Option, pub outbox_url: Option, pub followers_url: Option, pub following_url: Option, pub attachment: Vec<(String, String)>, pub last_fetched_at: DateTime, } ``` 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** ```bash cargo check --workspace 2>&1 | head -30 ``` - [ ] **Step 5: Commit** ```bash 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: ```rust async fn find_remote_actor_id(&self, actor_ap_url: &url::Url) -> Result, DomainError>; async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result; 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, DomainError>; async fn get_actor_ap_urls(&self, user_id: &UserId) -> Result, DomainError>; ``` To: ```rust async fn find_remote_actor_id(&self, actor_ap_url: &str) -> Result, DomainError>; async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result; 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: ```rust // 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** ```bash cargo check --workspace 2>&1 | head -20 ``` - [ ] **Step 5: Commit** ```bash 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.