A deleted post's id given to a new post is that new post

PrivaPub remembers a deleted remote post's id for 90 days so that a late Create cannot bring it back. Gancio numbers a
new event after the last one it keeps, so deleting its last event gives the next the same id, and that event was
dropped as deleted. A Create published after the deletion is now kept as the new post; the deleted one's own Create,
however late, still is not. Checked live: Gancio's scenario passes again (17 checks).

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 16:29:21 +02:00
1 parent af2c61292b
commit cb34ab88f7
4 files changed
+76 -2

No files matched your search

+1 -1
View File
@@ -184,7 +184,7 @@ Received:
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back | | `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
| `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) | | `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept |
| `Flag` | becomes a report for this server's moderators | | `Flag` | becomes a report for this server's moderators |
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over | | `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly | | `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly |
@@ -0,0 +1,64 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// A deleted remote post is never brought back by its own Create, however late it comes; an id its server gives a new
// post afterwards (Gancio numbers an event after the last one it keeps) is that new post
[Trait("Category", "Integration")]
public sealed class ReusedIdTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_deleted_posts_create_stays_dropped_and_a_new_post_under_its_id_is_kept()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await Follows(alice.Id, carol);
var first = PublicNote(carol, "<p>the first event</p>");
first["published"] = DateTime.UtcNow.AddMinutes(-10).ToString("O");
await _harness.Deliver(carol, "/human-centipede", Create(carol, first));
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = NewId(carol, "deletes"), ["type"] = "Delete", ["actor"] = carol.Id,
["object"] = new JsonObject { ["id"] = IdOf(first), ["type"] = "Tombstone" }
});
Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == IdOf(first)).ExecuteAnyAsync(token));
// the first Create again, late
await _harness.Deliver(carol, "/human-centipede", Create(carol, (JsonObject)first.DeepClone()));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(first) && !p.DeletedAt.HasValue).ExecuteAnyAsync(token));
var second = PublicNote(carol, "<p>a new event, numbered as the first</p>");
second["id"] = IdOf(first);
second["published"] = DateTime.UtcNow.ToString("O");
await _harness.Deliver(carol, "/human-centipede", Create(carol, second));
var kept = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(first) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
Assert.Contains("a new event", kept?.ContentHtml);
Assert.False(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == IdOf(first)).ExecuteAnyAsync(token));
}
}
}
@@ -118,11 +118,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
Arrival.Drop("duplicate"); Arrival.Drop("duplicate");
return; return;
} }
if (await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token)) // a deleted post stays deleted however late or often its Create comes; but an id its server gives a new post again
// (Gancio numbers an event after the last one it keeps, so a deleted last event's id comes back), published after
// the deletion, is that new post
if (await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteFirstAsync(token) is { } tombstone)
{
if (Value(note.Raw, "published") == default || note.Published <= tombstone.DeletedAt)
{ {
Arrival.Drop("deleted"); Arrival.Drop("deleted");
return; return;
} }
await DB.Default.DeleteAsync<DeletedObject>(tombstone.ID);
}
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList(); var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList(); var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList();
+3
View File
@@ -1078,6 +1078,9 @@ FEP-8a8e (draft) is the common reference.
leaves it (the row gone); comments both ways; the organiser's edit, closing the comments (PrivaPub then refuses a leaves it (the row gone); comments both ways; the organiser's edit, closing the comments (PrivaPub then refuses a
reply) and deletes of a comment and the event; a group post with its title; the unfollow; statistics. reply) and deletes of a comment and the event; a group post with its title; the unfollow; statistics.
- **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP. - **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP.
- **Ids come back:** Gancio numbers a new event after the last one it keeps, so deleting its last event gives the next
one the same id (`/federation/m/1` twice). PrivaPub, which refuses a deleted id's `Create` for 90 days, keeps the new
one when its `published` is after the deletion (2026-10-06).
- **Pasture evidence (2026-10-05, Gancio 1.28.2, `tools/pasture/scenarios/gancio.sh`):** 17 checks pass: alice follows - **Pasture evidence (2026-10-05, Gancio 1.28.2, `tools/pasture/scenarios/gancio.sh`):** 17 checks pass: alice follows
its actor `relay`; a published event arrives as an Event with its start, end and place; her reply is kept as one of its actor `relay`; a published event arrives as an Event with its start, end and place; her reply is kept as one of
the event's resources (once `enable_resources` is on); its edit and deletion reach PrivaPub; the unfollow; the event's resources (once `enable_resources` is on); its edit and deletion reach PrivaPub; the unfollow;