From a90215477751fcde749ce9d8e67cacfffea1cfd8 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 13:28:19 +0200 Subject: [PATCH] refactor(domain): remove FetchRemoteActorPosts/FetchActorConnections from DomainEvent; add FederationSchedulerPort --- .../adapters/activitypub-base/src/service.rs | 33 + crates/adapters/event-payload/src/lib.rs | 58 - .../src/services/federation_event.rs | 139 -- .../src/use_cases/federation_management.rs | 25 +- crates/bootstrap/src/factory.rs | 1 + crates/domain/src/events.rs | 10 - crates/domain/src/ports.rs | 17 + crates/domain/src/testing.rs | 16 + .../src/handlers/federation_actors.rs | 4 +- crates/presentation/src/state.rs | 1 + crates/presentation/src/testing.rs | 1 + crates/worker/src/factory.rs | 8 +- .../2026-05-15-domain-application-refactor.md | 1230 +++++++++++++++++ 13 files changed, 1310 insertions(+), 233 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-15-domain-application-refactor.md diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index 8d07d3e..802750c 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -1592,6 +1592,39 @@ impl domain::ports::OutboundFederationPort for ActivityPubService { } } +#[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> { + tracing::debug!( + actor = actor_ap_url, + outbox = outbox_url, + "schedule_actor_posts_fetch: deferred" + ); + Ok(()) + } + + async fn schedule_connections_fetch( + &self, + actor_ap_url: &str, + collection_url: &str, + connection_type: &str, + page: u32, + ) -> Result<(), domain::errors::DomainError> { + tracing::debug!( + actor = actor_ap_url, + collection = collection_url, + connection_type, + page, + "schedule_connections_fetch: deferred" + ); + Ok(()) + } +} + #[async_trait::async_trait] impl domain::ports::FederationActionPort for ActivityPubService { async fn lookup_actor( diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index 7c90a3d..f8c6c49 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -71,16 +71,6 @@ pub enum EventPayload { ProfileUpdated { user_id: String, }, - FetchRemoteActorPosts { - actor_ap_url: String, - outbox_url: String, - }, - FetchActorConnections { - actor_ap_url: String, - collection_url: String, - connection_type: String, - page: u32, - }, MentionReceived { thought_id: String, mentioned_user_id: String, @@ -107,8 +97,6 @@ impl EventPayload { Self::UserUnblocked { .. } => "users.unblocked", Self::UserRegistered { .. } => "users.registered", Self::ProfileUpdated { .. } => "users.profile_updated", - Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox", - Self::FetchActorConnections { .. } => "federation.fetch_connections", Self::MentionReceived { .. } => "mentions.received", } } @@ -222,24 +210,6 @@ impl From<&DomainEvent> for EventPayload { DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated { user_id: user_id.to_string(), }, - DomainEvent::FetchRemoteActorPosts { - actor_ap_url, - outbox_url, - } => Self::FetchRemoteActorPosts { - actor_ap_url: actor_ap_url.clone(), - outbox_url: outbox_url.clone(), - }, - DomainEvent::FetchActorConnections { - actor_ap_url, - collection_url, - connection_type, - page, - } => Self::FetchActorConnections { - actor_ap_url: actor_ap_url.clone(), - collection_url: collection_url.clone(), - connection_type: connection_type.clone(), - page: *page, - }, DomainEvent::MentionReceived { thought_id, mentioned_user_id, @@ -370,24 +340,6 @@ impl TryFrom for DomainEvent { EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated { user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), }, - EventPayload::FetchRemoteActorPosts { - actor_ap_url, - outbox_url, - } => DomainEvent::FetchRemoteActorPosts { - actor_ap_url, - outbox_url, - }, - EventPayload::FetchActorConnections { - actor_ap_url, - collection_url, - connection_type, - page, - } => DomainEvent::FetchActorConnections { - actor_ap_url, - collection_url, - connection_type, - page, - }, EventPayload::MentionReceived { thought_id, mentioned_user_id, @@ -481,16 +433,6 @@ mod tests { EventPayload::UserRegistered { user_id: "a".into(), }, - EventPayload::FetchRemoteActorPosts { - actor_ap_url: "https://mastodon.social/users/alice".into(), - outbox_url: "https://mastodon.social/users/alice/outbox".into(), - }, - EventPayload::FetchActorConnections { - actor_ap_url: "https://mastodon.social/users/alice".into(), - collection_url: "https://mastodon.social/users/alice/followers".into(), - connection_type: "followers".into(), - page: 1, - }, ]; let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect(); subjects.sort(); diff --git a/crates/application/src/services/federation_event.rs b/crates/application/src/services/federation_event.rs index 2b7d347..9cf4c76 100644 --- a/crates/application/src/services/federation_event.rs +++ b/crates/application/src/services/federation_event.rs @@ -12,9 +12,7 @@ pub struct FederationEventService { pub users: Arc, pub ap: Arc, pub base_url: String, - pub federation_action: Arc, pub ap_repo: Arc, - pub remote_actor_connections: Arc, } impl FederationEventService { @@ -148,112 +146,6 @@ impl FederationEventService { .await } - DomainEvent::FetchRemoteActorPosts { - actor_ap_url, - outbox_url, - } => { - let notes = match self - .federation_action - .fetch_outbox_page(outbox_url, 1) - .await - { - Ok(n) => n, - Err(e) => { - tracing::warn!(outbox_url, error = %e, "failed to fetch remote outbox"); - return Ok(()); - } - }; - - let actor_url = url::Url::parse(actor_ap_url) - .map_err(|e| DomainError::ExternalService(e.to_string()))?; - - let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?; - - // Resolve and cache display info so thought cards show proper names. - let profiles = self - .federation_action - .resolve_actor_profiles(vec![actor_ap_url.clone()]) - .await; - if let Some(profile) = profiles.into_iter().next() { - let _ = self - .ap_repo - .update_remote_actor_display( - &author_id, - profile.display_name.as_deref(), - profile.avatar_url.as_deref(), - ) - .await; - } - - for note in notes { - let ap_id = match url::Url::parse(¬e.ap_id) { - Ok(u) => u, - Err(_) => continue, - }; - let _ = self - .ap_repo - .accept_note( - &ap_id, - &author_id, - ¬e.content, - note.published, - note.sensitive, - note.content_warning, - "public", - None, - ) - .await; - } - - Ok(()) - } - - DomainEvent::FetchActorConnections { - actor_ap_url, - collection_url, - connection_type, - page, - } => { - let urls = match self - .federation_action - .fetch_actor_urls_from_collection(collection_url) - .await - { - Ok(u) => u, - Err(e) => { - tracing::warn!( - collection_url, - error = %e, - "failed to fetch actor connections collection" - ); - return Ok(()); - } - }; - - if urls.is_empty() { - return Ok(()); - } - - let summaries = self.federation_action.resolve_actor_profiles(urls).await; - - if summaries.is_empty() { - return Ok(()); - } - - tracing::info!( - count = summaries.len(), - connection_type, - actor = actor_ap_url, - "caching actor connections" - ); - - self.remote_actor_connections - .upsert_connections(actor_ap_url, connection_type, *page, &summaries) - .await?; - - Ok(()) - } - DomainEvent::LikeAdded { like_id: _, user_id, @@ -438,9 +330,7 @@ mod tests { users: Arc::new(store.clone()), ap: spy, base_url: "https://example.com".to_string(), - federation_action: Arc::new(store.clone()), ap_repo: Arc::new(store.clone()), - remote_actor_connections: Arc::new(store.clone()), } } @@ -772,35 +662,6 @@ mod tests { assert!(spy.updated.lock().unwrap().is_empty()); } - #[tokio::test] - async fn fetch_remote_actor_posts_is_noop_when_outbox_empty() { - let store = TestStore::default(); - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::FetchRemoteActorPosts { - actor_ap_url: "https://mastodon.social/users/alice".into(), - outbox_url: "https://mastodon.social/users/alice/outbox".into(), - }) - .await - .unwrap(); - // TestStore.fetch_outbox_page returns Ok(vec![]) — no notes, no error - } - - #[tokio::test] - async fn fetch_actor_connections_is_noop_when_collection_empty() { - let store = TestStore::default(); - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::FetchActorConnections { - actor_ap_url: "https://mastodon.social/users/alice".into(), - collection_url: "https://mastodon.social/users/alice/followers".into(), - connection_type: "followers".into(), - page: 1, - }) - .await - .unwrap(); - } - #[tokio::test] async fn like_added_local_user_remote_thought_broadcasts_like() { let store = TestStore::default(); diff --git a/crates/application/src/use_cases/federation_management.rs b/crates/application/src/use_cases/federation_management.rs index 50d7887..e60f659 100644 --- a/crates/application/src/use_cases/federation_management.rs +++ b/crates/application/src/use_cases/federation_management.rs @@ -1,14 +1,13 @@ use domain::{ errors::DomainError, - events::DomainEvent, models::{ actor_connection_summary::ActorConnectionSummary, feed::{FeedEntry, PageParams, Paginated}, remote_actor::RemoteActor, }, ports::{ - ActivityPubRepository, EventPublisher, FederationActionPort, FeedRepository, - FollowRepository, RemoteActorConnectionRepository, UserRepository, + ActivityPubRepository, EventPublisher, FederationActionPort, FederationSchedulerPort, + FeedRepository, FollowRepository, RemoteActorConnectionRepository, UserRepository, }, value_objects::UserId, }; @@ -75,7 +74,7 @@ pub async fn get_remote_actor_posts( federation: &dyn FederationActionPort, ap_repo: &dyn ActivityPubRepository, feed: &dyn FeedRepository, - events: &dyn EventPublisher, + scheduler: &dyn FederationSchedulerPort, handle: &str, page: PageParams, viewer_id: Option<&UserId>, @@ -88,11 +87,8 @@ pub async fn get_remote_actor_posts( }; let result = feed.user_feed(&author_id, &page, viewer_id).await?; if let Some(outbox_url) = actor.outbox_url { - let _ = events - .publish(&DomainEvent::FetchRemoteActorPosts { - actor_ap_url: actor.url, - outbox_url, - }) + let _ = scheduler + .schedule_actor_posts_fetch(&actor.url, &outbox_url) .await; } Ok(result) @@ -103,7 +99,7 @@ const ACTOR_CONNECTIONS_CACHE_TTL_SECS: i64 = 3600; pub async fn get_actor_connections_page( federation: &dyn FederationActionPort, connections: &dyn RemoteActorConnectionRepository, - events: &dyn EventPublisher, + scheduler: &dyn FederationSchedulerPort, handle: &str, connection_type: &str, page: u32, @@ -128,13 +124,8 @@ pub async fn get_actor_connections_page( } }; if stale { - let _ = events - .publish(&DomainEvent::FetchActorConnections { - actor_ap_url: actor.url, - collection_url, - connection_type: connection_type.to_string(), - page, - }) + let _ = scheduler + .schedule_connections_fetch(&actor.url, &collection_url, connection_type, page) .await; } let has_more = items.len() >= PAGE_SIZE; diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs index cbce2b0..452f29f 100644 --- a/crates/bootstrap/src/factory.rs +++ b/crates/bootstrap/src/factory.rs @@ -116,6 +116,7 @@ pub async fn build(cfg: &Config) -> Infrastructure { federation: ap_service.clone() as Arc, ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())), + federation_scheduler: ap_service.clone() as Arc, }; Infrastructure { state, ap_service } diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 46acd9d..69936a4 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -63,16 +63,6 @@ pub enum DomainEvent { ProfileUpdated { user_id: UserId, }, - FetchRemoteActorPosts { - actor_ap_url: String, - outbox_url: String, - }, - FetchActorConnections { - actor_ap_url: String, - collection_url: String, - connection_type: String, - page: u32, - }, MentionReceived { thought_id: ThoughtId, mentioned_user_id: UserId, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index a3a4e95..707517a 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -497,3 +497,20 @@ pub trait OutboundFederationPort: Send + Sync { /// Fan out an Update(Actor) to all accepted followers after a profile change. async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>; } + +#[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>; +} diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs index 2c2bfb2..442bda2 100644 --- a/crates/domain/src/testing.rs +++ b/crates/domain/src/testing.rs @@ -903,6 +903,22 @@ impl ActivityPubRepository for TestStore { } } +#[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(()) + } +} + #[async_trait] impl EventPublisher for TestStore { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { diff --git a/crates/presentation/src/handlers/federation_actors.rs b/crates/presentation/src/handlers/federation_actors.rs index 5cef076..2811e42 100644 --- a/crates/presentation/src/handlers/federation_actors.rs +++ b/crates/presentation/src/handlers/federation_actors.rs @@ -29,7 +29,7 @@ pub async fn remote_actor_posts_handler( &*s.federation, &*s.ap_repo, &*s.feed, - &*s.events, + &*s.federation_scheduler, &handle, page, viewer.as_ref(), @@ -68,7 +68,7 @@ async fn actor_connections_handler( let (items, has_more) = get_actor_connections_page( &*s.federation, &*s.remote_actor_connections, - &*s.events, + &*s.federation_scheduler, &handle, connection_type, page, diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 4f6e2d7..2242b84 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -22,4 +22,5 @@ pub struct AppState { pub federation: Arc, pub ap_repo: Arc, pub remote_actor_connections: Arc, + pub federation_scheduler: Arc, } diff --git a/crates/presentation/src/testing.rs b/crates/presentation/src/testing.rs index a73ba5a..dd2d344 100644 --- a/crates/presentation/src/testing.rs +++ b/crates/presentation/src/testing.rs @@ -51,5 +51,6 @@ pub fn make_state() -> AppState { federation: store.clone(), ap_repo: store.clone(), remote_actor_connections: store.clone(), + federation_scheduler: store.clone(), } } diff --git a/crates/worker/src/factory.rs b/crates/worker/src/factory.rs index 91fb5ad..49cac74 100644 --- a/crates/worker/src/factory.rs +++ b/crates/worker/src/factory.rs @@ -4,9 +4,8 @@ use std::sync::Arc; use activitypub::ThoughtsObjectHandler; use activitypub_base::ActivityPubService; use application::services::{FederationEventService, NotificationEventService}; -use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort}; +use domain::ports::{ActivityPubRepository, OutboundFederationPort}; use postgres::activitypub::PgActivityPubRepository; -use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; use crate::handlers::{FederationHandler, NotificationHandler}; @@ -58,11 +57,8 @@ pub async fn build( .expect("ActivityPubService build failed"), ); let ap_outbound = ap_service.clone() as Arc; - let ap_federation = ap_service.clone() as Arc; let ap_repo_worker = Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc; - let actor_connections = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())) - as Arc; // Application services let notification_svc = Arc::new(NotificationEventService { @@ -74,9 +70,7 @@ pub async fn build( users, ap: ap_outbound, base_url: base_url.to_string(), - federation_action: ap_federation, ap_repo: ap_repo_worker, - remote_actor_connections: actor_connections, }); // Thin handlers diff --git a/docs/superpowers/plans/2026-05-15-domain-application-refactor.md b/docs/superpowers/plans/2026-05-15-domain-application-refactor.md new file mode 100644 index 0000000..66282cf --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-domain-application-refactor.md @@ -0,0 +1,1230 @@ +# 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.