From cff505901beeb66638bd98dfe70960a22568a844 Mon Sep 17 00:00:00 2001 From: Bretton Date: Thu, 13 Aug 2026 04:25:49 +0000 Subject: [PATCH] wip(task19): cycle 11 — reemit rank learns both post eras + acceptance/removal tier; sweep pin Co-Authored-By: Claude Fable 5 --- internal/ingest/moderation_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ internal/ingest/reemit.go | 17 ++++++++++++++++- internal/ingest/reemit_test.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 file(s) changed, 123 insertion(s)(+), 1 deletion(s)(-) diff --git a/internal/ingest/moderation_test.go b/internal/ingest/moderation_test.go --- a/internal/ingest/moderation_test.go +++ b/internal/ingest/moderation_test.go @@ -416,6 +416,46 @@ assert.Equal(t, removalCIDBefore, removalCIDAfter, "the removal record must not be rewritten") assert.Equal(t, removalBefore["createdAt"], removalAfter["createdAt"]) } +// TestSweepDeletedPostV2RemovesAcceptanceWithoutRemoval (N2): the +// origin-verified delete sweep is CLEANUP, not moderation. When the origin +// stops serving a post, the bridge stops carrying it — the postv2 goes, and +// its acceptance must go with it or the community is left attesting to a +// record that no longer exists. No removal record is written: nobody +// moderated anything, and a removal would put a moderation action in the +// community's log against an author whose instance simply deleted the post. +// +// Note the sweep's safety rule stands unchanged: a 404 is NOT a delete (an +// instance hiding an object it will not serve us is indistinguishable from a +// missing one), so only the origin's explicit Tombstone triggers this. +func TestSweepDeletedPostV2RemovesAcceptanceWithoutRemoval(t *testing.T) { + h := newHarness(t) + post := setupModeratedPost(t, h) + ctx := context.Background() + + // The origin now serves Lemmy's deleted-object shape. + h.serveObject(urlPath(t, pageID), map[string]any{"id": pageID, "type": "Tombstone"}) + out := h.sweep(pageID) + require.Equal(t, OutcomeDeleted, out.Result[0].Outcome) + require.Equal(t, 1, out.Deleted) + + _, _, err := h.manager.GetRecord(ctx, post.authorDID, materialize.CollectionPostV2, post.rkey) + assert.True(t, errors.IsNotFound(err), + "the swept postv2 must be deleted from the author's repo (err=%v)", err) + _, _, err = h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionAcceptance, post.digestRKey) + assert.True(t, errors.IsNotFound(err), + "the acceptance must not outlive the post it attests to (err=%v)", err) + _, _, err = h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionRemoval, post.digestRKey) + assert.True(t, errors.IsNotFound(err), + "an origin-verified sweep is cleanup, not moderation: no removal record (err=%v)", err) + + mapping, err := h.objects.GetByAPID(ctx, pageID) + require.NoError(t, err) + assert.True(t, mapping.IsDeleted(), "the mapping must be soft-deleted") + tombstoned, err := h.tombstones.ExistsFor(ctx, pageID, "") + require.NoError(t, err) + assert.True(t, tombstoned, "the create-after-delete marker must be recorded") +} + // TestLegacyPostModRemovalKeepsV1Semantics (R6) is the mixed-era control. // Moderation records are postv2-only in this task: a pre-flip post has no // acceptance to delete, and writing a removal for it would announce a diff --git a/internal/ingest/reemit.go b/internal/ingest/reemit.go --- a/internal/ingest/reemit.go +++ b/internal/ingest/reemit.go @@ -28,13 +28,28 @@ // indexed before its posts arrive (and an author's profile before their // comments), or the content lands with dangling references. Cross-repo // order is relay-dependent regardless (see README), so this is best-effort // within one repo, not a delivery guarantee. +// +// The tiers are what references what: profiles are referenced by everything, +// posts are referenced by the comments that reply to them, and acceptance and +// removal records strongRef a post, so they come last. BOTH post eras share +// tier 1 — a postv2 is a post, and the deprecated collection is the same tier +// because the records already written under it still index the same way. That +// pairing is the whole point: matching only the old name put an author's +// comments ahead of the roots they reply to the moment the flip landed, which +// is precisely the dangling reference this ordering exists to prevent. func reemitCollectionRank(collection string) int { switch collection { case "social.coves.community.profile", "social.coves.actor.profile": return 0 - case "social.coves.community.post": + case "social.coves.community.post", "social.coves.community.postv2": return 1 + case "social.coves.community.acceptance", "social.coves.community.removal": + // After ALL content: each pins a post by strongRef, and a community's + // attestation about a post is meaningless to an indexer that has not + // seen the post yet. + return 3 default: + // Comments and anything not listed. Content, so after posts. return 2 } } diff --git a/internal/ingest/reemit_test.go b/internal/ingest/reemit_test.go --- a/internal/ingest/reemit_test.go +++ b/internal/ingest/reemit_test.go @@ -110,3 +110,70 @@ res := reemitRepo(t.Context(), f, "did:plc:nope", logger) assert.NotEmpty(t, res.Error) }) } + +// putIndex finds where a record's re-create landed in the call sequence. +func putIndex(t *testing.T, calls []string, did, collection, rkey string) int { + t.Helper() + want := fmt.Sprintf("put %s %s/%s", did, collection, rkey) + for i, call := range calls { + if call == want { + return i + } + } + t.Fatalf("no re-emit of %s/%s in %v", collection, rkey, calls) + return -1 +} + +// TestReemitRepoOrdersBothPostEras pins the re-emit ordering across the two +// post eras and the community-side moderation records. +// +// The rank exists so an indexer never sees a record before the thing it +// references. postv2 falling into the default bucket puts an author's +// comments ahead of the root posts they reply to — the exact dangling- +// reference the ordering was written to prevent, reintroduced by the flip +// because the rank matched on the deprecated collection name only. +// +// Acceptance and removal rank AFTER content: reemitRepo iterates the repo's +// RECORDS (ListRecords), not ap_objects mappings, so these are re-emitted +// like anything else — and each strongRef-pins a post, so they must not +// precede one. (Their subject lives in the AUTHOR's repo while they live in +// the COMMUNITY's, and cross-repo order is relay-dependent regardless; this +// is the within-repo half the bridge can actually control.) +func TestReemitRepoOrdersBothPostEras(t *testing.T) { + const did = "did:plc:community" + // Deliberately adversarial input order: every record before the one it + // should follow. + f := &fakeReemitter{records: map[string][]repo.RecordEntry{ + did: { + entry("social.coves.community.acceptance", "a1"), + entry("social.coves.community.comment", "c1"), + entry("social.coves.community.postv2", "p2"), + entry("social.coves.community.removal", "r1"), + entry("social.coves.community.post", "p1"), + entry("social.coves.community.profile", "self"), + }, + }} + + res := reemitRepo(t.Context(), f, did, slog.Default()) + require.Empty(t, res.Error) + require.Equal(t, 6, res.Reemited) + + profile := putIndex(t, f.calls, did, "social.coves.community.profile", "self") + legacyPost := putIndex(t, f.calls, did, "social.coves.community.post", "p1") + postV2 := putIndex(t, f.calls, did, "social.coves.community.postv2", "p2") + comment := putIndex(t, f.calls, did, "social.coves.community.comment", "c1") + acceptance := putIndex(t, f.calls, did, "social.coves.community.acceptance", "a1") + removal := putIndex(t, f.calls, did, "social.coves.community.removal", "r1") + + assert.Less(t, postV2, comment, + "a postv2 must be re-emitted BEFORE comments, exactly as a legacy post is: a comment "+ + "reply.root-pins its post, and an indexer that sees the comment first has a dangling ref") + assert.Less(t, profile, postV2, "profiles still precede content of either era") + assert.Less(t, profile, legacyPost) + assert.Less(t, comment, acceptance, + "acceptance records strongRef a post and must follow all content") + assert.Less(t, comment, removal, + "removal records strongRef a post and must follow all content") + assert.Less(t, postV2, acceptance) + assert.Less(t, postV2, removal) +} -- tangled.sh