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:
likedtable:status_uri(TEXT PK),created_at(INTEGER NOT NULL)blockstable:actor_uri(TEXT PK),created_at(INTEGER NOT NULL)- Add
like_count(INTEGER DEFAULT 0) andboost_count(INTEGER DEFAULT 0) columns to thestatusestable - Add
is_boost(INTEGER DEFAULT 0) andoriginal_status_uri(TEXT) columns to theinboxtable for boost tracking mediatable: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:
tombstonestable:handle(TEXT PK),deleted_at(INTEGER NOT NULL),expires_at(INTEGER NOT NULL)- Add index on
tombstones.expires_atfor 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_activityso followers (local for now) receive the updated profile
- Define
UpdateProfileArgsandUpdateProfileResponsein thedidcrate 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
Updateactivity is sent to the Federation Canister - Integration test: update profile, verify
get_profilereturns 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
DeleteProfileResponsein thedidcrate
Acceptance Criteria:
- Calling
delete_profileon the Directory removes the user record - The User Canister is stopped and deleted via the IC management canister
- A
Deleteactivity is delivered to local followers - The deleted user’s handle cannot be reused immediately (tombstone)
whoamireturns an error after deletionget_userreturns 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_activityhandler: handle incomingUndo(Follow):- Remove the requester from the followers list
- Define
UnfollowUserArgs,UnfollowUserResponsein thedidcrate - Add
UndotoActivityTypein thedidcrate
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,SearchProfilesResponsein thedidcrate
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
Likeactivity targeting the status - Send the activity to the Federation Canister
- Idempotent: if the caller has already liked the status, return
Okwithout inserting a duplicate row and without re-sending theLikeactivity
- 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_activityhandler: handle incomingLike:- 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,GetLikedResponsein thedidcrate - Add
LiketoActivityType
Acceptance Criteria:
- Liking a status records it in the liked collection
- A
Likeactivity is sent to the status author - The author’s status like count is incremented
get_likedreturns 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 theLikeactivity - Integration test: Alice likes Bob’s status, verify like count and liked list
- Integration test: Alice calls
like_statustwice on the same status, verify second call returnsOk, 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_urlalready exists, returnOkwithout 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
statusestable when the target is local, or the local inbox row when the target is remote - Insert a wrapper row into
statusesowned 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
boostslinking the wrapper (status_id) to theoriginal_status_uri - Insert a
feedentry withsource = Outboxfor the wrapper so the boost appears in the booster’s own feed - Build an
Announceactivity and send it to the Federation Canister (targets: status author + all of the booster’s followers)
- Resolve the original status (content, author URI, visibility,
spoiler, sensitive flag) by looking up the local
- Implement
undo_boost(UndoBoostArgs { status_url }):- Authorize the caller (owner only) at the inspect layer
- Idempotent: if no boost row for
status_urlexists, returnOkwithout dispatching anUndo(Announce)activity - Otherwise, remove the
boostsrow, the wrapperstatusesrow, and the correspondingfeedoutbox entry. Then send anUndo(Announce)activity to the same targets
- User Canister
receive_activityhandler: handle incomingAnnounce:- Insert an
inboxrow withis_boost = true,original_status_uri = <target>,actor_uri = booster,activity_type = Announce - Insert a
feedentry withsource = Inboxfor that row so the boost appears in the recipient’s feed - If the local target status exists, increment
statuses.boost_count(saturating)
- Insert an
- Handle incoming
Undo(Announce):- Delete the matching inbox row and its
feedentry - If the local target status exists, decrement
statuses.boost_count(saturating at 0)
- Delete the matching inbox row and its
- Feed rendering (
read_feed):- Outbox path: when a
statusesrow is referenced by aboostsrow, hydrate the feed item using the denormalized copy and setboosted_by = Some(owner_actor_uri),author = original author URI. Closes the existingboosted_by: NoneFIXME incrates/canisters/user/src/domain/feed/read_feed.rs - Inbox path: when an
inboxrow hasis_boost = true, hydrate the feed item by resolvingoriginal_status_uri(localstatusesif present, otherwise from any cached inbox row), and setboosted_by = Some(actor_uri),author = original author URI. Closes the corresponding inbox-side FIXME
- Outbox path: when a
- Define
BoostStatusArgs,BoostStatusResponse,UndoBoostArgs,UndoBoostResponsein thedidcrate. Both error enums expose onlyInternal(String); authorization failures are rejected at the inspect layer (noUnauthorizedvariant) - Extend
Status/FeedItemwithboosted_by: Option<Text> - Add
AnnouncetoActivityType
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
boostsrow links the wrapper tooriginal_status_uri - The booster sees their own boost in their feed with
boosted_by = selfandauthor = original author URI - An
Announceactivity is sent to the author and the booster’s followers - Followers see the boost in their feed with
boosted_by = boosterandauthor = original author URI boost_statusis idempotent: calling it twice for the samestatus_urlreturnsOkboth times, stores a single boost row + wrapper, and dispatches theAnnounceactivity only once- Undoing a boost removes the
boostsrow, the wrapper status, the outbox feed entry, and dispatches anUndo(Announce)activity - Receivers delete the inbox boost row and feed entry on
Undo(Announce) undo_boostis idempotent: calling it for a status that is not boosted returnsOkwithout dispatching a secondUndo(Announce)- Self-boost is allowed
- Integration test: Alice boosts Bob’s status, Charlie (Alice’s follower)
sees it in his feed with
boosted_by = aliceandauthor = 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,DeleteStatusResponsein thedidcrate - Add
DeletetoActivityTypeif 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
Deleteactivity to followers - Suspended users cannot call any methods on their User Canister
- Define
AddModeratorArgs,AddModeratorResponse,RemoveModeratorArgs,RemoveModeratorResponse,SuspendArgs,SuspendResponsein thedidcrate
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
Deleteactivity is sent to the suspended user’s followers search_profilesexcludes 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
Blockactivity to the Federation Canister
- User Canister
receive_activityhandler: handle incomingBlock:- Hide the blocking user’s content from the blocked user
- Activities from blocked users should be silently dropped in
receive_activity - Define
BlockUserArgs,BlockUserResponsein thedidcrate - Add
BlocktoActivityType
Acceptance Criteria:
- Blocking a user removes mutual follow relationships
- Activities from a blocked user are dropped
- A
Blockactivity 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)
- Authorize the caller (must be a controller via
- 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
PendingorFailed(n < 5)canisters, callinstall_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
- Per-canister states:
- Implement
get_upgrade_status()query:- Return
UpgradeStatuswith: total count, completed count, failed count, permanently failed count, and whether an upgrade is in progress
- Return
- Define
UpgradeUserCanistersArgs,UpgradeUserCanistersResponse,UpgradeStatusin thedidcrate
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_statusaccurately 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_upflow 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
- Before any processing, check
- Add
InsufficientCyclesvariant to the sign-up error type in thedidcrate - 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
RateLimitExceedederror - If no: record the current timestamp and proceed
- Apply the rate limiter at the top of these methods:
post_status,delete_statusfollow_user,unfollow_userlike_status,undo_likeboost_status,undo_boostblock_user
- Add
RateLimitExceededvariant to the relevant error types in thedidcrate - 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
PublishStatusArgsin thedidcrate:- Add
in_reply_tofield (optional status URI string)
- Add
- Extend the
Statustype in thedidcrate:- Add
in_reply_tofield (optional status URI string)
- Add
- Extend the
statusesdatabase table:- Add
in_reply_to_uricolumn (TEXT, nullable) - Add index on
in_reply_to_urifor thread lookups
- Add
- Update
publish_statusin the User Canister:- If
in_reply_tois provided, validate the URI format - Store the
in_reply_to_uriin the statuses table - Include
inReplyToin theCreate(Note)activity sent via the Federation Canister - Send the activity to both the original author and the caller’s followers
- If
- Implement
get_thread(GetThreadArgs)query:- Accept a status ID
- Return the status and all replies (statuses where
in_reply_to_urimatches), sorted chronologically - Support pagination
- Update
receive_activityhandler forCreate(Note):- Parse and store the
inReplyTofield from incoming notes
- Parse and store the
- Define
GetThreadArgs,GetThreadResponsein thedidcrate
Acceptance Criteria:
- A reply status is created with a valid
in_reply_toreference - The reply appears in the author’s outbox and followers’ inboxes
- The original status author receives the reply in their inbox
get_threadreturns 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 theinReplyTofield - Incoming
Create(Note)activities withinReplyToare 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
didcrate:MediaAttachment: id, media_type, description (alt text), blurhash- Add
mediafield (Vec of media bytes + metadata) toPublishStatusArgs - Add
mediafield (Vec ofMediaAttachment) toStatus
- Update
publish_statusin the User Canister:- For each attachment: generate an ID, store the blob and metadata in the
mediatable with the status ID as foreign key - Include attachment metadata in the
Create(Note)activity (as ActivityPubAttachmentobjects with media type, name/description, and a URL pointing to the media retrieval endpoint)
- For each attachment: generate an ID, store the blob and metadata in the
- 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
MediaAttachmentmetadata for that status
- Update
receive_activityhandler forCreate(Note):- Parse attachment metadata from incoming notes and store references (remote media is referenced by URL, not downloaded)
- Define
GetMediaArgs,GetMediaResponse,GetStatusMediaArgs,GetStatusMediaResponsein thedidcrate - 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
mediatable linked to the status get_mediareturns the correct blob and content typeget_status_mediareturns 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_toset, verifyget_threadreturns 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_mediareturns the attachment metadata, verifyget_mediareturns 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