From 2e48760b387e35b8276c5106fefa7a90c26ddb68 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 22 Feb 2026 19:59:48 +0000 Subject: [PATCH] Fix AP re-publish: use versioned object IDs after Delete After Delete + re-Create, Mastodon tombstones the old object ID and ignores new Creates with the same ID. Now appends /v2, /v3 etc. so remote servers treat re-publishes as fresh posts. Co-Authored-By: Claude Opus 4.6 --- contracts/protocols.py | 5 +++++ services/federation_impl.py | 14 ++++++++++++++ services/federation_publish.py | 18 +++++++++++++++--- services/stubs.py | 3 +++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/contracts/protocols.py b/contracts/protocols.py index 8f3ac3c..b27b870 100644 --- a/contracts/protocols.py +++ b/contracts/protocols.py @@ -216,6 +216,11 @@ class FederationService(Protocol): self, session: AsyncSession, source_type: str, source_id: int, ) -> APActivityDTO | None: ... + async def count_activities_for_source( + self, session: AsyncSession, source_type: str, source_id: int, + *, activity_type: str, + ) -> int: ... + # -- Followers ------------------------------------------------------------ async def get_followers( self, session: AsyncSession, username: str, diff --git a/services/federation_impl.py b/services/federation_impl.py index 442ed2f..f1eac78 100644 --- a/services/federation_impl.py +++ b/services/federation_impl.py @@ -288,6 +288,20 @@ class SqlFederationService: ).scalars().first() return _activity_to_dto(a) if a else None + async def count_activities_for_source( + self, session: AsyncSession, source_type: str, source_id: int, + *, activity_type: str, + ) -> int: + from sqlalchemy import func + result = await session.execute( + select(func.count()).select_from(APActivity).where( + APActivity.source_type == source_type, + APActivity.source_id == source_id, + APActivity.activity_type == activity_type, + ) + ) + return result.scalar_one() + # -- Followers ------------------------------------------------------------ async def get_followers( diff --git a/services/federation_publish.py b/services/federation_publish.py index da5945b..95debd4 100644 --- a/services/federation_publish.py +++ b/services/federation_publish.py @@ -56,13 +56,25 @@ async def try_publish( elif activity_type in ("Delete", "Update"): return # never published, nothing to delete/update - # Stable object ID: same source always gets the same object id so - # Mastodon treats Create/Update/Delete as the same post. + # Stable object ID within a publish cycle. After Delete + re-Create + # we append a version suffix so remote servers (Mastodon) treat it as + # a brand-new post rather than ignoring the tombstoned ID. domain = os.getenv("AP_DOMAIN", "rose-ash.com") - object_data["id"] = ( + base_object_id = ( f"https://{domain}/users/{actor.preferred_username}" f"/objects/{source_type.lower()}/{source_id}" ) + if activity_type == "Create" and existing and existing.activity_type == "Delete": + # Count prior Creates to derive a version number + create_count = await services.federation.count_activities_for_source( + session, source_type, source_id, activity_type="Create", + ) + object_data["id"] = f"{base_object_id}/v{create_count + 1}" + elif activity_type in ("Update", "Delete") and existing and existing.object_data: + # Use the same object ID as the most recent activity + object_data["id"] = existing.object_data.get("id", base_object_id) + else: + object_data["id"] = base_object_id try: await services.federation.publish_activity( diff --git a/services/stubs.py b/services/stubs.py index 520eefd..38b31f4 100644 --- a/services/stubs.py +++ b/services/stubs.py @@ -217,6 +217,9 @@ class StubFederationService: async def get_activity_for_source(self, session, source_type, source_id): return None + async def count_activities_for_source(self, session, source_type, source_id, *, activity_type): + return 0 + async def get_followers(self, session, username): return []