# Remote Actor Search & Follow 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:** Let local users search for and follow ActivityPub users on other instances (e.g. `@user@mastodon.social`) from the existing search page. **Architecture:** New `FederationActionPort` domain trait (lookup + follow), implemented by `ActivityPubService` in `activitypub-base`. Injected into `AppState` via bootstrap. Two new REST endpoints at `/federation/lookup` and `/federation/follow`. Frontend detects `@user@instance` handle format in the search bar and renders a `RemoteUserCard` with a Follow button. **Tech Stack:** Rust (axum, sqlx, activitypub_federation crate), Next.js 15 (App Router, server components), TypeScript, Zod, shadcn/ui. --- ## File Map | Action | Path | Purpose | |--------|------|---------| | Modify | `crates/domain/src/models/remote_actor.rs` | Add `avatar_url` field | | Modify | `crates/domain/src/errors.rs` | Add `ExternalService` variant | | Modify | `crates/domain/src/ports.rs` | Add `FederationActionPort` trait | | Modify | `crates/domain/src/testing.rs` | Impl `FederationActionPort` for `TestStore` | | Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `FederationActionPort` for `ActivityPubService` | | Modify | `crates/adapters/activitypub-base/src/lib.rs` | Re-export trait impl visibility | | Modify | `crates/presentation/src/state.rs` | Add `federation` field | | Modify | `crates/presentation/src/errors.rs` | Map `ExternalService` → 502 | | Modify | `crates/bootstrap/src/factory.rs` | Build `ActivityPubService`, wire `federation` | | Modify | `crates/bootstrap/src/main.rs` | Use `ap_service.federation_config()` for middleware | | Modify | `crates/api-types/src/responses.rs` | Add `RemoteActorResponse` | | Create | `crates/presentation/src/handlers/federation.rs` | `lookup` + `follow_remote` handlers | | Modify | `crates/presentation/src/handlers/mod.rs` | Expose `federation` module | | Modify | `crates/presentation/src/routes.rs` | Mount `/federation/*` routes | | Modify | `thoughts-frontend/lib/api.ts` | Add schema, `lookupRemoteActor`, `followRemoteUser` | | Modify | `thoughts-frontend/app/search/page.tsx` | Detect handle, call lookup, pass result | | Create | `thoughts-frontend/components/remote-user-card.tsx` | Shows remote actor + Follow button | --- ## Task 1: Domain model + port **Files:** - Modify: `crates/domain/src/models/remote_actor.rs` - Modify: `crates/domain/src/errors.rs` - Modify: `crates/domain/src/ports.rs` - Modify: `crates/domain/src/testing.rs` - [ ] **Step 1: Add `avatar_url` to `RemoteActor`** In `crates/domain/src/models/remote_actor.rs`, add one field: ```rust use chrono::{DateTime, Utc}; #[derive(Debug, Clone)] pub struct RemoteActor { pub url: String, pub handle: String, pub display_name: Option, pub inbox_url: String, pub shared_inbox_url: Option, pub public_key: String, pub avatar_url: Option, // ← add this pub last_fetched_at: DateTime, } ``` - [ ] **Step 2: Add `ExternalService` to `DomainError`** In `crates/domain/src/errors.rs`, add the variant: ```rust #[derive(Debug, Error, Clone)] pub enum DomainError { #[error("not found")] NotFound, #[error("unauthorized")] Unauthorized, #[error("forbidden")] Forbidden, #[error("conflict: {0}")] Conflict(String), #[error("invalid input: {0}")] InvalidInput(String), #[error("external service error: {0}")] ExternalService(String), // ← add this #[error("internal error: {0}")] Internal(String), } ``` - [ ] **Step 3: Add `FederationActionPort` trait** In `crates/domain/src/ports.rs`, after the `RemoteActorRepository` trait block, add: ```rust #[async_trait] pub trait FederationActionPort: Send + Sync { async fn lookup_actor(&self, handle: &str) -> Result; async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>; } ``` Make sure `RemoteActor` is already imported — it's in the existing `use crate::models::remote_actor::RemoteActor;` import block. - [ ] **Step 4: Write failing tests for the trait in `testing.rs`** At the bottom of `crates/domain/src/testing.rs`, add: ```rust #[cfg(test)] mod federation_port_tests { use super::*; use crate::value_objects::UserId; fn uid() -> UserId { UserId::new() } #[tokio::test] async fn test_store_lookup_returns_not_found() { let store = TestStore::default(); let err = store.lookup_actor("@alice@example.com").await.unwrap_err(); assert!(matches!(err, DomainError::NotFound)); } #[tokio::test] async fn test_store_follow_remote_is_noop_ok() { let store = TestStore::default(); store.follow_remote(&uid(), "@alice@example.com").await.unwrap(); } } ``` - [ ] **Step 5: Run the tests to see them fail** ```bash cargo test -p domain -- federation_port_tests 2>&1 | tail -20 ``` Expected: compile error — `lookup_actor` and `follow_remote` not implemented on `TestStore`, and `FederationActionPort` trait not found. - [ ] **Step 6: Implement `FederationActionPort` for `TestStore`** In `crates/domain/src/testing.rs`, add after the existing `impl RemoteActorRepository for TestStore` block: ```rust #[async_trait] impl FederationActionPort for TestStore { async fn lookup_actor(&self, _handle: &str) -> Result { Err(DomainError::NotFound) } async fn follow_remote(&self, _local_user_id: &UserId, _handle: &str) -> Result<(), DomainError> { Ok(()) } } ``` - [ ] **Step 7: Run tests to confirm they pass** ```bash cargo test -p domain -- federation_port_tests 2>&1 | tail -10 ``` Expected: `test federation_port_tests::test_store_lookup_returns_not_found ... ok` and `test_store_follow_remote_is_noop_ok ... ok`. - [ ] **Step 8: Confirm the whole domain crate still compiles** ```bash cargo check -p domain 2>&1 | tail -10 ``` Expected: no errors. - [ ] **Step 9: Commit** ```bash git add crates/domain/src/models/remote_actor.rs \ crates/domain/src/errors.rs \ crates/domain/src/ports.rs \ crates/domain/src/testing.rs git commit -m "feat(domain): FederationActionPort trait + avatar_url on RemoteActor" ``` --- ## Task 2: `activitypub-base` — implement `FederationActionPort` **Files:** - Modify: `crates/adapters/activitypub-base/src/service.rs` - [ ] **Step 1: Write a compile-time impl check in `tests/service.rs`** In `crates/adapters/activitypub-base/src/tests/service.rs`, add at the top: ```rust // Verify ActivityPubService satisfies the FederationActionPort contract at compile time. fn _assert_impl_federation_action_port() where crate::service::ActivityPubService: domain::ports::FederationActionPort, { } ``` - [ ] **Step 2: Run to see compile failure** ```bash cargo check -p activitypub-base 2>&1 | tail -15 ``` Expected: error — `ActivityPubService` does not implement `FederationActionPort`. - [ ] **Step 3: Implement `FederationActionPort` for `ActivityPubService`** At the bottom of `crates/adapters/activitypub-base/src/service.rs`, before the closing of the file, add: ```rust #[async_trait::async_trait] impl domain::ports::FederationActionPort for ActivityPubService { async fn lookup_actor( &self, handle: &str, ) -> Result { use activitypub_federation::fetch::webfinger::webfinger_resolve_actor; let data = self.federation_config.to_request_data(); let actor: crate::actors::DbActor = webfinger_resolve_actor(handle, &data) .await .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; Ok(domain::models::remote_actor::RemoteActor { url: actor.ap_id.to_string(), handle: actor.username.clone(), display_name: actor.bio.clone(), inbox_url: actor.inbox_url.to_string(), shared_inbox_url: None, public_key: actor.public_key_pem.clone(), avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()), last_fetched_at: actor.last_refreshed_at, }) } async fn follow_remote( &self, local_user_id: &domain::value_objects::UserId, handle: &str, ) -> Result<(), domain::errors::DomainError> { self.follow(local_user_id.inner(), handle) .await .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) } } ``` Note: `UserId::inner()` returns the underlying `uuid::Uuid`. Verify the method name with `grep -n "fn inner\|fn as_uuid\|fn into_uuid" crates/domain/src/value_objects.rs` — adjust if the method is named differently. - [ ] **Step 4: Check `UserId` accessor method name** ```bash grep -n "fn inner\|fn as_uuid\|fn into_uuid\|pub fn " /mnt/drive/dev/thoughts/crates/domain/src/value_objects.rs | grep -i "userid\|UserId" | head -10 ``` If `inner()` doesn't exist, replace `local_user_id.inner()` with the correct method (e.g. `local_user_id.0`, `local_user_id.as_uuid()`, etc.). - [ ] **Step 5: Compile to confirm the impl satisfies the trait** ```bash cargo check -p activitypub-base 2>&1 | tail -10 ``` Expected: no errors. - [ ] **Step 6: Commit** ```bash git add crates/adapters/activitypub-base/src/service.rs \ crates/adapters/activitypub-base/src/tests/service.rs git commit -m "feat(activitypub-base): impl FederationActionPort for ActivityPubService" ``` --- ## Task 3: Bootstrap — wire `ActivityPubService` into `AppState` **Files:** - Modify: `crates/presentation/src/state.rs` - Modify: `crates/presentation/src/errors.rs` - Modify: `crates/bootstrap/src/factory.rs` - Modify: `crates/bootstrap/src/main.rs` - [ ] **Step 1: Add `federation` to `AppState`** In `crates/presentation/src/state.rs`, add the new field: ```rust use domain::ports::*; use std::sync::Arc; #[derive(Clone)] pub struct AppState { pub users: Arc, pub thoughts: Arc, pub likes: Arc, pub boosts: Arc, pub follows: Arc, pub blocks: Arc, pub tags: Arc, pub api_keys: Arc, pub top_friends: Arc, pub notifications: Arc, pub remote_actors: Arc, pub feed: Arc, pub search: Arc, pub auth: Arc, pub hasher: Arc, pub events: Arc, pub federation: Arc, // ← add this } ``` - [ ] **Step 2: Map `ExternalService` error in `presentation/src/errors.rs`** Add the new match arm in `IntoResponse for ApiError`: ```rust Self::Domain(DomainError::ExternalService(_)) => ( StatusCode::BAD_GATEWAY, "external service error".into(), ), ``` Place it before the `Self::Domain(DomainError::Internal(_))` arm. - [ ] **Step 3: Refactor `factory.rs` to build `ActivityPubService`** In `crates/bootstrap/src/factory.rs`, change the imports and the federation setup block. Add import at top: ```rust use activitypub_base::service::ActivityPubService; use domain::ports::FederationActionPort; ``` Change `Infrastructure` struct: ```rust pub struct Infrastructure { pub state: AppState, pub ap_service: Arc, } ``` Replace the current "3. ActivityPub federation" block (which builds `fed_data` + `fed_config`) with: ```rust // 3. ActivityPub federation let ap_service = Arc::new( ActivityPubService::new( Arc::new(PostgresFederationRepository::new(pool.clone())), Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())), Arc::new(ThoughtsObjectHandler::new( Arc::new(PgActivityPubRepository::new(pool.clone())), &cfg.base_url, )), cfg.base_url.clone(), cfg.allow_registration, "thoughts".to_string(), cfg.debug, None, ) .await .expect("Failed to build ActivityPubService"), ); ``` Remove the old `let fed_config = ...` line entirely. In the `AppState { ... }` construction, add: ```rust federation: ap_service.clone() as Arc, ``` Change the `Infrastructure { ... }` return to: ```rust Infrastructure { state, ap_service } ``` - [ ] **Step 4: Update `main.rs` to use `ap_service`** In `crates/bootstrap/src/main.rs`, change the middleware line from: ```rust .layer(infra.fed_config.middleware()); ``` to: ```rust .layer(infra.ap_service.federation_config().middleware()); ``` Also update the AP router handlers — they use `actor_handler`, `inbox_handler`, etc. from `activitypub_base`. These don't change; only the middleware source changes. - [ ] **Step 5: Confirm everything compiles** ```bash cargo check -p bootstrap 2>&1 | tail -15 ``` Expected: no errors. If `fed_config` is referenced elsewhere in `main.rs` or `factory.rs`, fix those references to use `ap_service.federation_config()`. - [ ] **Step 6: Commit** ```bash git add crates/presentation/src/state.rs \ crates/presentation/src/errors.rs \ crates/bootstrap/src/factory.rs \ crates/bootstrap/src/main.rs git commit -m "feat(bootstrap): wire ActivityPubService as FederationActionPort in AppState" ``` --- ## Task 4: REST endpoints — lookup + follow **Files:** - Modify: `crates/api-types/src/responses.rs` - Create: `crates/presentation/src/handlers/federation.rs` - Modify: `crates/presentation/src/handlers/mod.rs` - Modify: `crates/presentation/src/routes.rs` - [ ] **Step 1: Add `RemoteActorResponse` to `api-types`** In `crates/api-types/src/responses.rs`, add: ```rust #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct RemoteActorResponse { pub handle: String, pub display_name: Option, pub avatar_url: Option, pub url: String, } ``` - [ ] **Step 2: Write failing handler tests** Create `crates/presentation/src/handlers/federation.rs` with the test module first: ```rust use axum::{ extract::{Query, State}, http::StatusCode, Json, }; use serde::Deserialize; use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse}; use domain::errors::DomainError; use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; pub async fn lookup_handler( State(_s): State, Query(_q): Query, ) -> Result, ApiError> { todo!() } pub async fn follow_remote_handler( State(_s): State, AuthUser(_uid): AuthUser, Json(_body): Json, ) -> Result { todo!() } #[derive(Deserialize)] pub struct LookupQuery { pub handle: String, } #[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::{Request, header}, routing::{get, post}, Router, }; use domain::testing::TestStore; use std::sync::Arc; use tower::ServiceExt; fn make_state() -> AppState { let store = Arc::new(TestStore::default()); AppState { users: store.clone(), thoughts: store.clone(), likes: store.clone(), boosts: store.clone(), follows: store.clone(), blocks: store.clone(), tags: store.clone(), api_keys: store.clone(), top_friends: store.clone(), notifications: store.clone(), remote_actors: store.clone(), feed: store.clone(), search: store.clone(), auth: store.clone(), hasher: store.clone(), events: store.clone(), federation: store.clone(), } } fn app() -> Router { Router::new() .route("/federation/lookup", get(lookup_handler)) .route("/federation/follow", post(follow_remote_handler)) .with_state(make_state()) } #[tokio::test] async fn lookup_unknown_handle_returns_404() { let resp = app() .oneshot( Request::builder() .uri("/federation/lookup?handle=%40alice%40example.com") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn follow_remote_without_auth_returns_401() { let resp = app() .oneshot( Request::builder() .method("POST") .uri("/federation/follow") .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"handle":"@alice@example.com"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } } ``` Note: `TestStore` must implement `AuthService`, `PasswordHasher`, and `FederationActionPort` for `make_state()` to compile. Check `crates/domain/src/testing.rs` — if `TestStore` doesn't implement `AuthService` or `PasswordHasher`, use the existing pattern from other handler test setups in the codebase. You may need to construct `AppState` slightly differently (e.g. using a `NoOpAuth` stub). Check `crates/presentation/src/handlers/auth.rs` for any existing test patterns. - [ ] **Step 3: Add `FollowRemoteRequest` to `api-types`** In `crates/api-types/src/requests.rs`, add: ```rust #[derive(serde::Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct FollowRemoteRequest { pub handle: String, } ``` - [ ] **Step 4: Run tests to see them fail** ```bash cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -20 ``` Expected: compile errors (handler bodies are `todo!()`) or panics. The goal is to confirm the tests exist and the wiring is right. - [ ] **Step 5: Implement the handlers** Replace the `todo!()` bodies in `federation.rs`: ```rust pub async fn lookup_handler( State(s): State, Query(q): Query, ) -> Result, ApiError> { let actor = s.federation.lookup_actor(&q.handle).await?; Ok(Json(RemoteActorResponse { handle: actor.handle, display_name: actor.display_name, avatar_url: actor.avatar_url, url: actor.url, })) } pub async fn follow_remote_handler( State(s): State, AuthUser(uid): AuthUser, Json(body): Json, ) -> Result { s.federation.follow_remote(&uid, &body.handle).await?; Ok(StatusCode::NO_CONTENT) } ``` - [ ] **Step 6: Expose the module** In `crates/presentation/src/handlers/mod.rs`, add: ```rust pub mod federation; ``` - [ ] **Step 7: Mount routes** In `crates/presentation/src/routes.rs`, add these two routes inside `let api_routes = Router::new()`: ```rust .route("/federation/lookup", get(federation::lookup_handler)) .route("/federation/follow", post(federation::follow_remote_handler)) ``` Place them after the `/search` route for clarity. - [ ] **Step 8: Run tests again to confirm they pass** ```bash cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -15 ``` Expected: ``` test handlers::federation::tests::lookup_unknown_handle_returns_404 ... ok test handlers::federation::tests::follow_remote_without_auth_returns_401 ... ok ``` - [ ] **Step 9: Full compile check** ```bash cargo check 2>&1 | tail -15 ``` Expected: no errors. - [ ] **Step 10: Commit** ```bash git add crates/api-types/src/responses.rs \ crates/api-types/src/requests.rs \ crates/presentation/src/handlers/federation.rs \ crates/presentation/src/handlers/mod.rs \ crates/presentation/src/routes.rs git commit -m "feat(presentation): /federation/lookup and /federation/follow endpoints" ``` --- ## Task 5: Frontend — API client + search integration + RemoteUserCard **Files:** - Modify: `thoughts-frontend/lib/api.ts` - Modify: `thoughts-frontend/app/search/page.tsx` - Create: `thoughts-frontend/components/remote-user-card.tsx` - [ ] **Step 1: Add types and API functions to `lib/api.ts`** After the `UserSchema` block (around line 15), add: ```typescript export const RemoteActorSchema = z.object({ handle: z.string(), displayName: z.string().nullable(), avatarUrl: z.string().nullable(), url: z.string(), }); export type RemoteActor = z.infer; ``` After the existing `followUser` and `unfollowUser` functions, add: ```typescript export const lookupRemoteActor = (handle: string, token: string | null) => apiFetch( `/federation/lookup?handle=${encodeURIComponent(handle)}`, {}, RemoteActorSchema, token ); export const followRemoteUser = (handle: string, token: string) => apiFetch( `/federation/follow`, { method: "POST", body: JSON.stringify({ handle }) }, z.null(), token ); ``` - [ ] **Step 2: Create `RemoteUserCard` component** Create `thoughts-frontend/components/remote-user-card.tsx`: ```typescript "use client"; import { useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import { followRemoteUser, RemoteActor } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { UserAvatar } from "@/components/user-avatar"; import { toast } from "sonner"; import { UserPlus } from "lucide-react"; interface RemoteUserCardProps { actor: RemoteActor; } export function RemoteUserCard({ actor }: RemoteUserCardProps) { const [followed, setFollowed] = useState(false); const [loading, setLoading] = useState(false); const { token } = useAuth(); const handleFollow = async () => { if (!token) { toast.error("You must be logged in to follow users."); return; } setLoading(true); try { await followRemoteUser(actor.handle, token); setFollowed(true); toast.success(`Follow request sent to ${actor.handle}`); } catch { toast.error("Failed to send follow request."); } finally { setLoading(false); } }; return (

{actor.displayName ?? actor.handle}

{actor.handle}

); } ``` Note: Check how `UserAvatar` is used in other components (e.g. `user-list-card.tsx`) to confirm the prop names match. - [ ] **Step 3: Check `UserAvatar` props** ```bash grep -n "UserAvatar\|avatarUrl\|username" /mnt/drive/dev/thoughts/thoughts-frontend/components/user-avatar.tsx | head -10 ``` Adjust the `UserAvatar` usage in `RemoteUserCard` to match the actual props. - [ ] **Step 4: Update `app/search/page.tsx` to detect handles and show remote result** Replace the file with: ```typescript import { cookies } from "next/headers"; import { getMe, search, lookupRemoteActor, User, RemoteActor } from "@/lib/api"; import { UserListCard } from "@/components/user-list-card"; import { RemoteUserCard } from "@/components/remote-user-card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ThoughtList } from "@/components/thought-list"; const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/; interface SearchPageProps { searchParams: Promise<{ q?: string }>; } export default async function SearchPage({ searchParams }: SearchPageProps) { const { q } = await searchParams; const query = q || ""; const token = (await cookies()).get("auth_token")?.value ?? null; if (!query) { return (

Search Thoughts

Find users and thoughts across the platform.

); } const isHandle = HANDLE_RE.test(query); const [results, remoteActor, me] = await Promise.all([ isHandle ? null : search(query, token).catch(() => null), isHandle ? lookupRemoteActor(query, token).catch(() => null) : null, token ? getMe(token).catch(() => null) : null, ]); const authorDetails = new Map(); if (results) { results.users.forEach((user: User) => { authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); }); } return (

Search Results

Showing results for: "{query}"

{isHandle ? ( remoteActor ? (

Remote user

) : (

No user found at {query}

) ) : results ? ( Thoughts ({results.thoughts.length}) Users ({results.users.length}) ) : (

No results found or an error occurred.

)}
); } ``` - [ ] **Step 5: Type-check the frontend** ```bash cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20 ``` Expected: no errors. Fix any type mismatches before continuing. - [ ] **Step 6: Commit** ```bash cd /mnt/drive/dev/thoughts/thoughts-frontend git add lib/api.ts app/search/page.tsx components/remote-user-card.tsx cd .. git commit -m "feat(frontend): remote actor lookup and follow from search page" ``` --- ## Self-Review **Spec coverage check:** - ✅ `FederationActionPort` trait with `lookup_actor` + `follow_remote` — Task 1 - ✅ `avatar_url` on `RemoteActor` — Task 1 - ✅ `ExternalService` error variant — Task 1 - ✅ `ActivityPubService` impl — Task 2 - ✅ Bootstrap refactor + `AppState.federation` — Task 3 - ✅ `RemoteActorResponse` + `FollowRemoteRequest` — Task 4 - ✅ `/federation/lookup` + `/federation/follow` endpoints — Task 4 - ✅ Error mapping (ExternalService → 502) — Task 3 - ✅ Frontend API client additions — Task 5 - ✅ Handle detection regex in search page — Task 5 - ✅ `RemoteUserCard` component — Task 5 **Placeholder check:** None found. **Type consistency check:** - `RemoteActor.avatar_url: Option` used in Task 1, mapped from `DbActor.avatar_url: Option` in Task 2 via `.map(|u| u.to_string())` ✅ - `FollowRemoteRequest.handle` → `follow_remote(&uid, &body.handle)` ✅ - `RemoteActorResponse` fields match `RemoteActor` domain model fields ✅ - Frontend `RemoteActorSchema` camelCase fields match `#[serde(rename_all = "camelCase")]` on `RemoteActorResponse` ✅ - `UserId::inner()` — verified as an assumption in Task 2 Step 4 with an explicit check step ✅