diff --git a/CLAUDE.md b/CLAUDE.md
index 35865e2..4ec20df 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -252,6 +252,12 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed
`Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
(that fetch is the owner's decision 1, for P6).
+- **An `Update` is an edit only when its `updated` is newer than ours** (`UpdateHandler.IsEdit`). Otherwise it refreshes
+ the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll
+ counts with bare Updates.
+- **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
+ hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls
+ go only to the poll's author, without `published` (PieFed counts a vote only then).
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
diff --git a/FEDERATION.md b/FEDERATION.md
index 229965b..8ea9ae7 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -108,7 +108,17 @@ Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
-tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
+tags.
+
+**Polls** (FEP-9967) go out as a `Question` with `oneOf` or `anyOf`, each option's count in `replies.totalItems`,
+`endTime`, `votersCount`, and `closed` once it has ended. Incoming votes are `Create{Note{name, inReplyTo}}` with no
+content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
+minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
+An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
+
+**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
+**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
+`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
`name`. A content warning without its own text uses the title, or "Content warning".
Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`,
@@ -142,7 +152,7 @@ Posts with a location (shown to nearby users of this server) never leave the ser
## Known limitations
-- Polls and custom emoji are not implemented.
+- Our own posts carry no custom emoji: emoji are received and shown, not offered.
- Remote media is fetched through this server's proxy when a local client displays it.
- Collections expose counts, not members.
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
diff --git a/PrivaPub.Tests/Federation/ActorProfileTests.cs b/PrivaPub.Tests/Federation/ActorProfileTests.cs
new file mode 100644
index 0000000..8e987b7
--- /dev/null
+++ b/PrivaPub.Tests/Federation/ActorProfileTests.cs
@@ -0,0 +1,41 @@
+using PrivaPub.Federation.Actors;
+
+using System.Text.Json;
+
+namespace PrivaPub.Tests.Federation
+{
+ public class ActorProfileTests
+ {
+ [Fact]
+ public void Reads_a_mastodon_profile_with_its_header_fields_emoji_and_lock()
+ {
+ using var json = JsonDocument.Parse("""
+ {
+ "id": "https://m.example/users/ann", "type": "Person", "preferredUsername": "ann", "name": "Ann :sparkle:",
+ "inbox": "https://m.example/users/ann/inbox", "manuallyApprovesFollowers": true, "indexable": true, "memorial": false,
+ "published": "2023-04-01T00:00:00Z", "movedTo": "https://n.example/users/ann",
+ "icon": { "type": "Image", "url": "https://m.example/a.png", "summary": "a cat" },
+ "image": { "type": "Image", "url": "https://m.example/h.png", "name": "mountains" },
+ "tag": [{ "type": "Emoji", "name": ":sparkle:", "icon": { "type": "Image", "url": "https://m.example/emoji/sparkle.png" } }],
+ "attachment": [
+ { "type": "PropertyValue", "name": "Site", "value": "ann.example" },
+ { "type": "PropertyValue", "name": "Pronouns", "value": "she/her" }
+ ]
+ }
+ """);
+
+ var actor = ActorDocument.Parse(json.RootElement);
+
+ Assert.True(actor.Locked);
+ Assert.True(actor.Indexable);
+ Assert.Equal("https://m.example/h.png", actor.Header);
+ Assert.Equal("a cat", actor.IconDescription);
+ Assert.Equal("mountains", actor.HeaderDescription);
+ Assert.Equal("https://n.example/users/ann", actor.MovedTo);
+ Assert.Equal(new DateTime(2023, 4, 1, 0, 0, 0, DateTimeKind.Utc), actor.Published);
+ Assert.Equal("sparkle", Assert.Single(actor.Emojis).Shortcode);
+ Assert.Equal(new[] { "Site", "Pronouns" }, actor.Fields.Keys);
+ Assert.DoesNotContain("