Owner decisions of 2026-10-06; personas' public posts go to relays

The owner decided four gated questions, now in ROADMAP: personas publish a wall (G-0009), their public posts go to the
relays PrivaPub subscribes to, public threads publish their replies and context, and FEP-8fcf follower digests are sent.

The first is in: a persona's own public post outside any group, its edit and its deletion also go to the relays that
accepted us, as Mastodon sends them; nothing less public, and no boost. Checked live (scenarios/relay.sh, 15 checks):
the post reaches Activity-Relay, and Mastodon through aode-relay's announce. Mastodon drops what Activity-Relay forwards
without an LD signature or FEP-8b32 proof, which PrivaPub does not add yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 07:08:37 +02:00
1 parent b45321f28d
commit 93e13e5563
8 files changed
+70 -21

No files matched your search

+2 -1
View File
@@ -591,7 +591,8 @@ tools/pasture/run.sh down # removes e
`peers/aoderelay.sh` aode-relay 0.3.129 as `aoderelay.test`):** `appsettings.Pasture.json` names both in
`Federation:Relays`, so PrivaPub subscribes a minute after it starts. `scenarios/relay.sh` has Mastodon subscribe to
each in turn, checks that a post of an account nobody here follows reaches the federated timeline (forwarded by one,
announced by the other), and has Mastodon leave again, so the town sees no relayed posts. 13 checks.
announced by the other), that alice's public post goes to the relays (and through aode-relay to Mastodon) and nothing
less public, and has Mastodon leave again, so the town sees no relayed posts. 15 checks.
- **Smithereen (1.0.3):** its image on the shared MySQL (database `smithereen`, its schema from the image's commit),
with imgproxy and a file server behind Caddy as `smithereen.test` (`/i` and `/s`), trusting the CA through a JDK
store with it added (`JAVA_TOOL_OPTIONS`). MySQL takes its stored functions only with
+5 -2
View File
@@ -265,8 +265,11 @@ Its instance actor follows `Public` at each, as Mastodon subscribes (`Federation
and every six hours: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets
the `Undo`). What an accepted relay passes on comes to the federated timeline, never to anyone's home: a public post it
forwards as its author sent it (Activity-Relay), read again from its origin like any forwarded post, and a post it
announces (aode-relay), kept as its author's and never as the relay's boost. Nothing else is taken from a relay, and
nothing of a persona's is sent to one: sending public posts to relays waits for the owner.
announces (aode-relay), kept as its author's and never as the relay's boost. Nothing else is taken from a relay. A
persona's public post outside any group, its edit and its deletion also go to the relays that accepted us (owner decision
2026-10-06), as Mastodon sends them; nothing less public, and no boost. Mastodon takes a post Activity-Relay forwards
only with an LD signature or an FEP-8b32 proof, which PrivaPub does not yet add, so it reaches Mastodon through relays
that announce (aode-relay).
## Server descriptions and the crawler
+18
View File
@@ -104,6 +104,24 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(PostVisibility.Public, kept[0].Visibility);
}
// owner decision 2026-10-06: a persona's public post, its edit and its deletion go to the relays too; nothing less
// public does, nor a boost
[Fact]
public async Task A_personas_public_post_goes_to_the_relays_and_nothing_less_public()
{
var relay = await Subscribed();
var (_, alice) = await _harness.Persona("alice");
var token = Token;
var open = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "for everyone", Visibility = PostVisibility.Public }, token);
await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "quietly", Visibility = PostVisibility.Unlisted }, token);
await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "for followers", Visibility = PostVisibility.FollowersOnly }, token);
var sent = (await _harness.Outgoing(relay.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Create").ToList();
var create = Assert.Single(sent);
Assert.Equal(alice.PostUri(open.Post.ID), create["object"]!["id"]!.GetValue<string>());
}
[Fact]
public async Task A_post_a_relay_announces_is_its_authors_never_the_relays_boost()
{
+2 -2
View File
@@ -44,13 +44,14 @@ namespace PrivaPub.Tests.Support
Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
Delivery = new DeliveryService(Db, Queue);
Relays = new Relays(Microsoft.Extensions.Options.Options.Create(RelayOptions), Remote, Local, Delivery);
Fanout = new Fanout(Db);
Groups = new GroupDistributor(Delivery);
Records = new ObjectRecords(Queue);
Polls = new PollService(Db, Local, Delivery, Queue);
Reactions = new Reactions(Db, Delivery);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews());
Outbox = new OutboxPublisher(Db, Local, Delivery);
Outbox = new OutboxPublisher(Db, Local, Delivery, Relays);
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox);
Participations = new Participations(Db, Local, Delivery);
@@ -58,7 +59,6 @@ namespace PrivaPub.Tests.Support
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Relationships = new RelationshipService(Db, Follows, Delivery);
Featured = new FeaturedPosts(Db, Remote, RemotePosts);
Relays = new Relays(Microsoft.Extensions.Options.Options.Create(RelayOptions), Remote, Local, Delivery);
Handlers = new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
@@ -28,9 +28,11 @@ namespace PrivaPub.Federation.Outbox
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly Relays.IRelays _relays;
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, Relays.IRelays relays = default)
{
_relays = relays;
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
@@ -79,6 +81,12 @@ namespace PrivaPub.Federation.Outbox
inboxes.Add(inbox);
}
// a persona's own public post, outside any group, also goes to the relays we subscribe to (owner decision
// 2026-10-06), its edits and deletion with it
if (_relays != default && post.Visibility == PostVisibility.Public && author.Kind == LocalActorKind.Person
&& string.IsNullOrEmpty(post.ReblogOfPostId) && string.IsNullOrEmpty(post.GroupId))
inboxes.AddRange(await _relays.Inboxes(token));
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
+8 -1
View File
@@ -21,11 +21,13 @@ namespace PrivaPub.Federation.Relays
Task Reconcile(CancellationToken token);
Task<bool> Answered(JsonNode activity, ForeignAvatar actor, bool accepted, CancellationToken token);
Task<bool> Passes(string actorUri, CancellationToken token);
Task<IReadOnlyList<string>> Inboxes(CancellationToken token);
}
// Relays (Activity-Relay, aode-relay, pub-relay): the instance actor follows Public at each relay the configuration
// names, as Mastodon subscribes, and takes what they pass on: public posts, forwarded as their authors sent them (read
// again from their origin, as any forwarded post) or announced by the relay. Nothing of a persona's is sent to a relay.
// again from their origin, as any forwarded post) or announced by the relay. A persona's public posts go to the relays
// that accepted us (owner decision 2026-10-06), as Mastodon sends them; nothing else of a persona's does.
public class Relays : IRelays
{
static readonly TimeSpan AskAgain = TimeSpan.FromDays(1);
@@ -108,6 +110,11 @@ namespace PrivaPub.Federation.Relays
return accepted.Contains(actorUri);
}
// the inboxes of the relays that accepted our subscription, where a persona's public posts also go
public async Task<IReadOnlyList<string>> Inboxes(CancellationToken token) =>
(await DB.Default.Find<RelaySubscription>().Match(s => s.State == RelayState.Accepted).ExecuteAsync(token))
.Select(s => s.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
// the relay's actor, from its address or, for a relay named by its inbox, from the /actor beside it
async Task<ForeignAvatar> Resolve(string address, CancellationToken token)
{
+12 -6
View File
@@ -278,9 +278,14 @@ and circles (see Owner decisions).
| An account a persona follows moves | **Move the follow, as Mastodon does.** After a verified `Move` the persona follows the new account (a Follow to its server) and unfollows the old, in the same lists; a mute or a block of the old account carries over. |
| Should PrivaPub pass on replies to a persona's posts? | **Yes, "the fediverse is broken without".** A public or unlisted reply that someone on another server writes to a persona's post goes on to the persona's followers, as Mastodon forwards it: the activity as its author signed it, never to the replier's own server, never for a post that is local-only, in a group or not shown to followers. Its edits and deletion follow the same way. |
**Waiting for the owner:** a persona's wall (G-0009). Smithereen sends a post written on someone else's wall only to
servers whose actors publish a wall (`sm:wall`, FEP-400e). Publishing one would also let Smithereen's users write on a
persona's wall, and PrivaPub would then host their posts and announce them with `Add{Note}`.
### Owner decisions on walls, relays, threads and followers (2026-10-06)
| Question | Decision |
|---|---|
| A persona's wall (G-0009) | **Yes, publish walls.** A persona's actor names its wall (`sm:wall`, FEP-400e); Smithereen's users may write on it, PrivaPub hosts their posts there and tells the persona's followers with `Add{Note}`. |
| Personas' public posts and relays | **Send public posts too.** A persona's public post (not unlisted, not followers-only, not local-only) also goes to the relays PrivaPub subscribes to, as Mastodon sends them. |
| A post's `replies` and `context` | **Publish public threads.** Public and unlisted posts name their `replies` and `context` collections, which list only the public and unlisted replies PrivaPub holds; followers-only, circle, direct and local-only posts never do. |
| FEP-8fcf followers synchronisation | **Send digests.** A delivery to a server that the persona's followers there would get carries a `Collection-Synchronization` header: a digest of the persona's followers on that server only, and where that server reads that partial list. |
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
@@ -682,9 +687,10 @@ it, raw where it doesn't.
- FEP-8b32 proof verification;
- `hs2019` with SHA-512.
- **Discovery:**
- a relay client for both relay styles: **reading done 2026-10-05** (`Federation:Relays`; forwarded posts read again
from their origin, announces unwrapped; checked live against Activity-Relay and aode-relay); sending public posts to
relays waits for the owner;
- a relay client for both relay styles: **done 2026-10-05/06** (`Federation:Relays`; forwarded posts read again
from their origin, announces unwrapped; personas' public posts sent to them, owner decision; checked live against
Activity-Relay and aode-relay). Mastodon drops what Activity-Relay forwards without an LD signature or FEP-8b32
proof;
- instance actor discovery (FEP-d556, FEP-2677);
- `implements` (FEP-844e).
- **Mastodon API:** ~~streaming WebSocket~~ (done 2026-10-05: `/api/v1/streaming` as a WebSocket and as server-sent
+14 -8
View File
@@ -1,8 +1,8 @@
# Relays as PrivaPub reads them (Federation:Relays in appsettings.Pasture.json): Activity-Relay, which forwards what its
# Relays as PrivaPub uses them (Federation:Relays in appsettings.Pasture.json): Activity-Relay, which forwards what its
# subscribers send, and aode-relay, which announces it. For each, the instance actor subscribes, Mastodon subscribes too,
# and a public post of a Mastodon account nobody here follows reaches PrivaPub's federated timeline through the relay;
# nothing of a persona's goes to a relay. Mastodon leaves each relay after, so the town sees no relayed posts. Needs the
# relay, aoderelay and mastodon peers.
# a public post of a Mastodon account nobody here follows reaches PrivaPub's federated timeline through the relay, and a
# persona's public post goes to the relay (owner decision 2026-10-06), nothing less public. Mastodon leaves each relay
# after, so the town sees no relayed posts. Needs the relay, aoderelay and mastodon peers.
M=https://mastodon.test:6443
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh"
@@ -36,10 +36,12 @@ s_uri=$(relayed_post relay)
until_true 60 'p_public_has "$s_uri"' && ok "a public post of an account nobody here follows reaches the federated timeline, forwarded" || ko "the forwarded post never arrived"
[ "$(curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(any(s['uri'] == '$s_uri' for s in d))")" = "False" ] \
&& ok "and no one's home" || ko "the relayed post landed in alice's home"
p_post=$(curl -s -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post the relay never sees $run&visibility=public" | j "print(d['uri'])")
sleep 5
[ "$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Job.countDocuments({Host:"relay.test", Payload:/Create/}))')" = "0" ] \
&& ok "nothing of a persona's goes to the relay" || ko "PrivaPub sent the relay a post"
# relay_creates <text>: the Creates PrivaPub has queued for relay.test naming the text
relay_creates() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Job.countDocuments({Host:"relay.test", Payload:/Create/, Payload:/'"$1"'/}))'; }
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post for the relay $run&visibility=public"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a quiet PrivaPub post $run&visibility=unlisted"
until_true 30 '[ "$(relay_creates "a PrivaPub post for the relay $run")" = "1" ]' && ok "alice's public post goes to the relay" || ko "PrivaPub never sent the relay alice's public post"
[ "$(relay_creates "a quiet PrivaPub post $run")" = "0" ] && ok "and nothing less public" || ko "PrivaPub sent the relay an unlisted post"
m_unrelay relay.test
until_true 45 '! relay_subscribers | grep -qx mastodon.test' && ok "Mastodon leaves Activity-Relay" || ko "Mastodon is still subscribed to Activity-Relay"
@@ -52,5 +54,9 @@ a_uri=$(relayed_post aoderelay)
until_true 60 'p_public_has "$a_uri"' && ok "a post aode-relay announces reaches the federated timeline as its author's" || ko "the announced post never arrived"
[ "$(curl -s -H "$PH" "$P/api/v1/timelines/public?remote=true&limit=40" | j "print(any((s.get('reblog') or {}).get('uri') == '$a_uri' for s in d))")" = "False" ] \
&& ok "never as the relay's boost" || ko "the relay's announce shows as a boost"
# alice's public post reaches Mastodon through aode-relay's announce, though nobody there follows her
a_post=$(curl -s -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post aode-relay brings $run&visibility=public" | j "print(d['uri'])")
until_true 60 '[ "$(podman exec pasture-mastodon bin/rails runner "puts Status.exists?(uri: \"$a_post\")" 2>/dev/null | tail -1)" = "true" ]' \
&& ok "alice's public post reaches Mastodon through aode-relay" || ko "alice's post never reached Mastodon through aode-relay"
m_unrelay aoderelay.test
until_true 45 '! aoderelay_connected | grep -q mastodon.test' && ok "Mastodon leaves aode-relay" || ko "Mastodon is still subscribed to aode-relay"