Skip to content

Fediverse Interop Spec — ActivityPub as a second federation transport, milestone M18

Design position: ActivityPub is a second, additive federation transport that reaches the wider Fediverse (Mastodon, GoToSocial, ~10M actors), sitting alongside — never replacing — Matrix federation (federation spec §8). The decision to adopt is gated by ADR 0014 in Proposed status; this spec is its design surface and the checklist for the M18 sweep (issues #209–#221).

The standing rule holds twice over: Fgentic assumes neither a single homeserver nor a single federation protocol forever. AP is open (W3C Recommendation), so it fits the open-standards-only principle with no license concern — but reach is only earned once every M8 governance control has a proven ActivityPub twin (§3).

§1 — Scope and non-goals

  1. In scope. A sovereign agent is reachable from the Fediverse by a stable handle; a Fediverse user follows it, @mentions it, and receives one governed, signed A2A-backed reply. Discovery (WebFinger, NodeInfo), integrity, identity binding, per-actor budget admission, and honest bot/attribution audit are all in scope as governed surface.
  2. Non-goals. ActivityPub does not replace Matrix rooms as the collaboration plane; it does not become a general-purpose social server; it does not carry model credentials; and it does not couple into the mautrix bridge. Human↔human Fediverse bridging (Kazarma, #221) is a separate, AGPL-gated, human-approved profile, not part of the agent gateway.

§2 — Architectural spine

All ActivityPub surface lives in ONE new self-contained app, apps/activitypub-agent-gateway/ — its own Go module, Dockerfile, Helm chart, and deploy/ Flux unit, exactly like apps/matrix-a2a-bridge/ and apps/synapse-federation-policy/. It:

  1. Reuses the a2a-go client (ADR 0004) to reach kagent through agentgateway — the same egress chokepoint every caller uses, so no agent holds a model credential (ADR 0006).
  2. Is never bundled into the mautrix bridge, keeping that bridge AGPL-free and homeserver-portable and inside its surface budget (ADR 0012, licensing spec §10).
  3. Exposes public AP endpoints only through the Gateway API with TLS, and reaches kagent on ClusterIP behind NetworkPolicy — the AP gateway is a caller of agentgateway, never a peer of kagent.

Each exposed agent is presented as an ActivityPub Service actor (§3, row bot typing); a collaboration room may additionally be presented as a Group actor for cross-org collaboration (#217). The gateway is a translation and governance border between the AP object graph and A2A SendMessage, mirroring how the mautrix bridge translates Matrix events to A2A (bridge spec §6).

The deploy/ unit bounds its activitypub namespace with the shared small ResourceQuota and container defaults, and is now reconciled on the demo profile only (issue #489) by the activitypub Flux Kustomization in clusters/demo/; local and gcp keep it absent. That Kustomization sources platform-settings (plus the optional overrides ConfigMap) through post-build substitution and depends on platform-secrets, agentgateway, and kagent — never the homeserver. Because the policy border is on, the gateway needs its durable inbox activity ledger and async delegation queue (#321): it gets its own scoped activitypub role and database in the shared CloudNativePG cluster (composed for demo via infra/postgres/components/activitypub), matching the "own database + scoped role" rule. The unit ships a default-deny NetworkPolicy (ingress only from the Gateway and Prometheus; egress only DNS, the shared Postgres cluster, and the agentgateway A2A chokepoint) as deployment-side defense-in-depth beside the in-code SSRF guard (#320), and its identity/signing/credential/database secrets come from the cluster-only demo secret path, never the SOPS production path. Public exposure stays gated: httpRoute is disabled on every profile until the signed border is proven in force. Applying the unit directly with unresolved quota variables is unsupported.

The runtime interop proof (mise run test:activitypub-interop, run after mise run demo:up) exercises the full governed border over the real HTTP-signature transport and the real Postgres dedup ledger: a scripted Mastodon/GoToSocial-wire-compatible signing peer (clusters/demo/interop-peer/) follows the agent by its fediverse handle, @mentions it, and receives one FEP-8b32-signed, budget-admitted A2A-backed reply; a pinned-but-non-allowlisted peer is refused fail-closed (403, apgateway_rejected_total{reason="border_off_allowlist"}, zero delegation/reservation); and a byte-identical redelivery yields exactly one apgateway_delegations_total{outcome="duplicate"} with no repeated A2A call or reservation (#321). The harness owns the peer's RSA #main-key so it can deterministically replay a byte-identical signed activity — which a real GoToSocial binary never exposes. Because an in-cluster peer cannot be SSRF-fetched by the #320-guarded key resolver, the gateway verifies it from an out-of-band pinned key (ADR 0021, demo-scoped) which strictly reduces SSRF surface — every unpinned actor still uses the guarded HTTPS resolver unchanged. The demo relaxes only the inbound FEP-8b32 object-proof requirement (a Mastodon/GtS peer signs HTTP Signatures, not object proofs); the signature border, allowlist, budget admission, dedup, and FEP-8b32 outbound signing all stay on.

§3 — Control mapping: every M8 control has an ActivityPub twin

No ActivityPub feature ships without the twin control in this table proven fail-closed first. The left column is the settled Matrix/A2A control; the right is its AP equivalent and the issue that lands it.

Governance control Matrix / A2A mechanism (settled) ActivityPub twin (M18) Issue
Limited federation Synapse federation_domain_whitelist + m.room.server_acl allowlist (§8.2) Git-declared instance/actor allowlist enforced at the AP inbox border; unlisted origins deny-by-default #211
Signed border Synapse module callbacks (should_drop_federated_event), git-reloadable (§8.2) HTTP Signatures + authorized-fetch border, policy reloaded from git without replacing the gateway #211
Object integrity A2A v1.0 Signed AgentCard (ES256 / JCS) (§8.3) FEP-8b32 object integrity proofs (eddsa-jcs-2022, Ed25519) on every agent reply activity #212
Pinned identity Pinned P-256 JWK per remote agent, verified per call FEP-c390 identity proof binding the AP actor to the A2A AgentCard key/DID; keys published, verified per call #218
Budget admission Per-azp maxTokens reservation at admission (D7/D8) Per-actor/domain token-budget admission before any A2A call; reservation ≠ consumption, metrics stay aggregate #213
Honest attribution Asserted X-User-Id MXID + bounded origin audit fields (audit spec) Bot/Service actor typing + ActivityPub attribution audit fields (actor URI, domain, delivery id) #214
Egress containment agentgateway is the sole model-credential chokepoint (ADR 0006) AP gateway calls kagent only via agentgateway/A2A; kagent stays ClusterIP behind NetworkPolicy #210

Reading the table: the shape of each control is preserved — allowlist deny-by-default, a git-reloadable signed border, per-object integrity, pinned per-caller identity, admission-time budget reservation, and content-free honest audit — expressed in ActivityPub's native primitives (HTTP Signatures, FEPs, actor types) instead of Matrix's.

SSRF-safe public fetches (#320, delivered in-app). Signature keyId and object-proof verificationMethod documents are dereferenced before policy admission, while follower actor documents and declared inboxes drive outbound requests after admission. All four boundaries therefore use one fail-closed HTTP client: only HTTPS URLs without userinfo are accepted; the IANA special-purpose IPv4 and IPv6 registries, IPv4-mapped IPv6, and unallocated IPv6 space are conservatively rejected; every DNS answer must be public; and DNS is resolved again immediately before each new connection, which is opened to that exact validated address by a package-owned dialer to prevent rebinding or caller bypass between validation and dial. Redirects traverse the same guarded transport. The namespace's default-deny NetworkPolicy and narrow egress rules remain mandatory defense in depth—neither the code guard nor network policy substitutes for the other.

At-least-once delivery cost safety (#321, delivered in-app). A delegating Create needs one absolute HTTPS activity ID. After transport, actor, allowlist, and object-integrity verification—but before budget reservation—the gateway commits that ID and its bounded body to a dedicated Postgres ledger, returns 202 Accepted with an opaque status-capability Location, and performs budget admission plus A2A/group work on the single background processor. The status stays 202 while pending/running, exposes only a terminal state when there is no direct result, and serves the exact persisted reply Activity after success; the reply's canonical Activity IRI resolves through the same ledger after restart. Exact redeliveries return the same durable status with no repeated reservation or A2A call, including after restart; an ID reused with different immutable intake data is a conflict, not a cached retry. Pending work survives restart. A running record is never replayed after interruption because an external call may already have spent tokens: startup terminalizes it as failed, choosing an explicit missing reply over duplicate spend. Raw inbox bodies are erased on terminal transition while a SHA-256 digest preserves collision detection; terminal state, digest, and cached result are periodically pruned after the configured retention window. The atomically enforced pending/running capacity bounds pre-budget storage and returns retryable 503 backpressure without charging a new ID. A non-mention is inserted atomically as terminal ignored, so the processor can never race it into budget or A2A. The database is mandatory whenever the federation policy border is enabled, and the public HTTPRoute is forbidden without that border.

Transport-signature negotiation (#476, delivered in-app). Following the SWICG double-knocking guidance, outbound inbox delivery prefers RFC 9421 with one signature covering @method, @target-uri, and RFC 9530 Content-Digest, plus the required created and keyid parameters. If a peer synchronously rejects that signature with 401, the gateway retries once with the Cavage profile and remembers the successful profile per remote authority in a bounded, process-local cache; later 401s can negotiate back in the other direction. It does not double-send after transport errors or 5xx responses, where the activity may already have been processed. The running gateway uses a dedicated RSA PKCS#1 v1.5/SHA-256 transport key for both profiles—the Mastodon interoperability baseline—and publishes that exact key as each delivering actor's #main-key. This is hop authentication only: the separate Ed25519 key and verification method for FEP-8b32 object proofs remain unchanged, as do FEP-844e discovery and FEP-c390 identity binding.

Inbound signature freshness (#322, delivered in-app). A valid cryptographic signature is insufficient unless it also binds the request and a bounded point in time. RFC 9421 intake therefore requires @method plus either the stronger combined @target-uri component or both @path and @authority; Cavage intake requires (request-target) plus host. Either profile must cover a created parameter or Date header. The configured replay age rejects old requests, while future signer-clock tolerance has a hard five-minute ceiling. Missing, stale, future-dated, or target-unbound signatures fail closed with content-free evidence before key resolution and with no A2A call.

Object integrity (#212, delivered). HTTP Signatures authenticate only the transport hop; a relayed or cached activity loses that provenance. The gateway therefore signs every outbound reply with a FEP-8b32 DataIntegrityProof (eddsa-jcs-2022: Ed25519 over the RFC 8785 JCS-canonicalized activity) and publishes each actor's assertionMethod Multikey, so any remote verifier confirms a sovereign agent authored the reply even after relaying. When requireInbound is set, the border also verifies an inbound proof and binds its key controller to the activity actor: a missing, invalid, or mis-bound proof fails closed with content-free evidence and no A2A call — untrusted room content cannot be laundered through a trusted actor. The signing key is a SOPS-backed Ed25519 PKCS#8 secret, never committed plaintext. Interop with the apsig reference verifier is pinned byte-for-byte by a golden test vector and re-derivable live with mise --cd apps/activitypub-agent-gateway run interop (an app-scoped task, not exposed at the repo root).

Budget admission (#213, delivered). Every AP mention that reaches an agent is an LLM invocation, so cost is a correctness constraint (D7/D8): without a ceiling, one remote instance could drive unbounded spend. The border therefore reserves each delegation's token estimate from the verified actor's and domain's per-window pools — declared in the same git-reloadable policy.json (budgets), keyed on the F3/F4-verified actor URI, never a spoofable handle — before any A2A call. Both pools must have room (all-or-nothing, so an over-budget actor cannot partially spend a domain's budget); an allowlisted-but-unbudgeted domain is denied by default. A reservation gates admission and is never consumption (D8): actual model-token metering stays aggregate at agentgateway, and the gateway's own reservation counter is labelled by ghost + outcome only — never by remote actor — so a remote org cannot mine another's usage.

§4 — Discovery and instance description

  1. WebFinger + FEP-844e (#215) resolves a acct:agent-<name>@<domain> handle to the agent's Service actor and publishes the A2A AgentCard as actor metadata, so a Fediverse client can both follow the agent and discover its A2A endpoint.
  2. NodeInfo + an instance application actor (#216) advertises which agents/skills the instance exposes, honestly and enumerably, without leaking internal topology.
  3. Discovery is exposure: it ships only after the §3 border, integrity, and budget twins are proven fail-closed.

Cross-protocol discovery (#215, delivered — novel). One WebFinger lookup on a fediverse handle reveals both transports, so a remote org can choose the higher-fidelity A2A delegation over degraded Note-passing — with no proprietary directory in the path. The flow is fully open-standard and decentralized (the per-actor complement to the AGNTCY directory, #146):

  1. GET /.well-known/webfinger?resource=acct:agent-<name>@<domain> returns two links: rel="self" → the ActivityPub Service actor, and rel="https://fgentic.fmind.ai/ns/a2a#agent-card" → the agent's published A2A AgentCard.
  2. The actor document advertises the same capability inline via the FEP-844e implements shape ({href: <A2A endpoint>, name: "A2A", agentCard: <card URL>}).
  3. GET …/agent-card.json returns a synthesized A2A AgentCard (protocol version, name, description, endpoint, transport) built from the agents.yaml allowlist — so only allowlisted agents are discoverable, and the authoritative full card (skills, exact capabilities) is fetched from the endpoint's own well-known path. Endpoint reachability stays governed by the §3 federation A2A route; discovery advertises the capability, exposure remains gated.

Handle-pinned outbound routing (#220, delivered in-app). The Matrix bridge may map a remote ghost to acct:agent@org.example through the ActivityPub gateway's authenticated, ClusterIP-only broker. WebFinger and actor fetches enforce public HTTPS with dial-time DNS guards. If the exact actor advertises A2A, the bridge verifies its operator-pinned Signed AgentCard against the discovered endpoint and card URL before using SendMessage. Otherwise the broker requires a fresh FEP-8b32 proof under the mapping's exact actor URI, verification method, Ed25519 Multikey, and proof-age ceiling, then sends a FEP-8b32 and HTTP-signed Create(Note). The fallback acknowledges delivery only; replies remain asynchronous. Neither path receives the local A2A_API_KEY, and a handle without its protocol pin confers no trust.

Instance self-description (#216, delivered). The instance-scope twin of per-actor discovery, the Fediverse parallel of /.well-known/matrix/server. GET /.well-known/nodeinfo points to a NodeInfo 2.1 document (FEP-0151, 2025 ed.) at /nodeinfo/2.1 that advertises openRegistrations: false, the exposed agents (handle, summary, actor + card pointers) sourced live from the agents.yaml allowlist, and the implemented open protocols (ActivityPub, A2A, FEP-8b32, FEP-844e). A FEP-2677 Application actor at /ap/instance machine-describes the whole instance with the same implements list. Adding or removing an agent in agents.yaml changes the advertised set deterministically; no unlisted agent is ever announced, and no hand-maintained manifest can drift from the allowlist.

§5 — Novel collaboration surface

Three items extend the governed core with capabilities Matrix federation does not have a one-to-one analog for, and are marked novel in the backlog:

  1. Group actor per collaboration room (#217) — a room is projected as an AP Group so cross-org participants collaborate through follow/announce semantics, the Fediverse equivalent of a shared federated room.
  2. FEP-c390 identity proof (#218) — unifies the AP actor identity with the A2A AgentCard key/DID so one verifiable identity spans both transports.
  3. Follow-to-subscribe status/outbox feed (#219) — following an agent subscribes to its status/outbox, turning task progress into a governed, observable feed rather than an opaque call.

Group collaboration (#217, implemented and demo-reconciled; public routing disabled and live group interop unproven — novel, the M18 headline). A designated Matrix collaboration room is projected as an ActivityPub Group actor at /ap/groups/<room> (WebFinger-resolvable), so a remote org collaborates cross-organization without running Matrix federation at all — a peer needs only an AP client. The flow, modelled on the guppe fan-out design (reimplemented in Go with go-ap/activitypub, not embedded):

  1. A remote actor sends a signed Follow; the group records it (keyed on the full actor URI, never a localpart, so it never assumes a single homeserver) and delivers a signed Accept to the follower's resolved inbox.
  2. On an inbound Create(Note), the group auto-Announces it to all other followers (never echoing the author) — the guppe rebroadcast.
  3. An @agent-<name> mention routes through the governed path (ReserveBudget → A2A SendMessage); the agent's reply is published as a FEP-8b32-signed Note and Announced back to the group.
  4. The F3/F4/F5 border gates all inbound group traffic: signature + allowlist on every activity, and a per-mention budget reservation before any A2A call. Off-allowlist or over-budget traffic is dropped before an agent is invoked.

Outbound Accept/Announce deliveries use the §3 dual-profile HTTP-signature negotiation with the group's published publicKey, the symmetric counterpart of the inbound signature border. The Group is the AP projection of the room; wiring the two transports' message bodies at runtime is deployment configuration and stays outside this AGPL-free app.

Cross-transport identity (#218, implemented in-app — novel). An agent now has two federation faces — an ActivityPub actor and an A2A endpoint. FEP-c390 cryptographically links them: each actor attaches a VerifiableIdentityStatement proving it also-knows-as a P-256 did:key, and its A2A AgentCard publishes the matching JWK. A verifier confirms — from the two published faces alone — that they share one sovereign key, failing closed on a key mismatch or a missing statement. Because the key, not the hostname, is the anchor, the identity survives a domain move: re-issuing the statement for the new actor URI verifies unchanged and yields the same did, so a verifier who pinned the DID still recognizes the principal. Implemented with stdlib crypto/ecdsa + a minimal did:key (P-256 multicodec) and ecdsa-jcs-2019 Data Integrity proof — Go's DID tooling is thin, so this deliberately avoids a heavy framework; the did:key encoding is pinned against the Python cryptography reference by a golden test.

Follow-to-subscribe status feed (#219, implemented in-app). Sovereign, pull-based notifications with no webhook lock-in: an agent publishes operational updates (deploy events, task completions, LLM cost-alert firings) as public Notes in its outbox, and any human or org Follows to subscribe — unfollowing is the unsubscribe. The event source is the existing Prometheus alert layer: Alertmanager POSTs to an /alerts receiver on the gateway's internal metrics port (never the public AP surface), and each firing alert labelled with an allowlisted agent becomes a FEP-8b32-signed status Note fanned out to that agent's followers. Output is rate-limited per agent so a flapping alert cannot spam. The Follow/Undo(Follow) handshake is the whole subscription lifecycle, gated by the F3/F4 border; the cost alert (the token-burn PrometheusRule, observability §9) becomes a first-class, self-hosted, standard-protocol signal — no per-subscriber outbound webhook is ever introduced.

§6 — Honesty clauses (stated out loud)

  1. Replication and deletion are best-effort, exactly as with Matrix (§8.1): an activity delivered to remote inboxes cannot be technically recalled; data residency across instances is a contractual control, not a technical one.
  2. A signed, reachable actor is not a governed one. As with the Matrix onboarding preflight, public discoverability (WebFinger/NodeInfo resolving) never proves that allowlist, budget, and integrity policy are in force — those require separate operator evidence.
  3. Reservations are not consumption. Per-actor maxTokens admission reserves budget; actual model token metrics remain aggregate at agentgateway (D7/D8). Never present a reservation as spend.
  4. Two transports, name yours. Any cross-org capability claim must state whether it rode Matrix or ActivityPub; they are additive, not interchangeable.

§7 — Definition of done (M18 epic)

On the demo profile, a Mastodon/GoToSocial user follows a sovereign agent by its Fediverse handle, @mentions it, and receives one governed, signed, A2A-backed reply — with the ActivityPub federation policy border proven fail-closed before any public exposure, and every §3 twin control demonstrably in force.