# SocialSecretary Handoff Envelope Specification
## Version 1

*The complete contract between a publishing tool and SocialSecretary.*

---

## Overview

The handoff envelope is a JSON file written by a publisher's tool and placed
in the actor's `outbox/` directory. SocialSecretary polls this directory,
processes each envelope, and deletes it after handling. The envelope is the
complete interface between the publisher and Secretary. Nothing else crosses
this boundary.

The publisher's tool is responsible for creating the envelope. This might be
a plugin inside a larger publishing system, a dedicated command-line tool, or
a simple script. The writer never authors the envelope directly — their
publishing tool generates it from their content and metadata. Secretary does
not know which kind of tool wrote any given envelope, and nothing in this
specification should require it to: the envelope is the whole interface, and
a contract that named one implementation would not be a contract every
implementation could rely on equally.

The relationship is the one between a sender and a postal service. A postal
service delivers what is on the envelope, to the address on the envelope,
and does not open it, read it, or decide the sender probably meant a
different address. If the address is wrong, the letter comes back; the
postal service does not guess a better one and send it there instead.
Secretary keeps the same discipline: it acts on what an envelope says, not
on what it infers the publisher must have intended when an envelope doesn't
match anything Secretary already holds. See Update Envelopes, below, for
where this matters most concretely.

---

## Envelope Types

Six envelope types are defined:

| Type | Description |
|------|-------------|
| `create` | Publish a new post or Note to followers |
| `update` | Update a previously published post |
| `delete` | Retract a previously published post |
| `like` | Send a Like activity to a remote post's author |
| `boost` | Send an Announce (boost) activity for a remote post |
| `undo` | Retract a previously sent like or boost |

---

## Create and Update Envelopes

### Required Fields

| Field | Type | Description |
|-------|------|-------------|
| `version` | string | Always `"1"`. |
| `type` | string | `"create"` or `"update"`. |
| `id` | string | A stable unique identifier assigned by the publisher tool at compose time. Carried into Secretary's archive as `sourceEnvelopeId`. Must be identical across create, update, and delete envelopes for the same post — this is how Secretary correlates them. |
| `published` | ISO 8601 | Original publication date, with timezone (`Z` or offset). Required for Secretary's watermark comparison — envelopes older than the watermark are skipped as already-broadcast. |

For `update` envelopes, `updated` is also required (see below).

### Content Fields

| Field | Type | Description |
|-------|------|-------------|
| `url` | string | Canonical URL of the post on the publisher's site. Secretary uses this as the ActivityPub Note `id` — the stable identity of the post in the Fediverse. If absent, Secretary constructs a URL using the configured `objectBaseUrl` and a generated identifier. Most publishers should provide this field: it is what Fediverse clients show as "Open original post" and what appears in "Copy link to post." |
| `title` | string | Post title. Used as fallback Note content when `content` is absent. Not sent as a separate ActivityPub field — it is folded into the Note body as needed. |
| `summary` | string | Short excerpt, recommended ≤200 characters. Mastodon renders this as a content warning header above a "Show more" fold. When Secretary is configured with `includeFullContent: false`, the summary is the primary Fediverse-facing content. |
| `content` | string | The Fediverse-facing text, as HTML. This is what appears in follower timelines. Secretary passes this through without modification — it does not validate, sanitize, or transform the content. Publishers are responsible for providing well-formed HTML. Fediverse clients typically support a restricted subset: `<p>`, `<br>`, `<strong>`, `<em>`, `<a>`, `<code>`, `<pre>`. Structural elements (`<article>`, `<section>`, `<figure>`) are not part of the ActivityPub Note vocabulary and will be stripped or ignored by most clients. |
| `updated` | ISO 8601 | Last modification date, with timezone. Required for `type: update`. Must not be earlier than `published`. |

Note: `content` carries the Fediverse-facing text only. If the publisher has
a longer post body intended for their own site, that is their concern and
does not belong in the envelope.

### Social Fields

| Field | Type | Description |
|-------|------|-------------|
| `visibility` | string | `"public"` (default), `"followers"`, or `"direct"`. Controls ActivityPub `to`/`cc` addressing. See Visibility below. |
| `inReplyTo` | string | URL of the post being replied to. Sets threading in Fediverse timelines. The publisher must obtain this URL from a Fediverse client or other source — Secretary does not resolve it. |
| `to` | array | Explicit recipient handles as `@handle@domain` strings. Required when `visibility` is `"direct"`. Secretary resolves these to actor URLs via WebFinger at broadcast time. |
| `mentions` | array | Handles to notify via ActivityPub Mention tag, as `@handle@domain` strings. This is an explicit declaration of intent — Secretary does not scan content for handles automatically. Including a handle in `content` does not generate a notification unless it is also declared here. Secretary resolves these via WebFinger and includes them in the Note's `tag` array, which is required for Mastodon to send mention notifications. |

### Attachment Fields

| Field | Type | Description |
|-------|------|-------------|
| `attachments` | array | Media attachments to include in the ActivityPub Note. Each entry is an object with `url`, `mediaType`, and `description` (alt text). See Attachments below. |

### Visibility

| Value | Behavior |
|-------|----------|
| `"public"` | Addressed to the ActivityStreams Public collection. Appears in public timelines. |
| `"followers"` | Addressed to the actor's followers collection. Does not appear in public timelines. |
| `"direct"` | Addressed only to explicit recipients in `to`. No Public, no followers collection. Mastodon renders these as Private Mentions. Note: `visibility: direct` carries social and administrative privacy only — it is not cryptographically private. The post is visible to server operators on both ends. |

### Attachments

Each attachment entry:

```json
{
  "url": "https://example.com/images/photo.jpg",
  "mediaType": "image/jpeg",
  "description": "A black and white photograph of a canal in Venice."
}
```

Supported media types (per Mastodon compatibility):

| Type | Formats |
|------|---------|
| Image | `image/jpeg`, `image/png`, `image/gif`, `image/webp` |
| Video | `video/mp4` |
| Audio | `audio/mp3`, `audio/ogg` |

An attachment with an unsupported media type is dropped, not fatal to the
rest of the post. The maximum number of attachments delivered to Mastodon is
configurable in `socialsecretary_config.mjs` (`maxAttachments`, default 4);
excess attachments are truncated in order — the first N are sent, the rest
are dropped. Every dropped attachment, of either kind, is written to the
activity log as `attachment.skipped` with which one and why — see the
Activity Log Specification. Nothing is dropped without a record of it.

### Minimal Create Envelope

```json
{
  "version": "1",
  "type": "create",
  "id": "my-post-20260416120000",
  "published": "2026-04-16T12:00:00Z",
  "url": "https://example.com/posts/my-post-20260416120000",
  "content": "<p>Hello, Fediverse.</p>"
}
```

### Complete Create Envelope

```json
{
  "version": "1",
  "type": "create",
  "id": "my-post-20260416120000",
  "published": "2026-04-16T12:00:00Z",
  "url": "https://example.com/posts/my-post-20260416120000",
  "title": "My Post Title",
  "summary": "A short teaser for Fediverse timelines.",
  "content": "<p>This is what followers see in their timeline.</p>",
  "visibility": "public",
  "inReplyTo": null,
  "to": [],
  "mentions": ["@alice@mastodon.social"],
  "attachments": [
    {
      "url": "https://example.com/images/photo.jpg",
      "mediaType": "image/jpeg",
      "description": "Alt text for the image."
    }
  ]
}
```

### Update Envelope

An update envelope carries the same `id` as the original create envelope.
Secretary uses this to correlate the update with the original object in its
archive and constructs an ActivityPub `Update` activity.

An update for an `id` Secretary has no record of is not sent, in any form.
Secretary does not convert it to a `Create` on the theory that this is
probably what the publisher wanted. There are exactly three ways a
publisher ends up sending an update Secretary cannot place: a bug in the
publisher's `id` generation, the publisher's own state having drifted from
what Secretary's archive actually holds, or the original create having
failed silently at some earlier point. Guessing "send it as new" does not
fix any of these — it hides all three behind a post that appears to have
worked, while quietly creating a second, unrelated object. That is not a
recovered update; it is a duplicate, and the publisher has no way to know
one was made. The envelope is returned to sender, in effect: Secretary logs
`envelope.skipped` with reason `unknown-object` and does nothing else. A
publisher tool that wants upsert behavior — try an update, fall back to a
create if that fails — is welcome to build it, watching Secretary's
response; that policy belongs to the publisher, who has the full picture of
its own state, not to Secretary, which only ever sees the one envelope in
front of it.

An update carries only the fields that changed. A field the envelope omits
keeps its previously archived value; a field present with an empty string
(`"summary": ""`) is a change to that value, not an omission. `title`,
`summary`, `content`, `visibility`, `inReplyTo`, `to`, and `mentions` all
follow this rule.

```json
{
  "version": "1",
  "type": "update",
  "id": "my-post-20260416120000",
  "published": "2026-04-16T12:00:00Z",
  "updated": "2026-04-16T14:30:00Z",
  "url": "https://example.com/posts/my-post-20260416120000",
  "title": "My Post Title (corrected)",
  "content": "<p>The corrected content.</p>"
}
```

---

## Delete Envelopes

A delete envelope retracts a previously published post. Secretary constructs
an ActivityPub `Delete` activity and delivers it to all follower inboxes.
Remote servers receiving the Delete will remove their cached copies of the
post.

### Required Fields

| Field | Type | Description |
|-------|------|-------------|
| `version` | string | Always `"1"`. |
| `type` | string | Always `"delete"`. |
| `id` | string | The same `id` as the original create envelope. |

### Optional Fields

| Field | Type | Description |
|-------|------|-------------|
| `url` | string | The canonical URL of the post being deleted. Providing this allows Secretary to construct a more precise Delete activity. If absent, Secretary derives the object URL from its archive. |

All other fields may be included and are ignored.

### Delete Envelope

```json
{
  "version": "1",
  "type": "delete",
  "id": "my-post-20260416120000",
  "url": "https://example.com/posts/my-post-20260416120000"
}
```

---

## Like and Boost Envelopes

Like and boost envelopes are social actions directed at a specific remote
post. They are not content publications — they have no `published` date, no
content fields, and do not go through the watermark check. They are dispatched
immediately when Secretary processes the outbox.

Secretary fetches the remote post to find the author's inbox, constructs the
appropriate ActivityPub activity (`Like` or `Announce`), and delivers it.
The activity is recorded in Secretary's activity log.

### Fields

| Field | Type | Description |
|-------|------|-------------|
| `version` | string | Always `"1"`. |
| `type` | string | `"like"` or `"boost"`. |
| `id` | string | Required, as of Version 1 (September 2026). A stable identifier for this action, generated the same way a create envelope's `id` is. An undo envelope names this action by this `id` — see Undo Envelopes below — so an action sent without one cannot later be undone. Earlier drafts of this specification called it optional; every publisher tool should treat it as required going forward. |
| `object` | string | The URL of the remote post being liked or boosted. Required. Secretary fetches this URL to find the author's inbox. |

### Like Envelope

```json
{
  "version": "1",
  "type": "like",
  "id": "like-20260916141200",
  "object": "https://mastodon.social/@alice/123456789"
}
```

### Boost Envelope

```json
{
  "version": "1",
  "type": "boost",
  "id": "boost-20260916141530",
  "object": "https://mastodon.social/@alice/123456789"
}
```

---

## Undo Envelopes

An undo envelope retracts a like or boost that was sent earlier in the same
session or a previous one. Secretary finds the action by the `id` the
publisher gave it — which is why that `id` is required (see Like and Boost
Envelopes above) — constructs an ActivityPub `Undo` embedding the original
`Like` or `Announce` activity as it was actually sent, and delivers it to
the same audience the original reached: the target post's author, and, for
a boost, the publisher's followers as well.

Like a like or boost, an undo has no `published` date and does not go
through the watermark check. It is dispatched as soon as Secretary processes
the outbox.

An undo for an `id` Secretary has no record of, or whose `id` does not refer
to a like or boost (a create, say), is not sent. There is nothing to
retract, and Secretary does not guess at one.

### Fields

| Field | Type | Description |
|-------|------|-------------|
| `version` | string | Always `"1"`. |
| `type` | string | Always `"undo"`. |
| `id` | string | Optional. A stable identifier for this undo action itself, in the same sense a like or boost has one. Not to be confused with `object` below — this is the undo's own id, not the id of what it undoes. |
| `object` | string | Required. The `id` the publisher gave the original like or boost envelope — not a URL. This is what tells Secretary which prior action to retract. |

### Undo Envelope

```json
{
  "version": "1",
  "type": "undo",
  "object": "like-20260916141200"
}
```

This retracts the like sent earlier with `"id": "like-20260916141200"` (see
the Like Envelope example above). The same shape undoes a boost — `object`
is whatever `id` the original boost envelope carried.

### What a Retracted Like or Boost Looked Like to the Recipient

Undo does not erase history. The target post's author, and — for a boost —
the publisher's followers, received the original `Like` or `Announce` and
may have already seen it in a timeline or notification before the `Undo`
arrives. What `Undo` does is tell every recipient's server that the
engagement no longer holds, so their own records (a like count, a boosted-
into-timeline copy) can be corrected. Whether and how quickly a remote
server acts on that is the remote server's implementation, not something
this specification or Secretary controls.

---

## The Publisher's Responsibility

The publisher tool is responsible for:

- Generating a stable `id` for each post at compose time
- Deriving `title`, `summary`, and `content` from the author's content
- Providing `url` (strongly recommended for all public posts)
- Setting `published` to the correct publication date with timezone
- Carrying through `visibility`, `inReplyTo`, `to`, and `mentions` from
  whatever metadata the author set in their platform
- Identifying attachments and providing their `url`, `mediaType`, and
  `description`
- Writing the envelope file to the configured `outbox/` directory
- Optionally pinging Secretary's `/trigger` endpoint to request immediate
  broadcast rather than waiting for the file watcher

The publisher tool is not responsible for:

- Understanding ActivityPub
- Constructing ActivityPub activities
- Managing followers or inboxes
- Signing requests
- Knowing anything about how Secretary works internally

### Id Stability

The `id` field is the publisher's handle on its own federated content. Without
a reliable way to track or reproduce the original `id`, the publisher cannot
send update or delete envelopes for a post after initial broadcast, cannot
correlate inbound engagement (likes, boosts, replies) back to the original
post, and cannot consult Secretary's archive for delivery history.

The publisher tool must either store the `id` persistently alongside the post
record, or use a deterministic generation scheme that can reproduce the same
`id` from stable post attributes (such as a slug derived from the post URL
combined with the publication timestamp). Either approach is valid. What is
not valid is generating a random `id` at broadcast time with no record of it
-- the post becomes irrecoverable from Secretary's perspective the moment the
outbox file is deleted.

One deterministic scheme a publisher tool might use: `<url-slug>-<YYYYMMDDHHmmss>`.
Any publisher can adopt a pattern like this or define their own, as long as
it is stable and recoverable.

### Like and Boost Interfaces

Like and boost envelopes are ActivityPub-native social actions with no natural
equivalent in most publishing workflows. A traditional publishing tool has no
concept of reacting to remote content -- it publishes, it does not respond.

A publisher tool that wants to support likes and boosts must build or expose
an interface that does not already exist in standard publishing: some way for
the author to reference a specific remote Fediverse post and express a social
reaction to it. This might be a CLI flag (`--like URL`), a field in a post's
metadata block, or a separate tool entirely.

The envelope format for likes and boosts is simple. The interface challenge
is upstream of the envelope: the author needs a way to find and reference
remote content in the first place, which typically requires access to a
Fediverse client or equivalent.

---

## Scope

Keep the postal service analogy in mind. Secretary delivers to the
Fediverse and receives from it. It does not browse the Fediverse, search
it, or discover content on it, for the same reason it does not browse the
web: those are a client's job. A publisher finds what they want to
reference — a post to reply to, a page to link — with whatever they
already use to find things, and hands Secretary the reference once they
have it. Everything from there is Secretary's: signing, delivery, retries,
the record of what was sent.

Another way to see the same boundary: Secretary functions as the API
between the open web and ActivityPub. A publisher's tool is the client of
that API — it supplies a reference in the shape the envelope defines, and
Secretary does everything on the far side of that reference. Neither
framing changes what Secretary does; they're the same boundary seen from
two directions, one behavioral and one structural.

The same discipline holds at the level of individual fields, not just the
whole envelope. Secretary does not validate or transform content. It does
not scan `content` for mentions and auto-populate the `mentions` array. It
does not enforce HTML restrictions on `content`, `title`, or `summary`. It
does not check that `url` resolves, and it does not verify that handles in
`to` or `mentions` are reachable before attempting delivery — resolution
happens at broadcast time, and whether it succeeded is reported afterward,
not guessed at in advance. Secretary trusts the envelope. The publisher
tool is responsible for the quality of what it provides.

Every decision Secretary makes about an envelope — accept it, skip it,
reject it before it could even be read, or fail partway through — is
written to the activity log at the moment the decision is made. Nothing
about an envelope's fate is silent; see the Activity Log Specification for
the complete account of what gets recorded and when.

The fuller case for why this particular division — the publisher finds it,
Secretary delivers it — is the right one, and what it implies about the
relationship between the open web and any social layer built on top of it,
is bigger than an envelope format needs to carry. It's written up
separately, outside this document.

---

## The Archive Record

Every envelope Secretary accepts becomes one JSON record in `archive/`,
addressed by a Secretary-assigned `secObjectId`. This is not part of the
envelope contract — a publisher never writes to the archive and does not
need to read it to use Secretary — but a publisher's client that wants to
inspect delivery history, correlate a like with the id it can later undo, or
recover after losing its own local state will read it, so its shape is
documented here rather than left to be reverse-engineered from the source.

A record has four parts:

| Part | Contents |
|------|----------|
| `secObjectId` | Secretary's own id for the object. Stable for the object's lifetime. |
| `current` | Secretary's present understanding of the object: its content (for a note) or the remote post it acts on (for a like, boost, or undo), and its `transportState`. |
| `activitypub` | The ActivityPub-facing ids: `objectId` and `createActivityId`, derived from `secObjectId` at acceptance, and `activityId` — the id of the most recent activity Secretary actually sent for this object, recorded at the moment it was built, before delivery. `activityId` is what an undo embeds; if it is absent, the object predates the id being recorded and an undo can only match on actor and object, which not every remote implementation accepts. |
| `history` | An ordered, append-only list of what happened: `accepted`, `activity_built`, `broadcast`, `delivery_failed`, `updated`, `deleted`, `undone` — each with its own timestamp and, where relevant, the `sourceEnvelopeId` that caused it. A retry attempt is not its own history entry; the archive records that delivery failed and, separately, whether it eventually succeeded. The attempt-by-attempt detail — each retry queued, each one that failed again, each one abandoned — is in the activity log, not the archive; see the Activity Log Specification. |

`current.transportState` is one of: `queued` (accepted, nothing delivered
yet), `broadcast` (delivered to at least one inbox), `partial` (some
inboxes reached, others queued for retry), `updated` (superseded by a later
update, still live), `deleted` (tombstoned — a delete envelope was
accepted), or `undone` (a like or boost that a later undo retracted).
`deleted` and `undone` are sticky: once set, a later successful delivery of
the Delete or Undo activity does not move the record back to `broadcast`,
because the publisher's intent, not the state of delivery, is what those
two states record.

`current.object`, present on a like, boost, or undo record, is the URL of
the remote post the action targets — not to be confused with `current.url`,
which is a create or update record's own canonical URL and is `null` on a
social-action record.

A publisher's client that wants the full picture of what Secretary has done
with a piece of content correlates by the envelope `id` it already knows:
Secretary indexes both `by-url` (for content with a `url`) and
`by-envelope-id` (for every envelope with an `id` — which, per the tables
above, is now all of them), and either lookup resolves to a `secObjectId`
and from there to the full record. This is the same correlation Secretary
performs internally for update, delete, and undo envelopes; a client asking
"what happened to the post I published with id X" is asking the identical
question Secretary itself asks when it receives an update for X.

---

## Field Summary

| Field | Types | Required | Description |
|-------|-------|----------|-------------|
| `version` | all | yes | Always `"1"` |
| `type` | all | yes | `create`, `update`, `delete`, `like`, `boost`, `undo` |
| `id` | create, update, delete, like, boost | yes | Stable publisher-assigned identifier |
| `id` | undo | no | Identifier for the undo action itself (distinct from `object`, below) |
| `published` | create, update | yes | ISO 8601 with timezone |
| `updated` | update | yes | ISO 8601 with timezone |
| `url` | create, update, delete | recommended | Canonical post URL |
| `title` | create, update | no | Post title |
| `summary` | create, update | no | Short excerpt / CW |
| `content` | create, update | no | Fediverse-facing HTML |
| `visibility` | create, update | no | `public` (default), `followers`, `direct` |
| `inReplyTo` | create, update | no | URL of post being replied to |
| `to` | create, update | if direct | Recipient handles |
| `mentions` | create, update | no | Handles to notify |
| `attachments` | create, update | no | Media attachment objects |
| `object` | like, boost | yes | URL of remote post |
| `object` | undo | yes | The `id` of the like or boost envelope being retracted — not a URL |

---

*SocialSecretary Handoff Envelope Specification — Version 1*
*socialsecretary.pub*
