Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Mastic

logo

license-mit repo-stars conventional-commits

ci


About

Mastic is a federated social platform fully compatible with Mastodon and the Fediverse via ActivityPub, running entirely on the Internet Computer as Rust WASM canisters.


Documentation

Architecture

System architecture, canister design, and sequence diagrams for all major flows:

ActivityPub

ActivityPub protocol reference and Mastic-specific mapping:

Candid Interfaces

Canonical Candid interface definitions for each canister:

Project Specification

  • Project Spec - User stories, milestones, and interface definitions

Architecture

This document describes the architecture of Mastic, detailing how each canister works internally, how they communicate with each other, and how data flows through the system.

Overview

Mastic is composed of three canister types and a frontend, all deployed on the Internet Computer:

block-beta
    columns 2
    Alice (("Alice")):2

    block:mastic
        columns 2
          fe["Frontend"]:2
          uc["User Canister"]
          dir["Directory Canister"]
          fed["Federation Canister"]:2
    end

    space

    mastodon("Mastodon Web2"):2

    Bob(("Bob")):2

    Alice --> mastic
    mastic --> mastodon
    mastodon --> Bob

  • Directory Canister – Global singleton. User registry, canister lifecycle management, and moderation.
  • User Canister – One per user. Stores the user’s profile, statuses, inbox, and social graph. Acts as the ActivityPub actor.
  • Federation Canister – Global singleton. HTTP boundary for all server-to-server ActivityPub traffic, WebFinger discovery, and activity routing.
  • Frontend – React asset canister providing the web UI and Internet Identity authentication.

Directory Canister

The Directory Canister is the global entry point for Mastic. It maps Internet Identity principals to handles and User Canister IDs, manages the full canister lifecycle (creation and deletion), and enforces moderation policies.

Directory Responsibilities

  • User registry – maintains the users table mapping each principal to a handle and an optional User Canister ID.
  • Sign-up – on sign_up, validates the handle, inserts the user record, then spawns a worker that calls the IC management canister to create and install a new User Canister with the caller’s principal as owner.
  • Sign-inwhoami and user_canister resolve the caller’s principal to their handle and User Canister ID.
  • Profile deletion – creates a tombstone for the user, notifies the User Canister (which fans out a Delete activity), then destroys the canister via stop_canister + delete_canister.
  • Moderation – moderators (stored in the moderators table) can suspend users and manage the moderator list. The initial moderator is set at install time.
  • Profile searchsearch_profiles provides paginated, case-insensitive substring lookup over registered handles. Only Active users with a canister are returned; suspended, pending, failed, and deletion-pending accounts are filtered out.

Directory Storage

Uses wasm-dbms for persistent relational storage in stable memory. Tables are registered once during init and survive upgrades without re-registration.

Three tables:

TablePurpose
settingsKey-value configuration (federation canister)
moderatorsModerator principals and creation timestamps
usersPrincipal-to-handle-to-canister mapping

See Database Schema for full column definitions.

Directory Install Arguments

Uses the Init / Upgrade enum variant pattern:

  • Init – requires initial_moderator (Principal), federation_canister (Principal), and public_url (String). Registers the database schema, seeds the first moderator, and stores the instance public URL.
  • Upgrade – empty variant. Validates that the caller did not accidentally pass Init args on upgrade.

User Canister

Every Mastic user gets their own User Canister, created by the Directory Canister during sign-up. The User Canister is the ActivityPub actor: it owns the user’s profile, statuses, social graph, and cryptographic identity.

User Canister Responsibilities

  • Profile management – single-row profiles table stores display name, bio, avatar, and header image. Updated via update_profile.
  • Status publishingpublish_status generates a Snowflake ID, persists the status in the statuses table, then sends a Create activity to the Federation Canister for fan-out.
  • Feed aggregationread_feed merges the user’s own statuses (outbox) with received activities (inbox) into a paginated, chronologically-sorted feed.
  • Social graphfollowers and following tables track the user’s relationships. Follow requests go through Pending/Accepted states. Rejected follows are deleted so the user can re-follow.
  • Inboxreceive_activity (called only by the Federation Canister) writes inbound ActivityPub activities into the inbox table.
  • Outbound activitiesfollow_user, like_status, boost_status, block_user, and their undo counterparts each send the corresponding ActivityPub activity to the Federation Canister via send_activity.
  • HTTP Signatures – stores an RSA key pair (public + private PEM) in settings, used by the Federation Canister to sign outbound HTTP requests on behalf of this actor.

User Canister Storage

Uses wasm-dbms, same as the Directory Canister. Six tables:

TablePurpose
settingsOwner principal, federation canister, RSA key pair
profilesSingle-row profile (handle, display name, bio, etc.)
statusesUser’s own statuses, keyed by Snowflake ID
inboxInbound ActivityPub activities
followersActor URIs of accounts following this user
followingActor URIs this user follows, with request status

See Database Schema for full column definitions.

User Canister Install Arguments

  • Init – requires owner (Principal), federation_canister (Principal), handle (String), and public_url (String). Registers the database schema and stores all values in settings.
  • Upgrade – empty variant.

Custom Data Types

The User Canister defines three single-byte enum types for compact database storage:

  • VisibilityPublic (0), Unlisted (1), FollowersOnly (2), Direct (3). Controls status distribution scope.
  • ActivityType – 14 variants (Create, Update, Delete, Follow, Accept, Reject, Like, Announce, Undo, Block, Add, Remove, Flag, Move). Discriminates inbox activities.
  • FollowStatusPending (0), Accepted (1). Tracks the lifecycle of follow requests. Rejected follows are deleted.

Federation Canister

The Federation Canister is the HTTP boundary of the Mastic node. It handles all server-to-server ActivityPub communication: receiving activities from remote Fediverse instances, sending activities out, and serving WebFinger discovery and actor profiles.

Federation Responsibilities

  • Inbound HTTPhttp_request (query) and http_request_update (update) handle GET and POST requests from remote Fediverse instances. GET serves WebFinger, actor profiles, and collections. POST receives activities into user inboxes.
  • Outbound activitiessend_activity (called by User Canisters) routes activities to their destinations. For local recipients, it resolves the target handle via the Directory Canister and calls receive_activity on the target User Canister. For remote recipients, it performs HTTPS outcalls with HTTP Signatures.
  • Activity buffering – during profile deletion, the Federation Canister buffers the Delete activity payload so it can still be served after the User Canister has been destroyed.
  • WebFinger – responds to /.well-known/webfinger?resource=acct:user@domain queries, enabling remote instances to discover Mastic actors.

Federation Storage

Unlike the Directory and User canisters, the Federation Canister uses ic-stable-structures directly (via IcMemoryManager with DefaultMemoryImpl) rather than wasm-dbms. This is because the Federation Canister primarily buffers transient data and does not need a relational schema.

Federation Install Arguments

  • Init – empty (no configuration fields required at install time).

Authorization Model

Mastic uses principal-based authorization. Each canister checks the caller’s principal against an expected set configured at install time.

graph LR
    User([User]) -->|owner principal| UC[User Canister]
    UC -->|registered principal| FED[Federation Canister]
    FED -->|federation principal| UC
    FED -->|directory principal| DIR[Directory Canister]
    DIR -->|moderator principal| DIR
    DIR -->|management canister| IC[IC Mgmt]
CallerTargetTrust Basis
User (browser)User CanisterCaller matches owner principal set at install
User CanisterFederation CanisterUser Canister registered by Directory at creation
Federation CanisterUser CanisterFederation principal set in User Canister install args
ModeratorDirectory CanisterCaller present in moderators table
Directory CanisterIC ManagementController of dynamically-created User Canisters

Anonymous principals are rejected by all authenticated endpoints.

Inter-Canister Communication

All inter-canister calls use standard Candid-encoded ic_cdk::call invocations. The key communication patterns are:

  1. Activity fan-out – User Canister calls send_activity on the Federation Canister. Federation resolves recipients (local via Directory, remote via HTTPS outcalls) and delivers to each inbox.
  2. Activity delivery – Federation Canister calls receive_activity on target User Canisters to deposit inbound activities.
  3. Handle resolution – Federation Canister calls the Directory Canister to resolve actor handles to User Canister principals.
  4. Canister lifecycle – Directory Canister calls the IC management canister (create_canister, install_code, stop_canister, delete_canister) to manage User Canister instances.

Boost Flow

Boosting (Announce) requires denormalizing the original status content into a wrapper row owned by the booster, so the feed can render the boost without re-fetching on every read. The booster’s User Canister never trusts content supplied by its caller; instead it fetches the verified Status through the Federation Canister.

sequenceDiagram
    actor A as Alice (booster)
    participant UC as Booster User Canister
    participant FED as Federation Canister
    participant DIR as Directory Canister
    participant TUC as Target User Canister (author)
    participant Fol as Follower User Canisters

    A->>UC: boost_status(status_url)
    UC->>FED: fetch_status(uri, requester=alice_actor_uri)
    FED->>DIR: lookup handle from URI
    DIR-->>FED: target canister id
    FED->>TUC: get_local_status(id, requester=alice_actor_uri)
    TUC-->>FED: Status (visibility-filtered)
    FED-->>UC: Status
    UC->>UC: tx { wrapper Status, Boost row, FeedEntry } (shared snowflake)
    UC->>FED: send_activity(Batch[Announce])
    FED->>TUC: receive_activity(Announce)  -- bumps boost_count
    FED->>Fol: receive_activity(Announce)  -- inbox row + feed entry
    UC-->>A: Ok

A single Snowflake is reused as boosts.id, boosts.status_id, the wrapper statuses.id, and the booster’s feed.id. The same Snowflake also forms the canonical id of the emitted Announce activity: <own_actor_uri>/statuses/<snowflake>.

Both boost_status and undo_boost are idempotent: a repeat call returns Ok without inserting a duplicate row or re-dispatching the activity. Remote URIs (host ≠ instance public_url) currently return Unsupported from Federation.fetch_status; Milestone 3 will extend the remote branch with HTTPS outcalls.

Shared Libraries

Four workspace crates provide shared functionality:

  • did (crates/libs/did) – Candid type definitions shared across all canisters. Defines request/response types, UserProfile, Status, Visibility, and install argument enums.
  • db-utils (crates/libs/db-utils) – Database utilities including HandleSanitizer, HandleValidator, and the Settings key-value abstraction used by Directory and User canisters.
  • ic-utils (crates/libs/ic-utils) – Test-friendly wrappers around IC APIs: caller(), now(), trap(). In unit tests these return dummy values instead of calling ic_cdk.
  • activitypub (crates/libs/activitypub) – W3C ActivityStreams 2.0 and ActivityPub protocol types: Activity, Actor, Object, PublicKey, WebFingerResponse, collection types, and Mastodon extensions.

Database Schema

Mastic uses wasm-dbms for persistent storage inside the Directory and User canisters. Because wasm-dbms manages its own stable memory, ic-stable-structures cannot be used alongside it in these canisters. Configuration values that would normally live in stable cells are stored in a shared settings key-value table instead.

The Federation Canister does not use wasm-dbms; it uses ic-stable-structures directly.

Shared Settings Table

Both the Directory and User canisters include a settings table with the same schema. Each row maps an integer key to a polymorphic value (SettingValue, backed by the wasm-dbms Value type).

ColumnTypeConstraintDescription
keyUINT16PRIMARY KEYSetting identifier
valueSettingValueTyped value for the entry

The Settings table and its helper methods (set_config_key, get_required_settings_value, get_settings_value, get_as_principal) live in the db-utils crate and are shared by both canisters.

Directory Canister

Directory Canister Settings Keys

KeyConstantValue TypeDescription
0SETTING_FEDERATION_CANISTERBLOBPrincipal of the Federation canister

moderators Table

ColumnTypeConstraintDescription
principalPrincipalPRIMARY KEYThe moderator’s principal
created_atUINT64Timestamp when added

users Table

ColumnTypeConstraintDescription
principalPrincipalPRIMARY KEYThe user’s principal
handleTEXTUNIQUE, validatedUser’s unique handle
canister_idNullable<Principal>UNIQUEUser Canister ID
canister_statusCanisterStatusActive, CreationPending, …
created_atUINT64Timestamp when registered

The handle column uses HandleSanitizer (trims whitespace, lowercases, strips leading @) and HandleValidator (enforces the handle rules). See the Handle Validation page for the full specification.

tombstones Table

Retains deleted handles to block immediate re-registration and to keep an audit trail. handle uses the same sanitizer/validator pair as the users.handle column; see the Handle Validation spec.

ColumnTypeConstraintDescription
handleTEXTPRIMARY KEY, sanitized, validatedHandle of the deleted user
principalPrincipalPrincipal of the deleted user
deleted_atUINT64Timestamp when the user was deleted

reports Table

Stores user-submitted moderation reports. See the Reports spec for validation rules on reason and target_status_uri.

ColumnTypeConstraintDescription
idUINT64PRIMARY KEYSnowflake ID
reporterPrincipalPrincipal of the reporter
target_canisterPrincipalReported user’s canister principal
target_status_uriNullable<Text>validatedURI of the reported status, if any
reasonTEXTsanitized, validatedFree-form reason
stateReportStateOpen, Resolved, Dismissed
created_atUINT64INDEXSubmission timestamp
resolved_atNullable<Uint64>Timestamp when the report was resolved
resolved_byNullable<Principal>Moderator who resolved the report

User Canister

User Canister Settings Keys

KeyConstantValue TypeDescription
0SETTING_FEDERATION_CANISTERBLOBPrincipal of the Federation canister
1SETTING_OWNER_PRINCIPALBLOBPrincipal of the canister owner
2SETTING_PUBLIC_URLTEXTPublic URL of the Mastic instance
3SETTING_DIRECTORY_CANISTERBLOBPrincipal of the Directory canister

profiles Table

Single-row table holding the owner’s profile.

ColumnTypeConstraintDescription
principalPrincipalPRIMARY KEYOwner’s principal
handleTEXTUNIQUE, validatedUser’s unique handle
display_nameNullable<Text>Display name
bioNullable<Text>Biography
avatar_dataNullable<Blob>Avatar image data
header_dataNullable<Blob>Header / banner data
created_atUINT64Account creation time
updated_atUINT64Last profile update

statuses Table

See the Status Content spec for full validation rules on content, spoiler_text, and in_reply_to_uri.

ColumnTypeConstraintDescription
idUINT64PRIMARY KEYSnowflake ID
contentTEXTvalidatedStatus body
visibilityVisibilityPublic, Unlisted, FollowersOnly, Direct
like_countUINT64Cached Like count
boost_countUINT64Cached Announce (boost) count
in_reply_to_uriNullable<Text>INDEX, validatedURI of the replied-to status
spoiler_textNullable<Text>sanitized, validatedOptional content warning / spoiler
sensitiveBooleanWhether clients should hide behind a CW
edited_atNullable<Uint64>Timestamp of the last edit
created_atUINT64INDEXCreation timestamp (indexed for feed ordering)

inbox Table

Stores inbound ActivityPub activities.

ColumnTypeConstraintDescription
idUINT64PRIMARY KEYSnowflake ID
activity_typeActivityTypeActivity discriminator (Create, Follow, …)
actor_uriTEXTvalidatedOriginating actor’s URI
object_dataJSONActivity object payload
is_boostBooleantrue when entry is an Announce (boost)
original_status_uriNullable<Text>validatedURI of the boosted status
created_atUINT64INDEXReception timestamp (indexed for feed ordering)

follow_requests Table

ColumnTypeConstraintDescription
actor_uriTEXTPRIMARY KEYRequester’s actor URI
created_atUINT64Timestamp when follow request received

followers Table

ColumnTypeConstraintDescription
actor_uriTEXTPRIMARY KEYFollower’s actor URI
created_atUINT64Timestamp when follow accepted

following Table

ColumnTypeConstraintDescription
actor_uriTEXTPRIMARY KEYFollowed actor’s URI
statusFollowStatusPending or Accepted (rejected entries are deleted)
created_atUINT64Timestamp when follow was requested

liked Table

ColumnTypeConstraintDescription
status_uriTEXTPRIMARY KEY, validatedURI of the liked status
created_atUINT64Timestamp when the status was liked

blocks Table

ColumnTypeConstraintDescription
actor_uriTEXTPRIMARY KEY, validatedURI of the blocked actor
created_atUINT64Timestamp when block was added

mutes Table

ColumnTypeConstraintDescription
actor_uriTEXTPRIMARY KEY, validatedURI of the muted actor
created_atUINT64Timestamp when mute was added

bookmarks Table

ColumnTypeConstraintDescription
status_uriTEXTPRIMARY KEY, validatedURI of the bookmarked status
created_atUINT64Timestamp when bookmark was set

boosts Table

Tracks Announce activities emitted by the user. Each boost is paired with a wrapper row in the statuses table.

ColumnTypeConstraintDescription
idUINT64PRIMARY KEYSnowflake ID
status_idUINT64FK → statuses.idWrapper status row
original_status_uriTEXTvalidatedURI of the boosted status
created_atUINT64INDEXTimestamp when the boost was emitted

The same Snowflake is reused as boosts.id, boosts.status_id, the wrapper statuses.id, and the wrapper’s feed.id — making the wrapper status URL <actor>/statuses/<snowflake> also the canonical id of the emitted Announce activity. One sequence increment per boost; one URL that dereferences both the wrapper status and the boost activity.

media Table

See the Media Attachments spec for full validation rules on media_type, description, and blurhash.

ColumnTypeConstraintDescription
idUINT64PRIMARY KEYSnowflake ID
status_idUINT64FK → statuses.id, INDEXParent status
media_typeTEXTvalidatedMIME-like media type
descriptionNullable<Text>sanitized, validatedAlt-text description
blurhashNullable<Text>validatedBlurhash preview string
bytesBLOBRaw media bytes
created_atUINT64Creation timestamp

edit_history Table

previous_spoiler_text uses the same sanitizer/validator pair as statuses.spoiler_text; see the Status Content spec.

ColumnTypeConstraintDescription
idUINT64PRIMARY KEYSnowflake ID
status_idUINT64FK → statuses.id, INDEXStatus this entry belongs to
previous_contentTEXTContent before the edit
previous_spoiler_textNullable<Text>sanitized, validatedSpoiler text before the edit
edited_atUINT64INDEXTimestamp of the edit

hashtags Table

Local per-user index of hashtags referenced by the user’s statuses.

ColumnTypeConstraintDescription
idUINT64PRIMARY KEYSnowflake ID
tagTEXTUNIQUE, validatedSanitized, lowercase tag (without #)
created_atUINT64Timestamp when the hashtag was first seen

The tag column uses HashtagSanitizer (trims whitespace, lowercases, strips leading #) and HashtagValidator. See the Hashtag Validation page for the full specification.

status_hashtags Table

Join table between statuses and hashtags. Uses a surrogate id primary key because the underlying storage layer does not support composite primary keys; uniqueness of (status_id, hashtag_id) is enforced by the application layer.

ColumnTypeConstraintDescription
idUINT64PRIMARY KEYSnowflake ID
status_idUINT64FK → statuses.id, INDEXStatus
hashtag_idUINT64FK → hashtags.id, INDEXHashtag

Up to four hashtags featured on the user’s profile. The tag column uses the same HashtagSanitizer / HashtagValidator pair as the hashtags table.

ColumnTypeConstraintDescription
tagTEXTPRIMARY KEY, validatedSanitized, lowercase tag
positionUINT8UNIQUE (0..=3)Display position
created_atUINT64Timestamp when tag was featured

pinned_statuses Table

Up to five statuses pinned on the user’s profile.

ColumnTypeConstraintDescription
status_idUINT64PRIMARY KEY, FK → statuses.idPinned status
positionUINT8UNIQUE (0..=4)Display position
pinned_atUINT64Timestamp when status was pinned

profile_metadata Table

Up to four custom fields shown on the user’s profile. See the Profile Metadata spec for full validation rules.

ColumnTypeConstraintDescription
positionUINT8PRIMARY KEY (0..=3)Position in the list
nameTEXTsanitized, validatedField name
valueTEXTsanitized, validatedField value

Custom Data Types

The following custom types are used in the schema and stored as compact single-byte discriminants:

Visibility

Maps to did::common::Visibility.

ValueVariant
0Public
1Unlisted
2FollowersOnly
3Direct

ActivityType

Maps to activitypub::ActivityType.

ValueVariant
0Create
1Update
2Delete
3Follow
4Accept
5Reject
6Like
7Announce
8Undo
9Block
10Add
11Remove
12Flag
13Move

FollowStatus

ValueVariant
0Pending
1Accepted

ReportState

ValueVariant
0Open
1Resolved
2Dismissed

Persistence

All tables are created during canister init. Data survives canister upgrades because wasm-dbms stores everything in stable memory. The post_upgrade function does not need to re-register the schema.

ActivityPub on Mastic

This module provides a technical overview with simple diagrams of the ActivityPub protocol, which is used for federated social networking.

The diagrams illustrate the flow of activities between actors in a federated network, showing how they interact with each other through various endpoints, and so what it has to be implemented in order to support ActivityPub on Mastic.

Mapping to Mastic Architecture

The following table shows how core ActivityPub concepts map to Mastic’s canister-based architecture:

ActivityPub ConceptMastic ComponentNotes
ActorUser CanisterEach Mastic user is represented by a dedicated User Canister that acts as their ActivityPub Actor.
Inbox / OutboxUser CanisterThe actor’s inbox and outbox collections are stored in the User Canister. They are exposed to the Fediverse via HTTP endpoints served by the Federation Canister.
Social API (C2S)Candid calls to User CanisterInstead of HTTP-based Client-to-Server interactions, Mastic users interact with their User Canister through authenticated Candid calls, using Internet Identity for authentication.
Federation Protocol (S2S)Federation CanisterAll Server-to-Server HTTP traffic is handled by the Federation Canister, which receives incoming activities and forwards outgoing activities to remote instances.
HTTP SignaturesUser Canister (key storage) + Federation Canister (signing/verification)Each User Canister generates and stores an RSA key pair at creation time. The Federation Canister uses the private key to sign outgoing requests and serves the public key when the actor profile is requested.
WebFingerFederation CanisterWebFinger lookups (/.well-known/webfinger) are handled by the Federation Canister’s http_request query method, which resolves account handles to actor URIs via the Directory Canister.

Federation Canister HTTP Endpoints

The Federation Canister serves the following HTTP routes to enable ActivityPub federation and discovery:

MethodRouteDescription
GET/.well-known/webfingerWebFinger lookup — resolves acct: URIs to actor profiles via the Directory Canister
GET/users/{handle}Actor profile — returns the JSON-LD representation of the actor
GET/users/{handle}/inboxActor inbox — returns the inbox as an OrderedCollection
POST/users/{handle}/inboxReceive activities from remote instances (S2S) — validates HTTP Signatures and delivers to the User Canister
GET/users/{handle}/outboxActor outbox — returns the outbox as an OrderedCollection
GET/users/{handle}/followersFollowers collection — returns the actor’s followers as an OrderedCollection
GET/users/{handle}/followingFollowing collection — returns the actors followed by this actor as an OrderedCollection
GET/users/{handle}/likedLiked collection — returns the activities liked by this actor as an OrderedCollection

All GET endpoints are served by the Federation Canister’s http_request query method. The POST /users/{handle}/inbox endpoint is handled by http_request_update since it requires state changes (delivering activities to User Canisters).

Objects

All objects in ActivityPub are represented as JSON-LD documents. The objects can be of various types, such as Person, Note, Create, Like, etc.

Each object MUST have:

  • id: The object’s unique global identifier (unless the object is transient, in which case the id MAY be omitted).
  • type: The type of the object.

and can have various properties such as to, actor, and content.

Retrieving objects

Servers MUST present the ActivityStreams object representation in response to application/ld+json; profile="https://www.w3.org/ns/activitystreams", and SHOULD also present the ActivityStreams representation in response to application/activity+json as well.

The client MUST specify an Accept header with the application/ld+json; profile="https://www.w3.org/ns/activitystreams" media type in order to retrieve the activity.

Source

The Object also contains the source attribute, which has been originally used to derive the content:

{
  "content": "<p>I <em>really</em> like strawberries!</p>",
  "source": {
    "content": "I *really* like strawberries!",
    "mediaType": "text/markdown"
    }
}

Actors

Actors are the entities that perform actions in the ActivityPub protocol. They can be users, applications, or services. Each actor has a unique identifier and can have various properties such as name, icon, and preferred language.

Actors are represented as JSON-LD documents with the type set to Person, Application, or other types defined in the ActivityStreams vocabulary.

Each actor MUST, in addition to the properties for the Objects, have the following properties:

  • inbox: (OrderedCollection) The URL of the actor’s inbox, where it receives activities.
  • outbox: (OrderedCollection) The URL of the actor’s outbox, where it sends activities.
  • following: (OrderedCollection) An Url to an ActivityStreams collection that contains the actors that this actor is following.
  • followers: (OrderedCollection) An Url to an ActivityStreams collection that contains the actors that are following this actor.
  • liked: (OrderedCollection) An Url to an ActivityStreams collection that contains the activities that this actor has liked.
{
  "@context": ["https://www.w3.org/ns/activitystreams", { "@language": "ja" }],
  "type": "Person",
  "id": "https://kenzoishii.example.com/",
  "following": "https://kenzoishii.example.com/following.json",
  "followers": "https://kenzoishii.example.com/followers.json",
  "liked": "https://kenzoishii.example.com/liked.json",
  "inbox": "https://kenzoishii.example.com/inbox.json",
  "outbox": "https://kenzoishii.example.com/feed.json",
  "preferredUsername": "kenzoishii",
  "name": "石井健蔵",
  "summary": "この方はただの例です",
  "icon": ["https://kenzoishii.example.com/image/165987aklre4"]
}

Inbox and Outbox

Every actor has both an inbox and an outbox. The inbox is where the actor receives activities from other actors, while the outbox is where the actor sends activities to other actors.

From an implementation perspective, both the Inbox and the Outbox, are OrderedCollection objects.

block-beta
    columns 7

    U (["User"])
    space:2

    block:queues
    columns 1
    IN ("Inbox")
    OUT ("Outbox")
    end

    space:2

    RO ("Rest of the world")

    U -- "POST messages to" --> OUT
    IN -- "GET messages from" --> U
    RO -- "POST messages to" --> IN
    OUT -- "GET messages from" --> RO

Actor data:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Person",
  "id": "https://social.example/alice/",
  "name": "alice P. Hacker",
  "preferredUsername": "alice",
  "summary": "Lisp enthusiast hailing from MIT",
  "inbox": "https://social.example/alice/inbox/",
  "outbox": "https://social.example/alice/outbox/",
  "followers": "https://social.example/alice/followers/",
  "following": "https://social.example/alice/following/",
  "liked": "https://social.example/alice/liked/"
}

Now let’s say Alice wants to send a message to Bob. The following diagram illustrates the flow of this activity:

block-beta
    columns 13

    A (["Alice"])
    space

    block:aliq
        columns 1
        AIN ("Inbox")
        AOUT ("Outbox")
    end

    space

    AS ("Alice's Server")

    space:2

    BS ("Bob's Server")

    space

    block:bobq
        columns 1
        BIN ("Inbox")
        BOUT ("Outbox")
    end

    space:2

    B ("Bob")

    A -- "POST message" --> AOUT
    AOUT --> AS
    AS -- "POST message to" --> BS
    BS --> BIN
    BIN -- "GET message" --> B

First Alice sends a message to her outbox:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Note",
  "to": ["https://chatty.example/ben/"],
  "attributedTo": "https://social.example/alice/",
  "content": "Say, did you finish reading that book I lent you?"
}

Then Alice’s server creates the post and forwards the message to Bob’s server:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Create",
  "id": "https://social.example/alice/posts/a29a6843-9feb-4c74-a7f7-081b9c9201d3",
  "to": ["https://chatty.example/ben/"],
  "actor": "https://social.example/alice/",
  "object": {
    "type": "Note",
    "id": "https://social.example/alice/posts/49e2d03d-b53a-4c4c-a95c-94a6abf45a19",
    "attributedTo": "https://social.example/alice/",
    "to": ["https://chatty.example/ben/"],
    "content": "Say, did you finish reading that book I lent you?"
  }
}

Later after Bob has answered, Alice can fetch her inbox with a GET and see the answer to that message:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Create",
  "id": "https://chatty.example/ben/p/51086",
  "to": ["https://social.example/alice/"],
  "actor": "https://chatty.example/ben/",
  "object": {
    "type": "Note",
    "id": "https://chatty.example/ben/p/51085",
    "attributedTo": "https://chatty.example/ben/",
    "to": ["https://social.example/alice/"],
    "inReplyTo": "https://social.example/alice/posts/49e2d03d-b53a-4c4c-a95c-94a6abf45a19",
    "content": "<p>Argh, yeah, sorry, I'll get it back to you tomorrow.</p><p>I was reviewing the section on register machines,since it's been a while since I wrote one.</p>"
  }
}

Further interactions can be made, such as liking the reply:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Like",
  "id": "https://social.example/alice/posts/5312e10e-5110-42e5-a09b-934882b3ecec",
  "to": ["https://chatty.example/ben/"],
  "actor": "https://social.example/alice/",
  "object": "https://chatty.example/ben/p/51086"
}

And this will follow the same flow as before.

Activity Streams

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://www.w3.org/ns/activitystreams",
  "type": "Collection"
}

ActivityStreams defines the collection concept; ActivityPub defines several collections with special behavior. Note that ActivityPub makes use of ActivityStreams paging to traverse large sets of objects.

Note that some of these collections are specified to be of type OrderedCollection specifically, while others are permitted to be either a Collection or an OrderedCollection. An OrderedCollection MUST be presented consistently in reverse chronological order.

Public collections

Some collections are marked as Public

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://www.w3.org/ns/activitystreams#Public",
  "type": "Collection"
}

And MUST be accessible to anyone, regardless of whether they are authenticated or not.

Likes Collection

Objects MAY have a likes collection. This is an OrderedCollection containing all Like activities referencing that object. Servers SHOULD update this collection when a Like or Undo Like activity is received. The collection is typically exposed with at least a totalItems count.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://social.example/alice/posts/1/likes",
  "type": "OrderedCollection",
  "totalItems": 5,
  "orderedItems": [
    "https://social.example/bob/likes/1",
    "https://social.example/carol/likes/3"
  ]
}

Shares Collection

Objects MAY have a shares collection. This is an OrderedCollection containing all Announce activities referencing that object. Servers SHOULD update this collection when an Announce or Undo Announce activity is received. Like the likes collection, it is typically exposed with at least a totalItems count.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://social.example/alice/posts/1/shares",
  "type": "OrderedCollection",
  "totalItems": 3,
  "orderedItems": [
    "https://chatty.example/ben/announces/1"
  ]
}

Protocol

The protocol is based on HTTP and uses JSON-LD for data representation.

Two APIs are defined:

  • Social API: It’s a client-to-server API that allows clients to interact with the server, such as creating posts, following users, and liking content.
  • Federation Protocol: It’s a server-to-server API that allows servers to exchange activities with each other, such as sending posts, following users, and liking content.

Mastic is implemented as a ActivityPub conformant Federated Server, with a significant variation.

While the Federation Protocol is implemented with HTTP, the Social API is implemented using the Internet Computer’s native capabilities, such as update calls and query calls, and calls are so authenticated using the Internet Computer’s Internet Identity.

Social API

In the standard ActivityPub specification, client-to-server (C2S) interaction takes place through clients posting Activities to an actor’s outbox via HTTP POST requests. Mastic replaces this HTTP-based C2S layer with typed Candid methods on the User Canister.

Instead of discovering an outbox URL and POSTing JSON-LD payloads to it, Mastic users call specific Candid methods on their User Canister, such as publish_status, like_status, follow_user, boost_status, block_user, etc. Each method accepts a typed Candid argument struct and returns a typed response. The User Canister internally manages the actor’s outbox, appending the corresponding ActivityPub activity for each operation.

Requests MUST be authenticated using the Internet Computer’s Internet Identity — the caller’s principal must match the owner principal configured at canister install time.

The User Canister handles the same side effects that the ActivityPub spec requires for outbox operations:

  • The server (User Canister) MUST generate a new id for each Activity.
  • The server MUST remove bto and/or bcc properties before delivery, but MUST utilize the addressing originally stored on these properties for determining recipients.
  • The server MUST add the new Activity to the outbox collection.
  • Depending on the type of Activity, the server may carry out further side effects as described per individual Activity below.

Client Addressing

Clients are responsible for addressing new Activities appropriately. To some extent, this is dependent upon the particular client implementation, but clients must be aware that the server will only forward new Activities to addressees in the to, bto, cc, bcc, and audience fields.

Create Activity

The Create activity is used when posting a new object. This has the side effect that the object embedded within the Activity (in the object property) is created.

When a Create activity is posted, the actor of the activity SHOULD be copied onto the object’s attributedTo field.

For client to server posting, it is possible to submit an object for creation without a surrounding activity. The server MUST accept a valid ActivityStreams object that isn’t a subtype of Activity in the POST request to the outbox. The server then MUST attach this object as the object of a Create Activity. For non-transient objects, the server MUST attach an id to both the wrapping Create and its wrapped Object.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Note",
  "content": "This is a note",
  "published": "2015-02-10T15:04:55Z",
  "to": ["https://example.org/~john/"],
  "cc": ["https://example.com/~erik/followers",
         "https://www.w3.org/ns/activitystreams#Public"]
}

Is equivalent to this and both MUST be accepted by the server:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Create",
  "id": "https://example.net/~mallory/87374",
  "actor": "https://example.net/~mallory",
  "object": {
    "id": "https://example.com/~mallory/note/72",
    "type": "Note",
    "attributedTo": "https://example.net/~mallory",
    "content": "This is a note",
    "published": "2015-02-10T15:04:55Z",
    "to": ["https://example.org/~john/"],
    "cc": ["https://example.com/~erik/followers",
           "https://www.w3.org/ns/activitystreams#Public"]
  },
  "published": "2015-02-10T15:04:55Z",
  "to": ["https://example.org/~john/"],
  "cc": ["https://example.com/~erik/followers",
         "https://www.w3.org/ns/activitystreams#Public"]
}

Update Activity

The Update activity is used when updating an already existing object. The side effect of this is that the object MUST be modified to reflect the new structure as defined in the update activity, assuming the actor has permission to update this object.

Usually updates are partial.

Delete Activity

The Delete activity is used to delete an already existing object

The side effect of this is that the server MAY replace the object with a Tombstone of the object that will be displayed in activities which reference the deleted object. If the deleted object is requested the server SHOULD respond with either the Gone status code if a Tombstone object is presented as the response body, otherwise respond with a NotFound.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://example.com/~alice/note/72",
  "type": "Tombstone",
  "published": "2015-02-10T15:04:55Z",
  "updated": "2015-02-10T15:04:55Z",
  "deleted": "2015-02-10T15:04:55Z"
}

Follow Activity

The Follow activity is used to subscribe to the activities of another actor.

Add Activity

Upon receipt of an Add activity into the outbox, the server SHOULD add the object to the collection specified in the target property, unless:

  • the target is not owned by the receiving server, and thus they are not authorized to update it.
  • the object is not allowed to be added to the target collection for some other reason, at the receiving server’s discretion.

Remove Activity

Upon receipt of a Remove activity into the outbox, the server SHOULD remove the object from the collection specified in the target property, unless:

  • the target is not owned by the receiving server, and thus they are not authorized to update it.
  • the object is not allowed to be removed from the target collection for some other reason, at the receiving server’s discretion.

Like Activity

The Like activity indicates the actor likes the object.

The side effect of receiving this in an outbox is that the server SHOULD add the object to the actor’s liked Collection.

Block Activity

The Block activity is used to indicate that the posting actor does not want another actor (defined in the object property) to be able to interact with objects posted by the actor posting the Block activity. The server SHOULD prevent the blocked user from interacting with any object posted by the actor.

Servers SHOULD NOT deliver Block Activities to their object.

Announce Activity

The Announce activity is used to share an existing object with the actor’s followers — this is the ActivityPub equivalent of a “boost” or “reblog”. When an Announce is posted to an actor’s outbox, the server SHOULD add the object of the Announce to the actor’s outbox as a reference and deliver it to the actor’s followers.

The side effect of receiving an Announce on the inbox (S2S) is that the server SHOULD increment the shares count of the announced object and MAY display the object as having been shared by the announcing actor.

Mastic implementation (M1): Outbound Announce is emitted by the User Canister’s boost_status flow (with Undo(Announce) from undo_boost). Inbound Announce is processed by handle_announce in crates/canisters/user/src/domain/activity/handle_incoming.rs, which inserts an inbox row, a feed entry, and increments the local target’s statuses.boost_count (saturating). Inbound Undo(Announce) is dispatched to handle_undo_announce, which deletes the inbox / feed rows and saturating-decrements the boost count.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://social.example/alice/posts/announce-1",
  "type": "Announce",
  "actor": "https://social.example/alice",
  "published": "2024-06-01T12:00:00Z",
  "to": ["https://www.w3.org/ns/activitystreams#Public"],
  "cc": ["https://social.example/alice/followers"],
  "object": "https://chatty.example/ben/p/51086"
}

Undo Activity

The Undo activity is used to undo a previous activity. See the Activity Vocabulary documentation on Inverse Activities and “Undo”. For example, Undo may be used to undo a previous Like, Follow, or Block. The undo activity and the activity being undone MUST both have the same actor. Side effects should be undone, to the extent possible. For example, if undoing a Like, any counter that had been incremented previously should be decremented appropriately.

There are some exceptions where there is an existing and explicit “inverse activity” which should be used instead. Create based activities should instead use Delete, and Add activities should use Remove.

Delivery

Federated servers MUST perform delivery on all Activities posted to the outbox according to outbox delivery.

Federation Protocol

Servers communicate with other servers and propagate information across the social graph by posting activities to actors’ inbox endpoints. An Activity sent over the network SHOULD have an id, unless it is intended to be transient (in which case it MAY omit the id).

POST requests (eg. to the inbox) MUST be made with a Content-Type of application/ld+json; profile="https://www.w3.org/ns/activitystreams" and GET requests (see also 3.2 Retrieving objects) with an Accept header of application/ld+json; profile="https://www.w3.org/ns/activitystreams".

Servers SHOULD interpret a Content-Type or Accept header of application/activity+json as equivalent to application/ld+json; profile="https://www.w3.org/ns/activitystreams“ for server-to-server interactions.

In order to propagate updates throughout the social graph, Activities are sent to the appropriate recipients. First, these recipients are determined through following the appropriate links between objects until you reach an actor, and then the Activity is inserted into the actor’s inbox (delivery). This allows recipient servers to:

  1. conduct any side effects related to the Activity (for example, notification that an actor has liked an object is used to update the object’s like count);
  2. deliver the Activity to recipients of the original object, to ensure updates are propagated to the whole social graph (see inbox delivery).

Delivery is usually triggered by, for example:

  • an Activity being created in an actor’s outbox with their Followers Collection as the recipient.
  • an Activity being created in an actor’s outbox with directly addressed recipients.
  • an Activity being created in an actors’s outbox with user-curated collections as recipients.
  • an Activity being created in an actor’s outbox or inbox which references another object.

Servers performing delivery to the inbox or sharedInbox properties of actors on other servers MUST provide the object property in the activity: Create, Update, Delete, Follow, Add, Remove, Like, Block, Undo. Additionally, servers performing server to server delivery of the following activities MUST also provide the target property: Add, Remove.

An activity is delivered to its targets (which are actors) by first looking up the targets’ inboxes and then posting the activity to those inboxes. Targets for delivery are determined by checking the ActivityStreams audience targeting; namely, the to, bto, cc, bcc, and audience fields of the activity.

Accept Activity

The Accept activity is used on the server side (S2S) to approve a previously received Follow request. When a server receives a Follow activity in an actor’s inbox, the server SHOULD either automatically respond with an Accept (for unlocked accounts) or queue the follow request for manual approval. Once approved, the server sends an Accept wrapping the original Follow back to the requesting actor’s inbox.

The side effect of receiving an Accept of a Follow is that the requesting actor is added to the target actor’s followers collection, and the target actor is added to the requesting actor’s following collection.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://social.example/alice/accepts/1",
  "type": "Accept",
  "actor": "https://social.example/alice",
  "object": {
    "id": "https://chatty.example/ben/follows/1",
    "type": "Follow",
    "actor": "https://chatty.example/ben",
    "object": "https://social.example/alice"
  }
}

Reject Activity

The Reject activity is used on the server side (S2S) to deny a previously received Follow request. When a locked account’s owner denies a follow request, the server sends a Reject wrapping the original Follow back to the requesting actor’s inbox.

The side effect of receiving a Reject of a Follow is that the follow relationship is not established and the requesting server SHOULD remove any pending follow state.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://social.example/alice/rejects/1",
  "type": "Reject",
  "actor": "https://social.example/alice",
  "object": {
    "id": "https://chatty.example/ben/follows/1",
    "type": "Follow",
    "actor": "https://chatty.example/ben",
    "object": "https://social.example/alice"
  }
}

Shared Inbox Delivery

To efficiently deliver activities to multiple recipients on the same remote server, ActivityPub defines the shared inbox mechanism. Instead of sending a separate copy of an activity to each recipient’s individual inbox, the sending server MAY deliver a single copy to the remote server’s shared inbox endpoint.

An actor MAY advertise a shared inbox via the endpoints.sharedInbox property:

{
  "id": "https://social.example/alice",
  "type": "Person",
  "inbox": "https://social.example/alice/inbox",
  "endpoints": {
    "sharedInbox": "https://social.example/inbox"
  }
}

When delivering to a shared inbox, the activity MUST still contain its full addressing (to, cc, etc.) so the receiving server can determine which local actors should see it. The sending server SHOULD use the shared inbox when an activity is addressed to multiple recipients on the same domain.

Mastic implementation: In Mastic, the Federation Canister acts as the shared inbox endpoint for all local users. The POST /inbox endpoint (without a handle) can serve as the shared inbox, routing activities to the appropriate User Canisters based on the addressing fields.

Inbox Forwarding

When an activity is received in an actor’s inbox and the activity’s object is addressed to recipients that the receiving server is aware of but which are not explicitly addressed in the activity itself, the server SHOULD forward the activity to those recipients. This mechanism ensures that replies in a conversation thread are propagated to all participants.

A server MUST only forward an activity if:

  1. The values of to, cc, or audience are collections owned by the server.
  2. The values of inReplyTo, object, target, or tag are objects owned by the server. The server SHOULD recurse through these values to look for linked objects owned by the server.
  3. The values of the object or target match a collection owned by the server (e.g., the followers collection).

When forwarding, the server MUST sign the forwarded request with an HTTP Signature from the forwarding actor (not the original actor).

Server Side Activities

Just follow 1:1 the document described here:

https://www.w3.org/TR/activitypub/#create-activity-inbox

Mastodon

Note: Not all Mastodon extensions described in this section will be implemented in the initial milestones. Features such as pinned posts (Featured Collection), Flag/reporting, and Move/account migration are documented here for completeness and future reference. See the milestone plan in docs/project.md for the implementation timeline.

Statuses Federation

In Mastodon statuses are posts, aka toots, of the type of Notes of the ActivityPub protocol.

Mastodon supports the following activities for Statuses:

  • Create: Transformed into a status and saved into database
  • Update: Update an existing status in the database
  • Delete: Delete a Status from the database
  • Like: Favourited a Status
  • Announce: Boost a status (like rt on Twitter). Mastic M1: outbound via boost_status, inbound via handle_announce.
  • Undo: Undo a Like or a Boost. Mastic M1: outbound via undo_like / undo_boost, inbound via handle_undo_like / handle_undo_announce.
  • Flag: Transformed into a report to the moderation team. See the Reports extension for more information
  • QuoteRequest: Request approval for a quote post. See the Quote Posts extension

Payloads

The first-class Object types supported by Mastodon are Note and Question. Additionally, the following types are accepted and transformed into statuses:

  • Note — transformed into regular statuses.
  • Question — transformed into a poll status. See the Polls extension for more information.
  • Article — long-form content, transformed into a status.
  • Page — web page representation, transformed into a status.
  • Image — image post, transformed into a status with media attachment.
  • Audio — audio post, transformed into a status with audio attachment.
  • Video — video post, transformed into a status with video attachment.
  • Event — event representation, transformed into a status.

HTML Sanitization

https://docs.joinmastodon.org/spec/activitypub/#sanitization.

Status Properties

These are the properties used:

  • content: status text content
  • name: Used as status text, if content is not provided on a transformed Object type
  • summary: Used as CW (Content warning) text
  • sensitive: Used to determine whether status media or text should be hidden by default. See the Sensitive content extension section for more information about as:sensitive
  • inReplyTo: Used for threading a status as a reply to another status
  • published: status published date
  • url: status permalink
  • attributedTo: Used to determine the profile which authored the status
  • to/cc: Used to determine audience and visibility of a status, in combination with mentions. See Mentions for adddressing and notifications.
  • tag: Used to mark up mentions and hashtags.
    • type: Either Mention, Hashtag, or Emoji is currently supported. See the Hashtag and Custom emoji extension sections for more information.
    • name: The plain-text Webfinger address of a profile Mention (@user or @user@domain), or the plain-text Hashtag (#tag), or the custom Emoji shortcode (:thounking:)
    • href: The URL of the actor or tag
  • attachment: Used to include attached images, videos, or audio
    • url: Used to fetch the media attachment
    • summary: Used as media description alt
    • blurhash: Used to generate a blurred preview image corresponding to the colors used within the image. See Blurhash for more details
  • replies: A Collection of statuses that are in reply to the current status. Up to 5 replies from the same server will be fetched upon discovery of a remote status, in order to resolve threads more fully. On Mastodon’s side, the first page contains self-replies, and additional pages contain replies from other people.
  • likes: A Collection used to represent Like activities received for this status. The actual activities are not exposed by Mastodon at this time.
    • totalItems: The number of likes this status has received
  • shares: A Collection used to represent Announce activities received for this status. The actual activities are not exposed by Mastodon at this time.
    • totalItems: The number of Announce activities received for this status.

Poll specific properties

  • endTime: The timestamp for when voting will close on the poll
  • closed: The timestamp for when voting closed on the poll. The timestamp will likely match the endTime timestamp. If this property is present, the poll is assumed to be closed.
  • votersCount: How many people have voted in the poll. Distinct from how many votes have been cast (in the case of multiple-choice polls)
  • oneOf: Single-choice poll options
    • name: The poll option’s text
    • replies:
      • totalItems: The poll option’s vote count
  • anyOf: Multiple-choice poll options
    • name: The poll option’s text
    • replies:
      • totalItems: The poll option’s vote count

Profiles Federation

Profiles are represented as Person objects in ActivityPub, and they are used to represent users on the platform. Mastodon supports the following activities for profiles:

  • Follow: Indicate interest in receiving status updates from a profile.
  • Accept/Reject: Used to approve or deny Follow activities. Unlocked accounts will automatically reply with an Accept, while locked accounts can manually choose whether to approve or deny a follow request.
  • Add/Remove: Manage pinned posts and featured collections.
  • Update: Refresh account details
  • Delete: Remove an account from the database, as well as all of their statuses.
  • Undo: Undo a previous Follow, Accept Follow, or Block.
  • Block: Signal to a remote server that they should hide your profile from that user. Not guaranteed.
  • Flag: Report a user to their moderation team. See the Reports extension for more information
  • Move: Migrate followers from one account to another. Requires alsoKnownAs to be set on the new account pointing to the old account

Profile Properties

  • preferredUsername: Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI.
  • name: Used as profile display name.
  • summary: Used as profile bio.
  • type: Assumed to be Person. If type is Application or Service, it will be interpreted as a bot flag.
  • url: Used as profile link.
  • icon: Used as profile avatar.
  • image: Used as profile header.
  • manuallyApprovesFollowers: Will be shown as a locked account.
  • discoverable: Will be shown in the profile directory.
  • indexable: Posts by this account can be indexed for full-text search
  • publicKey: Required for signatures. See Public Key for more information.
  • featured: Pinned posts. See Featured collection
  • attachment: Used for profile fields. See Profile metadata.
  • alsoKnownAs: Required for Move activity
  • published: When the profile was created.
  • memorial: Whether the account is a memorial account. See Memorial Flag for more information.
  • suspended: Whether the account is currently suspended. See Suspended Flag for more information.
  • attributionDomains: Domains allowed to use fediverse:creator for this actor in published articles.

Reports Extension

To report profiles and/or posts on remote servers, Mastodon will send a Flag activity from the instance actor. The object of this activity contains the user being reported, as well as any posts attached to the report. If a comment is attached to the report, it will be used as the content of the activity.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://mastodon.example/ccb4f39a-506a-490e-9a8c-71831c7713a4",
  "type": "Flag",
  "actor": "https://mastodon.example/actor",
  "content": "Please take a look at this user and their posts",
  "object": [
    "https://example.com/users/1",
    "https://example.com/posts/380590",
    "https://example.com/posts/380591"
  ],
  "to": "https://example.com/users/1"
}

Sensitive Extension

Mastodon uses the as:sensitive extension property to mark certain posts as sensitive. When a post is marked as sensitive, any media attached to it will be hidden by default, and if a summary is present, the status content will be collapsed behind this summary. In Mastodon, this is known as a content warning.

Hashtag

Similar to the Mention subtype of Link already defined in ActivityStreams, Mastodon will use Hashtag as a subtype of Link in order to surface posts referencing some common topic identified by a string key. The Hashtag has a name containing the #hashtag microsyntax – a # followed by a string sequence representing a topic. This is similar to the @mention microsyntax, where an @ is followed by some string sequence representing a resource (where in Mastodon’s case, this resource is expected to be an account). Mastodon will also normalize hashtags to be case-insensitive lowercase strings, performing ASCII folding and removing invalid characters.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "Hashtag": "https://www.w3.org/ns/activitystreams#Hashtag"
    }
  ],
  "id": "https://example.com/some-post",
  "type": "Note",
  "attributedTo": "https://example.com",
  "content": "I love #cats",
  "tag": [
    {
      "type": "Hashtag",
      "name": "#cats",
      "href": "https://example.com/tagged/cats"
    }
  ]
}

Custom Emoji

Mastodon supports arbitrary emojis by including a tag of the Emoji type. Handling of custom emojis is similar to handling of mentions and hashtags, where the name of the tagged entity is found as a substring of the natural language properties (name, summary, content) and then linked to the local representation of some resource or topic. In the case of emoji shortcodes, the name is replaced by the HTML for an inline image represented by the icon property (where icon.url links to the image resource).

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "Emoji": "http://joinmastodon.org/ns#Emoji",
    }
  ],

  "id": "https://example.com/@alice/hello-world",
  "type": "Note",
  "content": "Hello world :kappa:",
  "tag": [
    {
      "id": "https://example.com/emoji/123",
      "type": "Emoji",
      "name": ":kappa:",
      "icon": {
        "type": "Image",
        "mediaType": "image/png",
        "url": "https://example.com/files/kappa.png"
      }
    }
  ]
}

Focal Points

Mastodon supports setting a focal point on uploaded images, so that wherever that image is displayed, the focal point stays in view. This is implemented using an extra property focalPoint on Image objects. The property is an array of two floating points between -1.0 and 1.0, with 0,0 being the center of the image, the first value being x (-1.0 is the left edge, +1.0 is the right edge) and the second value being y (-1.0 is the bottom edge, +1.0 is the top edge).

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "focalPoint": {
        "@container": "@list",
        "@id": "http://joinmastodon.org/ns#focalPoint"
      }
    }
  ],

  "id": "https://example.com/@alice/hello-world",
  "type": "Note",
  "content": "A picture attached!",
  "attachment": [
    {
      "type": "Image",
      "mediaType": "image/png",
      "url": "https://example.com/files/cats.png",
      "focalPoint": [
        -0.55,
        0.43
      ]
    }
  ]
}

Quote Posts

Mastodon implements experimental support for handling remote quote posts according to FEP-044f. Additionally, it understands quoteUri, quoteUrl and _misskey_quote for compatibility.

Should a post contain multiple quotes, Mastodon only accepts the first one.

Furthermore, Mastodon does not handle the full range of interaction policies, but instead converts the authorized followers to a combination of “public”, “followers” and “unknown”, defaulting to “nobody”.

At this time, Mastodon does not offer authoring quotes, nor does it expose a quote policy, or produce stamps for incoming quote requests.

Discoverability Flag

Mastodon allows users to opt-in or opt-out of discoverability features like the profile directory. This flag may also be used as an indicator of the user’s preferences toward being included in external discovery services. If you are implementing such a tool, it is recommended that you respect this property if it is present. This is implemented using an extra property discoverable on objects mapping to profiles.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "discoverable": "http://joinmastodon.org/ns#discoverable"
    }
  ],
  "id": "https://mastodon.social/users/Gargron",
  "type": "Person",
  "discoverable": true
}

Indexable Flag

Mastodon allows users to opt-in or opt-out of indexing features like full-text search of public statuses. If you are implementing such a tool, it is recommended that you respect this property if it is present. This is implemented using an extra property indexable on objects mapping to profiles.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "indexable": "http://joinmastodon.org/ns#indexable"
    }
  ],
  "id": "https://mastodon.social/users/Gargron",
  "type": "Person",
  "indexable": true
}

Suspended Flag

Mastodon reports whether a user was locally suspended, for better handling of these accounts. Suspended accounts in Mastodon return empty data. If a remote account is marked as suspended, it cannot be unsuspended locally. Suspended accounts can be targeted by activities such as Update, Undo, Reject, and Delete. This functionality is implemented using an extra property suspended on objects.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "suspended": "http://joinmastodon.org/ns#suspended"
    }
  ],
  "id": "https://example.com/@eve",
  "type": "Person",
  "suspended": true
}

Memorial Flag

Mastodon reports whether a user’s profile was memorialized, for better handling of these accounts. Memorial accounts in Mastodon return normal data, but are rendered with a header indicating that the account is a memorial account. This functionality is implemented using an extra property memorial on objects.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "memorial": "http://joinmastodon.org/ns#memorial"
    }
  ],
  "id": "https://example.com/@alice",
  "type": "Person",
  "memorial": true
}

Polls

The ActivityStreams Vocabulary specification describes loosely (non-normatively) how a question might be represented. Mastodon’s implementation of polls is somewhat inspired by this section. The following implementation details can be observed:

Question is used as an Object type instead of as an IntransitiveActivity; rather than being sent directly, it is wrapped in a Create just like any other status.

Poll options are serialized using oneOf or anyOf as an array.

Each item in this array has no id, has a type of Note, and has a name representing the text of the poll option.

Each item in this array also has a replies property, representing the responses to this particular poll option. This node has no id, has a type of Collection, and has a totalItems property representing the total number of votes received for this option.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "votersCount": "http://joinmastodon.org/ns#votersCount"
    }
  ],
  "id": "https://mastodon.example/users/alice/statuses/1009947848598745",
  "type": "Question",
  "content": "What should I eat for breakfast today?",
  "published": "2023-03-05T07:40:13Z",
  "endTime": "2023-03-06T07:40:13Z",
  "votersCount": 7,
  "anyOf": [
    {
      "type": "Note",
      "name": "apple",
      "replies": {
        "type": "Collection",
        "totalItems": 3
      }
    },
    {
      "type": "Note",
      "name": "orange",
      "replies": {
        "type": "Collection",
        "totalItems": 7
      }
    },
    {
      "type": "Note",
      "name": "banana",
      "replies": {
        "type": "Collection",
        "totalItems": 6
      }
    }
  ]
}

Poll votes are serialized as Create activities, where the object is a Note with a name that exactly matches the name of the poll option. The Note.inReplyTo points to the URI of the Question object.

For multiple-choice polls, multiple activities may be sent. Votes will be counted if you have not previously voted for that option.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://mastodon.example/users/bob#votes/827163/activity",
  "to": "https://mastodon.example/users/alice",
  "actor": "https://mastodon.example/users/bob",
  "type": "Create",
  "object": {
    "id": "https://mastodon.example/users/bob#votes/827163",
    "type": "Note",
    "name": "orange",
    "attributedTo": "https://mastodon.example/users/bob",
    "to": "https://mastodon.example/users/alice",
    "inReplyTo": "https://mastodon.example/users/alice/statuses/1009947848598745"
  }
}
{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://mastodon.example/users/bob#votes/827164/activity",
  "to": "https://mastodon.example/users/alice",
  "actor": "https://mastodon.example/users/bob",
  "type": "Create",
  "object": {
    "id": "https://mastodon.example/users/bob#votes/827164",
    "type": "Note",
    "name": "banana",
    "attributedTo": "https://mastodon.example/users/bob",
    "to": "https://mastodon.example/users/alice",
    "inReplyTo": "https://mastodon.example/users/alice/statuses/1009947848598745"
  }
}

Mentions

In the ActivityStreams Vocabulary, Mention is a subtype of Link that is intended to represent the microsyntax of @mentions. The tag property is intended to add references to other Objects or Links. For Link tags, the name of the Link should be a substring of the natural language properties (name, summary, content) on that object. Wherever such a substring is found, it can be transformed into a hyperlink reference to the href.

However, Mastodon also uses Mention tags for addressing in some cases. Based on the presence or exclusion of Mention tags, and compared to the explicitly declared audiences in to and cc, Mastodon will calculate a visibility level for the post. Additionally, Mastodon requires Mention tags in order to generate a notification. (The mentioned actor must still be included within to or cc explicitly in order to receive the post.)

  • public: Public statuses have the as:Public magic collection in to
  • unlisted: Unlisted statuses have the as:Public magic collection in cc
  • private: Followers-only statuses have an actor’s follower collection in to or cc, but do not include the as:Public magic collection
  • limited: Limited-audience statuses have actors in to or cc, at least one of which is not Mentioned in tag
  • direct: Mentions-only statuses have actors in to or cc, all of which are Mentioned in tag

Public Key

Public keys are used for HTTP Signatures and Linked Data Signatures. This is implemented using an extra property publicKey on actor objects. See HTTP Signatures for more information.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1"
  ],
  "id": "https://mastodon.social/users/Gargron",
  "type": "Person",
  "publicKey": {
    "id": "https://mastodon.social/users/Gargron#main-key",
    "owner": "https://mastodon.social/users/Gargron",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n"
  }
}

Blurhash

Mastodon generates colorful preview thumbnails for attachments. This is implemented using an extra property blurhash on Image objects. The property is a string generated by the BlurHash algorithm.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "blurhash": "http://joinmastodon.org/ns#blurhash"
    }
  ],

  "id": "https://example.com/@alice/hello-world",
  "type": "Note",
  "content": "A picture attached!",
  "attachment": [
    {
      "type": "Image",
      "mediaType": "image/png",
      "url": "https://example.com/files/cats.png",
      "blurhash": "UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH"
    }
  ]
}

What is known in Mastodon as “pinned statuses”, or statuses that are always featured at the top of people’s profiles, is implemented using an extra property featured on the actor object that points to a Collection of objects.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "featured": {
        "@id": "http://joinmastodon.org/ns#featured",
        "@type": "@id"
      }
    }
  ],

  "id": "https://example.com/@alice",
  "type": "Person",
  "featured": "https://example.com/@alice/collections/featured"
}

Mastodon allows users to feature specific hashtags on their profile for easy browsing, as a discoverability mechanism. This is implemented using an extra property featuredTags on the actor object that points to a Collection of Hashtag objects specifically.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "featuredTags": {
        "@id": "http://joinmastodon.org/ns#featuredTags",
        "@type": "@id"
      }
    }
  ],

  "id": "https://example.com/@alice",
  "type": "Person",
  "featuredTags": "https://example.com/@alice/collections/tags"
}

Profile Metadata

Mastodon supports arbitrary profile fields containing name-value pairs. This is implemented using the attachment property on actor objects, with objects in the array having a type of PropertyValue and a value property, both from the schema.org namespace.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "schema": "http://schema.org#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value"
    }
  ],
  "id": "https://mastodon.social/users/Gargron",
  "type": "Person",
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "Patreon",
      "value": "<a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span}"
    },
    {
      "type": "PropertyValue",
      "name": "Homepage",
      "value": "<a href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">zeonfederated.com</span><span class=\"invisible\"></span}"
    }
  ]
}

Account Migration

Mastodon uses the Move activity to signal that an account has migrated to a different account. For the migration to be considered valid, Mastodon checks that the new account has defined an alias pointing to the old account (via the alsoKnownAs property).

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://mastodon.example/users/alice#moves/1",
  "actor": "https://mastodon.example/users/alice",
  "type": "Move",
  "object": "https://mastodon.example/users/alice",
  "target": "https://alice.com/users/109835986274379",
  "to": "https://mastodon.example/users/alice/followers"
}

Remote Blocking

ActivityPub defines the Block activity for client-to-server (C2S) use-cases, but not for server-to-server (S2S) – it recommends that servers SHOULD NOT deliver Block activities to their object. However, Mastodon will send this activity when a local user blocks a remote user. When Mastodon receives a Block activity where the object is an actor on the local domain, it will interpret this as a signal to hide the actor’s profile and posts from the local user, as well as disallowing mentions of that actor by that local user.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://mastodon.example/bd06bb61-01e0-447a-9dc8-95915db9aec8",
  "type": "Block",
  "actor": "https://mastodon.example/users/alice",
  "object": "https://example.com/~mallory",
  "to": "https://example.com/~mallory"
}

Secure Mode

Mastodon supports a secure mode (also known as “authorized fetch”) where all cross-server HTTP requests — including GET requests to public resources like actor profiles and statuses — MUST be signed with HTTP Signatures. In normal mode, only POST requests to inboxes require signatures; in secure mode, unsigned GET requests are rejected.

When secure mode is enabled:

  • All outgoing GET requests for ActivityPub resources are signed using the requesting user’s key, or a server-wide system actor key if no specific user context applies.
  • All incoming GET requests for ActivityPub resources are verified against the requesting actor’s public key.
  • This forms the foundation for limited federation mode, where the server can restrict which instances are allowed to interact.

Mastic implementation: Since all HTTP traffic flows through the Federation Canister, secure mode can be enforced by requiring HTTP Signature verification on all incoming requests (both GET and POST) and signing all outgoing requests. A system actor key pair can be generated at Federation Canister install time for requests without a specific user context.

Follower Synchronization

Mastodon implements a follower synchronization mechanism to detect and correct discrepancies in followers-only post delivery. Over time, follow relationships can become inconsistent between servers (e.g., due to network failures or bugs), causing followers-only posts to be delivered to actors who have unfollowed or missed by actors who have followed.

The mechanism uses a Collection-Synchronization HTTP header on POST requests to remote inboxes:

Collection-Synchronization: collectionId="https://social.example/alice/followers", url="https://social.example/alice/followers_synchronization", digest="sha-256=abcdef..."

The header contains three fields:

  • collectionId: The sender’s followers collection URI.
  • url: A URL to a partial collection containing the identifiers of followers on the receiving domain. This URL requires a signed request to access.
  • digest: A SHA-256 digest computed by XOR-ing the individual SHA-256 hashes of each follower identifier on the receiving domain.

The receiving server computes its own digest of the followers it believes it has for that actor. If the digests differ, the receiving server fetches the partial collection URL to reconcile the follow state.


HTTP Signatures

HTTP Signatures are used to authenticate requests between servers (S2S) part of the Federation Protocol.

In particular, in the Public data for each user there is a publicKey property that contains the public key of the user. This public key is used to verify the signature of the request.

When a user is created on the server, the server generates and stores securely the private key of the user.

When the server sends an activity to the inbox of another server, the request MUST be signed with the private key of the user.

The server receiving the request MUST verify the signature using the public key of the user.

Mastic implementation: In Mastic, each User Canister generates an RSA key pair at creation time. The private key is stored securely within the User Canister and is used by the Federation Canister to sign outgoing HTTP requests on behalf of the user. The public key is served by the Federation Canister when the actor profile is requested by a remote instance (e.g. to verify a signature).

For any HTTP request incoming to Mastodon for the Federation Protocol, the Signature header MUST be present and contain the signature of the request:

Signature: keyId="https://my.example.com/username#main-key",headers="(request-target) host date",signature="Y2FiYW...IxNGRiZDk4ZA=="

The three parts of the Signature: header can be broken down like so:

Signature:
  keyId="https://my.example.com/username#main-key",
  headers="(request-target) host date",
  signature="Y2FiYW...IxNGRiZDk4ZA=="

The keyId should correspond to the actor and the key being used to generate the signature, whose value is equal to all parameters in headers concatenated together and signed by the key, then Base64-encoded. See Public key for more information on actor keys.

An example key looks like this:

{
  "publicKey": {
    "id": "https://my.example.com/username#main-key",
    "owner": "https://my.example.com/username",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n"
 }
}

Signing POST requests

When making a POST request to Mastodon, you must calculate the RSA-SHA256 digest hash of your request’s body and include this hash (in base64 encoding) within the Digest: header. The Digest: header must also be included within the headers parameter of the Signature: header. For example:

POST /users/username/inbox HTTP/1.1
HOST: mastodon.example
Date: 18 Dec 2019 10:08:46 GMT
Digest: sha-256=hcK0GZB1BM4R0eenYrj9clYBuyXs/lemt5iWRYmIX0A=
Signature: keyId="https://my.example.com/actor#main-key",headers="(request-target) host date digest",signature="Y2FiYW...IxNGRiZDk4ZA=="
Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams"

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "actor": "https://my.example.com/actor",
  "type": "Create",
  "object": {
    "type": "Note",
    "content": "Hello!"
  },
  "to": "https://mastodon.example/users/username"
}

Verifying Signatures

Mastodon verifies the signature using the following algorithm:

  1. Split Signature: into its separate parameters.
  2. Construct the signature string from the value of headers.
  3. Fetch the keyId and resolve to an actor’s publicKey.
  4. RSA-SHA256 hash the signature string and compare to the Base64-decoded signature as decrypted by publicKey[publicKeyPem].
  5. Use the Date: header to check that the signed request was made within the past 12 hours.

WebFinger

For fully-featured Mastodon support, Mastic also implements the WebFinger protocol, which is used to discover information about users and their profiles on the Fediverse. WebFinger is a protocol that allows clients to discover information about a user based on their account name or email address.

WebFinger Simple Flow

Suppose we want to lookup the user @Gargron hosted on the mastodon.social website.

Just make a request to that domain’s /.well-known/webfinger endpoint, with the resource query parameter set to an acct: URI (e.g. acct:veeso_dev@hachyderm.io).

For instance: https://hachyderm.io/.well-known/webfinger?resource=acct%3Aveeso_dev%40hachyderm.io

{
  "subject": "acct:veeso_dev@hachyderm.io",
  "aliases": [
    "https://hachyderm.io/@veeso_dev",
    "https://hachyderm.io/users/veeso_dev"
  ],
  "links": [
    {
      "rel": "http://webfinger.net/rel/profile-page",
      "type": "text/html",
      "href": "https://hachyderm.io/@veeso_dev"
    },
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://hachyderm.io/users/veeso_dev"
    },
    {
      "rel": "http://ostatus.org/schema/1.0/subscribe",
      "template": "https://hachyderm.io/authorize_interaction?uri={uri}"
    },
    {
      "rel": "http://webfinger.net/rel/avatar",
      "type": "image/png",
      "href": "https://media.hachyderm.io/accounts/avatars/114/410/957/328/747/476/original/1cc6bed1aa3ad81e.png"
    }
  ]
}

Specs

Format and validation specifications for Mastic data types.

Handle Validation

This document defines the validation rules for user handles in Mastic. These rules follow the Mastodon username conventions to ensure compatibility with the broader fediverse.

Local Handle Format

A local handle is the username portion of a Mastic account (e.g. alice in @alice@mastic.social).

RuleValue
Allowed charactersa-z, 0-9, _
Minimum length1
Maximum length30
Case sensitivityCase-insensitive
StorageStored as lowercase

Regex: ^[a-z0-9_]{1,30}$

Underscores are allowed in any position, including leading, trailing, and consecutive.

Local handles are intentionally restricted to ASCII. Mastic owns the local namespace and the users.handle column carries a unique index that must remain free of Unicode normalization ambiguity (NFC vs NFD, confusables, IDN homograph). Users on other fediverse instances may have Unicode handles — see the next section.

Remote Handle Format

A remote handle identifies a user on another fediverse instance (e.g. @bob@mastodon.social, @user@ꩰ.com). Remote handles are discovered via WebFinger (RFC 7033) using the acct: URI scheme defined by RFC 7565, where both the userpart and the host are UTF-8.

To remain federation-compatible with Mastodon, Pleroma, Misskey, GoToSocial, and other ActivityPub implementations — many of which allow Unicode usernames and IDN domains — Mastic accepts a broader character set for remote handles.

Userpart

RuleValue
Allowed charactersUnicode letters, Unicode numbers, _, ., -
Minimum length1
Maximum length64 Unicode scalar values
Case sensitivityCompared case-insensitively (Unicode-aware fold)

Pattern (PCRE-style): ^[\p{L}\p{N}_.\-]{1,64}$.

Punctuation other than _, ., - is rejected. Whitespace and control characters are rejected.

Host (domain)

The host is treated as an Internationalized Domain Name (IDN). Unicode domains such as ꩰ.com are accepted; for storage and comparison the host is normalized to its ASCII Compatible Encoding (Punycode, UTS #46).

RuleValue
Input formU-label (Unicode) or A-label (Punycode/ACE)
Stored formA-label (lowercase, ASCII)
Maximum length253 octets in A-label form (DNS limit)

Storage

Remote handles are stored in canonical form <unicode_userpart>@<ace_domain>, with the userpart Unicode-lowercased and the domain in lowercase A-label form. Two handles compare equal iff their canonical forms are byte-equal.

Implementation note. Mastic does not yet perform IDN normalization at runtime; remote handles arriving with Unicode hosts are accepted but stored as received. Punycode normalization will be wired into the federation canister alongside WebFinger lookup (tracked separately).

Reserved Handles

The following handles are reserved and cannot be claimed during sign-up. These match system routes and well-known service names commonly reserved across fediverse implementations.

HandleReason
adminSystem role
administratorSystem role
autoconfigService discovery
autodiscoverService discovery
helpSystem route
hostmasterService role
infoSystem route
mailer-daemonEmail service
postmasterEmail service
rootSystem role
ssladminCertificate admin
supportSystem route
webmasterService role

Hashtag Validation

This document defines the validation rules for hashtags in Mastic. These rules follow the Mastodon hashtag conventions to ensure compatibility with the broader fediverse.

Hashtags are stored in two User Canister tables — hashtags (local per-user index) and featured_tags (profile-featured list) — and are materialized into the status_hashtags join table when a status is published. A future Hashtag Canister (WI-1.28) will aggregate hashtags across the instance.

Tag Format

A tag is the text portion of a hashtag, stripped of the leading #. For example, the hashtag #rust has tag rust.

RuleValue
Allowed charactersUnicode letters, Unicode numbers, _
Minimum length1
Maximum length30 Unicode scalar values
Case sensitivityCase-insensitive (Unicode-aware lowercasing)
StorageStored as lowercase, no #

Pattern (PCRE-style): ^[\p{L}\p{N}_]{1,30}$, with the additional constraint that no character may be in uppercase form after sanitization.

Underscores are allowed in any position, including leading, trailing, and consecutive. Cased Unicode letters (Latin, Greek, Cyrillic, etc.) are folded to lowercase by the sanitizer; non-cased scripts (Han, Arabic, Myanmar, etc.) are accepted as-is.

Hyphens (-), dots (.), whitespace, punctuation, and emoji are not allowed.

Unicode normalization

Mastic does not currently apply Unicode Normalization Form C (NFC) to incoming tags. Producers SHOULD send tags in NFC form. Two visually identical tags that differ only in normalization (e.g. precomposed é vs e + COMBINING ACUTE) will be treated as distinct values until normalization is added (tracked separately).

Sanitization

Input is sanitized before validation:

  1. Leading and trailing whitespace is trimmed.
  2. The string is lowercased.
  3. A single leading #, if present, is stripped.

# characters in any other position are not stripped and will cause validation to fail.

Examples

InputSanitizedValid
rustrustyes
Rustrustyes
#Rust rustyes
web3web3yes
rust_langrust_langyes
汉字汉字yes (Han)
Cafécaféyes
Ελληνικάελληνικάyes (Greek)
rust-langrust-langno (hyphen)
#rust#2rust#2no (# in middle)
🦀🦀no (emoji)
`` (empty)``no (too short)
a × 31a × 31no (too long)

Implementation

The rules are enforced by HashtagSanitizer and HashtagValidator in the db-utils crate. Both are attached to every Text column holding a tag (hashtags.tag, featured_tags.tag). status_hashtags does not store a tag string directly — it references hashtags.id — so no validator is needed there.

Media Attachments

This document defines the validation rules for the media table in the User Canister. Limits follow the Mastodon defaults to ensure compatibility with the broader fediverse.

media_type

MIME type of the attachment. Enforced by MimeValidator in db-utils.

RuleValue
Formattype/subtype per RFC 6838
Slash countExactly one /
Allowed charsLowercase ASCII graphic (!..=~ minus uppercase)
WhitespaceRejected
Maximum length127 bytes
NullableNo

Examples accepted: image/png, image/jpeg, video/mp4, application/vnd.mastic.v1+json. Examples rejected: Image/png (uppercase), image /png (whitespace), image/png/x (extra slash), imagepng (no slash).

description

Alt-text for the attachment.

RuleValue
SanitizationTrim leading/trailing whitespace
Maximum length1500 characters
Minimum length1 (empty string rejected)
NullableYes
Length unitUnicode scalar values

Enforced by TrimSanitizer + BoundedTextValidator(1500) in db-utils.

blurhash

Compact blurhash preview string. Enforced by BlurhashValidator in db-utils.

RuleValue
AlphabetBase83: 0-9, A-Z, a-z, `#$%*+,-.:;=?@[]^_{
Minimum length6 bytes
Maximum length128 bytes
NullableYes

Blurhash length is a function of the encoded component count. The allowed range covers every valid componentsX × componentsY pairing with a reasonable safety margin on the upper bound to prevent storage blow-up.

bytes

Raw media payload (BLOB). No schema-level validation; size and content-type enforcement happen at the upload-endpoint layer. See WI-1.16 (#59) and WI-1.17 (#67) for chunked upload handling.

status_id

Foreign key to statuses.id. Enforced by the storage layer.

Profile Metadata

The profile_metadata table stores up to four custom key/value rows that are shown on the user’s profile. This document defines the validation rules for the name and value columns. Limits follow the Mastodon defaults.

position

RuleValue
TypeUINT8
Range0..=3 (primary key)

The table is capped at four rows via the primary key range. Enforcement of the upper bound is done by the application layer that writes to the table (WI-1.27).

name / value

Both columns share the same rules.

RuleValue
SanitizationTrim leading/trailing whitespace
Maximum length255 characters
Minimum length1 (empty string rejected)
NullableNo
Length unitUnicode scalar values

Enforced by TrimSanitizer + BoundedTextValidator(255) in db-utils.

Reports

The reports table in the Directory Canister stores moderation reports submitted by users. This document defines the validation rules for report columns.

reason

Free-form text supplied by the reporter.

RuleValue
SanitizationTrim leading/trailing whitespace
Maximum length1000 characters
Minimum length1 (empty string rejected)
NullableNo
Length unitUnicode scalar values

Enforced by TrimSanitizer + BoundedTextValidator(1000) in db-utils.

target_status_uri

Optional URI of the specific status being reported. When null, the report targets the user’s account as a whole.

RuleValue
FormatValid URL (per the url crate)
NullableYes

Enforced by NullableUrlValidator in db-utils.

state

Open → submitted, awaiting moderator review. Resolved → moderator took action. Dismissed → moderator reviewed and declined to act.

See ReportState in the database schema reference for the on-disk encoding.

reporter, target_canister, resolved_by

Typed Principal columns. Validated by the candid/type layer; no schema-level validator.

created_at, resolved_at

UINT64 timestamps. created_at is indexed for recent-first listing. resolved_at is null until state transitions out of Open.

Snowflake IDs

Mastic uses Snowflake IDs as unique identifiers for statuses and other user-generated entities. A Snowflake ID is a 64-bit unsigned integer that encodes a creation timestamp, making IDs roughly sortable by time without requiring a secondary index.

Bit Layout

A Mastic Snowflake ID is a u64 with the following structure:

BitsWidthFieldDescription
63-1648TimestampMilliseconds since the Mastic epoch
15-016SequencePer-millisecond monotonic counter (0-65 535)

Total: 48 + 16 = 64 bits.

Epoch

The Mastic epoch is 2026-01-01T00:00:00Z (Unix timestamp 1 767 225 600 000 ms).

With 48 bits of millisecond precision the timestamp space covers approximately 8 919 years, which is more than sufficient even when measured from the Unix epoch (1970). The custom Mastic epoch is therefore not strictly necessary for correctness, but subtracting it produces smaller numeric values in the early years of the platform, resulting in shorter IDs when serialised as decimal strings in URLs and JSON-LD payloads.

Generation

Each User Canister maintains its own Snowflake generator with:

  • last_timestamp_ms: the timestamp of the last generated ID.
  • sequence: a 16-bit counter, reset to 0 whenever last_timestamp_ms advances.

Algorithm

1. current_ms = ic_cdk::api::time() / 1_000_000  (nanoseconds to milliseconds)
2. timestamp  = current_ms - MASTIC_EPOCH_MS
3. if timestamp == last_timestamp_ms:
       sequence += 1
       if sequence > 0xFFFF:
           trap("Snowflake sequence overflow")
   else:
       sequence = 0
       last_timestamp_ms = timestamp
4. id = (timestamp << 16) | sequence

Properties

  • Uniqueness: guaranteed within a single canister because the sequence counter prevents collisions within the same millisecond, and the IC provides monotonic time.
  • Global uniqueness: achieved because the full ActivityPub id is a URL that includes the user handle, e.g. https://{domain}/users/{handle}/statuses/{snowflake}.
  • Sortability: IDs are monotonically increasing within a canister and roughly chronologically ordered across canisters.

No Worker Bits

Unlike the original Twitter Snowflake or Mastodon’s variant, Mastic does not include worker/node bits. Each User Canister is the sole generator of its own IDs, so there is no risk of cross-worker collision. This simplifies the layout and maximises the sequence space.

Representation

  • On-chain (Candid): nat64
  • Over ActivityPub (JSON-LD): decimal string, e.g. "116301527915219032"
  • In URLs: decimal, e.g. /users/alice/statuses/116301527915219032

Status Content

This document defines the validation rules for status fields in Mastic. Limits follow the Mastodon defaults to ensure compatibility with the broader fediverse.

content

RuleValue
Maximum length500 characters
Minimum length1 (empty statuses denied)
EncodingUTF-8
Length unitUnicode scalar values

Statuses whose content exceeds 500 characters are rejected with the ContentTooLong error variant defined in PublishStatusError.

spoiler_text

Optional content warning / spoiler text shown by clients before the status body. Applies to both statuses.spoiler_text and edit_history.previous_spoiler_text.

RuleValue
SanitizationTrim leading/trailing whitespace
Maximum length500 characters
Minimum length1 (empty string rejected)
NullableYes
Length unitUnicode scalar values

Enforced by TrimSanitizer + BoundedTextValidator(500) in db-utils.

in_reply_to_uri

URI of the status this one replies to. Threads are resolved by looking up this URI against local statuses and remote inbox activities.

RuleValue
FormatValid URL (per the url crate)
NullableYes

Enforced by NullableUrlValidator in db-utils.

sensitive

Boolean flag. Clients are expected to hide media and content behind a “show more” gate when sensitive = true, even if spoiler_text is null. No validation beyond type.

Both spoiler_text and sensitive are part of the Status candid record returned by feed-rendering queries. When a status is boosted, the booster’s User Canister inserts a wrapper row in its own statuses table that denormalizes these fields from the original status (resolved through Federation.fetch_status), so the boosted content warning carries through into the booster’s outbox copy and into followers’ inboxes without any extra cross-canister read at feed render time.

edited_at

Nullable Uint64 timestamp. Written by the edit flow; never set by clients directly. No validation beyond type.

Canonical URL Patterns

All ActivityPub resource URLs in Mastic are built from the instance public_url and the user’s handle. These patterns are centralized in the User Canister’s domain::urls module to guarantee consistency across the codebase.

URL Table

PatternPurpose
{public_url}/users/{handle}Actor URI (profile)
{public_url}/users/{handle}/inboxActivityPub inbox
{public_url}/users/{handle}/outboxActivityPub outbox
{public_url}/users/{handle}/followersFollowers collection
{public_url}/users/{handle}/followingFollowing collection
{public_url}/users/{handle}/statuses/{id}Status URL

When a user boosts a status, the wrapper status URL <actor>/statuses/<snowflake> is also the canonical id of the emitted Announce activity. The booster’s Boost row, wrapper Status, FeedEntry, and the Announce activity all share a single Snowflake — one URL dereferences both the wrapper status and the boost activity.

Example

With public_url = "https://mastic.social" and handle = "alice":

  • Actor URI: https://mastic.social/users/alice
  • Inbox: https://mastic.social/users/alice/inbox
  • Outbox: https://mastic.social/users/alice/outbox
  • Followers: https://mastic.social/users/alice/followers
  • Following: https://mastic.social/users/alice/following

Public URL Propagation

The public_url is configured at deploy time on the Federation Canister and the Directory Canister. When the Directory Canister creates a new User Canister during sign-up, it passes public_url in the init args. Each User Canister stores it in settings and uses it via the domain::urls module.

Deploy ──► Federation Canister (public_url in init args)
       ──► Directory Canister  (public_url in init args)
                │
                ▼  sign_up
           User Canister (public_url passed in init args, stored in settings)

Interface

This directory contains the Candid interface definitions for the various canisters in the Mastic project.

  • Directory

    service : (DirectoryInstallArgs) -> {
      add_moderator : (AddModeratorArgs) -> (AddModeratorResponse);
      delete_profile : () -> (DeleteProfileResponse);
      get_user : (GetUserArgs) -> (GetUserResponse) query;
      remove_moderator : (RemoveModeratorArgs) -> (RemoveModeratorResponse);
      search_profiles : (SearchProfilesArgs) -> (SearchProfilesResponse) query;
      sign_up : (text) -> (SignUpResponse);
      suspend : (SuspendArgs) -> (SuspendResponse);
      user_canister : (opt Principal) -> (UserCanisterResponse) query;
      whoami : () -> (WhoAmIResponse) query
    }
    
  • Federation

    service : (FederationInstallArgs) -> {
      http_request : (HttpRequest) -> (HttpResponse) query;
      http_request_update : (HttpRequest) -> (HttpResponse);
      send_activity : (SendActivityArgs) -> (SendActivityResponse)
    }
    
  • User

    service : (UserInstallArgs) -> {
      accept_follow : (AcceptFollowArgs) -> (AcceptFollowResponse);
      block_user : (BlockUserArgs) -> (BlockUserResponse);
      boost_status : (BoostStatusArgs) -> (BoostStatusResponse);
      delete_profile : () -> (DeleteProfileResponse);
      delete_status : (DeleteStatusArgs) -> (DeleteStatusResponse);
      follow_user : (FollowUserArgs) -> (FollowUserResponse);
      get_follow_requests : (GetFollowRequestsArgs) -> (GetFollowRequestsResponse) query;
      get_followers : (GetFollowersArgs) -> (GetFollowersResponse) query;
      get_following : (GetFollowingArgs) -> (GetFollowingResponse) query;
      get_liked : (GetLikedArgs) -> (GetLikedResponse) query;
      get_profile : () -> (GetProfileResponse) query;
      get_statuses : (GetStatusesArgs) -> (GetStatusesResponse) composite_query;
      like_status : (LikeStatusArgs) -> (LikeStatusResponse);
      publish_status : (PublishStatusArgs) -> (PublishStatusResponse);
      read_feed : (ReadFeedArgs) -> (ReadFeedResponse) query;
      receive_activity : (ReceiveActivityArgs) -> (ReceiveActivityResponse);
      reject_follow : (RejectFollowArgs) -> (RejectFollowResponse);
      undo_boost : (UndoBoostArgs) -> (UndoBoostResponse);
      unfollow_user : (UnfollowUserArgs) -> (UnfollowUserResponse);
      unlike_status : (UnlikeStatusArgs) -> (UnlikeStatusResponse);
      update_profile : (UpdateProfileArgs) -> (UpdateProfileResponse)
    }
    

Candid Types

This document defines all shared Candid types used across the Directory, Federation, and User canisters.

Common Types

Visibility

Controls the audience of a status post. Maps to ActivityPub addressing:

  • Public: visible to everyone, appears in public timelines.
  • Unlisted: visible to everyone via direct link, but excluded from public timelines.
  • FollowersOnly: visible only to the author’s followers.
  • Direct: visible only to explicitly mentioned users.
type Visibility = variant {
  Public;
  Unlisted;
  FollowersOnly;
  Direct;
};

UserProfile

A user’s public profile information. Stored in the User Canister and returned by profile queries.

FieldDescription
handleUnique username chosen at sign-up (e.g. alice).
display_nameOptional human-readable name shown in the UI.
bioOptional free-text biography.
avatar_urlOptional URL pointing to the user’s avatar image.
created_atTimestamp (nanoseconds since epoch) of account creation.
type UserProfile = record {
  handle : text;
  display_name : opt text;
  bio : opt text;
  avatar_url : opt text;
  created_at : nat64;
};

Status

A single post authored by a user. Each status has a unique ID, content body, author principal, creation timestamp, and visibility setting.

FieldDescription
idSnowflake identifier of the status assigned by the User Canister.
contentThe text content of the post.
authorActivityPub actor URI of the status author.
created_atTimestamp (milliseconds since epoch) when the status was created.
visibilityAudience control for this status (see Visibility).
like_countCached count of Like activities received for this status.
boost_countCached count of Announce (boost) activities received.
spoiler_textOptional content warning / spoiler text shown before the content.
sensitiveIf true, clients should hide the content behind a CW by default.
type Status = record {
  id : nat64;
  content : text;
  author : text;
  created_at : nat64;
  visibility : Visibility;
  like_count : nat64;
  boost_count : nat64;
  spoiler_text : opt text;
  sensitive : bool;
};

When a status is boosted, the booster’s User Canister inserts a wrapper row in its own statuses table that denormalizes the original content, spoiler_text, and sensitive fields. Feed rendering reads the wrapper directly so the boosted CW carries through into the booster’s outbox copy.

FeedItem

A single entry in a user’s feed. Wraps a Status and optionally indicates that it was boosted (reblogged) by another user. Also surfaces per-viewer interaction flags so clients can render “you liked this” / “you boosted this” markers without an additional round-trip.

FieldDescription
statusThe status being displayed.
boosted_byIf present, the actor URI of the user who boosted this status into the feed.
likedtrue if the viewing user has liked the underlying status.
boostedtrue if the viewing user has boosted the underlying status. Always true for the viewer’s own boost wrapper rows.
type FeedItem = record {
  status : Status;
  boosted_by : opt text;
  liked : bool;
  boosted : bool;
};

Directory Canister Types

DirectoryInstallArgs

Install arguments for the Directory Canister. Uses the Init/Upgrade variant pattern required by IC canister lifecycle.

  • Init: provided on first install. Sets the initial moderator, the principal of the Federation Canister this directory cooperates with, and the public URL of the instance.
  • Upgrade: provided on subsequent upgrades (currently empty).
type DirectoryInstallArgs = variant {
  Init : record {
    initial_moderator : principal;
    federation_canister : principal;
    public_url : text;
  };
  Upgrade : record {};
};

SignUp

Response, request and error types for the sign_up method. Registers a new user in the directory, creating a User Canister and mapping the caller’s principal to the chosen handle.

  • AlreadyRegistered: the caller already has an account.
  • HandleTaken: the requested handle is in use by another user.
  • InvalidHandle: the handle does not meet validation rules (e.g. length, allowed characters).
  • AnonymousPrincipal: anonymous users are not allowed to sign up.
  • InternalError: an unexpected internal error occurred.
type SignUpRequest = record {
  handle : text;
};

type SignUpResponse = variant {
  Ok;
  Err : SignUpError;
};

type SignUpError = variant {
  AlreadyRegistered;
  HandleTaken;
  InvalidHandle;
  AnonymousPrincipal;
  InternalError : text;
};

RetrySignUp

Response and error types for the retry_sign_up method. Retries canister creation for a user whose canister creation failed during the sign-up process.

  • NotRegistered: the caller has no account to retry.
  • CanisterNotInFailedState: the caller’s canister is not in a failed state, so retrying is not allowed.
  • InternalError: an unexpected internal error occurred.
type RetrySignUpResponse = variant {
  Ok;
  Err : RetrySignUpError;
};

type RetrySignUpError = variant {
  NotRegistered;
  CanisterNotInFailedState;
  InternalError : text;
};

UserCanisterStatus

The lifecycle state of a user’s canister. Used in the WhoAmI response and as the eligibility filter for SearchProfiles.

  • Active: the canister is created and operational.
  • CreationPending: canister creation is in progress.
  • CreationFailed: canister creation failed; the user may retry via retry_sign_up.
  • DeletionPending: the user has requested account deletion and the canister is being torn down asynchronously.
  • Suspended: a moderator has suspended the user; the canister exists but is hidden from public discovery.
type UserCanisterStatus = variant {
  Active;
  CreationPending;
  CreationFailed;
  DeletionPending;
  Suspended;
};

WhoAmI

Response and error types for the who_am_i method. Returns the caller’s handle, User Canister ID, and canister status, allowing a logged-in user to discover their own identity and check canister readiness.

FieldDescription
handleThe caller’s registered handle.
user_canisterPrincipal of the caller’s User Canister. (Optional)
canister_statusStatus of the caller’s User Canister (see UserCanisterStatus).
  • NotRegistered: the caller has no account in the directory.
type WhoAmI = record {
  handle : text;
  user_canister : principal;
  canister_status : UserCanisterStatus;
};

type WhoAmIResponse = variant {
  Ok : WhoAmI;
  Err : WhoAmIError;
};

type WhoAmIError = variant {
  NotRegistered;
};

UserCanister

Response and error types for the user_canister method. Resolves the caller’s principal to their User Canister ID.

  • NotRegistered: the caller has no account in the directory.
type UserCanisterResponse = variant {
  Ok : principal;
  Err : UserCanisterError;
};

type UserCanisterError = variant {
  NotRegistered;
};

GetUser

Request, response, and error types for the get_user method. Looks up a user by handle and returns their handle and User Canister ID.

FieldDescription
handleThe handle to look up.
canister_idPrincipal of the looked-up user’s canister.
  • NotFound: no user exists with the given handle.
type GetUserArgs = record {
  handle : text;
};

type GetUser = record {
  handle : text;
  canister_id : principal;
};

type GetUserResponse = variant {
  Ok : GetUser;
  Err : GetUserError;
};

type GetUserError = variant {
  NotFound;
};

AddModerator

Request, response, and error types for the add_moderator method. Grants moderator privileges to a principal. Only existing moderators may call this.

FieldDescription
principalThe principal to promote to moderator.
  • Unauthorized: the caller is not a moderator.
  • AlreadyModerator: the target principal is already a moderator.
type AddModeratorArgs = record {
  principal : principal;
};

type AddModeratorResponse = variant {
  Ok;
  Err : AddModeratorError;
};

type AddModeratorError = variant {
  Unauthorized;
  AlreadyModerator;
};

RemoveModerator

Request, response, and error types for the remove_moderator method. Revokes moderator privileges from a principal. Only existing moderators may call this.

FieldDescription
principalThe principal to demote.
  • Unauthorized: the caller is not a moderator.
  • NotModerator: the target principal is not currently a moderator.
type RemoveModeratorArgs = record {
  principal : principal;
};

type RemoveModeratorResponse = variant {
  Ok;
  Err : RemoveModeratorError;
};

type RemoveModeratorError = variant {
  Unauthorized;
  NotModerator;
};

Suspend

Request, response, and error types for the suspend method. Suspends a user account, preventing further activity. Only moderators may call this.

FieldDescription
principalThe principal of the user to suspend.
  • Unauthorized: the caller is not a moderator.
  • NotFound: no user exists with the given principal.
type SuspendArgs = record {
  principal : principal;
};

type SuspendResponse = variant {
  Ok;
  Err : SuspendError;
};

type SuspendError = variant {
  Unauthorized;
  NotFound;
};

SearchProfiles

Request, response, and error types for the search_profiles query. Searches registered user handles by case-insensitive substring match with pagination.

The query is sanitized before matching: a leading @ is stripped, whitespace is trimmed, and the string is lowercased — the same pipeline applied to handles on insert. So @Alice, alice, and ALICE all match the handle alice.

Only users with canister_status = Active and a non-null canister_id are returned. CreationPending, CreationFailed, DeletionPending, and Suspended users are excluded by construction.

An empty query returns all eligible users, paginated.

FieldDescription
queryFree-text search string matched against handles.
offsetNumber of results to skip (for pagination).
limitMaximum results to return; must be in 1..=50.

Each result entry contains:

FieldDescription
handleThe matched user’s handle.
canister_idPrincipal of the matched user’s canister.

Errors:

  • BadArgs: the request was rejected (limit == 0, limit > 50, or the sanitized query failed handle validation). In practice the canister traps on these inputs at message-inspect time; the variant is reserved for parity with other endpoints.
  • Internal: an internal storage error occurred while running the query.
type SearchProfilesArgs = record {
  query : text;
  offset : nat64;
  limit : nat64;
};

type SearchProfileEntry = record {
  handle : text;
  canister_id : principal;
};

type SearchProfilesResponse = variant {
  Ok : vec SearchProfileEntry;
  Err : SearchProfilesError;
};

type SearchProfilesError = variant {
  BadArgs;
  Internal : text;
};

DeleteProfile (Directory)

Response and error types for the delete_profile method on the Directory Canister. Removes the caller’s account and handle mapping from the directory.

  • NotRegistered: the caller has no account to delete.
type DeleteProfileResponse = variant {
  Ok;
  Err : DeleteProfileError;
};

type DeleteProfileError = variant {
  NotRegistered;
};

User Canister Types

UserInstallArgs

Install arguments for the User Canister. Uses the Init/Upgrade variant pattern required by IC canister lifecycle.

  • Init: provided on first install. Sets the owner principal (the user’s Internet Identity), the Federation Canister principal used for outbound ActivityPub delivery, the user handle, and the instance public URL.
  • Upgrade: provided on subsequent upgrades (currently empty).
type UserInstallArgs = variant {
  Init : record {
    owner : principal;
    federation_canister : principal;
    handle : text;
    public_url : text;
  };
  Upgrade : record {};
};

GetProfile

Response and error types for the get_profile method. Returns the full UserProfile for this canister’s owner.

  • NotFound: the profile has not been initialized yet.
type GetProfileResponse = variant {
  Ok : UserProfile;
  Err : GetProfileError;
};

type GetProfileError = variant {
  NotFound;
};

UpdateProfile

Request, response, and error types for the update_profile method. Updates the caller’s profile fields. Only the canister owner may call this. All fields are optional; only provided fields are updated.

FieldDescription
display_nameNew display name, or null to leave unchanged.
bioNew biography, or null to leave unchanged.
avatar_urlNew avatar URL, or null to leave unchanged.
  • Unauthorized: the caller is not the canister owner.
type UpdateProfileArgs = record {
  display_name : opt text;
  bio : opt text;
  avatar_url : opt text;
};

type UpdateProfileResponse = variant {
  Ok;
  Err : UpdateProfileError;
};

type UpdateProfileError = variant {
  Unauthorized;
};

FollowUser

Request, response, and error types for the follow_user method. Sends a follow request to another user by handle.

FieldDescription
handleHandle of the user to follow.
  • Unauthorized: the caller is not the canister owner.
  • AlreadyFollowing: the caller already follows the target user.
  • CannotFollowSelf: the caller attempted to follow themselves.
  • Internal: an internal error occurred while processing the request.
type FollowUserArgs = record {
  handle : text;
};

type FollowUserResponse = variant {
  Ok;
  Err : FollowUserError;
};

type FollowUserError = variant {
  Unauthorized;
  AlreadyFollowing;
  CannotFollowSelf;
  Internal : text;
};

AcceptFollow

Request, response, and error types for the accept_follow method. Accepts a pending follow request from another user, adding them to the followers list.

FieldDescription
followerPrincipal of the User Canister whose follow request to accept.
  • Unauthorized: the caller is not the canister owner.
  • RequestNotFound: no pending follow request exists from the given principal.
type AcceptFollowArgs = record {
  follower : principal;
};

type AcceptFollowResponse = variant {
  Ok;
  Err : AcceptFollowError;
};

type AcceptFollowError = variant {
  Unauthorized;
  RequestNotFound;
};

RejectFollow

Request, response, and error types for the reject_follow method. Rejects a pending follow request from another user.

FieldDescription
followerPrincipal of the User Canister whose follow request to reject.
  • Unauthorized: the caller is not the canister owner.
  • RequestNotFound: no pending follow request exists from the given principal.
type RejectFollowArgs = record {
  follower : principal;
};

type RejectFollowResponse = variant {
  Ok;
  Err : RejectFollowError;
};

type RejectFollowError = variant {
  Unauthorized;
  RequestNotFound;
};

UnfollowUser

Request, response, and error types for the unfollow_user method. Removes the caller from the target user’s followers list and removes the target from the caller’s following list.

FieldDescription
canister_idPrincipal of the User Canister to unfollow.
  • Unauthorized: the caller is not the canister owner.
  • NotFollowing: the caller does not currently follow the target user.
type UnfollowUserArgs = record {
  canister_id : principal;
};

type UnfollowUserResponse = variant {
  Ok;
  Err : UnfollowUserError;
};

type UnfollowUserError = variant {
  Unauthorized;
  NotFollowing;
};

BlockUser

Request, response, and error types for the block_user method. Blocks another user, preventing them from following or interacting with the caller.

FieldDescription
canister_idPrincipal of the User Canister to block.
  • Unauthorized: the caller is not the canister owner.
type BlockUserArgs = record {
  canister_id : principal;
};

type BlockUserResponse = variant {
  Ok;
  Err : BlockUserError;
};

type BlockUserError = variant {
  Unauthorized;
};

GetFollowers

Request, response, and error types for the get_followers method. Returns a paginated list of actor URIs that follow this user. The limit must not exceed 50 (the maximum page size).

FieldDescription
offsetNumber of results to skip (for pagination).
limitMaximum number of results to return (max 50).
  • LimitExceeded: the requested limit exceeds the maximum page size (50).
  • Internal: an internal error occurred while querying followers.
type GetFollowersArgs = record {
  offset : nat64;
  limit : nat64;
};

type GetFollowersResponse = variant {
  Ok : vec text;
  Err : GetFollowersError;
};

type GetFollowersError = variant {
  LimitExceeded;
  Internal : text;
};

GetFollowing

Request, response, and error types for the get_following method. Returns a paginated list of actor URIs that this user follows. The limit must not exceed 50 (the maximum page size).

FieldDescription
offsetNumber of results to skip (for pagination).
limitMaximum number of results to return (max 50).
  • LimitExceeded: the requested limit exceeds the maximum page size (50).
  • Internal: an internal error occurred while querying the following list.
type GetFollowingArgs = record {
  offset : nat64;
  limit : nat64;
};

type GetFollowingResponse = variant {
  Ok : vec text;
  Err : GetFollowingError;
};

type GetFollowingError = variant {
  LimitExceeded;
  Internal : text;
};

PublishStatus

Request, response, and error types for the publish_status method. Creates a new status post in the caller’s outbox and distributes it via the Federation Canister. For Public, Unlisted, and FollowersOnly visibilities, the recipients are the author’s followers. For Direct visibility, the recipients are the explicitly listed mentions — followers are not addressed.

FieldDescription
contentThe text content of the new post.
visibilityAudience control for this status (see Visibility).
mentionsActor URIs explicitly mentioned. Required (non-empty) when visibility is Direct.

On success, returns the created Status with its assigned ID and timestamp.

  • Unauthorized: the caller is not the canister owner.
  • ContentEmpty: the content is empty or contains only whitespace.
  • ContentTooLong: the content exceeds the maximum allowed length.
  • NoRecipients: a Direct status was published with an empty mentions list.
  • Internal: an internal error occurred while publishing the status.
type PublishStatusArgs = record {
  content : text;
  visibility : Visibility;
  mentions : vec text;
};

type PublishStatusResponse = variant {
  Ok : Status;
  Err : PublishStatusError;
};

type PublishStatusError = variant {
  Unauthorized;
  ContentEmpty;
  ContentTooLong;
  NoRecipients;
  Internal : text;
};

DeleteStatus

Request, response, and error types for the delete_status method. Removes a status authored by the canister owner. The cascade drops the row from statuses (which propagates via FK to media, boosts, edit_history, status_hashtags, pinned_statuses), the matching feed entry, and any liked row that defensively references the same URI. A Delete(Note) activity is emitted to followers (excluding blocked actors) so receivers can purge their cached inbox + feed entries.

FieldDescription
status_uriCanonical URI of the status to delete.
  • NotFound: no status exists with the given URI on this canister.
  • InvalidUri: the URI does not match the …/statuses/{id} shape.
  • Internal: an unexpected error occurred during the delete or dispatch.
type DeleteStatusArgs = record {
  status_uri : text;
};

type DeleteStatusResponse = variant {
  Ok;
  Err : DeleteStatusError;
};

type DeleteStatusError = variant {
  NotFound;
  InvalidUri;
  Internal : text;
};

LikeStatus

Request, response, and error types for the like_status method. Records a like on a status authored by another user.

like_status is idempotent: calling it for a status the caller has already liked returns Ok without recording a duplicate row in the liked collection and without re-emitting a Like activity. Only the caller (canister owner) is authorized; non-owner calls are rejected at the inspect layer.

FieldDescription
status_urlActivityPub URI of the status to like.
  • Internal: an unexpected internal error occurred (database access failure, federation dispatch failure, etc.).
type LikeStatusArgs = record {
  status_url : text;
};

type LikeStatusResponse = variant {
  Ok;
  Err : LikeStatusError;
};

type LikeStatusError = variant {
  Internal : text;
};

UnlikeStatus

Request, response, and error types for the undo_like method. Removes a previously recorded like from a status.

FieldDescription
status_urlActivityPub URI of the status to unlike.
  • Unauthorized: the caller is not the canister owner.
  • NotFound: no like exists for the given status.
type UnlikeStatusArgs = record {
  status_url : text;
};

type UnlikeStatusResponse = variant {
  Ok;
  Err : UnlikeStatusError;
};

type UnlikeStatusError = variant {
  Unauthorized;
  NotFound;
};

BoostStatus

Request, response, and error types for the boost_status method. Boosts (reblogs) a status authored by another user, sharing it with the caller’s followers.

The flow resolves the original status through the Federation Canister’s fetch_status endpoint (which dereferences local authors via get_local_status), then inserts a wrapper Status row, a boosts row, and a feed outbox entry that all share a single Snowflake — also reused as the emitted Announce activity id.

boost_status is idempotent: calling it for a status the caller has already boosted returns Ok without recording a duplicate row in the boosts collection and without re-emitting an Announce activity. Only the caller (canister owner) is authorized; non-owner calls are rejected at the inspect layer.

FieldDescription
status_urlActivityPub URI of the status to boost.
  • Internal: an unexpected internal error occurred (database access failure, federation dispatch failure, etc.).
type BoostStatusArgs = record {
  status_url : text;
};

type BoostStatusResponse = variant {
  Ok;
  Err : BoostStatusError;
};

type BoostStatusError = variant {
  Internal : text;
};

UndoBoost

Request, response, and error types for the undo_boost method. Removes a previously recorded boost from a status.

undo_boost is idempotent: calling it for a status the caller has not boosted (or has already un-boosted) returns Ok without emitting a second Undo(Announce) activity. Only the caller (canister owner) is authorized; non-owner calls are rejected at the inspect layer.

FieldDescription
status_urlActivityPub URI of the status to un-boost.
  • Internal: an unexpected internal error occurred (database access failure, federation dispatch failure, etc.).
type UndoBoostArgs = record {
  status_url : text;
};

type UndoBoostResponse = variant {
  Ok;
  Err : UndoBoostError;
};

type UndoBoostError = variant {
  Internal : text;
};

GetLocalStatus

Request, response, and error types for the get_local_status query. Returns a single Status owned by this User Canister. Used by the Federation Canister’s fetch_status endpoint to dereference a local actor’s status.

get_local_status is a public query with no inspect block. The caller’s principal determines the visibility scope:

  • Owner principal: can read any status regardless of visibility.
  • Federation Canister principal with requester_actor_uri = Some(uri): returns Public and Unlisted always; returns FollowersOnly only if uri is in the followers list; returns NotFound for Direct.
  • Federation Canister principal with requester_actor_uri = None: returns Public and Unlisted only.
  • Anonymous / other principals: returns Public and Unlisted only.
FieldDescription
idSnowflake ID of the status to read.
requester_actor_uriActor URI of the requester (used for visibility filtering).
  • NotFound: no status with the given ID is visible to the caller.
  • Internal: an unexpected internal error occurred.
type GetLocalStatusArgs = record {
  id : nat64;
  requester_actor_uri : opt text;
};

type GetLocalStatusResponse = variant {
  Ok : Status;
  Err : GetLocalStatusError;
};

type GetLocalStatusError = variant {
  NotFound;
  Internal : text;
};

GetLiked

Request, response, and error types for the get_liked method. Returns a paginated list of status IDs that the caller has liked.

FieldDescription
offsetNumber of results to skip (for pagination).
limitMaximum number of results to return.
  • Unauthorized: the caller is not the canister owner.
type GetLikedArgs = record {
  offset : nat64;
  limit : nat64;
};

type GetLikedResponse = variant {
  Ok : vec text;
  Err : GetLikedError;
};

type GetLikedError = variant {
  Unauthorized;
};

ReadFeed

Request, response, and error types for the read_feed method. Returns a paginated list of feed items from the caller’s home timeline, including statuses from followed users and boosted content.

FieldDescription
offsetNumber of results to skip (for pagination).
limitMaximum number of results to return.
  • Unauthorized: the caller is not the canister owner.
type ReadFeedArgs = record {
  offset : nat64;
  limit : nat64;
};

type ReadFeedResponse = variant {
  Ok : vec FeedItem;
  Err : ReadFeedError;
};

type ReadFeedError = variant {
  Unauthorized;
};

ReceiveActivity

Request, response, and error types for the receive_activity method. Called by the Federation Canister to deliver an incoming ActivityPub activity (encoded as JSON) to this User Canister’s inbox.

FieldDescription
activity_jsonJSON-encoded ActivityPub activity object.
  • Unauthorized: the caller is not the Federation Canister.
  • InvalidActivity: the JSON could not be parsed as a valid ActivityPub activity.
  • ProcessingFailed: the activity was valid but could not be processed (e.g. references a non-existent status).
type ReceiveActivityArgs = record {
  activity_json : text;
};

type ReceiveActivityResponse = variant {
  Ok;
  Err : ReceiveActivityError;
};

type ReceiveActivityError = variant {
  Unauthorized;
  InvalidActivity;
  ProcessingFailed;
};

Federation Canister Types

FederationInstallArgs

Install arguments for the Federation Canister. Uses the Init/Upgrade variant pattern required by IC canister lifecycle.

  • Init: provided on first install. Sets the Directory Canister principal (used to resolve handles to User Canisters) and the public URL used for constructing ActivityPub actor URIs and WebFinger responses.
  • Upgrade: provided on subsequent upgrades (currently empty).
type FederationInstallArgs = variant {
  Init : record {
    directory_canister : principal;
    public_url : text;
  };
  Upgrade : record {};
};

SendActivity

Request, response, and error types for the send_activity method. Called by a registered User Canister to deliver an outbound ActivityPub activity. Supports a single activity (One) or a batch (Batch) per call. Local targets are routed to the recipient User Canister via the Directory Canister; remote targets are skipped (remote HTTP delivery is Milestone 2).

FieldDescription
activity_jsonJSON-encoded ActivityPub activity object to send.
target_inboxURL of the actor’s inbox to deliver the activity to.

SendActivityError:

  • InvalidTargetInbox(text): target_inbox URL failed to parse or has an unexpected path shape.
  • UnknownLocalUser(text): local inbox references a handle that is not registered in the Directory Canister.
  • DeliveryFailed(text): inter-canister call to the target User Canister failed (transport or decode).
  • Rejected(text): target User Canister accepted the call but rejected the activity.
type SendActivityArgsObject = record {
  activity_json : text;
  target_inbox : text;
};

type SendActivityArgs = variant {
  One : SendActivityArgsObject;
  Batch : vec SendActivityArgsObject;
};

type SendActivityResult = variant {
  Ok;
  Err : SendActivityError;
};

type SendActivityResponse = variant {
  One : SendActivityResult;
  Batch : vec SendActivityResult;
};

type SendActivityError = variant {
  InvalidTargetInbox : text;
  UnknownLocalUser : text;
  DeliveryFailed : text;
  Rejected : text;
};

FetchStatus

Request, response, and error types for the fetch_status method. Called by a User Canister to dereference a status by URI through the Federation Canister. The Federation Canister parses the URI, resolves the local handle via the Directory Canister, and forwards the request to the target User Canister’s get_local_status query.

In Milestone 1, only local URIs (host equal to the instance public_url) are supported. Remote URIs return Unsupported; Milestone 3 will add HTTPS outcalls for cross-instance fetches.

FieldDescription
uriActivityPub status URI to dereference.
requester_actor_uriActor URI of the requester (forwarded for visibility scoping).
  • InvalidUri: the URI failed to parse or did not match the expected <public_url>/users/<handle>/statuses/<id> shape.
  • Unsupported: the URI host is not local (Milestone 1 limitation).
  • NotFound: the URI is local but no matching handle / status exists, or the status is not visible to the requester.
  • Internal: an unexpected internal error occurred.
type FetchStatusArgs = record {
  uri : text;
  requester_actor_uri : opt text;
};

type FetchStatusResponse = variant {
  Ok : Status;
  Err : FetchStatusError;
};

type FetchStatusError = variant {
  InvalidUri;
  Unsupported;
  NotFound;
  Internal : text;
};

Mastic

Unleashing the power of IC on the Fediverse

Introduction

Mastic aims to bring the Fediverse — a decentralised network of interconnected social platforms — natively onto the Internet Computer ecosystem.

The Fediverse operates through the ActivityPub protocol, enabling independent instances of the social platform on the network to communicate with one another, thereby creating a distributed social network.

The Mastic project brings the possibility both to have a node dedicated to the Internet Computer community that can finally have a dedicated fully decentralised social network, but also any people that wish to set up their own Mastic node on the Internet Computer, empowering the decentralisation of the Fediverse, bringing web3 functionalities on it, and with the ease the IC brings in spinning up network services.

Mission

Our mission is to build a fully federated, ActivityPub-compatible social platform running entirely on the Internet Computer protocol, enabling seamless participation in the Fediverse.

This project aims to bring a truly decentralised social experience to the IC ecosystem by integrating with an already thriving network of over 1.4 million monthly active users across the Fediverse.

In particular, it will help engage the vibrant community of Rust developers, many of whom are active on Mastodon, by bridging their preferred social environment with the technical and philosophical foundations of the Internet Computer.

Impact

Mastic has the potential to reshape the future of federated social platforms by demonstrating how a modern, scalable, and fully on-chain implementation of ActivityPub can be achieved using Rust and the Internet Computer ecosystem.

While Mastodon has played a central role in popularising the Fediverse, its current architecture — based on Ruby on Rails and multiple supporting services (PostgreSQL, Redis, Sidekiq, Nginx, etc.) — presents significant barriers to contribution and scalability.

The complexity of deploying and maintaining Mastodon discourages smaller communities and individual developers, and its ageing technology stack makes it less attractive to modern developers, particularly those in the Web3 and Rust ecosystems.

By reimagining an ActivityPub-compatible server as a set of autonomous, composable canisters written in Rust, this project removes those barriers: no external infrastructure, no ops burden, and a far more approachable codebase for developers already aligned with the values of decentralisation.

Mastic can not only attract a new wave of Rust and Web3 contributors to the Internet Computer but also act as a reference architecture for building scalable, open social infrastructure in the post-cloud era.

Additionally, this would finally bring authentication to the Internet Identity on the Fediverse.

Governance & Sustainability Model

Mastic is not just about a technical implementation of a decentralised social platform - it is a long-term vision for how open social platforms can be sustainably governed and evolved without relying on centralised control or ad revenue models.

To ensure sustainability and a decentralised governance model, Mastic will be deployed under a **Service Nervous System ** (SNS), making Mastic a fully on-chain DAO governed by its community.

DAO-Based Governance

We propose giving the DAO community governance for upgrades, feature proposals, federation decisions and moderation policies.

Token holders will be able to vote on proposals and allocate funds to the treasury.

Participants in the DAO will be able to apply for moderation and will be elected by the community.

Market Analysis

Total Addressable Market

Currently, Mastodon has 1.4 million active users per month.

Most of these are developers, journalists, artists and niche communities. In particular, Mastodon has experienced significant growth following Elon Musk’s acquisition of Twitter.

Of course, some users have left Twitter but have not joined Mastodon, either because they are using BlueSky or other platforms, such as Reddit or Discord groups.

Serviceable Available Market

Above 10-15% of the TAM

Our first target is:

  • Rust Developers: 2.3 million developers in 2024
  • Web3 Developers: about 25k developers in 2024
  • IC developers: ~3k

Serviceable Obtainable Market

A realistic target within the first year could be around 20,000 users, if we primarily focus on developers and web3 developers.

Differentiation from Traditional Mastodon Instances

Mastic introduces a fundamentally new paradigm for federated social platforms by reimagining the Mastodon experience as a fully on-chain, modular architecture built natively on the Internet Computer. While Mastodon has pioneered the Fediverse movement, its traditional deployment model — based on a monolithic Ruby on Rails stack supported by PostgreSQL, Redis, Sidekiq, and other infrastructure — imposes significant operational overhead and limits scalability.

By contrast, Mastic offers the following distinct advantages:

  • Fully on-chain architecture: Every component of Mastic runs within canisters on the Internet Computer, eliminating the need for traditional DevOps, hosting, and external databases.
  • Modularity and scalability: Each user operates within their User Canister, ensuring composability, privacy, and scalability by design.
  • Internet Identity integration: Users can sign in with Internet Identity, bringing native IC authentication to the Fediverse.
  • Decentralised governance: Instead of relying on a central instance admin, Mastic is governed by a DAO through the Service Nervous System (SNS), enabling transparent, community-driven decisions around moderation and feature development.
  • Developer-friendly technology stack: Mastic is built entirely in Rust, offering a modern, secure, and performant alternative that resonates with a growing community of Rust and Web3 developers.

These innovations make Mastic not only more accessible for developers and small communities but also more aligned with the philosophical foundations of the decentralised web.

User Acquisition Strategy

Mastic’s adoption strategy is designed to activate key communities aligned with its technical foundation and decentralised vision, and to establish a sustainable user base through targeted outreach and integrations within the Web3 ecosystem.

Our multi-phase growth approach includes:

  • Developer-first onboarding: We will bootstrap the platform by targeting Rust and Internet Computer developers, communities that already value decentralisation, composability, and performance. Early access, bounties, and contributor incentives will be designed to attract this group.
  • Fediverse-native advocacy: As a fully compatible ActivityPub implementation, Mastic will be promoted within the Fediverse itself. We aim to attract privacy-conscious users and instance operators looking for a lower-maintenance, modern alternative to traditional Mastodon deployments.

Architecture Overview

The architecture of Mastic consists of 3 components, each of which will be a standalone canister.

Frontend

The frontend of Mastic provides an interface where each user can sign in to Mastic and interact with the Fediverse by publishing Statuses and interacting with other users, including those from the Mastodon Fediverse on the web2. The User will be able to sign in with Internet Identity on the frontend and interact with the backend canisters using Candid.

User Canister

Each user has one User Canister. The User canister is created by the Directory Canister via the IC management canister every time a user signs up on Mastic, and is deleted whenever they delete their account or migrate to another Fediverse instance. The User Canister provides the interface for users to interact with the Fediverse through the Inbox and Outbox, implementing the core functionality of the ActivityPub protocol.

The User canister will use wasm-dbms to store data inside of a relational database.

Directory Canister

The Directory Canister provides an index for all existing users on Mastic by creating a Map between a user’s identity and their handle (e.g., @veeso@mastic.social) and their User Canister instance.

Federation Canister

The Federation canister implements the HTTP Web server to handle both incoming and outgoing requests of the Federation Protocol[1], which is used to communicate with the other instances on the Fediverse. The Federation canister MUST also implement the WebFinger[2] protocol to search for users.

Authorization Model

Mastic uses principal-based authorization, where each canister checks the caller’s principal against an expected set of principals configured at install time via init args.

  • User → User Canister: The caller’s principal must match the owner principal that was set when the User Canister was installed. This ensures only the canister owner can call methods like publish_status, update_profile, like_status, etc.
  • Federation Canister → User Canister: The User Canister stores the Federation Canister’s principal at install time (passed via UserInstallArgs). Only the Federation Canister can call receive_activity to deliver incoming activities from the Fediverse.
  • User Canister → Federation Canister: The Federation Canister stores the Directory Canister’s principal at install time. The Directory Canister registers each new User Canister principal with the Federation Canister, so the Federation Canister maintains a list of all authorised User Canister principals that can call send_activity.
  • Directory Canister: Moderator actions (add_moderator, remove_moderator, suspend) require the caller’s principal to be present in the moderator list. The initial moderator is set at install time via DirectoryInstallArgs.

Interface

Here’s a description of the Candid interface of the Directory, Federation and User canisters. Types are not yet defined, but only calls are provided to provide an overview of the flows that will need to be implemented. For more information on the flows, see the User Stories.

Directory Interface

service : (DirectoryInstallArgs) -> {

add_moderator : (AddModeratorArgs) -> (AddModeratorResponse);

delete_profile : () -> (DeleteProfileResponse);

get_user : (GetUserArgs) -> (GetUserResponse) query;

remove_moderator : (RemoveModeratorArgs) -> (RemoveModeratorResponse);

retry_delete_profile : () -> (RetryDeleteProfileResponse);

retry_sign_up : () -> (RetrySignUpResponse);

search_profiles : (SearchProfilesArgs) -> (SearchProfilesResponse) query;

sign_up : (text) -> (SignUpResponse);

suspend : (SuspendArgs) -> (SuspendResponse);

user_canister : (opt Principal) -> (UserCanisterResponse) query;

whoami : () -> (WhoAmIResponse) query

}

Federation Interface

service : (FederationInstallArgs) -> {

http_request : (HttpRequest) -> (HttpResponse) query;

http_request_update : (HttpRequest) -> (HttpResponse);

fetch_status : (FetchStatusArgs) -> (FetchStatusResponse);

send_activity : (SendActivityArgs) -> (SendActivityResponse)

}

User Interface

service : (UserInstallArgs) -> {

accept_follow : (AcceptFollowArgs) -> (AcceptFollowResponse);

block_user : (BlockUserArgs) -> (BlockUserResponse);

boost_status : (BoostStatusArgs) -> (BoostStatusResponse);

delete_status : (DeleteStatusArgs) -> (DeleteStatusResponse);

emit_delete_profile_activity : () -> (EmitDeleteProfileActivityResponse);

follow_user : (FollowUserArgs) -> (FollowUserResponse);

get_follow_requests : (GetFollowRequestsArgs) -> (GetFollowRequestsResponse) query;

get_followers : (GetFollowersArgs) -> (GetFollowersResponse) query;

get_following : (GetFollowingArgs) -> (GetFollowingResponse) query;

get_liked : (GetLikedArgs) -> (GetLikedResponse) query;

get_local_status : (GetLocalStatusArgs) -> (GetLocalStatusResponse) query;

get_profile : () -> (GetProfileResponse) query;

like_status : (LikeStatusArgs) -> (LikeStatusResponse);

publish_status : (PublishStatusArgs) -> (PublishStatusResponse);

read_feed : (ReadFeedArgs) -> (ReadFeedResponse) query;

receive_activity : (ReceiveActivityArgs) -> (ReceiveActivityResponse);

reject_follow : (RejectFollowArgs) -> (RejectFollowResponse);

undo_boost : (UndoBoostArgs) -> (UndoBoostResponse);

undo_like : (UnlikeStatusArgs) -> (UnlikeStatusResponse);

unfollow_user : (UnfollowUserArgs) -> (UnfollowUserResponse);

update_profile : (UpdateProfileArgs) -> (UpdateProfileResponse)

}

User Stories

UC1: As a User, I should be able to create a Profile

  • Alice lands on Mastic Frontend
  • Alice signs in with her Internet Identity
  • Alice sends a sign_up call
  • The Directory Canister establishes a connection between Alice’s identity and her handle
  • The Directory Canister starts a worker to create her User Canister
  • The Directory Canister creates Alice’s User Canister via the IC management canister
  • The Directory Canister installs Alice’s User Canister via the IC management canister
  • The Directory Canister stores Alice’s User Canister ID for Alice’s Identity
  • Alice queries her User Canister ID with user_canister
  • The Directory Canister returns the ID to Alice

UC2: As a User, I should be able to Sign In

  • Alice lands on Mastic Frontend
  • Alice signs in with her Internet Identity
  • Alice queries the Directory Canister to get her user_canister with whoami
  • The Directory Canister returns her User Canister’s principal

UC3: As a User, I should be able to update my Profile

  • Alice signs in with her Internet Identity
  • Alice queries the Directory Canister with whoami to obtain her User Canister principal
  • Alice calls update_profile on her User Canister with the updated fields (display name, bio, avatar, etc.)
  • The User Canister persists the changes
  • The User Canister sends an Update activity to the Federation Canister so that remote followers receive the updated profile

UC4: As a User, I should be able to delete my Profile

  • Alice signs in with her Internet Identity
  • Alice calls delete_profile on the Directory Canister
  • The Directory Canister creates a tombstone for Alice and starts a delete worker
  • The Directory Canister notifies Alice’s User Canister, which aggregates a Delete activity for all of Alice’s followers
  • The User Canister sends the Delete activity to the Federation Canister
  • The Federation Canister buffers the activity data, then forwards it to all remote followers
  • The Directory Canister deletes Alice’s User Canister via the IC management canister

UC5: As a User, I should be able to follow another Profile

  • Alice signs in and obtains her User Canister principal via the Directory Canister
  • Alice calls follow_user on her User Canister with the target user’s handle
  • If the target is local: the User Canister resolves the handle via the Directory Canister, then sends a Follow activity to the target User Canister through the Federation Canister
  • If the target is remote: the User Canister sends a Follow activity to the Federation Canister, which forwards it to the remote instance via HTTP
  • The target user’s User Canister stores the incoming Follow as a pending follow request
  • The target user can view pending requests via get_follow_requests and call accept_follow or reject_follow
  • When the target accepts, an Accept activity is delivered back, and Alice’s User Canister records the follow relationship

UC6: As a User, I should be able to remove a Following

  • Alice signs in and obtains her User Canister principal via the Directory Canister
  • Alice calls unfollow_user on her User Canister with the target user’s handle
  • The User Canister removes the follow relationship locally
  • The User Canister sends an Undo(Follow) activity through the Federation Canister to notify the target (local or remote) that Alice has unfollowed them

UC7: As a User, I should be able to see a user’s profile

  • Alice signs in and obtains her User Canister principal via the Directory Canister
  • Alice calls get_user on the Directory Canister with the target handle
  • The Directory Canister returns the target user’s User Canister principal
  • Alice calls get_profile on the target User Canister to retrieve their public profile information

UC8: As a User, I should be able to search for other users

  • Alice signs in and obtains her User Canister principal via the Directory Canister
  • Alice calls search_profiles on the Directory Canister with a search query
  • The Directory Canister returns a list of matching user handles and their User Canister principals

UC9: As a User, I should be able to create a Status

  • Alice signs in and obtains her User Canister principal via the Directory Canister
  • Alice calls publish_status on her User Canister with the status content
  • The User Canister stores the status in Alice’s outbox
  • The User Canister aggregates a Create(Note) activity for each follower of Alice
  • The User Canister sends the activities to the Federation Canister
  • The Federation Canister routes local activities through the Directory Canister to each local follower’s User Canister inbox
  • The Federation Canister forwards remote activities via HTTP to external Fediverse instances

UC10: As a User, I should be able to like a Status

  • Alice signs in and obtains her User Canister principal via the Directory Canister
  • Alice calls like_status on her User Canister with the status URL
  • The User Canister records the like in Alice’s liked collection
  • The User Canister sends a Like activity to the Federation Canister
  • The Federation Canister routes the activity to the status author’s User Canister (local) or forwards it via HTTP (remote)
  • like_status is idempotent: if Alice has already liked the status, the call returns Ok without inserting a duplicate or re-sending the Like activity

UC11: As a User, I should be able to boost a Status

  • Alice signs in and obtains her User Canister principal via the Directory Canister
  • Alice calls boost_status on her User Canister with the status ID
  • The User Canister records the boost in Alice’s outbox
  • The User Canister sends an Announce activity to the Federation Canister
  • The Federation Canister routes the activity to the status author and Alice’s followers, handling both local delivery (through the Directory Canister) and remote forwarding via HTTP

UC12: As a User, I should be able to read my Feed

  • Alice signs in and obtains her User Canister principal via the Directory Canister
  • Alice calls read_feed on her User Canister with pagination parameters
  • The User Canister aggregates the feed from Alice’s inbox (statuses from followed users) and outbox (Alice’s own statuses)
  • The User Canister returns the paginated feed to Alice

UC13: As a User, I should be able to receive updates from users I follow on other Fediverse instances

  • Bob publishes a status on a remote Mastodon instance
  • The remote instance dispatches a Create(Note) activity to the Federation Canister via HTTP
  • The Federation Canister verifies the HTTP Signature and resolves the target user via the Directory Canister
  • The Federation Canister calls receive_activity on Alice’s User Canister to deliver the status to her inbox
  • Alice reads her feed and sees Bob’s status

UC14: As a User on a Web2 Mastodon Instance, I should be able to receive updates and interact with users on Mastic

  • Alice publishes a status on Mastic
  • The Federation Canister forwards the Create(Note) activity via HTTP to Bob’s remote Mastodon instance
  • Bob sees Alice’s status in his feed
  • Bob likes or replies to Alice’s status
  • The remote instance sends the corresponding activity (Like, Create(Note) reply) to the Federation Canister via HTTP
  • The Federation Canister routes the activity to Alice’s User Canister

UC15: As a Moderator, I should be able to remove a Status violating the policy

  • A Moderator signs in with their Internet Identity
  • The Moderator identifies a status that violates the instance policy
  • The Moderator calls delete_status on the offending User Canister (authorised by the moderator principal stored in the Directory Canister)
  • The User Canister removes the status from the user’s outbox
  • The User Canister sends a Delete activity through the Federation Canister to notify followers

UC16: As a Moderator, I should be able to suspend a Profile violating the policy

  • A Moderator signs in with their Internet Identity
  • The Moderator calls suspend on the Directory Canister with the offending user’s handle
  • The Directory Canister marks the user as suspended, preventing further API calls
  • The Directory Canister notifies the User Canister, which sends a Delete activity through the Federation Canister to notify remote followers that the account is no longer active

UC17: As a Controller, I should be able to upgrade all User Canisters

  • The Controller (deployer pre-SNS, SNS governance post-SNS) calls upgrade_user_canisters on the Directory Canister with the new WASM blob
  • The Directory Canister stores the WASM and starts a timer-based scheduler
  • The scheduler upgrades user canisters in batches, retrying failures up to 5 times
  • The Controller can query get_upgrade_status to monitor progress
  • Once all canisters are processed, the upgrade is marked as completed

UC18: As the System, sign-up spam should be prevented via a cycles fee

  • Alice calls sign_up on the Directory Canister
  • The Directory Canister checks that Alice attached enough cycles to cover the canister creation fee plus initial cycles
  • If insufficient cycles are attached, the call is rejected with an InsufficientCycles error
  • If sufficient, the Directory Canister accepts the cycles and proceeds with the sign-up flow

UC19: As the System, action spam should be prevented via rate limiting

  • Alice performs mutating social actions on her User Canister (post, follow, like, boost, block, etc.)
  • The User Canister enforces a rate limit of 20 actions per minute using a sliding window
  • If Alice exceeds the limit, the call is rejected with a RateLimitExceeded error
  • The rate limit resets on canister upgrade

UC20: As a User, I should be able to reply to a Status

  • Alice views a Status in her feed or on another user’s profile
  • Alice composes a reply, specifying the status she is replying to
  • The User Canister creates a new status with an in_reply_to reference to the original
  • A Create(Note) activity with inReplyTo is sent to the Federation Canister
  • The original author and Alice’s followers receive the reply in their inboxes
  • Anyone viewing the original status can see the reply thread

UC21: As a User, I should be able to attach media to a Status

  • Alice composes a new status and attaches one or more media files (images, videos, or audio)
  • The User Canister stores each media file as a blob in the media table, linked to the status via a foreign key
  • The status is published with attachment metadata (media type, description, blurhash)
  • Followers receive the status with attachment references in their inboxes
  • Anyone viewing the status can retrieve the attached media

Milestones

These are the milestones we plan to achieve during the first year of Mastic’s development cycle.

Milestone 0 - Proof of concept

Duration: 1.5 months

First implementation to demo the social platform.

Only basic functionalities are implemented, such as signing up, posting statuses, and reading the feed.

User stories:

  • UC1
  • UC2
  • UC9
  • UC5
  • UC12
  • UC7

Milestone 1 - Standalone Mastic Node

Duration: 3 months

Mastic is set up; we build all the data structures required by the user to sign up, operate on statuses (Create, Delete, like, and Boost), and find and follow other users on the Mastic node.

This Milestone won’t include the integration with the Fediverse yet, but it will provide an already usable Social Network, ready to be integrated onto the Fediverse soon.

These stories must be implemented during this phase:

  • UC3
  • UC4
  • UC6
  • UC8
  • UC10
  • UC11
  • UC15
  • UC16
  • UC17
  • UC18
  • UC19
  • UC20
  • UC21

Milestone 2 - Frontend

Duration: 2 months

Build the Mastic web frontend as a React application deployed as an IC asset canister. The frontend provides Internet Identity authentication and a Mastodon-like interface covering all user stories from Milestones 0 and 1.

These stories must be implemented during this phase:

  • UC1
  • UC2
  • UC3
  • UC4
  • UC5
  • UC6
  • UC7
  • UC8
  • UC9
  • UC10
  • UC11
  • UC12
  • UC15
  • UC16
  • UC20
  • UC21

Milestone 3 - Integrating the Fediverse

Duration: 2 months

This step requires implementing the Federation Canister using the Federation Protocol.

This will allow Mastic to be fully integrated with the Fediverse ecosystem.

These user stories must be implemented during this phase:

  • UC13
  • UC14

Milestone 4 - SNS Launch

Duration: 1 month

During this phase, we plan to launch Mastic on the SNS to add a fully decentralised governance to Mastic.

We need to implement a comprehensive integration for voting, specifically for adding and removing moderators and updating policies.

Reference

  1. ActivityPub: https://www.w3.org/TR/activitypub/
  2. ActivityStreams: https://www.w3.org/TR/activitystreams-core/
  3. Mastodon ActivityPub Spec: https://docs.joinmastodon.org/spec/activitypub/
  4. ActivityPub Federation framework implemented with Rust: https://docs.rs/activitypub_federation/0.6.5/activitypub_federation/
  5. Webfinger: https://docs.joinmastodon.org/spec/webfinger/

Flows

This page documents the sequence diagrams for all major Mastic flows. Each flow maps to one or more user stories defined in the project specification.

Create Profile

sequenceDiagram
    actor A as Alice
    participant II as Internet Identity
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant IC as IC Management Canister

    A->>II: Sign In with II
    A->>DIR: Create Profile (candid)
    DIR->>DIR: Add map between user and handle
    DIR->>DIR: Start worker to create user canister
    DIR->>IC: create_canister
    IC-->>DIR: Canister ID
    DIR->>IC: install_code (User Canister WASM)
    IC-->>UC: Install Canister
    DIR->>DIR: Store User Canister Principal for Alice
    A->>DIR: Get user canister Principal
    DIR->>A: Principal of User Canister

Sign In

sequenceDiagram
    actor A as Alice
    participant II as Internet Identity
    participant DIR as Directory Canister

    A->>II: Sign In with II
    A->>DIR: Get User Canister (candid)
    DIR->>A: Return Canister ID

Update Profile

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister

    A->>DIR: Get Alice's User Canister (candid)
    DIR->>A: User Canister Principal
    A->>UC: Update Profile (candid)
    UC->>UC: Update Profile in User Canister

Delete Profile

Note: The Federation Canister must buffer the Delete activity data before the User Canister is destroyed, since the User Canister will no longer exist to serve actor profile requests after deletion.

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant FED as Federation Canister
    participant IC as IC Management Canister
    participant M as Mastodon Web2

    A->>DIR: Delete profile (candid)
    DIR->>A: Ok
    DIR->>DIR: Create tombstone for Alice
    DIR->>DIR: Start delete canister worker
    DIR->>UC: Notify Delete
    UC->>UC: Aggregate notification based on followers
    UC->>FED: Send Delete Activity
    FED->>FED: Buffer Delete activity data
    FED->>DIR: Route Delete to local followers
    DIR->>DIR: Resolve local follower User Canisters
    FED->>M: Forward Delete Activity to remote followers
    UC->>DIR: Activity Sent
    DIR->>IC: stop_canister + delete_canister
    IC-->>DIR: Canister Deleted

Follow User

The follow lifecycle has three phases: request, pending, and accept/reject. Alice sends a follow request; Bob’s canister stores it as a pending follow request; Bob reviews pending requests and accepts or rejects each one.

Send follow request (local)

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant FED as Federation Canister
    participant BUC as Bob's User Canister

    A->>DIR: Get Alice's User Canister (candid)
    DIR->>A: User Canister Principal
    A->>UC: follow_user (candid)
    UC->>UC: Store pending follow (status: Pending)
    UC->>FED: Send Follow Activity
    FED->>DIR: Resolve Bob's User Canister
    DIR->>FED: Bob's User Canister Principal
    FED->>BUC: receive_activity (Follow)
    BUC->>BUC: Store follow request in follow_requests table

Send follow request (remote)

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant FED as Federation Canister
    participant M as Mastodon Web2

    A->>DIR: Get Alice's User Canister (candid)
    DIR->>A: User Canister Principal
    A->>UC: follow_user (candid)
    UC->>UC: Store pending follow (status: Pending)
    UC->>FED: Send Follow Activity
    FED->>M: Forward Follow Activity (ActivityPub / HTTP Signature)

Accept follow request (local)

sequenceDiagram
    actor B as Bob
    participant BUC as Bob's User Canister
    participant FED as Federation Canister
    participant DIR as Directory Canister
    participant UC as Alice's User Canister

    B->>BUC: get_follow_requests (candid)
    BUC->>B: List of pending follow requests
    B->>BUC: accept_follow (candid, Alice's actor URI)
    BUC->>BUC: Add Alice to followers table
    BUC->>BUC: Remove request from follow_requests table
    BUC->>FED: Send Accept(Follow) Activity
    FED->>DIR: Resolve Alice's User Canister
    DIR->>FED: Alice's User Canister Principal
    FED->>UC: receive_activity (Accept(Follow))
    UC->>UC: Update following status: Accepted

Accept follow request (remote target accepts)

sequenceDiagram
    participant M as Mastodon Web2
    participant FED as Federation Canister
    participant DIR as Directory Canister
    participant UC as Alice's User Canister

    M->>FED: Send Accept(Follow) Activity (ActivityPub)
    FED->>DIR: Resolve Alice's User Canister
    DIR->>FED: Alice's User Canister Principal
    FED->>UC: receive_activity (Accept(Follow))
    UC->>UC: Update following status: Accepted

Reject follow request (local)

sequenceDiagram
    actor B as Bob
    participant BUC as Bob's User Canister
    participant FED as Federation Canister
    participant DIR as Directory Canister
    participant UC as Alice's User Canister

    B->>BUC: get_follow_requests (candid)
    BUC->>B: List of pending follow requests
    B->>BUC: reject_follow (candid, Alice's actor URI)
    BUC->>BUC: Remove request from follow_requests table
    BUC->>FED: Send Reject(Follow) Activity
    FED->>DIR: Resolve Alice's User Canister
    DIR->>FED: Alice's User Canister Principal
    FED->>UC: receive_activity (Reject(Follow))
    UC->>UC: Remove pending follow entry

Reject follow request (remote target rejects)

sequenceDiagram
    participant M as Mastodon Web2
    participant FED as Federation Canister
    participant DIR as Directory Canister
    participant UC as Alice's User Canister

    M->>FED: Send Reject(Follow) Activity (ActivityPub)
    FED->>DIR: Resolve Alice's User Canister
    DIR->>FED: Alice's User Canister Principal
    FED->>UC: receive_activity (Reject(Follow))
    UC->>UC: Remove pending follow entry

Unfollow User

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant FED as Federation Canister
    participant BUC as Bob's User Canister (local)
    participant M as Mastodon Web2

    A->>DIR: Get Alice's User Canister (candid)
    DIR->>A: User Canister Principal
    A->>UC: unfollow_user (candid)
    UC->>UC: Remove following (Bob)
    UC->>FED: Send Undo(Follow) Activity
    alt Bob is local
        FED->>DIR: Resolve Bob's User Canister
        FED->>BUC: Deliver Undo(Follow) activity
        BUC->>BUC: Remove follower (Alice)
    else Bob is remote
        FED->>M: Forward Undo(Follow) Activity (ActivityPub)
    end

Block User

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant FED as Federation Canister
    participant BUC as Bob's User Canister (local)
    participant M as Mastodon Web2

    A->>DIR: Get Alice's User Canister (candid)
    DIR->>A: User Canister Principal
    A->>UC: block_user (candid)
    UC->>UC: Record block locally
    UC->>FED: Send Block Activity
    alt Bob is local
        FED->>DIR: Resolve Bob's User Canister
        FED->>BUC: Deliver Block activity
        BUC->>BUC: Hide Alice's profile from Bob
    else Bob is remote
        FED->>M: Forward Block Activity (ActivityPub)
    end

Create Status

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant FED as Federation Canister
    participant BUC as Bob's User Canister (local)
    participant M as Mastodon Web2
    actor B as Bob (remote)

    A->>DIR: Get Alice's User Canister (candid)
    DIR->>A: User Canister Principal
    A->>UC: Create Status (candid)
    UC->>UC: Store Status in Alice's Outbox
    UC->>UC: Aggregate Create activity for each follower
    UC->>FED: Forward Create Status Activities (ic)
    FED->>DIR: Resolve local followers
    DIR->>FED: Local follower User Canister principals
    FED->>BUC: Deliver Create activity to local follower inboxes
    FED->>M: Forward Create activities to remote instances (ActivityPub)
    B->>M: Get Feed
    M->>B: Return Alice's Status

Like Status

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant FED as Federation Canister
    participant TUC as Target User Canister (local)
    participant M as Mastodon Web2

    A->>DIR: Get Alice's User Canister (candid)
    DIR->>A: User Canister Principal
    A->>UC: Like Status (candid)
    UC->>UC: Store Like in Alice's Outbox
    UC->>FED: Forward Like Activity (ic)
    alt Status author is local
        FED->>DIR: Resolve author's User Canister
        FED->>TUC: Deliver Like activity
    else Status author is remote
        FED->>M: Forward Like Activity (ActivityPub)
    end

Boost Status

sequenceDiagram
    actor A as Alice (booster)
    participant UC as Booster User Canister
    participant FED as Federation Canister
    participant DIR as Directory Canister
    participant TUC as Target User Canister (author)
    participant Fol as Follower User Canisters

    A->>UC: boost_status(status_url)
    UC->>FED: fetch_status(uri, requester=alice_actor_uri)
    FED->>DIR: lookup handle from URI
    DIR-->>FED: target canister id
    FED->>TUC: get_local_status(id, requester=alice_actor_uri)
    TUC-->>FED: Status (visibility-filtered)
    FED-->>UC: Status
    UC->>UC: tx { wrapper Status, Boost row, FeedEntry } (shared snowflake)
    UC->>FED: send_activity(Batch[Announce])
    FED->>TUC: receive_activity(Announce)  -- bumps boost_count
    FED->>Fol: receive_activity(Announce)  -- inbox row + feed entry
    UC-->>A: Ok

The booster’s User Canister never trusts boost content from its caller: the wrapper row’s content, spoiler_text, and sensitive are populated from the Status returned by Federation.fetch_status, which in turn dereferences the local author through User.get_local_status (Milestone 1; Milestone 3 will extend the remote branch via HTTPS outcalls).

A single Snowflake is reused as boosts.id, the wrapper statuses.id, the feed.id for the booster’s outbox entry, and the Announce activity id (<own_actor_uri>/statuses/<snowflake>).

boost_status is idempotent: a duplicate boost of the same status_url returns Ok without inserting a second wrapper or re-emitting the Announce. undo_boost reverses the flow — it deletes the boosts row, the wrapper statuses row, and the feed outbox entry, then dispatches an Undo(Announce) to followers and the original author. undo_boost is also idempotent.

Remote author / follower delivery via HTTPS is Milestone 3.

Delete Status

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant FED as Federation Canister
    participant M as Mastodon Web2

    A->>DIR: Get Alice's User Canister (candid)
    DIR->>A: User Canister Principal
    A->>UC: Delete Status (candid)
    UC->>UC: Remove Status from Alice's Outbox
    UC->>FED: Forward Delete Status Activity (ic)
    FED->>DIR: Resolve local followers
    FED->>FED: Deliver Delete to local follower inboxes
    FED->>M: Forward Delete Activity to remote instances (ActivityPub)

Read Feed

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister

    A->>DIR: Get Alice's User Canister (candid)
    DIR->>A: User Canister Principal
    A->>UC: Read Feed page (candid)
    UC->>UC: Aggregate feed from Alice's Inbox and Outbox
    UC->>A: Return Feed

Receive Updates from Fediverse

sequenceDiagram
    actor A as Alice
    participant UC as Alice's User Canister
    participant DIR as Directory Canister
    participant FED as Federation Canister
    participant M as Mastodon Web2
    actor B as Bob

    B->>M: Publish Status
    M->>M: Get who Follows Bob
    M->>FED: Dispatch create Status Activity for Alice
    FED->>DIR: Get User Canister for Alice
    DIR->>FED: User Canister ID
    FED->>UC: Put Status to Alice's Inbox
    A->>UC: Read feed
    UC->>A: Return Bob's Post

Milestones

Implementation plans for each Mastic development milestone.

Milestone 0 - Proof of Concept

Duration: 1.5 months

Goal: First implementation to demo the social platform with basic functionalities: signing up, posting statuses, following users, and reading the feed.

User Stories: UC1, UC2, UC5, UC7, UC9, UC12

Work Items

WI-0.1: Implement ActivityPub types in the activitypub crate

Description: Implement all ActivityPub and ActivityStreams protocol types in the activitypub crate (crates/libs/activitypub). This crate provides the canonical Rust representation of the ActivityPub protocol used by the Federation Canister for S2S communication and JSON-LD serialization/deserialization. Types must round-trip correctly through serde_json and match the JSON-LD payloads documented in docs/src/activitypub.md.

What should be done:

  • Core types (activitypub::object):
    • Object — base type with id, type, content, name, summary, published, updated, url, to, cc, bto, bcc, audience, attributed_to, in_reply_to, source, tag, attachment, replies, likes, shares, sensitive
    • Sourcecontent + media_type
    • Tombstoneid, type, published, updated, deleted
    • ObjectType enum — Note, Question, Image, Tombstone, etc.
  • Actor types (activitypub::actor):
    • Actor — extends Object with inbox, outbox, following, followers, liked, preferred_username, public_key, endpoints, manually_approves_followers, discoverable, indexable, suspended, memorial, featured, featured_tags, also_known_as, attribution_domains, icon, image
    • ActorType enum — Person, Application, Service, Group, Organization
    • PublicKeyid, owner, public_key_pem
    • Endpointsshared_inbox
  • Activity types (activitypub::activity):
    • Activity — extends Object with actor, object, target, result, origin, instrument
    • ActivityType enum — Create, Update, Delete, Follow, Accept, Reject, Like, Announce, Undo, Block, Add, Remove, Flag, Move
  • Collection types (activitypub::collection):
    • Collectionid, type, total_items, first, last, current, items
    • OrderedCollection — same as Collection with ordered_items
    • CollectionPage / OrderedCollectionPagepart_of, next, prev, items/ordered_items
  • Link types (activitypub::link):
    • Linkhref, rel, media_type, name, hreflang, height, width
    • Mention — subtype of Link
    • Hashtag — subtype of Link
  • Tag types (activitypub::tag):
    • Tag enum — Mention, Hashtag, Emoji
    • Emojiid, name, icon (Image with url and media_type)
  • Mastodon extensions (activitypub::mastodon):
    • PropertyValuename, value (for profile metadata fields)
    • Poll support on Question objects: end_time, closed, voters_count, one_of/any_of with name + replies.total_items
    • Attachment properties: blurhash, focal_point
  • WebFinger types (activitypub::webfinger):
    • WebFingerResponsesubject, aliases, links
    • WebFingerLinkrel, type, href, template
  • JSON-LD context (activitypub::context):
    • Constants for standard context URIs (https://www.w3.org/ns/activitystreams, https://w3id.org/security/v1, Mastodon namespace http://joinmastodon.org/ns#)
    • Context type for @context serialization (single URI, array, or map)
  • All types derive serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq
  • Use #[serde(rename_all = "camelCase")] to match JSON-LD field naming
  • Use #[serde(skip_serializing_if = "Option::is_none")] for optional fields
  • Use #[serde(rename = "@context")] for the context field

Acceptance Criteria:

  • All types compile and are exported from the activitypub crate
  • serde_json round-trip tests for every top-level type
  • Deserialization tests using real-world Mastodon JSON-LD payloads from docs/src/activitypub.md examples
  • cargo clippy passes with zero warnings
  • Unit tests cover: Object, Actor, each ActivityType, Collection, OrderedCollection, CollectionPage, WebFinger, Mention, Hashtag, Emoji

WI-0.2: Define shared Candid types in the did crate

Description: Define all shared Candid types required by Milestone 0 in the did crate. These types are used across the Directory, Federation, and User canisters.

What should be done:

  • Define DirectoryInstallArgs (initial moderator principal, federation canister principal)
  • Define UserInstallArgs (owner principal, federation canister principal)
  • Define FederationInstallArgs (directory canister principal, domain name)
  • Define sign-up types: SignUpResponse
  • Define whoami types: WhoAmIResponse
  • Define user canister query types: UserCanisterResponse
  • Define get-user types: GetUserArgs, GetUserResponse
  • Define profile types: GetProfileResponse, UserProfile (handle, display name, bio, avatar URL, created at)
  • Define follow types: FollowUserArgs, FollowUserResponse, AcceptFollowArgs, AcceptFollowResponse, RejectFollowArgs, RejectFollowResponse
  • Define status types: PublishStatusArgs, PublishStatusResponse, Status (id, content, author, created at, visibility)
  • Define feed types: ReadFeedArgs, ReadFeedResponse, FeedItem
  • Define get-followers/following types: GetFollowersArgs, GetFollowersResponse, GetFollowingArgs, GetFollowingResponse
  • Define SendActivityArgs, SendActivityResponse for the Federation canister
  • Define ReceiveActivityArgs, ReceiveActivityResponse for the User canister
  • Ensure all types derive CandidType, Deserialize, Serialize, Clone, Debug

Acceptance Criteria:

  • All types compile and are exported from the did crate
  • Types match the .did interface files in docs/interface/
  • cargo clippy passes with zero warnings
  • Unit tests verify serialization/deserialization round-trips

WI-0.3: Design and implement database schema for Milestone 0

Description: Design the relational database schema using wasm-dbms for all entities required by Milestone 0 in the Directory and User canisters. Since wasm-dbms manages its own stable memory, ic-stable-structures cannot be used alongside it in these canisters. Canister init arguments and runtime configuration are persisted in a settings key-value table instead. The Federation Canister does not use wasm-dbms and uses ic-stable-structures directly (see WI-0.10).

What should be done:

  • Add ic-dbms-canister as a workspace dependency
  • Create crates/libs/db-utils crate:
    • Define SettingKey as a u32 newtype with named constants per canister (e.g., FEDERATION_PRINCIPAL, OWNER_PRINCIPAL, DOMAIN_NAME)
    • Define SettingValue enum wrapping ic-dbms-canister Value variants (Text, Integer, Blob) with typed accessor methods (as_text(), as_principal(), etc.)
    • Provide helper functions for reading/writing settings rows
    • Add the crate to the workspace in root Cargo.toml
  • Shared settings table (both canisters):
    • settings table: key (INTEGER PK), value (depends on key — TEXT, INTEGER, or BLOB)
    • Uses SettingKey constants from db-utils to identify entries
  • Directory Canister schema:
    • settings table — stores federation_principal from DirectoryInstallArgs
    • The initial moderator from DirectoryInstallArgs is inserted as the first row in the moderators table during init
    • users table: principal (PRINCIPAL PK), handle (TEXT UNIQUE NOT NULL), user_canister_id (PRINCIPAL NOT NULL), status (TEXT NOT NULL DEFAULT ‘active’), created_at (INTEGER NOT NULL)
    • moderators table: principal (PRINCIPAL PK), added_at (INTEGER NOT NULL)
    • Index on users.handle for fast lookups
  • User Canister schema:
    • settings table — stores owner_principal, federation_principal from UserInstallArgs
    • profile table (single-row): handle (TEXT NOT NULL), display_name (TEXT), bio (TEXT), avatar_url (TEXT), header_url (TEXT), created_at (INTEGER NOT NULL), updated_at (INTEGER NOT NULL)
    • statuses table: id (TEXT PK), content (TEXT NOT NULL), visibility (TEXT NOT NULL DEFAULT ‘public’), created_at (INTEGER NOT NULL)
    • inbox table: id (TEXT PK), activity_type (TEXT NOT NULL), actor_uri (TEXT NOT NULL), object_json (TEXT NOT NULL), created_at (INTEGER NOT NULL)
    • followers table: actor_uri (TEXT PK), created_at (INTEGER NOT NULL)
    • following table: actor_uri (TEXT PK), status (TEXT NOT NULL DEFAULT ‘pending’), created_at (INTEGER NOT NULL)
    • Ed25519 public key is derived at runtime via the IC threshold Schnorr API (schnorr_public_key) and cached in memory
    • Indexes on statuses.created_at, inbox.created_at for feed ordering
  • Initialize the schema in each canister’s init function and persist init args into the settings table
  • Data survives canister upgrades via wasm-dbms stable memory management

Acceptance Criteria:

  • All tables are created on canister initialization
  • Init args are persisted in the settings table and retrievable after upgrade
  • db-utils crate compiles and is usable from both canisters
  • Schema supports all queries needed by Milestone 0 work items
  • Data persists across canister upgrades
  • Unit tests verify table creation and basic CRUD operations

WI-0.4: Implement Directory Canister - sign-up flow

Description: Implement the sign_up method on the Directory Canister, which creates a new User Canister for the caller and maps their principal to a handle and canister ID.

What should be done:

  • Use the database schema from WI-0.3 (users, moderators, settings tables)

  • Implement init to accept DirectoryInstallArgs, create the schema, and persist init args into the settings table

  • Implement sign_up(handle):

    • Validate handle format (alphanumeric, lowercase, 1-30 chars)
    • Check handle uniqueness
    • Create a new User Canister via the IC management canister (ic_cdk::api::management_canister::main::create_canister)
    • Install the User Canister WASM via ic_cdk::api::management_canister::main::install_code
    • Store the mapping (principal -> handle, canister ID)
    • Register the new User Canister with the Federation Canister
    • Return SignUpResponse with the canister ID Acceptance Criteria:
  • Calling sign_up with a valid handle creates a User Canister and returns its principal

  • Duplicate handles are rejected

  • Duplicate sign-ups from the same principal are rejected

  • Invalid handles are rejected with a descriptive error

  • The user record is persisted across canister upgrades

  • Integration test: sign up, then verify the canister exists and is callable

WI-0.5: Implement Directory Canister - query methods

Description: Implement the read-only query methods on the Directory Canister that allow users to discover their canister and look up other users.

What should be done:

  • Implement whoami() query: return the caller’s UserRecord (handle + canister ID) or an error if not registered
  • Implement user_canister(opt principal) query: return the User Canister ID for the given principal (or the caller if None)
  • Implement get_user(GetUserArgs) query: look up a user by handle, return their public info (handle, canister ID)

Acceptance Criteria:

  • whoami returns the correct record for a registered user
  • whoami returns an error for an unregistered caller
  • user_canister(None) returns the caller’s canister
  • user_canister(Some(p)) returns the canister for principal p
  • get_user returns the correct user for a valid handle
  • get_user returns an error for a non-existent handle

WI-0.6: Implement User Canister - profile and state management

Description: Implement the User Canister’s internal state, initialization, Ed25519 signing via IC threshold Schnorr, and profile query method.

What should be done:

  • Use the database schema from WI-0.3 (settings, profile, statuses, inbox, followers, following tables)
  • Implement init to accept UserInstallArgs, create the schema, and persist init args into the settings table
  • Implement Ed25519 key retrieval and signing via the IC management canister’s threshold Schnorr API (schnorr_public_key, sign_with_schnorr with SchnorrAlgorithm::Ed25519):
    • The public key is fetched once and cached in a thread-local
    • Signing is performed on demand for HTTP Signatures
    • An adapter trait (SchnorrCanister) abstracts the management canister calls for testability
  • Implement get_profile() query: return the user’s profile (handle, display name, bio, avatar, created at)
  • Implement authorization guard: reject calls from non-owner principals for owner-only methods

Acceptance Criteria:

  • The User Canister initializes correctly with the provided args
  • The Ed25519 public key is retrievable via the Schnorr adapter
  • Signing produces a valid Ed25519 signature via the Schnorr adapter
  • get_profile returns the profile for any caller (public data)
  • Owner-only methods reject unauthorized callers
  • State survives canister upgrades

WI-0.7: Implement User Canister - publish status

Description: Implement the publish_status method on the User Canister, which stores a status in the user’s outbox (statuses table) and sends Create activities to followers via the Federation Canister. Also expose send_activity on the Federation Canister as a no-op stub (actual routing logic comes in WI-0.10).

What should be done:

  • Federation Canister: expose send_activity(SendActivityArgs) -> SendActivityResponse as a no-op — accept the call, return success, do nothing (actual routing is WI-0.10)
  • User Canister: implement publish_status(PublishStatusArgs):
    • Authorize the caller (owner only)
    • Generate a Snowflake ID for the status
    • Create and insert a Status record (id, content, visibility, created_at) into the statuses table
    • Query the followers table
    • For each follower, build a Create(Note) activity and call send_activity on the Federation Canister
    • Return PublishStatusResponse with the new status ID

Acceptance Criteria:

  • Only the owner can publish a status
  • The status is stored in the statuses table with a unique Snowflake ID
  • A Create(Note) activity is sent for each follower via send_activity
  • The status ID is returned to the caller
  • Statuses persist across upgrades
  • send_activity is exposed on the Federation Canister (no-op for now)

WI-0.8: Implement User Canister - follow user

Description: Implement follow management methods (follow_user, accept_follow, reject_follow), follower/following queries (get_followers, get_following), and the receive_activity inbox handler.

What should be done:

  • Implement follow_user(FollowUserArgs):
    • Authorize the caller (owner only)
    • Build a Follow activity targeting the given handle/actor URI
    • Send the activity to the Federation Canister via send_activity
    • Store a pending follow request locally
  • Implement accept_follow(AcceptFollowArgs):
    • Called by the Federation Canister when the target accepts
    • Add the requester to the followers list
    • Send an Accept(Follow) activity back via the Federation Canister
  • Implement reject_follow(RejectFollowArgs):
    • Called by the Federation Canister when the target rejects
    • Remove the pending follow request
    • Send a Reject(Follow) activity back via the Federation Canister
  • Implement get_followers(GetFollowersArgs) query:
    • Allow any caller (public query)
    • Return paginated list of followers from the followers table
    • Support cursor-based or offset-based pagination
  • Implement get_following(GetFollowingArgs) query:
    • Allow any caller (public query)
    • Return paginated list of followed actors from the following table
    • Support cursor-based or offset-based pagination
  • Implement receive_activity(ReceiveActivityArgs):
    • Authorize the caller (federation canister only)
    • Handle incoming Follow activities: auto-accept (for M0) and add to followers
    • Handle incoming Accept(Follow): add to following list
    • Handle incoming Reject(Follow): remove pending follow request from following list
    • Handle incoming Create(Note): store in inbox

Acceptance Criteria:

  • follow_user sends a Follow activity and records a pending request
  • When an Accept is received, the target is added to the following list
  • When a Reject is received, the pending request is removed from the following list
  • When a Follow is received, the requester is added to the followers list
  • get_followers returns the correct paginated follower list
  • get_following returns the correct paginated following list
  • Only the Federation Canister can call receive_activity
  • Only the owner can call follow_user

WI-0.9: Implement User Canister - read feed

Description: Implement the read_feed method, which aggregates the user’s inbox and outbox into a chronological, paginated feed.

What should be done:

  • Implement read_feed(ReadFeedArgs):
    • Authorize the caller (owner only)
    • Merge inbox items (statuses from followed users) and outbox items (own statuses)
    • Sort by timestamp descending
    • Apply pagination (cursor-based or offset-based as defined in ReadFeedArgs)
    • Return ReadFeedResponse with the page of FeedItem records
  • Visibility filtering in read_feed:
    • Public and Unlisted inbox items: always shown
    • FollowersOnly inbox items: shown (the user is a follower by definition, since the item is in their inbox)
    • Direct inbox items: only shown if the owner is in the to/cc list (mentioned)

Acceptance Criteria:

  • Feed contains both inbox and outbox items
  • Items are sorted by timestamp (newest first)
  • Pagination works correctly (returns the requested page size, provides a cursor/offset for the next page)
  • An empty feed returns an empty list (no error)
  • Only the owner can read their own feed
  • Direct messages only appear for mentioned recipients

WI-0.9-2: Implement User Canister - get statuses (public outbox)

Description: Implement a public query to retrieve a user’s published statuses, with visibility filtering. This is the read-side complement to publish_status and is needed for profile views and federation outbox collections.

What should be done:

  • Implement get_statuses(GetStatusesArgs) query:
    • Allow any caller (public query)
    • Return paginated statuses from the statuses table, sorted by timestamp descending
    • Apply visibility filtering based on the caller’s relationship to the owner:
      • Owner: see all statuses (including Direct and FollowersOnly)
      • Follower: see Public, Unlisted, and FollowersOnly statuses
      • Anyone else: see only Public and Unlisted statuses
      • Direct statuses are never returned via this endpoint (they are only visible in read_feed for mentioned users)
    • Support cursor-based or offset-based pagination
  • Define GetStatusesArgs and GetStatusesResponse in the did crate

Acceptance Criteria:

  • Any caller can query a user’s public statuses
  • Followers see FollowersOnly statuses; non-followers do not
  • Direct statuses are excluded for all callers except the owner
  • Pagination works correctly
  • Results are sorted by timestamp descending

WI-0.10: Implement Federation Canister - activity routing

Description: Implement the Federation Canister’s send_activity method, which routes activities between local User Canisters via the Directory Canister. Remote HTTP delivery is out of scope for Milestone 0.

What should be done:

  • Define canister state using ic-stable-structures: directory canister principal, domain name, set of authorized User Canister principals (the Federation Canister does not use wasm-dbms)
  • Implement init to accept FederationInstallArgs and persist state in stable memory
  • Implement a method to register User Canister principals (called by the Directory Canister during sign-up)
  • Implement send_activity(SendActivityArgs):
    • Authorize the caller (must be a registered User Canister)
    • Parse the activity to determine the target actor(s)
    • Apply visibility-aware delivery rules:
      • Public / Unlisted: deliver to all specified targets
      • FollowersOnly: deliver only to the sender’s followers (ignore non-follower targets)
      • Direct: deliver only to explicitly mentioned actors (from the activity’s to/cc fields)
    • For local targets: resolve the target User Canister via the Directory Canister, then call receive_activity on it
    • For remote targets: log/skip (federation is Milestone 2)
  • Return SendActivityResponse

Acceptance Criteria:

  • Only registered User Canisters can call send_activity
  • Local activities are correctly routed to the target User Canister
  • The Federation Canister resolves local handles via the Directory Canister
  • Remote targets are gracefully skipped (no crash)
  • FollowersOnly activities are only delivered to followers
  • Direct activities are only delivered to mentioned actors
  • Integration test: Alice follows Bob (both local), Bob sees Alice in followers

WI-0.11: Integration tests for Milestone 0 flows

Description: Write end-to-end integration tests using pocket-ic that exercise the complete Milestone 0 user flows.

What should be done:

  • Test UC1 (Create Profile): Deploy Directory + Federation canisters, call sign_up, verify the User Canister is created and callable
  • Test UC2 (Sign In): After sign-up, call whoami and verify the correct canister ID is returned
  • Test UC7 (View Profile): After sign-up, call get_user on the Directory, then get_profile on the User Canister
  • Test UC5 (Follow User): Two users sign up, Alice follows Bob, verify follower/following lists
  • Test UC9 (Create Status): Publish a status, verify it appears in the author’s outbox and is delivered to followers’ inboxes
  • Test UC12 (Read Feed): Publish multiple statuses from different users, verify the feed is correctly aggregated and paginated
  • Test Get Statuses: Alice publishes public and followers-only statuses, Bob (follower) calls get_statuses and sees both, Charlie (non-follower) calls get_statuses and sees only public statuses

Acceptance Criteria:

  • All user story flows pass as integration tests
  • Tests run in CI via just integration_test
  • Tests use pocket-ic with realistic canister deployment
  • Each test is independent and can run in isolation

Milestone 1 - Standalone Mastic Node

Duration: 3 months

Goal: Build all remaining local-node features: profile management (update, delete), status interactions (like, boost, delete), user search, and moderation. After this milestone, Mastic is a fully usable social network within a single node, ready for Fediverse integration.

User Stories: UC3, UC4, UC6, UC8, UC10, UC11, UC15, UC16, UC17, UC18, UC19, UC20, UC21

Prerequisites: Milestone 0 completed.

Work Items

WI-1.1: Extend database schema for Milestone 1

Description: Extend the wasm-dbms schema to support all new entities introduced in Milestone 1: likes, boosts, blocks, tombstones, and status deletion tracking.

What should be done:

  • User Canister schema additions:
    • liked table: status_uri (TEXT PK), created_at (INTEGER NOT NULL)
    • blocks table: actor_uri (TEXT PK), created_at (INTEGER NOT NULL)
    • Add like_count (INTEGER DEFAULT 0) and boost_count (INTEGER DEFAULT 0) columns to the statuses table
    • Add is_boost (INTEGER DEFAULT 0) and original_status_uri (TEXT) columns to the inbox table for boost tracking
    • media table: id (TEXT PK), status_id (TEXT NOT NULL, FK → statuses.id), media_type (TEXT NOT NULL), description (TEXT), blurhash (TEXT), data (BLOB NOT NULL), created_at (INTEGER NOT NULL)
    • Add index on media.status_id
  • Directory Canister schema additions:
    • tombstones table: handle (TEXT PK), deleted_at (INTEGER NOT NULL), expires_at (INTEGER NOT NULL)
    • Add index on tombstones.expires_at for cleanup
  • Run schema migrations on canister upgrade (add new tables/columns without losing existing data)

Acceptance Criteria:

  • New tables and columns are created on upgrade from Milestone 0 schema
  • Existing data is preserved during migration
  • Unit tests verify migration from M0 schema to M1 schema
  • All new queries needed by M1 work items are supported

WI-1.2: Implement User Canister - update profile (UC3)

Description: Allow the user to update their profile fields and propagate the change to followers via an Update activity.

What should be done:

  • Implement update_profile(UpdateProfileArgs):
    • Authorize the caller (owner only)
    • Accept optional fields: display name, bio, avatar URL, header URL
    • Update the profile in stable memory (only fields that are Some)
    • Build an Update(Person) activity
    • Send the activity to the Federation Canister via send_activity so followers (local for now) receive the updated profile
  • Define UpdateProfileArgs and UpdateProfileResponse in the did crate if not already present

Acceptance Criteria:

  • Only the owner can update their profile
  • Partial updates work (e.g., updating only the bio leaves other fields unchanged)
  • The updated profile is returned by get_profile
  • An Update activity is sent to the Federation Canister
  • Integration test: update profile, verify get_profile returns new values

WI-1.3: Implement delete profile flow (UC4)

Description: Implement account deletion across Directory, User, and Federation canisters.

What should be done:

  • Directory Canister: Implement delete_profile():
    • Authorize the caller (must be a registered user)
    • Create a tombstone record for the user (prevents handle reuse for a grace period)
    • Notify the User Canister to aggregate Delete activities
    • After activities are sent, delete the User Canister via the IC management canister (stop_canister + delete_canister)
    • Remove the user record from the directory
  • User Canister: Implement delete_profile():
    • Authorize the caller (owner only)
    • Aggregate a Delete(Person) activity for all followers
    • Send activities to the Federation Canister
    • Return success
  • Federation Canister: Handle Delete(Person) activities:
    • Buffer the activity data before forwarding (the User Canister will be destroyed)
    • Route to local followers via the Directory Canister
    • For remote: skip (Milestone 2)
  • Define DeleteProfileResponse in the did crate

Acceptance Criteria:

  • Calling delete_profile on the Directory removes the user record
  • The User Canister is stopped and deleted via the IC management canister
  • A Delete activity is delivered to local followers
  • The deleted user’s handle cannot be reused immediately (tombstone)
  • whoami returns an error after deletion
  • get_user returns an error for the deleted handle
  • Integration test: create user, delete, verify canister is gone

WI-1.4: Implement User Canister - unfollow user (UC6)

Description: Allow a user to unfollow another user and notify the target via an Undo(Follow) activity.

What should be done:

  • Implement unfollow_user(UnfollowUserArgs):
    • Authorize the caller (owner only)
    • Remove the target from the following list
    • Build an Undo(Follow) activity
    • Send the activity to the Federation Canister
  • User Canister receive_activity handler: handle incoming Undo(Follow):
    • Remove the requester from the followers list
  • Define UnfollowUserArgs, UnfollowUserResponse in the did crate
  • Add Undo to ActivityType in the did crate

Acceptance Criteria:

  • After unfollowing, the target is removed from the following list
  • The target’s follower list no longer contains the caller
  • An Undo(Follow) activity is delivered to the target
  • Unfollowing a user you don’t follow returns a descriptive error
  • Integration test: follow, then unfollow, verify lists are updated

WI-1.5: Implement Directory Canister - search profiles (UC8)

Description: Implement the search_profiles method for user discovery.

What should be done:

  • Implement search_profiles(SearchProfilesArgs) query:
    • Accept a search query string and pagination parameters
    • Search by handle prefix or substring match
    • Return a paginated list of matching users (handle + canister ID)
  • Define SearchProfilesArgs, SearchProfilesResponse in the did crate

Acceptance Criteria:

  • Searching by exact handle returns the correct user
  • Searching by prefix returns all matching users
  • Empty query returns a paginated list of all users
  • Pagination works correctly
  • Results do not include suspended or deleted users
  • Integration test: create multiple users, search, verify results

WI-1.6: Implement User Canister - like status (UC10)

Description: Allow a user to like a status and notify the author.

What should be done:

  • Implement like_status(LikeStatusArgs):
    • Authorize the caller (owner only)
    • Record the like in the user’s liked collection (stable memory)
    • Build a Like activity targeting the status
    • Send the activity to the Federation Canister
    • Idempotent: if the caller has already liked the status, return Ok without inserting a duplicate row and without re-sending the Like activity
  • Implement get_liked(GetLikedArgs) query:
    • Return the paginated list of statuses liked by the user
  • Implement undo_like(UnlikeStatusArgs):
    • Remove the like from the liked collection
    • Send an Undo(Like) activity to the Federation Canister
  • User Canister receive_activity handler: handle incoming Like:
    • Increment the like count on the target status
  • Handle incoming Undo(Like):
    • Decrement the like count on the target status
  • Define LikeStatusArgs, LikeStatusResponse, UnlikeStatusArgs, UnlikeStatusResponse, GetLikedArgs, GetLikedResponse in the did crate
  • Add Like to ActivityType

Acceptance Criteria:

  • Liking a status records it in the liked collection
  • A Like activity is sent to the status author
  • The author’s status like count is incremented
  • get_liked returns the correct list
  • Undoing a like removes it and sends an Undo(Like) activity
  • Liking a status the caller already liked is idempotent: returns Ok, does not insert a duplicate, and does not re-send the Like activity
  • Integration test: Alice likes Bob’s status, verify like count and liked list
  • Integration test: Alice calls like_status twice on the same status, verify second call returns Ok, liked list contains a single entry, and Bob’s status like count is incremented exactly once

WI-1.7: Implement User Canister - boost status (UC11)

Description: Allow a user to boost (reblog) a status and notify both the author and the user’s followers.

What should be done:

  • Implement boost_status(BoostStatusArgs { status_url }):
    • Authorize the caller (owner only) at the inspect layer
    • Idempotent: if a boost row for status_url already exists, return Ok without inserting a duplicate row and without re-dispatching the activity
    • Otherwise:
      • Resolve the original status (content, author URI, visibility, spoiler, sensitive flag) by looking up the local statuses table when the target is local, or the local inbox row when the target is remote
      • Insert a wrapper row into statuses owned by the booster with a denormalized copy of the original content fields. This is the booster’s outbox entry for the boost
      • Insert a row into boosts linking the wrapper (status_id) to the original_status_uri
      • Insert a feed entry with source = Outbox for the wrapper so the boost appears in the booster’s own feed
      • Build an Announce activity and send it to the Federation Canister (targets: status author + all of the booster’s followers)
  • Implement undo_boost(UndoBoostArgs { status_url }):
    • Authorize the caller (owner only) at the inspect layer
    • Idempotent: if no boost row for status_url exists, return Ok without dispatching an Undo(Announce) activity
    • Otherwise, remove the boosts row, the wrapper statuses row, and the corresponding feed outbox entry. Then send an Undo(Announce) activity to the same targets
  • User Canister receive_activity handler: handle incoming Announce:
    • Insert an inbox row with is_boost = true, original_status_uri = <target>, actor_uri = booster, activity_type = Announce
    • Insert a feed entry with source = Inbox for that row so the boost appears in the recipient’s feed
    • If the local target status exists, increment statuses.boost_count (saturating)
  • Handle incoming Undo(Announce):
    • Delete the matching inbox row and its feed entry
    • If the local target status exists, decrement statuses.boost_count (saturating at 0)
  • Feed rendering (read_feed):
    • Outbox path: when a statuses row is referenced by a boosts row, hydrate the feed item using the denormalized copy and set boosted_by = Some(owner_actor_uri), author = original author URI. Closes the existing boosted_by: None FIXME in crates/canisters/user/src/domain/feed/read_feed.rs
    • Inbox path: when an inbox row has is_boost = true, hydrate the feed item by resolving original_status_uri (local statuses if present, otherwise from any cached inbox row), and set boosted_by = Some(actor_uri), author = original author URI. Closes the corresponding inbox-side FIXME
  • Define BoostStatusArgs, BoostStatusResponse, UndoBoostArgs, UndoBoostResponse in the did crate. Both error enums expose only Internal(String); authorization failures are rejected at the inspect layer (no Unauthorized variant)
  • Extend Status / FeedItem with boosted_by: Option<Text>
  • Add Announce to ActivityType

Edit propagation note: the wrapper row holds a denormalized copy of the original status content. When the original author edits the status (WI-1.23), the resulting Update(Note) activity received by the booster’s canister must overwrite the wrapper’s content, spoiler_text, and sensitive fields in addition to the inbox row’s object_data. WI-1.23 covers this propagation.

Acceptance Criteria:

  • Boosting a status inserts a wrapper row into the booster’s outbox with a denormalized copy of the original content
  • A boosts row links the wrapper to original_status_uri
  • The booster sees their own boost in their feed with boosted_by = self and author = original author URI
  • An Announce activity is sent to the author and the booster’s followers
  • Followers see the boost in their feed with boosted_by = booster and author = original author URI
  • boost_status is idempotent: calling it twice for the same status_url returns Ok both times, stores a single boost row + wrapper, and dispatches the Announce activity only once
  • Undoing a boost removes the boosts row, the wrapper status, the outbox feed entry, and dispatches an Undo(Announce) activity
  • Receivers delete the inbox boost row and feed entry on Undo(Announce)
  • undo_boost is idempotent: calling it for a status that is not boosted returns Ok without dispatching a second Undo(Announce)
  • Self-boost is allowed
  • Integration test: Alice boosts Bob’s status, Charlie (Alice’s follower) sees it in his feed with boosted_by = alice and author = bob; Alice also sees the boost in her own feed; undo removes it from both

WI-1.8: Implement User Canister - delete status (UC15)

Description: Allow both the status owner and moderators to delete a status.

What should be done:

  • Implement delete_status(DeleteStatusArgs):
    • Authorize the caller: must be the owner or a moderator (the moderator list is resolved from the Directory Canister)
    • Remove the status from the outbox
    • Build a Delete(Note) activity
    • Send the activity to the Federation Canister to notify followers
  • Define DeleteStatusArgs, DeleteStatusResponse in the did crate
  • Add Delete to ActivityType if not already present

Acceptance Criteria:

  • The owner can delete their own status
  • A moderator can delete any user’s status
  • Non-owner, non-moderator callers are rejected
  • A Delete(Note) activity is sent to followers
  • The status no longer appears in feeds after deletion
  • Integration test: publish status, delete it, verify it’s gone from feeds

WI-1.9: Implement Directory Canister - moderation (UC16)

Description: Implement moderator management and user suspension on the Directory Canister.

What should be done:

  • Implement add_moderator(AddModeratorArgs):
    • Authorize the caller (must be an existing moderator)
    • Add the target principal to the moderator list
  • Implement remove_moderator(RemoveModeratorArgs):
    • Authorize the caller (must be an existing moderator)
    • Prevent removing the last moderator
    • Remove the target principal from the moderator list
  • Implement suspend(SuspendArgs):
    • Authorize the caller (must be a moderator)
    • Mark the user as suspended in the directory
    • Notify the User Canister to send a Delete activity to followers
    • Suspended users cannot call any methods on their User Canister
  • Define AddModeratorArgs, AddModeratorResponse, RemoveModeratorArgs, RemoveModeratorResponse, SuspendArgs, SuspendResponse in the did crate

Acceptance Criteria:

  • Only moderators can add/remove moderators
  • The last moderator cannot be removed
  • Suspending a user marks them as inactive in the directory
  • Suspended users cannot interact with their User Canister
  • A Delete activity is sent to the suspended user’s followers
  • search_profiles excludes suspended users
  • Integration test: add moderator, suspend user, verify user is locked out

WI-1.10: Implement User Canister - block user

Description: Allow a user to block another user, preventing interactions.

What should be done:

  • Implement block_user(BlockUserArgs):
    • Authorize the caller (owner only)
    • Record the block locally (block list in stable memory)
    • If the blocked user is a follower, remove them from the followers list
    • If the owner follows the blocked user, remove from following list
    • Send a Block activity to the Federation Canister
  • User Canister receive_activity handler: handle incoming Block:
    • Hide the blocking user’s content from the blocked user
  • Activities from blocked users should be silently dropped in receive_activity
  • Define BlockUserArgs, BlockUserResponse in the did crate
  • Add Block to ActivityType

Acceptance Criteria:

  • Blocking a user removes mutual follow relationships
  • Activities from a blocked user are dropped
  • A Block activity is sent via the Federation Canister
  • The blocked user does not appear in the blocker’s feeds
  • Integration test: Alice blocks Bob, verify follow removed and activities dropped

WI-1.12: Implement Directory Canister - upgrade user canisters (UC17)

Description: Implement a controller-only method to batch-upgrade all User Canister WASMs via a timer-based state machine.

What should be done:

  • Implement upgrade_user_canisters(UpgradeUserCanistersArgs):
    • Authorize the caller (must be a controller via ic_cdk::api::is_controller)
    • Reject if an upgrade is already in progress
    • Store the provided WASM blob
    • Build an upgrade queue containing all registered user canister IDs
    • Start a recurring timer that processes canisters in batches (5-10 per tick)
  • Implement the upgrade state machine (mirrors sign-up flow pattern):
    • Per-canister states: Pending, Upgrading, Completed, Failed(attempts), PermanentlyFailed
    • On each tick: pick next batch of Pending or Failed(n < 5) canisters, call install_code (mode: upgrade) via the management canister
    • On success: mark Completed
    • On failure: increment attempt counter; if attempts >= 5, mark PermanentlyFailed
    • When all canisters are processed, stop the timer and mark the batch as completed
  • Implement get_upgrade_status() query:
    • Return UpgradeStatus with: total count, completed count, failed count, permanently failed count, and whether an upgrade is in progress
  • Define UpgradeUserCanistersArgs, UpgradeUserCanistersResponse, UpgradeStatus in the did crate

Acceptance Criteria:

  • Only the controller can call upgrade_user_canisters
  • Concurrent upgrade requests are rejected
  • Canisters are upgraded in batches without hitting instruction limits
  • Failed canisters are retried up to 5 times
  • After 5 failures a canister is marked as permanently failed and skipped
  • get_upgrade_status accurately reports progress
  • Integration test: deploy user canisters, trigger upgrade with new WASM, verify all canisters run the new version

WI-1.13: Implement Directory Canister - sign-up fee (UC18)

Description: Require callers to attach cycles when signing up to cover the cost of User Canister creation, preventing spam account creation.

What should be done:

  • Modify the existing sign_up flow in the Directory Canister:
    • Before any processing, check ic_cdk::api::call::msg_cycles_available() against the required fee (canister creation fee + initial cycles, both existing constants)
    • If insufficient: reject with InsufficientCycles { required, provided } error
    • If sufficient: accept cycles via msg_cycles_accept() and proceed with the existing sign-up flow, forwarding cycles to the management canister for canister creation
  • Add InsufficientCycles variant to the sign-up error type in the did crate
  • Update existing sign-up integration tests to attach the required cycles

Acceptance Criteria:

  • Sign-up without cycles is rejected with a clear error showing required amount
  • Sign-up with insufficient cycles is rejected
  • Sign-up with exact or excess cycles succeeds
  • Accepted cycles are forwarded to the management canister for User Canister creation
  • Existing sign-up tests are updated and pass
  • Integration test: attempt sign-up without cycles, verify rejection; sign up with cycles, verify success

WI-1.14: Implement User Canister - action rate limiting (UC19)

Description: Enforce a per-user rate limit on mutating social actions to prevent action spam.

What should be done:

  • Implement a rate limiter module in the User Canister:
    • Circular buffer of 20 timestamps stored in heap memory
    • On each rate-limited call: check if the oldest entry is less than 60 seconds ago
    • If yes: reject with RateLimitExceeded error
    • If no: record the current timestamp and proceed
  • Apply the rate limiter at the top of these methods:
    • post_status, delete_status
    • follow_user, unfollow_user
    • like_status, undo_like
    • boost_status, undo_boost
    • block_user
  • Add RateLimitExceeded variant to the relevant error types in the did crate
  • Constants: 20 actions per 60-second window (compile-time)

Acceptance Criteria:

  • Actions within the limit succeed normally
  • The 21st action within 60 seconds is rejected with RateLimitExceeded
  • After 60 seconds the window slides and actions succeed again
  • Rate limit state resets on canister upgrade (heap-only)
  • All rate-limited methods enforce the check
  • Unit test: simulate rapid actions, verify rejection at threshold
  • Integration test: call 20 actions in quick succession, verify 21st fails

WI-1.15: Implement User Canister - reply to status (UC20)

Description: Allow a user to reply to an existing status, creating a thread. This is essential for meaningful social interaction and for Fediverse compatibility (M3), where remote instances expect inReplyTo on Note objects.

What should be done:

  • Extend PublishStatusArgs in the did crate:
    • Add in_reply_to field (optional status URI string)
  • Extend the Status type in the did crate:
    • Add in_reply_to field (optional status URI string)
  • Extend the statuses database table:
    • Add in_reply_to_uri column (TEXT, nullable)
    • Add index on in_reply_to_uri for thread lookups
  • Update publish_status in the User Canister:
    • If in_reply_to is provided, validate the URI format
    • Store the in_reply_to_uri in the statuses table
    • Include inReplyTo in the Create(Note) activity sent via the Federation Canister
    • Send the activity to both the original author and the caller’s followers
  • Implement get_thread(GetThreadArgs) query:
    • Accept a status ID
    • Return the status and all replies (statuses where in_reply_to_uri matches), sorted chronologically
    • Support pagination
  • Update receive_activity handler for Create(Note):
    • Parse and store the inReplyTo field from incoming notes
  • Define GetThreadArgs, GetThreadResponse in the did crate

Acceptance Criteria:

  • A reply status is created with a valid in_reply_to reference
  • The reply appears in the author’s outbox and followers’ inboxes
  • The original status author receives the reply in their inbox
  • get_thread returns the original status and all its replies in order
  • Replying to a non-existent status URI still succeeds (the URI is stored as-is for federation compatibility)
  • The Create(Note) activity includes the inReplyTo field
  • Incoming Create(Note) activities with inReplyTo are stored correctly

WI-1.16: Implement User Canister - media attachments (UC21)

Description: Allow users to attach media files (images, video, audio) to statuses. Media is stored as blobs in the User Canister’s media table, linked to a status via foreign key.

What should be done:

  • Define types in the did crate:
    • MediaAttachment: id, media_type, description (alt text), blurhash
    • Add media field (Vec of media bytes + metadata) to PublishStatusArgs
    • Add media field (Vec of MediaAttachment) to Status
  • Update publish_status in the User Canister:
    • For each attachment: generate an ID, store the blob and metadata in the media table with the status ID as foreign key
    • Include attachment metadata in the Create(Note) activity (as ActivityPub Attachment objects with media type, name/description, and a URL pointing to the media retrieval endpoint)
  • Implement get_media(GetMediaArgs) query:
    • Accept a media ID
    • Return the raw media blob and its content type
    • Allow any caller (public query, needed for federation)
  • Implement get_status_media(GetStatusMediaArgs) query:
    • Accept a status ID
    • Return the list of MediaAttachment metadata for that status
  • Update receive_activity handler for Create(Note):
    • Parse attachment metadata from incoming notes and store references (remote media is referenced by URL, not downloaded)
  • Define GetMediaArgs, GetMediaResponse, GetStatusMediaArgs, GetStatusMediaResponse in the did crate
  • When a status is deleted (delete_status), cascade-delete its media rows

Acceptance Criteria:

  • A status can be published with one or more media attachments
  • Media blobs are stored in the media table linked to the status
  • get_media returns the correct blob and content type
  • get_status_media returns metadata for all attachments on a status
  • The Create(Note) activity includes attachment objects
  • Deleting a status also deletes its associated media
  • Incoming notes with attachments store the remote attachment metadata
  • A status without attachments works as before (no regression)

WI-1.11: Integration tests for Milestone 1 flows

Description: Write end-to-end integration tests for all Milestone 1 user stories.

What should be done:

  • Test UC3 (Update Profile): Update profile fields, verify changes
  • Test UC4 (Delete Profile): Delete account, verify canister removed
  • Test UC6 (Unfollow): Follow then unfollow, verify lists
  • Test UC8 (Search): Create users, search by prefix, verify results
  • Test UC10 (Like): Like a status, verify like count and liked list; undo like
  • Test UC11 (Boost): Boost a status, verify followers see it; undo boost
  • Test UC15 (Delete Status): Publish and delete status, verify removal
  • Test UC16 (Moderation): Add moderator, suspend user, verify lockout
  • Test Block: Block user, verify follow removal and activity filtering
  • Test UC17 (Upgrade): Deploy user canisters, trigger WASM upgrade, verify all run new version
  • Test UC18 (Sign-up Fee): Attempt sign-up without cycles, verify rejection; sign up with cycles, verify success
  • Test UC19 (Rate Limit): Perform 20 rapid actions, verify 21st is rejected
  • Test UC20 (Reply): Alice publishes a status, Bob replies, verify reply has in_reply_to set, verify get_thread returns both statuses in order, verify Alice receives the reply in her inbox
  • Test UC21 (Media): Alice publishes a status with a media attachment, verify get_status_media returns the attachment metadata, verify get_media returns the blob, delete the status and verify the media is also deleted

Acceptance Criteria:

  • All user story flows pass as integration tests
  • Tests run in CI via just integration_test
  • Each test is independent and can run in isolation
  • Tests cover both success and error paths

Milestone 2 - Frontend

Duration: 2 months

Goal: Build the Mastic web frontend as a React application deployed as an IC asset canister. The frontend provides Internet Identity authentication and a Mastodon-like interface covering all user stories from Milestones 0 and 1. After this milestone, users can interact with the Mastic node entirely through a browser.

User Stories: UC1, UC2, UC3, UC4, UC5, UC6, UC7, UC8, UC9, UC10, UC11, UC12, UC15, UC16, UC20, UC21

Prerequisites: Milestone 1 completed.

Work Items

WI-2.1: Frontend project scaffold & Internet Identity authentication

Description: Set up the React project as an IC asset canister with Internet Identity sign-in, agent configuration, and basic app shell.

What should be done:

  • Initialize a React project (Vite + TypeScript) under crates/canisters/frontend/
  • Configure dfx.json with the frontend asset canister
  • Set up @dfinity/agent with actor factories generated from the .did files for Directory, Federation, and User canisters
  • Integrate @dfinity/auth-client for Internet Identity sign-in/sign-out
  • Implement sign-up page: handle input + call sign_up on the Directory Canister
  • Post-auth routing: call whoami to resolve the User Canister principal, store in app state
  • Basic app shell: navigation bar with auth status, client-side routing skeleton

Acceptance Criteria:

  • The frontend deploys as an asset canister via dfx deploy
  • Users can sign in with Internet Identity and sign out
  • New users can sign up by choosing a handle
  • After sign-in, the app resolves the User Canister principal and stores it in state
  • The navigation bar shows the authenticated user’s handle
  • Routing works for at least /, /sign-up, and a placeholder home page

WI-2.2: Feed view & status composer

Description: Build the main timeline view with paginated feed and a status composer for publishing new statuses.

What should be done:

  • Implement a feed page that calls read_feed on the User Canister and renders a paginated list of statuses
  • Build a status card component displaying: author handle, display name, avatar, content, timestamp, like count, boost count
  • Implement infinite scroll or “load more” pagination using the cursor returned by read_feed
  • Build a compose form: text input with character count + call publish_status on the User Canister
  • New statuses appear at the top of the feed after publishing

Acceptance Criteria:

  • The feed displays statuses from followed users and the user’s own statuses
  • Pagination loads additional statuses without reloading the page
  • Status cards show all required fields (author, content, timestamp, counts)
  • Publishing a status adds it to the feed
  • Empty feed shows a meaningful placeholder message

WI-2.3: Profile view & management

Description: Display user profiles and allow users to edit or delete their own profile.

What should be done:

  • Implement a profile page at /users/{handle}:
    • Call get_user on the Directory Canister to resolve the User Canister
    • Call get_profile on the target User Canister
    • Display: handle, display name, bio, avatar, header image, follower/following counts
  • Own profile: show an edit form for display name, bio, avatar URL, and header URL that calls update_profile on the User Canister
  • Delete account flow: confirmation dialog that calls delete_profile on the Directory Canister, then redirects to the landing page
  • Make author names/avatars in status cards clickable to navigate to the author’s profile

Acceptance Criteria:

  • Any user’s profile can be viewed by navigating to /users/{handle}
  • The own profile displays an edit button that opens the edit form
  • Updating a field persists the change (verified by reloading the profile)
  • Account deletion requires confirmation and redirects after success
  • Author links in the feed navigate to the correct profile page

WI-2.4: Follow, like & boost interactions

Description: Implement follow/unfollow, like/unlike, and boost/unboost UI interactions.

What should be done:

  • Follow/unfollow button on profile pages:
    • Show “Follow” or “Unfollow” based on current relationship
    • Call follow_user / unfollow_user on the User Canister
  • Followers and following lists on the profile page:
    • Call get_followers / get_following on the User Canister
    • Render paginated lists of user cards linking to their profiles
  • Like button on status cards:
    • Toggle like state, call like_status / undo_like
    • Update like count optimistically
  • Boost button on status cards:
    • Toggle boost state, call boost_status / undo_boost
    • Update boost count optimistically
  • Liked statuses page: call get_liked and render the list

Acceptance Criteria:

  • Follow/unfollow toggles correctly and updates the button state
  • Followers and following lists display correct users with pagination
  • Like and boost buttons toggle state and update counts immediately
  • Undoing a like or boost reverses the action
  • The liked statuses page shows all statuses the user has liked

Description: Implement a search interface for discovering users on the Mastic node.

What should be done:

  • Add a search bar in the navigation or a dedicated search page
  • Call search_profiles on the Directory Canister with the query string
  • Display results as user cards (avatar, handle, display name) linking to the user’s profile
  • Implement pagination for search results
  • Debounce input to avoid excessive queries

Acceptance Criteria:

  • Typing a query returns matching users
  • Results link to the correct user profiles
  • Pagination works when there are many results
  • Empty or whitespace-only queries are handled gracefully
  • No excessive API calls while the user is still typing

WI-2.6: Moderation tools

Description: Build moderator-specific UI for content and user moderation. These controls are only visible to users who are moderators.

What should be done:

  • Detect moderator status (e.g., by checking with the Directory Canister whether the current principal is a moderator)
  • Show a delete button on any status card when the user is a moderator:
    • Call delete_status on the author’s User Canister
    • Remove the status from the feed on success
  • Show a suspend button on user profiles when the user is a moderator:
    • Confirmation dialog explaining the action
    • Call suspend on the Directory Canister
  • Moderator management page (accessible from settings or nav):
    • List current moderators
    • Add moderator by principal: call add_moderator
    • Remove moderator: call remove_moderator (with safeguard against removing the last moderator)
  • Block user button on profile pages (available to all users):
    • Call block_user on the User Canister

Acceptance Criteria:

  • Non-moderators do not see moderation controls (delete on others’ statuses, suspend, moderator management)
  • Moderators can delete any status and it disappears from the feed
  • Moderators can suspend a user, who then cannot interact with the platform
  • The moderator list is displayed correctly
  • Adding and removing moderators works, with a safeguard against removing the last one
  • Any user can block another user from their profile page

WI-2.7: Frontend build pipeline & deployment

Description: Integrate the frontend build into the existing just command workflow and CI pipeline.

What should be done:

  • Add just build_frontend command: runs the Vite production build and outputs to the asset canister directory
  • Add just dfx_deploy_frontend command: deploys the asset canister locally
  • Update just build_all to include the frontend build
  • Update just dfx_deploy_local to include the frontend canister
  • Ensure the frontend build works in CI (install Node.js dependencies, run build)
  • Add just test_frontend command for running frontend unit tests

Acceptance Criteria:

  • just build_frontend produces a production build without errors
  • just dfx_deploy_local deploys all canisters including the frontend
  • The deployed frontend is accessible at the asset canister URL
  • CI can build and deploy the frontend
  • Frontend unit tests run via just test_frontend

WI-2.8: Frontend end-to-end tests

Description: Write end-to-end tests that exercise the full user journey through the frontend against real canisters deployed on a local dfx replica.

What should be done:

  • Set up a Playwright (or Cypress) test harness:
    • Install test dependencies and configure the test runner
    • Add just test_frontend_e2e command
    • Tests start a local dfx replica, deploy all canisters, and run against the deployed frontend
  • Implement e2e test flows:
    • Sign-up & sign-in: Navigate to sign-up page, create account with a handle, verify redirect to home feed
    • Publish status: Compose and publish a text status, verify it appears in the feed
    • View profile: Navigate to own profile, verify handle, display name, and status list are shown
    • Update profile: Edit display name and bio, verify changes persist after reload
    • Follow user: Navigate to another user’s profile, click follow, verify follower count updates and statuses appear in feed
    • Unfollow user: Unfollow a followed user, verify follower count decreases
    • Like & boost: Like and boost a status, verify count updates; undo both, verify counts revert
    • Reply to status: Reply to a status, verify the reply appears in the thread view
    • Media attachment: Publish a status with an image attachment, verify it renders in the feed
    • Search: Search for a user by handle prefix, verify results link to the correct profile
    • Delete status: Delete own status, verify it disappears from feed
    • Delete account: Delete account, verify redirect to landing page and sign-in fails
    • Moderation (moderator role): As a moderator, delete another user’s status and suspend a user, verify the actions take effect
    • Block user: Block a user, verify their content is hidden
  • CI integration: add e2e tests to the CI pipeline (may run as a separate job due to replica startup time)

Acceptance Criteria:

  • All e2e test flows pass against a local dfx replica
  • Tests run via just test_frontend_e2e
  • Tests are independent and idempotent (each test sets up its own state)
  • CI runs e2e tests (or they can be triggered manually)
  • Tests cover both happy paths and key error cases (e.g., duplicate handle on sign-up)

Milestone 3 - Integrating the Fediverse

Duration: 2 months

Goal: Implement the Federation Protocol to make Mastic fully compatible with the Fediverse. Remote Mastodon instances can discover Mastic users via WebFinger, fetch actor profiles, and exchange activities over HTTP with ActivityPub and HTTP Signatures.

User Stories: UC13, UC14

Prerequisites: Milestone 2 completed.

Work Items

WI-3.1: Extend database schema for Milestone 3

Description: Extend the wasm-dbms schema to support federation-specific data: remote actor cache, delivery queue, and HTTP signature key references.

What should be done:

  • Federation Canister schema:
    • remote_actors table: actor_uri (TEXT PK), inbox_url (TEXT NOT NULL), shared_inbox_url (TEXT), public_key_pem (TEXT NOT NULL), display_name (TEXT), summary (TEXT), icon_url (TEXT), fetched_at (INTEGER NOT NULL), expires_at (INTEGER NOT NULL)
    • delivery_queue table: id (TEXT PK), activity_json (TEXT NOT NULL), target_inbox_url (TEXT NOT NULL), sender_canister_id (TEXT NOT NULL), attempts (INTEGER DEFAULT 0), last_attempt_at (INTEGER), status (TEXT NOT NULL DEFAULT ‘pending’), created_at (INTEGER NOT NULL)
    • authorized_canisters table: canister_id (TEXT PK), registered_at (INTEGER NOT NULL)
    • Index on delivery_queue.status for pending delivery lookup
    • Index on remote_actors.expires_at for cache eviction
  • User Canister schema additions:
    • Add actor_uri (TEXT) column to followers and following tables to distinguish local vs remote actors
  • Run schema migrations on canister upgrade

Acceptance Criteria:

  • New tables and columns are created on upgrade from M2 schema
  • Existing data is preserved during migration
  • Cache eviction queries work on the remote_actors table
  • Delivery queue supports retry queries (find pending with attempts < max)

WI-3.2: Implement WebFinger endpoint

Description: Serve WebFinger responses so remote instances can discover Mastic users by their acct: URI.

What should be done:

  • In the Federation Canister, handle GET /.well-known/webfinger in http_request (query)
  • Parse the resource query parameter (e.g., acct:alice@mastic.social)
  • Extract the handle, resolve it via the Directory Canister
  • Return a JSON Resource Descriptor (JRD) with:
    • subject: the acct: URI
    • links: a self link pointing to the actor’s ActivityPub profile URL with type: application/activity+json
  • Return 404 for unknown handles
  • Return 400 for malformed requests

Acceptance Criteria:

  • GET /.well-known/webfinger?resource=acct:alice@mastic.social returns a valid JRD with the correct actor URL
  • Unknown handles return 404
  • Malformed resource parameters return 400
  • Response has Content-Type: application/jrd+json
  • Integration test: create user, query WebFinger, verify JRD

WI-3.3: Serve ActivityPub actor profiles

Description: Serve actor profile JSON for remote instances that look up Mastic users.

What should be done:

  • In the Federation Canister, handle GET /users/{handle} in http_request (query) when Accept header includes application/activity+json
  • Resolve the handle via the Directory Canister
  • Fetch the user’s profile from their User Canister
  • Fetch the user’s RSA public key from their User Canister
  • Build an ActivityPub Person object with:
    • id, url, preferredUsername, name, summary
    • inbox, outbox, followers, following collection URLs
    • publicKey block (key ID, owner, PEM-encoded RSA public key)
    • icon and image if avatar/header are set
  • Return the JSON-LD response

Acceptance Criteria:

  • GET /users/alice with the correct Accept header returns a valid ActivityPub Person object
  • The publicKey block contains the correct RSA public key
  • Collection URLs are well-formed
  • Unknown handles return 404
  • Integration test: create user, fetch actor profile, verify all fields

WI-3.4: Serve ActivityPub collections

Description: Serve the outbox, followers, and following OrderedCollection endpoints for remote instances.

What should be done:

  • Handle GET /users/{handle}/outbox in http_request:
    • Return an OrderedCollection with totalItems and paginated OrderedCollectionPage items
    • Fetch outbox items from the User Canister
  • Handle GET /users/{handle}/followers in http_request:
    • Return an OrderedCollection of follower actor URIs
  • Handle GET /users/{handle}/following in http_request:
    • Return an OrderedCollection of following actor URIs
  • Support pagination via page query parameter

Acceptance Criteria:

  • Each collection endpoint returns valid ActivityPub OrderedCollection JSON
  • Pagination works correctly
  • Empty collections return totalItems: 0
  • Unknown handles return 404
  • Integration test: create user with statuses and follows, verify collections

WI-3.5: Implement HTTP Signatures for outgoing requests

Description: Sign all outgoing HTTP requests from the Federation Canister using the sender’s RSA private key, per the HTTP Signatures spec used by Mastodon.

What should be done:

  • Implement HTTP Signature generation:
    • Sign headers: (request-target), host, date, digest, content-type
    • Use RSA-SHA256 algorithm
    • Fetch the sender’s private key from their User Canister
    • Build the Signature header string
  • Add the Signature and Digest headers to all outgoing ActivityPub requests
  • Implement a helper to compute SHA-256 digest of the request body

Acceptance Criteria:

  • All outgoing ActivityPub requests include a valid Signature header
  • The Digest header matches the SHA-256 hash of the body
  • The signature can be verified using the sender’s public key
  • Unit test: sign a request, verify the signature with the public key

WI-3.6: Implement HTTP Signature verification for incoming requests

Description: Verify HTTP Signatures on incoming ActivityPub requests to ensure authenticity.

What should be done:

  • In the Federation Canister http_request_update handler, before processing any incoming activity:
    • Parse the Signature header to extract keyId, headers, signature
    • Fetch the remote actor’s profile from the keyId URL (via ic_cdk::api::management_canister::http_request)
    • Extract the remote actor’s RSA public key
    • Reconstruct the signing string from the specified headers
    • Verify the signature using the remote public key
  • Cache remote actor public keys to avoid repeated fetches (with TTL)
  • Reject requests with invalid or missing signatures

Acceptance Criteria:

  • Incoming requests with valid signatures are accepted
  • Incoming requests with invalid signatures are rejected with 401
  • Incoming requests with missing signatures are rejected with 401
  • Remote public keys are cached with a reasonable TTL
  • Unit test: construct a signed request, verify it passes validation

WI-3.7: Implement incoming activity processing (inbox)

Description: Process incoming ActivityPub activities received via HTTP POST to the shared inbox.

What should be done:

  • In the Federation Canister, handle POST /inbox in http_request_update:
    • Verify HTTP Signature (WI-3.5)
    • Parse the activity JSON
    • Determine the activity type and target
    • Route to the appropriate User Canister(s) via receive_activity
  • Handle the following incoming activity types:
    • Create(Note): deliver to the target user’s inbox
    • Follow: deliver to the target user for acceptance
    • Accept(Follow): deliver to the original requester
    • Reject(Follow): deliver to the original requester
    • Undo(Follow): deliver to the target user
    • Like: deliver to the status author
    • Undo(Like): deliver to the status author
    • Announce: deliver to the target user
    • Undo(Announce): deliver to the target user
    • Delete: deliver to affected users
    • Update(Person): update cached remote actor info
    • Block: deliver to the blocked user

Acceptance Criteria:

  • All listed activity types are correctly parsed and routed
  • Invalid JSON returns 400
  • Unknown activity types are gracefully ignored (return 202)
  • Activities targeting non-existent local users return 404
  • Integration test: simulate an incoming Create(Note) from a remote instance

WI-3.8: Implement outgoing activity delivery (HTTP POST)

Description: Deliver activities to remote Fediverse instances via signed HTTP POST requests.

What should be done:

  • In the Federation Canister send_activity handler, when the target is a remote actor:
    • Resolve the remote actor’s inbox URL (fetch actor profile if not cached)
    • Serialize the activity as JSON-LD
    • Set ActivityPub to/cc addressing based on visibility:
      • Public: to: [as:Public], cc: [followers collection]
      • Unlisted: to: [followers collection], cc: [as:Public]
      • FollowersOnly: to: [followers collection], no as:Public
      • Direct: to: [mentioned actors only], no cc
    • Sign the request using the sender’s RSA key (WI-3.5)
    • Send the HTTP POST via ic_cdk::api::management_canister::http_request
    • Handle retries for transient failures (e.g., 5xx responses)
  • Implement delivery to shared inboxes when multiple recipients share the same instance
  • Handle delivery failures gracefully (log, do not block the caller)

Acceptance Criteria:

  • Activities are delivered to remote inboxes via signed HTTP POST
  • Shared inbox optimization works (one request per remote instance)
  • Transient failures are retried (up to a configurable limit)
  • Permanent failures (4xx) are not retried
  • The caller is not blocked by slow remote deliveries
  • to/cc fields correctly reflect the status visibility level

WI-3.9: Implement remote actor resolution and caching

Description: Fetch and cache remote actor profiles for use in activity routing and display.

What should be done:

  • Implement a remote actor resolver in the Federation Canister:
    • Given a remote actor URI, perform WebFinger lookup to find the actor URL
    • Fetch the actor profile via HTTP GET with Accept: application/activity+json
    • Parse the actor profile to extract: display name, summary, public key, inbox URL, followers/following URLs, icon
    • Cache the actor profile in stable memory with a TTL (e.g., 24 hours)
  • Provide a method for User Canisters to request remote actor info (for display in feeds)

Acceptance Criteria:

  • Remote actor profiles are fetched and cached
  • Cached entries expire after the TTL
  • Invalid actor URIs return a descriptive error
  • The resolver handles redirects and content negotiation
  • Unit test: mock a remote actor endpoint, verify parsing

WI-3.10: Implement NodeInfo endpoint

Description: Serve the NodeInfo endpoint so remote instances and monitoring tools can discover Mastic’s software and protocol information.

What should be done:

  • Handle GET /.well-known/nodeinfo in http_request:
    • Return a JSON document with a link to the NodeInfo 2.0 schema URL
  • Handle GET /nodeinfo/2.0 in http_request:
    • Return NodeInfo 2.0 JSON with: software name (“mastic”), version, protocols ([“activitypub”]), open registrations status, usage statistics (total users, active users, local posts)
    • Fetch statistics from the Directory Canister

Acceptance Criteria:

  • GET /.well-known/nodeinfo returns a valid link to the NodeInfo endpoint
  • GET /nodeinfo/2.0 returns valid NodeInfo 2.0 JSON
  • Statistics reflect actual counts from the Directory Canister
  • Integration test: deploy canisters, query NodeInfo, verify response

WI-3.11: Integration tests for federation flows

Description: Write integration tests that exercise the full federation flows, verifying interoperability with the ActivityPub protocol.

What should be done:

  • Test UC13 (Receive Updates from Fediverse): Simulate a remote instance sending a Create(Note) activity, verify it appears in the local user’s feed
  • Test UC14 (Interact with Mastic from Web2): Simulate a local user publishing a status, verify the Federation Canister produces a correctly signed HTTP request with the right ActivityPub payload
  • Test WebFinger: Query WebFinger for a local user, verify the JRD
  • Test Actor Profile: Fetch a local user’s actor profile, verify the Person object
  • Test Collections: Fetch outbox/followers/following collections, verify pagination
  • Test HTTP Signature round-trip: Sign a request, verify it passes validation
  • Test incoming Follow from remote: Simulate a remote Follow, verify the local user gets a new follower

Acceptance Criteria:

  • All federation flows pass as integration tests
  • Tests run in CI via just integration_test
  • Tests simulate remote instances by crafting raw HTTP requests with valid signatures
  • Each test is independent and can run in isolation

Milestone 4 - SNS Launch

Duration: 1 month

Goal: Launch Mastic on the Service Nervous System (SNS) to establish fully decentralised, community-driven governance. Token holders can vote on proposals for moderation, policy changes, canister upgrades, and treasury management.

User Stories: None (infrastructure milestone)

Prerequisites: Milestone 3 completed, all canisters deployed and stable on mainnet.

Work Items

WI-4.1: Prepare SNS configuration (sns_init.yaml)

Description: Define the SNS initialization parameters, token distribution, and governance model for the Mastic DAO in the canonical sns_init.yaml configuration file.

What should be done:

  • Create sns_init.yaml with all required parameters:
    • Project metadata: name, description, logo, URL
    • NNS proposal text: title, forum URL, summary
    • Fallback controllers: principal IDs that regain control if the swap fails (critical — without these the dapp becomes uncontrollable)
    • Dapp canisters: Directory and Federation canister IDs to decentralize (User Canisters are managed by Directory, not directly by SNS Root)
    • Token configuration: name, symbol, transaction fee, logo
    • Governance parameters:
      • Proposal rejection fee
      • Initial voting period (>= 4 days recommended)
      • Maximum wait-for-quiet deadline extension
      • Minimum neuron creation stake
      • Minimum dissolve delay for voting (>= 1 month)
      • Dissolve delay bonus (duration + percentage)
      • Age bonus (duration + percentage)
      • Reward rate (initial, final, transition duration)
    • Token distribution:
      • Developer neurons with dissolve delay (>= 6 months) and vesting period (12-48 months) to signal long-term commitment
      • Seed investor neurons (if any) with vesting
      • Treasury allocation (DAO-controlled)
      • Swap allocation (sold during decentralization swap)
      • Total supply must equal the sum of all allocations
    • Decentralization swap parameters:
      • minimum_participants (100-200 recommended, not too high)
      • Minimum/maximum direct participation ICP
      • Per-participant minimum/maximum ICP
      • Duration (3-7 days recommended)
      • Neurons fund participation (true/false)
      • Vesting schedule for swap neurons (events + interval)
      • Confirmation text (legal disclaimer)
      • Restricted countries list
  • Validate the configuration with dfx sns init-config-file validate
  • Document the governance model and tokenomics rationale in docs/src/governance.md
  • Study successful SNS launches (OpenChat, Hot or Not, Kinic) for parameter ranges the NNS community accepts

Acceptance Criteria:

  • sns_init.yaml passes dfx sns init-config-file validate
  • Token distribution adds up to the total supply exactly
  • Developer neurons have non-zero dissolve delay and vesting period
  • Governance parameters are reasonable (voting period >= 4 days, quorum defined, rejection fee set)
  • Fallback controller principals are defined
  • Documentation explains tokenomics and governance model clearly

WI-4.2: Implement SNS-compatible canister upgrade path

Description: Ensure the Directory and Federation canisters can be upgraded through SNS proposals, and that User Canisters (dynamically created) can be batch-upgraded by the Directory Canister.

What should be done:

  • Verify pre_upgrade and post_upgrade hooks correctly serialize and deserialize all state for Directory and Federation canisters
  • For User Canisters: verify wasm-dbms stable memory survives upgrades
  • Implement set_sns_governance on the Directory Canister:
    • Accept a principal ID for the SNS governance canister
    • Only callable by canister controllers (before SNS launch) or by the already-set governance principal
    • Can only be set once (trap on second call)
  • Implement a require_governance(caller) guard for governance-gated methods
  • Implement upgrade_user_canisters method on the Directory Canister:
    • Accept new User Canister WASM as argument
    • Callable only by SNS governance (via proposal)
    • Iterate over all registered User Canisters
    • Call install_code with mode Upgrade for each
    • Track progress and report failures (individual failures must not block the batch)
  • Test upgrade paths with state preservation

Acceptance Criteria:

  • All canister state survives an upgrade cycle
  • set_sns_governance can only be called once by a controller
  • Governance-gated methods reject unauthorized callers
  • The Directory Canister can batch-upgrade all User Canisters
  • Upgrade failures for individual User Canisters do not block the batch
  • Integration test: deploy, populate state, upgrade, verify state preserved

WI-4.3: Implement SNS-governed moderation proposals

Description: Transition moderation actions from direct moderator calls to SNS proposal-based governance. The SNS governance canister becomes the sole authority for moderation.

What should be done:

  • Implement a generic proposal execution interface on the Directory Canister:
    • Accept proposals from the SNS governance canister
    • Parse proposal payloads to determine the action
  • Supported proposal types:
    • AddModerator: add a principal to the moderator list
    • RemoveModerator: remove a principal from the moderator list
    • SuspendUser: suspend a user by handle
    • UnsuspendUser: reactivate a suspended user
    • UpdatePolicy: update instance moderation policies (e.g., content rules text)
  • Restrict existing direct add_moderator, remove_moderator, and suspend methods to the SNS governance canister principal only (no longer callable by individual moderators directly)

Acceptance Criteria:

  • Moderation actions can only be executed via SNS proposals
  • The Directory Canister correctly parses and executes each proposal type
  • Invalid proposal payloads are rejected with a descriptive error
  • The SNS governance canister principal is the only authorized caller for moderation methods
  • Integration test: simulate a proposal execution, verify the action is applied

WI-4.4: Implement UnsuspendUser flow

Description: Add the ability to reactivate a suspended user account via SNS governance.

What should be done:

  • Implement unsuspend method on the Directory Canister:
    • Authorize the caller (SNS governance canister only)
    • Remove the suspended flag from the user record
    • Notify the User Canister to resume operations
    • Optionally send an Undo(Delete) or Update(Person) activity to re-announce the user to followers
  • Define UnsuspendArgs, UnsuspendResponse in the did crate

Acceptance Criteria:

  • A suspended user can be reactivated via the unsuspend method
  • Only the SNS governance canister can call unsuspend
  • After unsuspension, the user can interact with their User Canister again
  • The user reappears in search_profiles results
  • Integration test: suspend, then unsuspend, verify the user is active

WI-4.5: SNS testflight on local replica and mainnet

Description: Deploy a testflight (mock) SNS to validate the full governance flow before submitting the real NNS proposal. This catches configuration issues early, before they are visible to the NNS community.

What should be done:

  • Local testflight:
    • Deploy NNS canisters locally using sns-testing repo tooling
    • Deploy Mastic canisters locally
    • Deploy a local testflight SNS using sns_init.yaml
    • Test: submit a proposal to upgrade the Directory Canister, vote, verify upgrade succeeds
    • Test: submit a moderation proposal, vote, verify action is applied
    • Test: batch-upgrade User Canisters via proposal
  • Mainnet testflight:
    • Deploy a mock SNS on mainnet (does not run a real swap)
    • Verify governance flows: proposal submission, voting, execution
    • Verify canister upgrade path end-to-end
    • Verify User Canister batch upgrade

Acceptance Criteria:

  • Local testflight passes all governance flow tests
  • Mainnet testflight demonstrates working proposal → vote → execute cycle
  • No issues discovered that would block the real launch

WI-4.6: SNS deployment and decentralization swap

Description: Submit the NNS proposal, transfer canister control to SNS Root, and execute the decentralization swap. This follows the 11-stage SNS launch process.

What should be done:

  • Pre-submission:
    • Add NNS Root (r7inp-6aaaa-aaaaa-aaabq-cai) as co-controller of the Directory and Federation canisters using dfx sns prepare-canisters add-nns-root
    • Final validation of sns_init.yaml
    • Call set_sns_governance on the Directory Canister with the expected SNS governance canister ID (set after SNS-W deploys it)
  • Submit NNS proposal:
    • dfx sns propose --network ic --neuron $NEURON_ID sns_init.yaml
    • This is irreversible once submitted — double-check all parameters
  • During swap (3-7 days):
    • Monitor swap participation and ICP raised
    • Note: six governance proposal types are restricted during the swap (ManageNervousSystemParameters, TransferSnsTreasuryFunds, MintSnsTokens, UpgradeSnsControlledCanister, RegisterDappCanisters, DeregisterDappCanisters) — do not plan operations requiring these
  • Post-swap finalization:
    • Verify all canisters are controlled solely by SNS Root
    • Verify token holders can submit and vote on proposals
    • Document the post-swap governance workflow (how to submit proposals, vote, and execute upgrades)

Acceptance Criteria:

  • The NNS proposal is submitted and adopted by the community
  • SNS-W deploys all SNS canisters (Governance, Ledger, Root, Swap, Index, Archive)
  • SNS Root becomes sole controller of Directory and Federation canisters
  • The decentralization swap completes successfully (meets minimum participants and ICP thresholds)
  • Token holders can submit and vote on proposals
  • Post-swap documentation is published

WI-4.7: Integration tests for SNS governance flows

Description: Write integration tests that validate the SNS governance integration.

What should be done:

  • Test proposal execution: Simulate an SNS proposal to add a moderator, verify the moderator is added
  • Test canister upgrade via SNS: Simulate an upgrade proposal, verify state is preserved
  • Test User Canister batch upgrade: Upgrade all User Canisters via the Directory, verify state
  • Test suspend/unsuspend via proposal: Simulate suspend and unsuspend proposals
  • Test unauthorized access: Verify that direct moderation calls (not from SNS governance) are rejected
  • Test set_sns_governance: Verify it can only be set once and only by controllers

Acceptance Criteria:

  • All governance flows pass as integration tests
  • Tests simulate SNS governance canister calls
  • Each test is independent and can run in isolation
  • Tests run in CI via just integration_test