1
The Seven Traits
Gabriel Kaszewski edited this page 2026-05-29 02:11:48 +00:00

The Seven Traits

k-ap calls into your code through seven focused traits. Implement them against your database or storage layer. The simplest approach — used by both thoughts and movies-diary — is to implement all seven on a single struct Db and clone the Arc<Db> into each builder setter.

#[derive(Clone)]
struct Db { pool: PgPool }

// Implement all seven traits on Db...

let db = Arc::new(Db { pool });
let service = ActivityPubService::builder("https://example.com")
    .activity_repo(db.clone())
    .follow_repo(db.clone())
    .actor_repo(db.clone())
    .blocklist_repo(db.clone())
    .user_repo(db.clone())
    .content_reader(db.clone())
    .object_handler(db.clone())
    .build()
    .await?;

1. ActivityRepository

Idempotency for inbound deliveries. Every incoming activity is checked here before any handler is called.

#[async_trait]
pub trait ActivityRepository: Send + Sync {
    /// Returns true if this activity ID has already been processed.
    async fn is_activity_processed(&self, activity_id: &str) -> Result<bool>;

    /// Record that this activity ID has been processed.
    async fn mark_activity_processed(&self, activity_id: &str) -> Result<()>;
}

What to persist: a table of processed activity ID strings with a unique constraint. VARCHAR + unique index is sufficient.


2. FollowRepository

The follower/following graph plus account migration.

#[async_trait]
pub trait FollowRepository: Send + Sync {
    // Inbound followers (remote actor → local user)
    async fn add_follower(&self, local_user_id: Uuid, remote_actor_url: &str, status: FollowerStatus, follow_activity_id: &str) -> Result<()>;
    async fn get_follower_follow_activity_id(&self, local_user_id: Uuid, remote_actor_url: &str) -> Result<Option<String>>;
    async fn remove_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> Result<()>;
    async fn get_followers(&self, local_user_id: Uuid) -> Result<Vec<Follower>>;
    async fn get_followers_page(&self, local_user_id: Uuid, offset: u32, limit: usize) -> Result<Vec<Follower>>;
    async fn count_followers(&self, local_user_id: Uuid) -> Result<usize>;
    async fn update_follower_status(&self, local_user_id: Uuid, remote_actor_url: &str, status: FollowerStatus) -> Result<()>;
    async fn get_pending_followers(&self, local_user_id: Uuid) -> Result<Vec<RemoteActor>>;
    async fn get_accepted_follower_inboxes(&self, local_user_id: Uuid) -> Result<Vec<String>>;
    async fn count_accepted_followers(&self, local_user_id: Uuid) -> Result<usize>;
    async fn get_accepted_followers_page(&self, local_user_id: Uuid, offset: u32, limit: usize) -> Result<Vec<RemoteActor>>;

    // Outbound following (local user → remote actor)
    async fn add_following(&self, local_user_id: Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()>;
    async fn get_follow_activity_id(&self, local_user_id: Uuid, remote_actor_url: &str) -> Result<Option<String>>;
    async fn remove_following(&self, local_user_id: Uuid, actor_url: &str) -> Result<()>;
    async fn get_following(&self, local_user_id: Uuid) -> Result<Vec<RemoteActor>>;
    async fn get_following_page(&self, local_user_id: Uuid, offset: u32, limit: usize) -> Result<Vec<RemoteActor>>;
    async fn count_following(&self, local_user_id: Uuid) -> Result<usize>;
    async fn update_following_status(&self, local_user_id: Uuid, remote_actor_url: &str, status: FollowingStatus) -> Result<()>;
    async fn get_following_outbox_url(&self, local_user_id: Uuid, remote_actor_url: &str) -> Result<Option<String>>;

    // Account migration (Move activity)
    async fn migrate_follower_actor(&self, old_actor_url: &str, new_actor_url: &str) -> Result<Vec<Uuid>>;
}

Key types:

  • FollowerStatus: Pending | Accepted | Rejected
  • FollowingStatus: Pending | Accepted
  • Follower: { actor: RemoteActor, status: FollowerStatus }
  • RemoteActor: { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url }

get_accepted_follower_inboxes must return deduplicated inbox URLs (prefer shared_inbox_url over inbox_url) for accepted followers only, excluding blocked actors/domains. This is called on every broadcast — DB-side filtering matters for scale.

What to persist: two tables — followers (remote → local, with status and follow_activity_id) and following (local → remote, with status and follow_activity_id).


3. ActorRepository

Local keypairs, remote actor cache, and Announce tracking.

#[async_trait]
pub trait ActorRepository: Send + Sync {
    // Local keypairs
    async fn get_local_actor_keypair(&self, user_id: Uuid) -> Result<Option<(String, String)>>;
    async fn save_local_actor_keypair(&self, user_id: Uuid, public_key: String, private_key: String) -> Result<()>;

    // Remote actor cache
    async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
    async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;

    // Boost (Announce) tracking
    async fn add_announce(&self, activity_id: &str, object_url: &str, actor_url: &str, announced_at: DateTime<Utc>) -> Result<()>;
    async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()>;
    async fn count_announces(&self, object_url: &str) -> Result<usize>;
}

get_local_actor_keypair returns Some((public_key_pem, private_key_pem)) if the keypair exists, None if not yet generated. k-ap will call save_local_actor_keypair the first time it encounters a user with no keypair.

What to persist:

  • Local keypairs: RSA-2048 PEM strings, one pair per local user
  • Remote actor cache: remote_actors table (URL, inbox, shared_inbox, display_name, avatar_url, outbox_url)
  • Announce records: (activity_id, object_url, actor_url, announced_at)

4. BlocklistRepository

Domain and per-actor blocklists. k-ap checks these before processing inbound activities.

#[async_trait]
pub trait BlocklistRepository: Send + Sync {
    async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
    async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
    async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
    async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;

    async fn add_blocked_actor(&self, local_user_id: Uuid, actor_url: &str) -> Result<()>;
    async fn remove_blocked_actor(&self, local_user_id: Uuid, actor_url: &str) -> Result<()>;
    async fn get_blocked_actors(&self, local_user_id: Uuid) -> Result<Vec<String>>;
    async fn is_actor_blocked(&self, local_user_id: Uuid, actor_url: &str) -> Result<bool>;
}

BlockedDomain: { domain: String, reason: Option<String>, blocked_at: String }

Domain blocks are instance-wide. Actor blocks are per local user. What to persist: blocked_domains table and blocked_actors table.


5. ApUserRepository

Local user lookup. k-ap calls this to build actor JSON for outbox, inbox, and WebFinger responses.

#[async_trait]
pub trait ApUserRepository: Send + Sync {
    async fn find_by_id(&self, id: Uuid) -> anyhow::Result<Option<ApUser>>;
    async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
    async fn count_users(&self) -> anyhow::Result<usize>;
}

Returns ApUser — the AP-serializable representation of a local user. All fields control how actor JSON is built:

Field Type AP field
id Uuid — (used to build the actor URL)
username String preferredUsername
display_name Option<String> name
bio Option<String> summary
avatar_url Option<Url> icon.url
banner_url Option<Url> image.url
also_known_as Vec<String> alsoKnownAs — all known aliases for account migration
profile_url Option<Url> url
featured_url Option<Url> featured — points to /users/{id}/featured
attachment Vec<ApProfileField> attachment — PropertyValue metadata fields
manually_approves_followers bool manuallyApprovesFollowers
discoverable bool discoverable
actor_type ApActorType type — Person / Service / Application / Organization / Group

6. ApContentReader

The read side — provides local content to k-ap for outbox pagination and backfill.

#[async_trait]
pub trait ApContentReader: Send + Sync {
    /// Newest-first page of locally-authored objects for `user_id`, published
    /// strictly before `before` (pass None for the first page).
    /// Returns (ap_id, object_json, published_at) tuples.
    async fn get_local_objects_page(
        &self,
        user_id: Uuid,
        before: Option<DateTime<Utc>>,
        limit: usize,
    ) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;

    /// Total locally-authored posts across all users. Used by NodeInfo.
    async fn count_local_posts(&self) -> anyhow::Result<u64>;

    /// AP URLs of pinned objects for this user, in display order.
    /// Served at GET /users/{id}/featured. Default: empty.
    async fn get_featured_objects(&self, user_id: Uuid) -> anyhow::Result<Vec<Url>> {
        Ok(vec![])
    }
}

get_local_objects_page must return objects in descending published_at order, exclude deleted and draft content, and be consistent across pages (no duplicates, no gaps). The returned object_json is served directly in the outbox — it should be a fully serialized AP object (e.g. a Note JSON value).

count_local_posts has no user_id parameter — it counts all posts across all local users for NodeInfo.

Override get_featured_objects to expose pinned posts. See Pinned Posts.


7. ApObjectHandler

The write side — called when the inbox receives AP activities.

#[async_trait]
pub trait ApObjectHandler: Send + Sync {
    /// A remote actor published new content.
    async fn on_create(&self, ap_id: &Url, actor_url: &Url, object: serde_json::Value) -> anyhow::Result<()>;

    /// A remote actor edited existing content.
    async fn on_update(&self, ap_id: &Url, actor_url: &Url, object: serde_json::Value) -> anyhow::Result<()>;

    /// A remote actor deleted an object.
    async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;

    /// A remote actor was deleted or has unfollowed all local users.
    /// Remove all content and state for this actor from local storage.
    async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;

    /// A remote actor liked a locally-authored object.
    async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;

    /// A remote actor removed their like.
    async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;

    /// A remote actor boosted a locally-authored object.
    async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;

    /// A remote actor removed their boost (Undo(Announce)). Default: no-op.
    async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> {
        Ok(())
    }

    /// A remote actor boosted an object hosted on a different server.
    /// Use to surface cross-server boosts in local feeds.
    async fn on_announce_of_remote(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;

    /// A local user was mentioned in an inbound Create or Update.
    async fn on_mention(&self, object_ap_id: &Url, mentioned_user_id: Uuid, actor_url: &Url) -> anyhow::Result<()>;
}

Returning Err propagates a 500 back to the remote server, which triggers a retry. Return Ok(()) to silently accept an activity you don't want to act on.

Idempotency: methods may be called more than once for the same activity under duplicate delivery. Prefer upsert over insert.

One method has a default no-op implementation: on_announce_removed. All other methods are required, including on_announce_of_remote (its failures are logged and swallowed — they do not fail the activity).