From 6154cf764d007c6052bbce8a969730f2a50b5cc9 Mon Sep 17 00:00:00 2001 From: Bretton Date: Sat, 3 Oct 2026 16:42:41 -0700 Subject: [PATCH] fix(moderation): name every indexed subject in the public modlog Removals are public by default (decided 2026-10-03). The public log now names a removed subject, and selects it under its community, collection and subject filters, while the subject's own post or comment row is indexed, whatever its community admission status (pending, pending_reacceptance, rejected, community-removed, unadmitted) and even after an author soft-delete. Only a subject whose row is gone, as after account erasure, stays restricted. A comment no longer follows its root post. CSAM and doxing stay covered by the reason rule. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/PRD_ADMIN_MODERATION.md | 2 +- ...oderation_label_modlog_integration_test.go | 8 - internal/core/moderation/modlog.go | 5 +- internal/core/moderation/store.go | 10 +- ..._action_log_restricted_integration_test.go | 142 +++++++++++------- internal/db/postgres/moderation_repo.go | 28 ++-- internal/db/postgres/post_visibility.go | 5 +- tests/e2e/moderation_contract_test.go | 2 - 8 files changed, 107 insertions(+), 95 deletions(-) diff --git a/docs/PRD_ADMIN_MODERATION.md b/docs/PRD_ADMIN_MODERATION.md index b17fe10..deb809f 100644 --- a/docs/PRD_ADMIN_MODERATION.md +++ b/docs/PRD_ADMIN_MODERATION.md @@ -210,7 +210,7 @@ The web currently derives sensitivity from `post.record?.labels`; mobile's `Post - Preserve removals, restorations, label applications/retractions, and subsequent reapplications as separate events even though the active state changes. Include the affected label value on classification actions. - Do not silently rewrite history to correct public text. The initial API has no modlog edit/delete/amend endpoint. An approved operator-only safety-redaction procedure must retain action identity/provenance, record the responsible actor and reason, and prevent disclosure through old public projections/caches; storage retention/deletion rules for the sensitive original are separate. A general amendment UI/API is later scope, not part of the confirmed content actions. - Do not expose reporter identities, report text, private notes, removed content snippets, credentials, or evidence blobs. -- For content whose existence is access-restricted, show a generic restricted-subject entry rather than revealing its URI/community. Publishing a label also reveals its subject: restricted subjects need an explicit sharing policy, not merely modlog filtering. +- For content whose existence is access-restricted, show a generic restricted-subject entry rather than revealing its URI/community. Publishing a label also reveals its subject: restricted subjects need an explicit sharing policy, not merely modlog filtering. Decided 2026-10-03: removals are public by default. The public log names a subject (URI and CID) and selects it under its community, collection and subject filters while the subject's own post or comment row is indexed, soft-deleted or not, whatever its community admission status (pending, rejected, community-removed or unadmitted included). A public entry carries no community field of its own; the association is visible through the community filter and the record itself. Only a subject whose row is gone, as after account erasure, is restricted: listed without subject or community, and excluded from subject, collection and community filters. CSAM and doxing stay covered by the reason rule in section 1. - A signed label proves that the authority issued a signal. It does **not** prove which human clicked Remove. Local actor attribution comes from the authenticated audit record; public actor attribution is an assertion by that authority. ### Inherited actions diff --git a/internal/api/routes/moderation_label_modlog_integration_test.go b/internal/api/routes/moderation_label_modlog_integration_test.go index 986c7e1..ead1a0a 100644 --- a/internal/api/routes/moderation_label_modlog_integration_test.go +++ b/internal/api/routes/moderation_label_modlog_integration_test.go @@ -35,14 +35,6 @@ func (f *modlogFixture) labelPost(t *testing.T) moderation.StrongRef { VALUES ($1, $2, $3, $4, $5, 'modlog labelled post', 'indexed body', $6) `, subject.URI, subject.CID, rkey, f.authorDID, f.communityDID, time.Now()) require.NoError(t, err) - // Accepted, so the post is public and the admin log names it as the - // action's subject rather than as a restricted privateSubject. - _, err = f.db.ExecContext(t.Context(), ` - INSERT INTO community_post_admissions - (community_did, post_uri, status, accepted_cid, evaluated_cid, last_community_rev, last_community_op_rank, created_at, updated_at) - VALUES ($1, $2, 'accepted', $3, $3, '3lqqqqqqqqqq2', 1, NOW(), NOW()) - `, f.communityDID, subject.URI, subject.CID) - require.NoError(t, err) return subject } diff --git a/internal/core/moderation/modlog.go b/internal/core/moderation/modlog.go index 9721999..ebf1822 100644 --- a/internal/core/moderation/modlog.go +++ b/internal/core/moderation/modlog.go @@ -57,8 +57,9 @@ func hiddenAction(action Action) bool { return false } -// restrictedSubject reports a subject whose existence the public log must not -// reveal (PRD §6): its URI, CID and community stay out of the public view. +// restrictedSubject reports a subject the public log must no longer name (PRD +// §6), because its indexed row is gone: its URI, CID and community stay out of +// the public view. func restrictedSubject(action Action) bool { return action.SubjectAccess == SubjectAccessRestricted } diff --git a/internal/core/moderation/store.go b/internal/core/moderation/store.go index 2aa6792..0feb734 100644 --- a/internal/core/moderation/store.go +++ b/internal/core/moderation/store.go @@ -60,11 +60,11 @@ type Action struct { SubjectAccess SubjectAccess `json:"-"` } -// SubjectAccess says whether an action's subject is content the anonymous -// public can see. It follows post.get's anonymous admission rule: a post is -// public when its own community admitted it (or, outside postv2, when it has -// no admission row), and a comment follows its root post. A subject with no -// indexed content row to decide from is restricted. +// SubjectAccess says whether the public log may name an action's subject. +// Removals are public by default, whatever the subject's community admission +// status (decided 2026-10-03): a subject is public while its own post or +// comment row is indexed, soft-deleted or not. One whose row is gone, as after +// account erasure, is restricted. type SubjectAccess string const ( diff --git a/internal/db/postgres/moderation_action_log_restricted_integration_test.go b/internal/db/postgres/moderation_action_log_restricted_integration_test.go index f4d0c28..0d5993f 100644 --- a/internal/db/postgres/moderation_action_log_restricted_integration_test.go +++ b/internal/db/postgres/moderation_action_log_restricted_integration_test.go @@ -5,6 +5,7 @@ package postgres_test import ( "database/sql" "encoding/json" + "strings" "testing" "time" @@ -69,75 +70,102 @@ func restrictedLogIDs(page *moderation.ActionPage) []string { return ids } -// A removal of content the public cannot see must not disclose it through the -// public modlog (PRD §6 "restricted subjects do not leak"). Restricted-ness is -// post.get's anonymous admission rule, evaluated when the log is read: a -// pending, rejected, community-removed or unadmitted postv2, and a comment -// whose root post is one of those, is listed without its subject and is -// excluded whenever a subject, collection or community filter could tie it to -// its target. A post admitted later shows normally. -func TestModerationActionLogRestrictsNonPublicSubjects(t *testing.T) { +// Removals are public by default (decided 2026-10-03): the public log names a +// subject while its own post or comment row is indexed, whatever its community +// admission status and even after its author soft-deleted it. Only a subject whose row is gone, as after account erasure, +// is restricted: listed without its subject, and excluded whenever a subject, +// collection or community filter could tie it to its target. Access is decided +// when the log is read, so a subject indexed again shows normally. +func TestModerationActionLogRestrictsOnlyUnindexedSubjects(t *testing.T) { db := testkit.DB(t) communityDID, authorDID := moderationPostTombstoneActors(t, db) service := restrictedLogService(db) - post := func(title, admissionSQL string) moderation.StrongRef { + post := func(title, status string) moderation.StrongRef { subject := indexedModerationPost(t, db, moderation.PostV2Collection, communityDID, authorDID, title, "") - if admissionSQL != "" { - _, err := db.ExecContext(t.Context(), admissionSQL, communityDID, subject.URI) + switch status { + case "": + case "unadmitted": + _, err := db.ExecContext(t.Context(), `DELETE FROM community_post_admissions WHERE community_did = $1 AND post_uri = $2`, communityDID, subject.URI) + require.NoError(t, err) + default: + _, err := db.ExecContext(t.Context(), ` + UPDATE community_post_admissions SET status = $3, accepted_cid = NULL, + decision_code = 'rule-violation', decision_at = NOW(), updated_at = NOW() + WHERE community_did = $1 AND post_uri = $2 + `, communityDID, subject.URI, status) require.NoError(t, err) } return subject } - const pendingSQL = `UPDATE community_post_admissions SET status = 'pending', accepted_cid = NULL, updated_at = NOW() - WHERE community_did = $1 AND post_uri = $2` accepted := post("accepted", "") - pending := post("pending", pendingSQL) - rejected := post("rejected", `UPDATE community_post_admissions SET status = 'rejected', accepted_cid = NULL, - decision_code = 'off-topic', decision_at = NOW(), updated_at = NOW() WHERE community_did = $1 AND post_uri = $2`) - communityRemoved := post("community removed", `UPDATE community_post_admissions SET status = 'removed', accepted_cid = NULL, - decision_code = 'rule-violation', decision_at = NOW(), updated_at = NOW() WHERE community_did = $1 AND post_uri = $2`) - unadmitted := post("unadmitted", `DELETE FROM community_post_admissions WHERE community_did = $1 AND post_uri = $2`) - legacy := indexedModerationPost(t, db, moderation.LegacyPostCollection, communityDID, authorDID, "legacy", "") - pendingRoot := post("pending root", pendingSQL) - publicComment := restrictedLogComment(t, db, authorDID, accepted) - restrictedComment := restrictedLogComment(t, db, authorDID, pendingRoot) + pending := post("pending", "pending") + orphanRoot := post("orphan root", "") + erasedPost := post("erased post", "") + erasedComment := restrictedLogComment(t, db, authorDID, accepted) + softDeletedPost := post("soft deleted post", "") + softDeletedComment := restrictedLogComment(t, db, authorDID, accepted) type entry struct { subject moderation.StrongRef + collection string restricted bool } + subjects := []entry{ + {accepted, moderation.PostV2Collection, false}, + {pending, moderation.PostV2Collection, false}, + {post("pending reacceptance", "pending_reacceptance"), moderation.PostV2Collection, false}, + {post("rejected", "rejected"), moderation.PostV2Collection, false}, + {post("community removed", "removed"), moderation.PostV2Collection, false}, + {post("unadmitted", "unadmitted"), moderation.PostV2Collection, false}, + {indexedModerationPost(t, db, moderation.LegacyPostCollection, communityDID, authorDID, "legacy", ""), moderation.LegacyPostCollection, false}, + {restrictedLogComment(t, db, authorDID, pending), moderation.CommentCollection, false}, + {restrictedLogComment(t, db, authorDID, orphanRoot), moderation.CommentCollection, false}, + {softDeletedPost, moderation.PostV2Collection, false}, + {softDeletedComment, moderation.CommentCollection, false}, + {erasedPost, moderation.PostV2Collection, true}, + {erasedComment, moderation.CommentCollection, true}, + } entries := map[string]entry{} var publicIDs []string - for _, subject := range []struct { - ref moderation.StrongRef - restricted bool - }{ - {accepted, false}, - {pending, true}, - {rejected, true}, - {communityRemoved, true}, - {unadmitted, true}, - {legacy, false}, - {publicComment, false}, - {restrictedComment, true}, - } { - id := restrictedLogRemove(t, service, subject.ref) - entries[id] = entry{subject.ref, subject.restricted} + for _, subject := range subjects { + id := restrictedLogRemove(t, service, subject.subject) + entries[id] = subject if !subject.restricted { publicIDs = append([]string{id}, publicIDs...) } } + // Account erasure hard-deletes rows; an author's delete only sets + // deleted_at, so the row stays and the entry stays public. A comment whose + // root post is gone keeps its own row, so it stays public too. + for _, statement := range []struct{ sql, uri string }{ + {`UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, softDeletedPost.URI}, + {`UPDATE comments SET deleted_at = NOW() WHERE uri = $1`, softDeletedComment.URI}, + {`DELETE FROM posts WHERE uri = $1`, erasedPost.URI}, + {`DELETE FROM comments WHERE uri = $1`, erasedComment.URI}, + {`DELETE FROM posts WHERE uri = $1`, orphanRoot.URI}, + } { + _, err := db.ExecContext(t.Context(), statement.sql, statement.uri) + require.NoError(t, err) + } + publicIn := func(collection string) []string { + var ids []string + for _, id := range publicIDs { + if entries[id].collection == collection { + ids = append(ids, id) + } + } + return ids + } - t.Run("unfiltered public log lists restricted actions without their subjects", func(t *testing.T) { + t.Run("unfiltered public log names every indexed subject", func(t *testing.T) { page, encoded := restrictedLogPage(t, service, moderation.ListActionsParams{}) require.Len(t, page.Actions, len(entries)) for _, action := range page.Actions { want, found := entries[action.Ref.ActionID] require.True(t, found, action.Ref.ActionID) - assert.Empty(t, action.Scope.CommunityDID, action.Ref.ActionID) if want.restricted { - assert.Nil(t, action.Subject, "a restricted subject must not appear in the public log") + assert.Nil(t, action.Subject, "an erased subject must not appear in the public log") assert.NotContains(t, encoded, want.subject.URI) } else { assert.Equal(t, &moderation.SubjectRefView{URI: want.subject.URI, CID: want.subject.CID}, action.Subject) @@ -145,7 +173,7 @@ func TestModerationActionLogRestrictsNonPublicSubjects(t *testing.T) { } }) - t.Run("admin log keeps restricted subjects private", func(t *testing.T) { + t.Run("admin log keeps erased subjects private", func(t *testing.T) { page, err := service.ListAdminActions(t.Context(), moderation.ListAdminActionsParams{}) require.NoError(t, err) require.Len(t, page.Actions, len(entries)) @@ -162,33 +190,33 @@ func TestModerationActionLogRestrictsNonPublicSubjects(t *testing.T) { } }) - t.Run("filters never select a restricted subject", func(t *testing.T) { + t.Run("filters select every indexed subject and never an erased one", func(t *testing.T) { page, _ := restrictedLogPage(t, service, moderation.ListActionsParams{Community: communityDID}) - assert.Equal(t, publicIDs, restrictedLogIDs(page), "the community filter must not tie restricted subjects to the community") - page, _ = restrictedLogPage(t, service, moderation.ListActionsParams{Collection: moderation.PostV2Collection}) - assert.Equal(t, []string{publicIDs[2]}, restrictedLogIDs(page), "only the accepted postv2 may match its collection") - page, _ = restrictedLogPage(t, service, moderation.ListActionsParams{Collection: moderation.CommentCollection}) - assert.Equal(t, []string{publicIDs[0]}, restrictedLogIDs(page), "only the comment on an accepted root may match its collection") + assert.Equal(t, publicIDs, restrictedLogIDs(page), "the community filter lists every indexed subject, whatever its admission status") + for _, collection := range []string{moderation.PostV2Collection, moderation.LegacyPostCollection, moderation.CommentCollection} { + page, _ = restrictedLogPage(t, service, moderation.ListActionsParams{Collection: collection}) + assert.Equal(t, publicIn(collection), restrictedLogIDs(page), collection) + } never := "at://" + authorDID + "/" + moderation.PostV2Collection + "/" + testkit.TID() _, neverBody := restrictedLogPage(t, service, moderation.ListActionsParams{Subject: never}) for id, want := range entries { page, body := restrictedLogPage(t, service, moderation.ListActionsParams{Subject: want.subject.URI}) if want.restricted { - assert.Equal(t, neverBody, body, "a restricted subject filter must answer exactly as a never-moderated subject") + assert.Equal(t, neverBody, body, "an erased subject filter must answer exactly as a never-moderated subject") } else { assert.Equal(t, []string{id}, restrictedLogIDs(page)) } } }) - t.Run("filtered pagination skips restricted rows before the limit", func(t *testing.T) { + t.Run("filtered pagination skips erased rows before the limit", func(t *testing.T) { one := 1 params := moderation.ListActionsParams{Community: communityDID, Limit: &one} var walked []string for pages := 1; ; pages++ { require.LessOrEqual(t, pages, len(publicIDs), "every page of the walk must hold a public action") page, _ := restrictedLogPage(t, service, params) - require.Len(t, page.Actions, 1, "a page must not come back short because restricted rows used its limit") + require.Len(t, page.Actions, 1, "a page must not come back short because erased rows used its limit") walked = append(walked, restrictedLogIDs(page)...) if page.Cursor == "" { break @@ -198,15 +226,15 @@ func TestModerationActionLogRestrictsNonPublicSubjects(t *testing.T) { assert.Equal(t, publicIDs, walked) }) - t.Run("a post admitted later shows normally", func(t *testing.T) { + t.Run("a subject indexed again shows normally", func(t *testing.T) { _, err := db.ExecContext(t.Context(), ` - UPDATE community_post_admissions SET status = 'accepted', accepted_cid = $3, updated_at = NOW() - WHERE community_did = $1 AND post_uri = $2 - `, communityDID, pending.URI, pending.CID) + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, $2, $3, $4, $5, 'reindexed', NOW()) + `, erasedPost.URI, erasedPost.CID, erasedPost.URI[strings.LastIndex(erasedPost.URI, "/")+1:], authorDID, communityDID) require.NoError(t, err) - page, _ := restrictedLogPage(t, service, moderation.ListActionsParams{Subject: pending.URI}) + page, _ := restrictedLogPage(t, service, moderation.ListActionsParams{Subject: erasedPost.URI}) require.Len(t, page.Actions, 1) - assert.Equal(t, &moderation.SubjectRefView{URI: pending.URI, CID: pending.CID}, page.Actions[0].Subject) + assert.Equal(t, &moderation.SubjectRefView{URI: erasedPost.URI, CID: erasedPost.CID}, page.Actions[0].Subject) page, _ = restrictedLogPage(t, service, moderation.ListActionsParams{Community: communityDID}) assert.Len(t, page.Actions, len(publicIDs)+1) }) diff --git a/internal/db/postgres/moderation_repo.go b/internal/db/postgres/moderation_repo.go index e48dbd5..b5a7a73 100644 --- a/internal/db/postgres/moderation_repo.go +++ b/internal/db/postgres/moderation_repo.go @@ -674,35 +674,25 @@ func (r *ModerationRepository) BindTransaction(tx *sql.Tx) moderation.MediaTrans // ListActions reads a page of the action log. // -// Each row's SubjectAccess is decided now, not when the action was recorded, by -// the anonymous viewer's admission rule post.get applies before it discloses a -// removed post (admittedPostsPredicate): a post admitted after its removal is -// public from then on. A comment follows its root post. A subject with no -// indexed post to decide from is restricted. ExcludeRestricted filters in the -// WHERE clause, before LIMIT, so a filtered page is never short. +// Each row's SubjectAccess is decided now, not when the action was recorded: a +// subject whose own post or comment row is still indexed is public, whatever +// its community admission status and even when the author soft-deleted it, and +// one whose row is gone (account erasure hard-deletes it) is restricted. ExcludeRestricted filters in the WHERE +// clause, before LIMIT, so a filtered page is never short. func (r *ModerationRepository) ListActions(ctx context.Context, query moderation.ActionListQuery) ([]moderation.Action, error) { var beforeCreatedAt, beforeID any if query.Before != nil { beforeCreatedAt, beforeID = query.Before.CreatedAt, query.Before.ID } - // The admission join aliases community_post_admissions as a, shadowing the - // action alias inside the EXISTS, so the root URI is resolved one level out. - admissionJoin, admitted := admittedPostsPredicate(anonymousViewerSQL) rows, err := r.db.QueryContext(ctx, ` SELECT `+moderationActionColumns+`, reversed.reason, subject_access.access FROM moderation_actions a LEFT JOIN moderation_actions reversed ON reversed.id = a.reverses_action_id - LEFT JOIN comments subject_comment - ON a.subject_collection = $18 AND subject_comment.uri = a.subject_uri CROSS JOIN LATERAL ( - SELECT CASE WHEN a.subject_collection = $18 THEN subject_comment.root_uri - ELSE a.subject_uri END AS post_uri - ) subject_root - CROSS JOIN LATERAL ( - SELECT CASE WHEN EXISTS ( - SELECT 1 FROM posts p`+admissionJoin+` - WHERE p.uri = subject_root.post_uri AND `+admitted+` - ) THEN $20::text ELSE $21::text END AS access + SELECT CASE WHEN + EXISTS (SELECT 1 FROM comments c WHERE a.subject_collection = $18 AND c.uri = a.subject_uri) + OR EXISTS (SELECT 1 FROM posts p WHERE a.subject_collection <> $18 AND p.uri = a.subject_uri) + THEN $20::text ELSE $21::text END AS access ) subject_access WHERE ($2::timestamptz IS NULL OR (a.created_at, a.id) < ($2::timestamptz, $3::text)) AND (NULLIF($4::text, '') IS NULL OR a.subject_uri = $4) diff --git a/internal/db/postgres/post_visibility.go b/internal/db/postgres/post_visibility.go index b42129f..a3ebcf1 100644 --- a/internal/db/postgres/post_visibility.go +++ b/internal/db/postgres/post_visibility.go @@ -127,7 +127,10 @@ func visiblePostsPredicate(viewerExpr string) (joinSQL, whereSQL string) { // (AdmittedURIsForViewer): an instance removal may tell a viewer that a post // was removed only if that viewer could have seen the post had it not been // removed. Anything else would disclose a pending, rejected or unadmitted post -// and tie it to a community that never accepted it. +// and tie it to a community that never accepted it. The public moderation log +// deliberately does not follow this rule: removals are public by default +// (decided 2026-10-03, PRD section 6), so listActions names such a post and +// selects it by its community. func admittedPostsPredicate(viewerExpr string) (joinSQL, whereSQL string) { joinSQL = ` LEFT JOIN community_post_admissions a diff --git a/tests/e2e/moderation_contract_test.go b/tests/e2e/moderation_contract_test.go index 3f2ba8d..bc1a40a 100644 --- a/tests/e2e/moderation_contract_test.go +++ b/tests/e2e/moderation_contract_test.go @@ -902,8 +902,6 @@ func TestModerationModlogWiringContract(t *testing.T) { var restored strongRef var restoredRemoval mutation for index := range 2 { - // Accepted, because the public log never ties a post the public cannot - // see to its community. post := indexedPost(t, p, community, author, "modlog wiring "+testkit.UniqueID(t)) var removal mutation err := p.AppView.As(admin.ServiceAuth(t, communityInstanceDID, removeContentMethod)).Procedure( -- 2.51.2