diff --git a/AGENTS.md b/AGENTS.md index f331017..c084b63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,11 @@ Never run tests against production Redis. release gates; private transcripts and host details remain outside the repo. - `docs/SECURITY.md` records the current signature profile and additive security roadmap. +- `docs/FEP-AE0C-COMPATIBILITY.md` records the two retrospective relay + families, Activity-Relay extensions, unresolved behavior questions, and the + characterization fixtures that must remain synchronized with protocol + changes. + - Update `readme.md` and affected deployment documentation whenever behavior, packaging, configuration, or release procedures change. diff --git a/TODO.md b/TODO.md index ccb004a..074f734 100644 --- a/TODO.md +++ b/TODO.md @@ -32,16 +32,38 @@ operator-owned website content, and supported deployment models. - Container, native-package, interruption, identity, Redis, secure-mode, interoperability, production-deployment, and overnight-soak validation. -## Immediate post-release verification +## v2.5.0 release verification completed -- Verify the stable GitHub release metadata, release notes, checksums, internal - Debian version, package contents, and required examples. -- Verify `linux/amd64` and `linux/arm64` container manifests plus the `2.5.0`, - `2.5`, `2`, and `latest` tags. -- Deploy the stable `2.5.0-1` package to production and reconfirm actor identity, - dedicated Redis state, API and worker health, private observability, and - public endpoints. -- Retain the RC2 and stable-promotion evidence outside the public repository. +- Stable GitHub release metadata, reviewed release notes, checksums, internal + Debian version, package contents, and required examples passed independent + verification. +- The `2.5.0`, `2.5`, `2`, and `latest` container tags publish matching + `linux/amd64` and `linux/arm64` manifests. +- The stable `2.5.0-1` package is deployed in production with preserved actor + and key identity, dedicated Redis state, healthy API and worker services, + private observability, and healthy public endpoints. +- RC2, stable-promotion, publication, and production-deployment evidence is + retained outside the public repository. + +## Activity-Relay 3.0 design + +The next active protocol milestone is a modern signing architecture informed by +the retrospective FEP-ae0c relay profiles. + +- Land and maintain `docs/FEP-AE0C-COMPATIBILITY.md` and the machine-readable + fixture catalog before changing runtime behavior. +- Convert current Mastodon-style forwarding, LitePub reciprocal-follow and + Announce behavior, open publisher ingestion, NodeBB normalization, actor-path + compatibility, and authorized fetch into executable characterization tests. +- Resolve Public-in-`to` versus Public-only-in-`cc` through explicit + configuration and real interoperability evidence rather than an implicit + routing condition. +- Add RFC 9421 HTTP Message Signatures and RFC 9530 `Content-Digest` support + additively while preserving the established Fediverse `Signature` profile. +- Keep document-level Linked Data proof semantics separate from HTTP message + authentication; do not claim or add proof verification incidentally. +- Prove duplicate and reflection bounds in a mixed two-relay topology before + selecting 3.0 defaults. ## Deferred work after v2.5.0 @@ -49,12 +71,15 @@ operator-owned website content, and supported deployment models. reproduce failed attempts to follow `Julian@activitypub.space` from Friendica and WordPress, use a controlled NodeBB instance, and inspect the corresponding application logs. -- Define stale-receiver retention and cleanup policy after sufficient delivery - health history exists. -- Add RFC 9421 message-signature and RFC 9530 content-digest support - additively, without removing the established Fediverse `Signature` profile. -- Evaluate replicated or alternative durable-state designs without breaking - current Redis compatibility. +- Define stale-receiver retention and cleanup configuration after sufficient + delivery-health history exists. Begin in report-only dry-run mode; active + membership and queued or claimed work remain non-configurable deletion + barriers. +- Implement the Activity-Relay 3.0 RFC 9421 and RFC 9530 signing milestone + under the FEP-ae0c characterization and interoperability gates above. +- Replicated or alternative durable-state backends are tabled. Revisit only + when production experience, availability requirements, or operator demand + justify the value and compatibility cost. - Continue maintaining and rebasing the Redis-only Machinery fork for relevant upstream security and correctness fixes. diff --git a/contrib/ops/test_fep_ae0c_cases.py b/contrib/ops/test_fep_ae0c_cases.py new file mode 100644 index 0000000..8b68f2e --- /dev/null +++ b/contrib/ops/test_fep_ae0c_cases.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# File: contrib/ops/test_fep_ae0c_cases.py + +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from urllib.parse import urlparse + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +CASES_PATH = REPOSITORY_ROOT / "testdata" / "fep-ae0c" / "cases.json" + +REQUIRED_CASE_FIELDS = { + "id", + "description", + "profile", + "status", + "decision", + "distribution", + "current_v2_5", + "v3_assertion", +} + +PAYLOAD_FIELDS = {"activity", "actor", "http_request", "scenario"} + + +class FEPAE0CCasesTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.document = json.loads(CASES_PATH.read_text(encoding="utf-8")) + cls.allowed = cls.document["allowed_values"] + cls.cases = cls.document["cases"] + + def test_document_metadata(self) -> None: + self.assertEqual(self.document["schema_version"], 1) + self.assertEqual( + self.document["source"]["fep"], + "https://w3id.org/fep/ae0c", + ) + self.assertEqual( + self.document["source"]["activity_relay_baseline"], + "v2.5.0", + ) + self.assertGreaterEqual(len(self.cases), 16) + + def test_case_ids_are_unique_and_portable(self) -> None: + identifiers = [case["id"] for case in self.cases] + self.assertEqual(len(identifiers), len(set(identifiers))) + for identifier in identifiers: + self.assertRegex(identifier, r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + def test_required_fields_and_enums(self) -> None: + for case in self.cases: + with self.subTest(case=case.get("id")): + self.assertTrue(REQUIRED_CASE_FIELDS.issubset(case)) + self.assertIn(case["profile"], self.allowed["profile"]) + self.assertIn(case["status"], self.allowed["status"]) + self.assertIn(case["decision"], self.allowed["decision"]) + self.assertIn( + case["distribution"], + self.allowed["distribution"], + ) + self.assertEqual( + len(PAYLOAD_FIELDS.intersection(case)), + 1, + "each case must have exactly one payload/scenario field", + ) + + def test_activity_and_actor_payloads_are_activitystreams_shaped(self) -> None: + for case in self.cases: + payload = case.get("activity") or case.get("actor") + if payload is None: + continue + with self.subTest(case=case["id"]): + self.assertIn("@context", payload) + self.assertIsInstance(payload.get("id"), str) + self.assertIsInstance(payload.get("type"), str) + self.assertTrue(urlparse(payload["id"]).scheme) + if "actor" in payload and isinstance(payload["actor"], str): + self.assertTrue(urlparse(payload["actor"]).scheme) + + def test_decisions_include_preservation_extensions_and_open_questions(self) -> None: + decisions = {case["decision"] for case in self.cases} + self.assertTrue( + {"preserve", "document-extension", "investigate", "defer"} + .issubset(decisions) + ) + + def test_audit_required_cases_are_not_claimed_as_covered(self) -> None: + for case in self.cases: + if case["status"] == "audit-required": + with self.subTest(case=case["id"]): + self.assertEqual(case["decision"], "investigate") + + +if __name__ == "__main__": + unittest.main() + +# EOF: contrib/ops/test_fep_ae0c_cases.py diff --git a/docs/FEP-AE0C-COMPATIBILITY.md b/docs/FEP-AE0C-COMPATIBILITY.md new file mode 100644 index 0000000..d73b861 --- /dev/null +++ b/docs/FEP-AE0C-COMPATIBILITY.md @@ -0,0 +1,239 @@ +# FEP-ae0c compatibility and Activity-Relay 3.0 design input + +## Purpose + +[FEP-ae0c](https://w3id.org/fep/ae0c) is a retrospective description of two +relay protocol families deployed in the Fediverse: + +- the Mastodon-style protocol, which subscribes through a one-way `Follow` of + the ActivityStreams Public collection and forwards eligible activity bodies; +- the LitePub-style protocol, which follows a relay actor bidirectionally and + distributes content through `Announce`. + +It records behavior that emerged in implementations. It is therefore an +interoperability reference, not permission to copy every historical quirk +without evaluating privacy, authenticity, loop prevention, and current +cross-software behavior. + +This document characterizes Activity-Relay v2.5.0 against that retrospective +description and defines the questions that must be resolved before the 3.0 +signing and relay-policy work changes runtime behavior. + +The machine-readable companion is +[`testdata/fep-ae0c/cases.json`](../testdata/fep-ae0c/cases.json). + +## Terminology + +This repository uses the following terms consistently: + +- **relay/forward**: send the received ActivityStreams body to relay receivers + without replacing it with a relay-authored activity; +- **relay/announce**: create a relay-authored `Announce` and distribute that + wrapper; +- **traditional subscriber**: a receiver registered through the Mastodon-style + Public-collection follow flow; +- **follower-style receiver**: an actor participating in the LitePub-style + reciprocal follow flow; +- **publisher**: a valid signed sender whose public activity is accepted for + fan-out, whether or not that sender is a receiver. + +These terms distinguish relay behavior from ActivityPub collection expansion +and from an HTTP intermediary forwarding an entire request. + +## Current v2.5.0 behavior + +Activity-Relay v2.5.0 supports both relay families and several deliberate +extensions: + +1. A traditional `Follow` may establish an inbox subscription. +2. A follower-style actor may follow the relay actor, receive `Accept` and a + reciprocal relay `Follow`, then finalize the relationship with `Accept`. +3. Public `Create`, `Update`, `Delete`, and `Move` activities enter the + traditional forwarding path when the Public collection appears in either + `to` or `cc`. +4. A follower-style `Announce` with a referenced object is accepted only from an + established subscriber or follower; the relay fetches the original activity + before redistribution. +5. A same-origin public embedded `Announce`, as produced by current NodeBB + category actors, is normalized into one relay-authored `Announce` referencing + the canonical object ID. +6. Public URL-only or foreign-origin embedded `Announce` activities do not enter + the NodeBB normalization path; they are retained as publisher observations. +7. Valid HTTP-signed public publishers may send without becoming relay + receivers. +8. Remote `Application` and `Service` actors are accepted at + implementation-defined actor paths, with legacy `/relay` and `/friendica` + compatibility retained. +9. Remote actor and canonical-object fetches are HTTP-signed for authorized + fetch. +10. Unknown public activity types are acknowledged without fan-out. + +Inbound v2.5.0 requests use the established Fediverse HTTP `Signature` profile +and legacy `Digest` field. The relay does not currently generate or verify +Mastodon's historical Linked Data `RsaSignature2017` document proof. + +## Compatibility matrix + +| Area | FEP-ae0c Mastodon family | FEP-ae0c LitePub family | Activity-Relay v2.5.0 | 3.0 disposition | +| --- | --- | --- | --- | --- | +| Subscription target | Public collection through relay inbox | Relay actor | Supports both | Preserve | +| Relationship | One-way subscription | Reciprocal follow | Supports both receiver types | Preserve | +| Distribution form | Forward eligible body | Relay-authored `Announce` | Both, plus NodeBB normalization | Preserve and document extension | +| Public in `to` | Public | Public | Forwarded | Preserve | +| Public only in `cc` | Commonly interpreted as unlisted | Not central | Forwarded as public relay traffic | Investigate and make policy explicit | +| Original document proof | Historical Mastodon flow uses an embedded LD proof | Not central | Preserved if already in a forwarded body; not generated or verified | Document separately from HTTP signatures | +| HTTP request authentication | Historical Fediverse `Signature` | Historical Fediverse `Signature` | Required inbound and outbound | Preserve; add RFC 9421 and RFC 9530 additively | +| Sender must be receiver | Commonly coupled to subscription | Commonly coupled to reciprocal follow | Open signed publishers are supported | Preserve as an Activity-Relay extension | +| Actor type and path | Historically implementation-specific | Historically constrained in some software | `Application` or `Service`, arbitrary valid path | Preserve | +| Public embedded same-origin `Announce` | Not a core case | Announce-oriented | Canonical NodeBB normalization | Preserve as a documented extension | +| Public URL-only `Announce` | Implementation-dependent | Reference is common | Publisher observation only | Characterize before changing | +| Unsupported public activity | Not exhaustively defined | Not exhaustively defined | HTTP 202 without fan-out | Add explicit observability and tests | +| Duplicate/loop prevention | Must not relay an activity repeatedly | Must not relay an activity repeatedly | Existing storage and normalization reduce several loops | Complete a dedicated invariant audit | +| Optional `state` property | Seen in historical examples | Seen in some implementations | Not part of the public contract | Do not add without evidence | +| Authorized object fetch | Outside the original relay handshake | Referenced objects require fetching | Relay-signed GET supported | Preserve | + +## Material differences requiring decisions + +### Public in `to` versus `cc` + +The current routing condition treats the Public URI in `to` and `cc` +identically. Mastodon commonly maps Public in `to` to public visibility and +Public only in `cc` to unlisted visibility. Relaying both can therefore broaden +the distribution of content whose author expected public accessibility but not +public-timeline amplification. + +The 3.0 design should expose an explicit policy rather than retain an accidental +condition: + +```yaml +relay_policy: + traditional_visibility: public_to_or_cc +``` + +Candidate values are: + +- `public_to_only` +- `public_to_or_cc` + +The 3.0 default remains undecided until characterization and interoperability +tests demonstrate the consequences for Mastodon, Friendica, NodeBB, and +LitePub-family software. Migration documentation must state whether an existing +installation retains its v2.5 behavior. + +### Linked Data proofs and HTTP message signatures + +An embedded Linked Data proof authenticates an ActivityStreams document +independently of one HTTP hop. The established Fediverse `Signature` header and +RFC 9421 authenticate an HTTP message exchanged between two systems. RFC 9421 +does not replace document-level proof semantics. + +Activity-Relay currently: + +- preserves a proof already present when forwarding the original body; +- does not generate `RsaSignature2017`; +- does not verify that proof before fan-out; +- authenticates inbound and outbound HTTP requests; and +- creates relay-authored activities for normalization paths. + +The first RFC 9421/RFC 9530 milestone must not silently claim origin-document +verification. Document-proof support, if ever added, requires separate +canonicalization, algorithm, context, security, and interoperability review. + +### Open publishers + +FEP-ae0c is primarily organized around relay-client relationships. +Activity-Relay deliberately separates publishing permission from receiver +membership after validating the HTTP signer, actor binding, domain policy, and +public audience. + +This extension enables WordPress and other publishers without requiring them to +receive full relay traffic. It must remain covered by security and regression +tests throughout the 3.0 signing refactor. + +### Public Announce normalization + +Activity-Relay's NodeBB compatibility path is neither simple body forwarding nor +a generic LitePub relay. It replaces a same-origin embedded transport wrapper +with a relay-authored canonical-object `Announce` and sends the same authenticated +wrapper to all receiver styles. + +The same-origin restriction and refusal to amplify URL-only or foreign-origin +public boosts are anti-loop and anti-amplification policy. They must not be +generalized merely to resemble a retrospective example. + +### Activity selection and acknowledgement + +The current traditional path fans out `Create`, `Update`, `Delete`, and `Move`, +plus the selected public embedded `Announce` extension. Other public activity +types receive HTTP 202 without fan-out. + +Before 3.0, every accepted type should have a documented outcome and a bounded +metric or structured reason. HTTP acceptance, policy rejection, publisher +accounting, queue admission, and receiver delivery must remain distinguishable. + +### Follow object and historical protocol quirks + +A Mastodon-style `Follow` of the Public pseudo-collection is a deployed protocol +behavior even though ActivityPub generally defines a `Follow` object as an +actor. Activity-Relay should preserve demonstrated interoperability while +keeping permissive behavior explicit and test-covered. + +The optional historical `state` property is not required by Activity-Relay and +should not be emitted or required without a real interoperability case. + +### Duplicate and reflection protection + +FEP-ae0c warns about two relays following each other and reflecting activity. +Activity-Relay already limits several Announce paths, retains shared activity +records through fan-out, and rejects some unsafe normalization cases. A +dedicated audit must still prove the invariant across: + +- direct body forwarding; +- fetched LitePub references; +- relay-authored Announce wrappers; +- retries and at-least-once recovery; +- repeated inbound IDs; +- two-relay topologies; and +- mixed traditional and follower-style membership. + +No 3.0 protocol change should ship until this fixture is converted into +executable unit and multi-relay integration coverage. + +## 3.0 design decisions already accepted + +- Preserve both deployed relay families. +- Preserve the actor ID, actor key, inbox, collections, Redis compatibility, and + established Fediverse `Signature` profile. +- Add RFC 9421 HTTP Message Signatures and RFC 9530 `Content-Digest` + additively. +- Keep legacy-only, dual, and RFC-only signing profiles distinct in code and + tests; RFC-only must not become the default until interoperability supports + it. +- Keep open publisher ingestion and its actor-host security checks. +- Keep NodeBB normalization as a documented extension rather than presenting it + as FEP-ae0c behavior. +- Do not add Linked Data proof generation as an incidental part of RFC 9421 + work. +- Do not implement alternative durable-state backends as part of 3.0. + +## Characterization sequence + +1. Land this document and the fixture catalog without runtime changes. +2. Convert fixture rows into unit tests that freeze v2.5 behavior. +3. Add multi-relay loop and repeated-ID tests. +4. Test `to` versus `cc` behavior against real receiving software. +5. Refactor request signing and verification behind explicit legacy, RFC 9421, + and dual profiles. +6. Add RFC 9530 digest generation and verification from the exact transmitted + body bytes. +7. Repeat traditional, LitePub, open-publisher, NodeBB, authorized-fetch, and + receiver-presentation integration tests. +8. Select 3.0 defaults only after the compatibility evidence is recorded. + +## Fixture status + +The initial fixtures are specifications. They intentionally include both covered +behavior and unresolved audit cases. A fixture marked `specified` is not a claim +that an executable test already proves it. The fixture validation test checks +schema quality, identifiers, decisions, and payload shape; later milestones +will bind each fixture to runtime assertions. diff --git a/docs/INTEGRATION-TESTING.md b/docs/INTEGRATION-TESTING.md index 131ffba..672dab1 100644 --- a/docs/INTEGRATION-TESTING.md +++ b/docs/INTEGRATION-TESTING.md @@ -130,6 +130,14 @@ For Mastodon secure-mode testing, verify relay-signed actor and object fetches, redirect re-signing, exact wire authority, and the existing interoperable `Signature` header format. +FEP-ae0c and Activity-Relay extension changes must update +`testdata/fep-ae0c/cases.json` and +`docs/FEP-AE0C-COMPATIBILITY.md`. Before changing relay routing or wrapping +behavior, first convert the affected specification fixtures into executable +characterization tests. For changes involving direct forwarding, reciprocal +follows, Announce wrapping, audience handling, or loop prevention, exercise both +relay families and a mixed two-relay topology. + ## Promotion gate Do not tag a release candidate until both deployment forms pass the applicable diff --git a/docs/INTEROPERABILITY.md b/docs/INTEROPERABILITY.md index ef2511a..29197ec 100644 --- a/docs/INTEROPERABILITY.md +++ b/docs/INTEROPERABILITY.md @@ -21,6 +21,20 @@ Activity-Relay releases through `v2.5.0`. Historical `v2.4.0` results are kept for comparison; no matrix can guarantee that every version or configuration of every ActivityPub server behaves identically. +## FEP-ae0c compatibility + +Activity-Relay implements both relay families retrospectively described by +[FEP-ae0c](https://w3id.org/fep/ae0c), plus documented extensions for open +publishers, broad server-actor compatibility, NodeBB embedded-Announce +normalization, and authorized fetch. + +The exact comparison, unresolved `to` versus `cc` visibility question, +document-proof distinction, loop-prevention audit, and machine-readable +characterization cases are maintained in +[`FEP-AE0C-COMPATIBILITY.md`](FEP-AE0C-COMPATIBILITY.md). FEP compatibility is +an input to the Activity-Relay 3.0 design; v2.5.0 behavior remains unchanged by +that design document. + ## Subscription models Activity-Relay supports both common relay subscription models: diff --git a/testdata/fep-ae0c/cases.json b/testdata/fep-ae0c/cases.json new file mode 100644 index 0000000..1ce29cb --- /dev/null +++ b/testdata/fep-ae0c/cases.json @@ -0,0 +1,413 @@ +{ + "schema_version": 1, + "source": { + "fep": "https://w3id.org/fep/ae0c", + "activity_relay_baseline": "v2.5.0", + "documentation": "docs/FEP-AE0C-COMPATIBILITY.md" + }, + "allowed_values": { + "profile": [ + "mastodon", + "litepub", + "activity-relay-extension", + "cross-profile" + ], + "decision": [ + "preserve", + "document-extension", + "investigate", + "defer" + ], + "status": [ + "covered", + "specified", + "audit-required" + ], + "distribution": [ + "none", + "forward-original", + "relay-announce", + "relationship" + ] + }, + "cases": [ + { + "id": "mastodon-follow-public", + "description": "Traditional subscriber follows the Public pseudo-collection.", + "profile": "mastodon", + "status": "covered", + "decision": "preserve", + "distribution": "relationship", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://subscriber.example/activities/follow-public", + "type": "Follow", + "actor": "https://subscriber.example/actor", + "object": "https://www.w3.org/ns/activitystreams#Public" + }, + "current_v2_5": "May create a traditional inbox subscription after actor and policy validation.", + "v3_assertion": "Preserve deployed interoperability despite the Public object not being an actor." + }, + { + "id": "litepub-follow-relay-actor", + "description": "Follower-style server follows the relay actor.", + "profile": "litepub", + "status": "covered", + "decision": "preserve", + "distribution": "relationship", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://follower.example/activities/follow-relay", + "type": "Follow", + "actor": "https://follower.example/actor", + "object": "https://relay.example/actor", + "to": [ + "https://relay.example/actor" + ] + }, + "current_v2_5": "Begins follower-style Accept and reciprocal Follow processing.", + "v3_assertion": "Preserve reciprocal relationship behavior." + }, + { + "id": "litepub-accept-reciprocal-follow", + "description": "Remote actor accepts the relay's reciprocal Follow.", + "profile": "litepub", + "status": "covered", + "decision": "preserve", + "distribution": "relationship", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://follower.example/activities/accept-relay", + "type": "Accept", + "actor": "https://follower.example/actor", + "object": { + "id": "https://relay.example/activities/follow/follower.example", + "type": "Follow", + "actor": "https://relay.example/actor", + "object": "https://follower.example/actor" + }, + "to": [ + "https://relay.example/actor" + ] + }, + "current_v2_5": "Finalizes mutual-follow state for the receiver.", + "v3_assertion": "Preserve and characterize idempotency." + }, + { + "id": "traditional-create-public-to", + "description": "Public Create addresses Public in to.", + "profile": "mastodon", + "status": "covered", + "decision": "preserve", + "distribution": "forward-original", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://publisher.example/activities/create-public", + "type": "Create", + "actor": "https://publisher.example/actor", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": { + "id": "https://publisher.example/objects/1", + "type": "Note", + "attributedTo": "https://publisher.example/actor", + "content": "Public example" + } + }, + "current_v2_5": "Queues the original received body for relay fan-out.", + "v3_assertion": "Preserve public forwarding and exact-body characterization." + }, + { + "id": "traditional-create-public-cc", + "description": "Create addresses Public only in cc, commonly interpreted as unlisted.", + "profile": "mastodon", + "status": "specified", + "decision": "investigate", + "distribution": "forward-original", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://publisher.example/activities/create-unlisted", + "type": "Create", + "actor": "https://publisher.example/actor", + "to": [ + "https://publisher.example/actor/followers" + ], + "cc": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": { + "id": "https://publisher.example/objects/2", + "type": "Note", + "attributedTo": "https://publisher.example/actor", + "content": "Unlisted example" + } + }, + "current_v2_5": "Routes identically to Public in to and fans out the original body.", + "v3_assertion": "Measure receiver behavior and select an explicit configurable policy." + }, + { + "id": "traditional-forward-preserves-ld-proof", + "description": "A forwarded body already contains a historical document proof.", + "profile": "mastodon", + "status": "specified", + "decision": "investigate", + "distribution": "forward-original", + "activity": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/identity/v1" + ], + "id": "https://publisher.example/activities/create-with-proof", + "type": "Create", + "actor": "https://publisher.example/actor", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": "https://publisher.example/objects/3", + "signature": { + "type": "RsaSignature2017", + "creator": "https://publisher.example/actor#main-key", + "created": "2026-01-01T00:00:00Z", + "signatureValue": "fixture-not-a-real-signature" + } + }, + "current_v2_5": "Original bytes, including an existing proof, are retained for direct fan-out; the proof is not separately verified.", + "v3_assertion": "Do not conflate proof preservation with proof verification or RFC 9421." + }, + { + "id": "litepub-announce-reference", + "description": "Established follower-style receiver announces a referenced activity.", + "profile": "litepub", + "status": "covered", + "decision": "preserve", + "distribution": "relay-announce", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://follower.example/activities/announce-1", + "type": "Announce", + "actor": "https://follower.example/actor", + "to": [ + "https://relay.example/actor" + ], + "object": "https://follower.example/activities/create-1" + }, + "current_v2_5": "Requires prior relay membership, fetches the referenced activity, and redistributes through the announce path.", + "v3_assertion": "Preserve membership enforcement and characterize wrapper/body output." + }, + { + "id": "nodebb-embedded-announce-same-origin", + "description": "NodeBB-style same-origin embedded object is normalized.", + "profile": "activity-relay-extension", + "status": "covered", + "decision": "document-extension", + "distribution": "relay-announce", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://forum.example/post/3#activity/announce", + "type": "Announce", + "actor": "https://forum.example/category/5", + "to": [ + "https://forum.example/category/5/followers" + ], + "cc": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": { + "id": "https://forum.example/post/3", + "type": "Article", + "attributedTo": "https://forum.example/user/alice", + "content": "Forum example" + } + }, + "current_v2_5": "Creates one relay-authored Announce referencing the canonical object ID for all receiver styles.", + "v3_assertion": "Preserve signer/actor alignment and same-origin restriction." + }, + { + "id": "public-announce-url-only", + "description": "Public Announce references an object URL without embedding it.", + "profile": "cross-profile", + "status": "specified", + "decision": "investigate", + "distribution": "none", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://publisher.example/activities/announce-url", + "type": "Announce", + "actor": "https://publisher.example/actor", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": "https://origin.example/objects/1" + }, + "current_v2_5": "Records publisher activity but does not enter public embedded-Announce fan-out.", + "v3_assertion": "Keep anti-loop behavior until explicit interoperability and abuse analysis supports a change." + }, + { + "id": "public-announce-cross-origin-embedded", + "description": "Public embedded Announce object belongs to another origin.", + "profile": "activity-relay-extension", + "status": "covered", + "decision": "preserve", + "distribution": "none", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://publisher.example/activities/announce-foreign", + "type": "Announce", + "actor": "https://publisher.example/actor", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": { + "id": "https://origin.example/objects/2", + "type": "Note", + "content": "Foreign-origin object" + } + }, + "current_v2_5": "Records publisher activity but refuses NodeBB-style normalization.", + "v3_assertion": "Preserve the anti-amplification same-origin invariant." + }, + { + "id": "unsupported-public-like", + "description": "Public Like reaches the traditional activity-selection branch.", + "profile": "cross-profile", + "status": "specified", + "decision": "investigate", + "distribution": "none", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://publisher.example/activities/like-1", + "type": "Like", + "actor": "https://publisher.example/actor", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": "https://origin.example/objects/3" + }, + "current_v2_5": "Returns HTTP 202 without relay fan-out.", + "v3_assertion": "Add a documented outcome and bounded reason metric without amplifying reactions by default." + }, + { + "id": "open-publisher-public-create", + "description": "Valid signed public publisher is not a relay receiver.", + "profile": "activity-relay-extension", + "status": "covered", + "decision": "preserve", + "distribution": "forward-original", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://blog.example/activities/create-1", + "type": "Create", + "actor": "https://blog.example/author/1", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": { + "id": "https://blog.example/posts/1", + "type": "Article", + "attributedTo": "https://blog.example/author/1", + "content": "Blog example" + } + }, + "current_v2_5": "May fan out after HTTP-signature, actor-host, and domain-policy validation without adding receiver membership.", + "v3_assertion": "Preserve and repeat the security matrix under every signing profile." + }, + { + "id": "server-actor-arbitrary-path", + "description": "Application actor uses an implementation-defined actor path.", + "profile": "activity-relay-extension", + "status": "covered", + "decision": "preserve", + "distribution": "relationship", + "actor": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://server.example/activitypub/server", + "type": "Application", + "preferredUsername": "relay-client", + "inbox": "https://server.example/activitypub/server/inbox", + "endpoints": { + "sharedInbox": "https://server.example/inbox" + } + }, + "current_v2_5": "Accepts standards-style Application or Service actors without requiring /relay or /friendica.", + "v3_assertion": "Preserve actor-type and path flexibility." + }, + { + "id": "authorized-fetch-canonical-object", + "description": "Relay fetches a referenced canonical object from a secure-mode server.", + "profile": "activity-relay-extension", + "status": "covered", + "decision": "preserve", + "distribution": "none", + "http_request": { + "method": "GET", + "url": "https://secure.example/objects/1", + "accept": "application/activity+json", + "signature_profile": "legacy" + }, + "current_v2_5": "Signs the GET with the relay actor key and exact wire authority.", + "v3_assertion": "Preserve legacy signing and add dual/RFC 9421 fixtures." + }, + { + "id": "historical-state-property", + "description": "Historical relay activity includes an optional state property.", + "profile": "cross-profile", + "status": "specified", + "decision": "defer", + "distribution": "relationship", + "activity": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://subscriber.example/activities/follow-with-state", + "type": "Follow", + "actor": "https://subscriber.example/actor", + "object": "https://www.w3.org/ns/activitystreams#Public", + "state": "pending" + }, + "current_v2_5": "The property is not part of Activity-Relay's documented public contract.", + "v3_assertion": "Do not emit or require it without a demonstrated implementation need." + }, + { + "id": "repeat-id-two-relay-loop", + "description": "The same activity ID traverses a topology containing two relays.", + "profile": "cross-profile", + "status": "audit-required", + "decision": "investigate", + "distribution": "none", + "scenario": { + "activity_id": "https://publisher.example/activities/repeat-1", + "topology": [ + "relay-a.example", + "relay-b.example" + ], + "receiver_styles": [ + "traditional", + "follower" + ], + "repetitions": 3 + }, + "current_v2_5": "Several paths constrain loops, but the complete FEP reflection invariant is not yet represented by one executable multi-relay test.", + "v3_assertion": "Prove bounded processing and no repeated re-relay beyond intentional delivery retries." + }, + { + "id": "rfc9421-dual-profile-post", + "description": "Future dual-profile POST signs one exact body with legacy and modern fields.", + "profile": "cross-profile", + "status": "specified", + "decision": "investigate", + "distribution": "forward-original", + "http_request": { + "method": "POST", + "url": "https://receiver.example/inbox", + "headers": [ + "Digest", + "Content-Digest", + "Signature", + "Signature-Input" + ], + "signature_profile": "dual" + }, + "current_v2_5": "Not implemented; v2.5.0 emits the legacy Fediverse profile.", + "v3_assertion": "Both digest fields must cover identical transmitted bytes, with independent verification and downgrade rules." + } + ] +}